MyBatis-Plus同款Elasticsearch ORM框架,用起來夠優雅!

語言: CN / TW / HK

使用過Spring Data操作ES的小夥伴應該有所瞭解,它只能實現一些非常基本的資料管理工作,一旦遇到稍微複雜點的查詢,基本都要依賴ES官方提供的RestHighLevelClient,Spring Data只是在其基礎上進行了簡單的封裝。最近發現一款更優雅的ES ORM框架Easy-Es,使用它能像MyBatis-Plus一樣操作ES,今天就以mall專案中的商品搜尋功能為例,來聊聊它的使用!

SpringBoot實戰電商專案mall(50k+star)地址:http://github.com/macrozheng/mall

Easy-Es簡介

Easy-Es(簡稱EE)是一款基於Elasticsearch(簡稱ES)官方提供的RestHighLevelClient打造的ORM開發框架,在RestHighLevelClient的基礎上,只做增強不做改變,為簡化開發、提高效率而生。EE和Mybatis-Plus(簡稱MP)的用法非常相似,如果你之前使用過MP的話,應該能很快上手EE。EE的理念是:把簡單、易用、方便留給使用者,把複雜留給框架。

EE的主要特性如下:

  • 全自動索引託管:開發者無需關心索引的建立、更新及資料遷移等繁瑣步驟,框架能自動完成。
  • 遮蔽語言差異:開發者只需要會MySQL的語法即可使用ES。
  • 程式碼量極少:與直接使用官方提供的RestHighLevelClient相比,相同的查詢平均可以節省3-5倍的程式碼量。
  • 零魔法值:欄位名稱直接從實體中獲取,無需手寫。
  • 零額外學習成本: 開發者只要會國內最受歡迎的Mybatis-Plus用法,即可無縫遷移至EE。

MySQL與Easy-Es語法對比

首先我們來對MySQL、Easy-Es和RestHighLevelClient的語法做過對比,來快速學習下Easy-Es的語法。

| MySQL | Easy-Es | es-DSL/es java api | | ------------------ | ---------------- | ------------------------------------------------------------ | | and | and | must | | or | or | should | | = | eq | term | | != | ne | boolQueryBuilder.mustNot(queryBuilder) | | > | gt | QueryBuilders.rangeQuery('es field').gt() | | >= | ge | .rangeQuery('es field').gte() | | < | lt | .rangeQuery('es field').lt() | | <= | le | .rangeQuery('es field').lte() | | like '%field%' | like | QueryBuilders.wildcardQuery(field,value) | | not like '%field%' | notLike | must not wildcardQuery(field,value) | | like '%field' | likeLeft | QueryBuilders.wildcardQuery(field,value) | | like 'field%' | likeRight | QueryBuilders.wildcardQuery(field,value) | | between | between | QueryBuilders.rangeQuery('es field').from(xx).to(xx) | | notBetween | notBetween | must not QueryBuilders.rangeQuery('es field').from(xx).to(xx) | | is null | isNull | must not QueryBuilders.existsQuery(field) | | is notNull | isNotNull | QueryBuilders.existsQuery(field) | | in | in | QueryBuilders.termsQuery(" xx es field", xx) | | not in | notIn | must not QueryBuilders.termsQuery(" xx es field", xx) | | group by | groupBy | AggregationBuilders.terms() | | order by | orderBy | fieldSortBuilder.order(ASC/DESC) | | min | min | AggregationBuilders.min | | max | max | AggregationBuilders.max | | avg | avg | AggregationBuilders.avg | | sum | sum | AggregationBuilders.sum | | order by xxx asc | orderByAsc | fieldSortBuilder.order(SortOrder.ASC) | | order by xxx desc | orderByDesc | fieldSortBuilder.order(SortOrder.DESC) | | - | match | matchQuery | | - | matchPhrase | QueryBuilders.matchPhraseQuery | | - | matchPrefix | QueryBuilders.matchPhrasePrefixQuery | | - | queryStringQuery | QueryBuilders.queryStringQuery | | select * | matchAllQuery | QueryBuilders.matchAllQuery() | | - | highLight | HighlightBuilder.Field | | ... | ... | ... |

整合及配置

接下來把Easy-Es整合到專案中配置下就可以使用了。

  • 首先需要在pom.xml中新增Easy-Es的相關依賴;

xml <dependency> <groupId>cn.easy-es</groupId> <artifactId>easy-es-boot-starter</artifactId> <version>1.0.2</version> </dependency>

  • 由於底層使用了ES官方提供的RestHighLevelClient,這裡ES的相關依賴版本需要統一下,這裡使用的ES客戶端版本為7.14.0,ES版本為7.17.3

xml <dependencyManagement> <dependencies> <dependency> <groupId>org.elasticsearch.client</groupId> <artifactId>elasticsearch-rest-high-level-client</artifactId> <version>7.14.0</version> </dependency> <dependency> <groupId>org.elasticsearch</groupId> <artifactId>elasticsearch</artifactId> <version>7.14.0</version> </dependency> </dependencies> </dependencyManagement>

  • 再修改配置檔案application.yml對Easy-Es進行配置。

yaml easy-es: # 是否開啟EE自動配置 enable: true # ES連線地址+埠 address: localhost:9200 # 關閉自帶banner banner: false

  • 新增Easy-Es的Java配置,使用@EsMapperScan配置好Easy-Es的Mapper介面和文件物件路徑,如果你使用了MyBatis-Plus的話,需要和它的掃描路徑區分開來。

java /** * EasyEs配置類 * Created by macro on 2022/9/16. */ @Configuration @EsMapperScan("com.macro.mall.tiny.easyes") public class EasyEsConfig { }

使用

Easy-Es整合和配置完成後,就可以開始使用了。這裡還是以mall專案的商品搜尋功能為例,聊聊Easy-Es的使用,Spring Data的實現方式可以參考Elasticsearch專案實戰,商品搜尋功能設計與實現!

註解的使用

下面我們來學習下Easy-Es中註解的使用。

  • 首先我們需要建立文件物件EsProduct,然後給類和欄位新增上Easy-Es的註解;

java /** * 搜尋商品的資訊 * Created by macro on 2018/6/19. */ @Data @EqualsAndHashCode @IndexName(value = "pms", shardsNum = 1, replicasNum = 0) public class EsProduct implements Serializable { private static final long serialVersionUID = -1L; @IndexId(type = IdType.CUSTOMIZE) private Long id; @IndexField(fieldType = FieldType.KEYWORD) private String productSn; private Long brandId; @IndexField(fieldType = FieldType.KEYWORD) private String brandName; private Long productCategoryId; @IndexField(fieldType = FieldType.KEYWORD) private String productCategoryName; private String pic; @IndexField(fieldType = FieldType.TEXT, analyzer = "ik_max_word") private String name; @IndexField(fieldType = FieldType.TEXT, analyzer = "ik_max_word") private String subTitle; @IndexField(fieldType = FieldType.TEXT, analyzer = "ik_max_word") private String keywords; private BigDecimal price; private Integer sale; private Integer newStatus; private Integer recommandStatus; private Integer stock; private Integer promotionType; private Integer sort; @IndexField(fieldType = FieldType.NESTED, nestedClass = EsProductAttributeValue.class) private List<EsProductAttributeValue> attrValueList; @Score private Float score; }

  • EsProduct中的註解具體說明如下:

| 註解名稱 | 用途 | 引數 | | ----------- | ---------- | ------------------------------------------------------------ | | @IndexName | 索引名註解 | value:指定索引名;shardsNum:分片數;replicasNum:副本數 | | @IndexId | ES主鍵註解 | type:指定註解型別,CUSTOMIZE表示自定義 | | @IndexField | ES欄位註解 | fieldType:欄位在索引中的型別;analyzer:索引文件時用的分詞器;nestedClass:巢狀類 | | @Score | 得分註解 | decimalPlaces:得分保留小數位,實體類中被作為ES查詢得分返回的欄位使用 |

  • EsProduct中巢狀型別EsProductAttributeValue的程式碼如下。

java /** * 搜尋商品的屬性資訊 * Created by macro on 2018/6/27. */ @Data @EqualsAndHashCode public class EsProductAttributeValue implements Serializable { private static final long serialVersionUID = 1L; @IndexField(fieldType = FieldType.LONG) private Long id; @IndexField(fieldType = FieldType.KEYWORD) private Long productAttributeId; //屬性值 @IndexField(fieldType = FieldType.KEYWORD) private String value; //屬性引數:0->規格;1->引數 @IndexField(fieldType = FieldType.INTEGER) private Integer type; //屬性名稱 @IndexField(fieldType=FieldType.KEYWORD) private String name; }

商品資訊維護

下面我們來實現幾個簡單的商品資訊維護介面,包括商品資訊的匯入、建立和刪除。

  • 首先我們需要定義一個Mapper,繼承BaseEsMapper;

```java /* * 商品ES操作類 * Created by macro on 2018/6/19. / public interface EsProductMapper extends BaseEsMapper {

} ```

  • 然後在Service實現類中直接使用EsProductMapper內建方法實現即可,是不是和MyBatis-Plus的用法一致?

```java /* * 搜尋商品管理Service實現類 * Created by macro on 2018/6/19. / @Service public class EsProductServiceImpl implements EsProductService { @Autowired private EsProductDao productDao; @Autowired private EsProductMapper esProductMapper; @Override public int importAll() { List esProductList = productDao.getAllEsProductList(null); return esProductMapper.insertBatch(esProductList); }

@Override
public void delete(Long id) {
    esProductMapper.deleteById(id);
}

@Override
public EsProduct create(Long id) {
    EsProduct result = null;
    List<EsProduct> esProductList = productDao.getAllEsProductList(id);
    if (esProductList.size() > 0) {
        result = esProductList.get(0);
        esProductMapper.insert(result);
    }
    return result;
}

@Override
public void delete(List<Long> ids) {
    if (!CollectionUtils.isEmpty(ids)) {
        esProductMapper.deleteBatchIds(ids);
    }
}

} ```

簡單商品搜尋

下面我們來實現一個最簡單的商品搜尋,分頁搜尋商品名稱、副標題、關鍵詞中包含指定關鍵字的商品。

  • 通過QueryWrapper來構造查詢條件,然後使用Mapper中的方法來進行查詢,使用過MyBatis-Plus的小夥伴應該很熟悉了;

java /** * 搜尋商品管理Service實現類 * Created by macro on 2018/6/19. */ @Service public class EsProductServiceImpl implements EsProductService { @Autowired private EsProductMapper esProductMapper; @Override public PageInfo<EsProduct> search(String keyword, Integer pageNum, Integer pageSize) { LambdaEsQueryWrapper<EsProduct> wrapper = new LambdaEsQueryWrapper<>(); if(StrUtil.isEmpty(keyword)){ wrapper.matchAllQuery(); }else{ wrapper.multiMatchQuery(keyword,EsProduct::getName,EsProduct::getSubTitle,EsProduct::getKeywords); } return esProductMapper.pageQuery(wrapper, pageNum, pageSize); } }

  • 使用Swagger訪問介面後,可以在控制檯輸出檢視生成的DSL語句,訪問地址:http://localhost:8080/swagger-ui/

  • 把DSL語句直接複製Kibana中即可執行檢視結果了,這和我們手寫DSL語句沒什麼兩樣的。

綜合商品搜尋

下面我們來實現一個複雜的商品搜尋,涉及到過濾、不同欄位匹配權重不同以及可以進行排序。

  • 首先來說需求,按輸入的關鍵字搜尋商品名稱(權重10)、副標題(權重5)和關鍵詞(權重2),可以按品牌和分類進行篩選,可以有5種排序方式,預設按相關度進行排序,看下介面文件有助於理解;

  • 這個功能之前使用Spring Data來實現非常複雜,使用Easy-Es來實現確實簡潔不少,下面是使用Easy-Es的實現方式;

java /** * 搜尋商品管理Service實現類 * Created by macro on 2018/6/19. */ @Service public class EsProductServiceImpl implements EsProductService { @Autowired private EsProductMapper esProductMapper; @Override public PageInfo<EsProduct> search(String keyword, Long brandId, Long productCategoryId, Integer pageNum, Integer pageSize,Integer sort) { LambdaEsQueryWrapper<EsProduct> wrapper = new LambdaEsQueryWrapper<>(); //過濾 if (brandId != null || productCategoryId != null) { if (brandId != null) { wrapper.eq(EsProduct::getBrandId,brandId); } if (productCategoryId != null) { wrapper.eq(EsProduct::getProductCategoryId,productCategoryId).enableMust2Filter(true); } } //搜尋 if (StrUtil.isEmpty(keyword)) { wrapper.matchAllQuery(); } else { wrapper.and(i -> i.match(EsProduct::getName, keyword, 10f) .or().match(EsProduct::getSubTitle, keyword, 5f) .or().match(EsProduct::getKeywords, keyword, 2f)); } //排序 if(sort==1){ //按新品從新到舊 wrapper.orderByDesc(EsProduct::getId); }else if(sort==2){ //按銷量從高到低 wrapper.orderByDesc(EsProduct::getSale); }else if(sort==3){ //按價格從低到高 wrapper.orderByAsc(EsProduct::getPrice); }else if(sort==4){ //按價格從高到低 wrapper.orderByDesc(EsProduct::getPrice); }else{ //按相關度 wrapper.sortByScore(SortOrder.DESC); } return esProductMapper.pageQuery(wrapper, pageNum, pageSize); } }

  • 再對比下之前使用Spring Data的實現方式,沒有QueryWrapper來構造條件,還要硬編碼欄位名稱,確實優雅了不少!

相關商品推薦

當我們檢視相關商品的時候,一般底部會有一些商品推薦,這裡簡單來實現下。

  • 首先來說下需求,可以根據指定商品的ID來查詢相關商品,看下介面文件有助於理解;

  • 這裡我們的實現原理是這樣的:首先根據ID獲取指定商品資訊,然後以指定商品的名稱、品牌和分類來搜尋商品,並且要過濾掉當前商品,調整搜尋條件中的權重以獲取最好的匹配度;

  • 使用Easy-Es來實現依舊是那麼簡潔!

java /** * 搜尋商品管理Service實現類 * Created by macro on 2018/6/19. */ @Service public class EsProductServiceImpl implements EsProductService { @Autowired private EsProductMapper esProductMapper; @Override public PageInfo<EsProduct> recommend(Long id, Integer pageNum, Integer pageSize) { LambdaEsQueryWrapper<EsProduct> wrapper = new LambdaEsQueryWrapper<>(); List<EsProduct> esProductList = productDao.getAllEsProductList(id); if (esProductList.size() > 0) { EsProduct esProduct = esProductList.get(0); String keyword = esProduct.getName(); Long brandId = esProduct.getBrandId(); Long productCategoryId = esProduct.getProductCategoryId(); //用於過濾掉相同的商品 wrapper.ne(EsProduct::getId,id); //根據商品標題、品牌、分類進行搜尋 wrapper.and(i -> i.match(EsProduct::getName, keyword, 8f) .or().match(EsProduct::getSubTitle, keyword, 2f) .or().match(EsProduct::getKeywords, keyword, 2f) .or().match(EsProduct::getBrandId, brandId, 5f) .or().match(EsProduct::getProductCategoryId, productCategoryId, 3f)); return esProductMapper.pageQuery(wrapper, pageNum, pageSize); } return esProductMapper.pageQuery(wrapper, pageNum, pageSize); } }

聚合搜尋商品相關資訊

在搜尋商品時,經常會有一個篩選介面來幫助我們找到想要的商品,這裡我們來簡單實現下。

  • 首先來說下需求,可以根據搜尋關鍵字獲取到與關鍵字匹配商品相關的分類、品牌以及屬性,下面這張圖有助於理解;

  • 這裡我們可以使用ES的聚合來實現,搜尋出相關商品,聚合出商品的品牌、商品的分類以及商品的屬性,只要出現次數最多的前十個即可;

  • 由於Easy-Es目前只用groupBy實現了簡單的聚合,對於我們這種有巢狀物件的聚合無法支援,所以需要使用RestHighLevelClient來實現,如果你對照之前的Spring Data實現方式的話,可以發現用法差不多,看樣子Spring Data只是做了簡單的封裝而已。

```java / * 搜尋商品管理Service實現類 * Created by macro on 2018/6/19. / @Service public class EsProductServiceImpl implements EsProductService { @Autowired private EsProductMapper esProductMapper; @Override public EsProductRelatedInfo searchRelatedInfo(String keyword) { SearchRequest searchRequest = new SearchRequest(); searchRequest.indices("pms_"); SearchSourceBuilder builder = new SearchSourceBuilder(); //搜尋條件 if (StrUtil.isEmpty(keyword)) { builder.query(QueryBuilders.matchAllQuery()); } else { builder.query(QueryBuilders.multiMatchQuery(keyword, "name", "subTitle", "keywords")); } //聚合搜尋品牌名稱 builder.aggregation(AggregationBuilders.terms("brandNames").field("brandName")); //集合搜尋分類名稱 builder.aggregation(AggregationBuilders.terms("productCategoryNames").field("productCategoryName")); //聚合搜尋商品屬性,去除type=1的屬性 AbstractAggregationBuilder aggregationBuilder = AggregationBuilders.nested("allAttrValues", "attrValueList") .subAggregation(AggregationBuilders.filter("productAttrs", QueryBuilders.termQuery("attrValueList.type", 1)) .subAggregation(AggregationBuilders.terms("attrIds") .field("attrValueList.productAttributeId") .subAggregation(AggregationBuilders.terms("attrValues") .field("attrValueList.value")) .subAggregation(AggregationBuilders.terms("attrNames") .field("attrValueList.name")))); builder.aggregation(aggregationBuilder); searchRequest.source(builder); try { SearchResponse searchResponse = esProductMapper.search(searchRequest, RequestOptions.DEFAULT); return convertProductRelatedInfo(searchResponse); } catch (IOException e) { e.printStackTrace(); } return null; }

/**
 * 將返回結果轉換為物件
 */
private EsProductRelatedInfo convertProductRelatedInfo(SearchResponse response) {
    EsProductRelatedInfo productRelatedInfo = new EsProductRelatedInfo();
    Map<String, Aggregation> aggregationMap = response.getAggregations().asMap();
    //設定品牌
    Aggregation brandNames = aggregationMap.get("brandNames");
    List<String> brandNameList = new ArrayList<>();
    for(int i = 0; i<((Terms) brandNames).getBuckets().size(); i++){
        brandNameList.add(((Terms) brandNames).getBuckets().get(i).getKeyAsString());
    }
    productRelatedInfo.setBrandNames(brandNameList);
    //設定分類
    Aggregation productCategoryNames = aggregationMap.get("productCategoryNames");
    List<String> productCategoryNameList = new ArrayList<>();
    for(int i=0;i<((Terms) productCategoryNames).getBuckets().size();i++){
        productCategoryNameList.add(((Terms) productCategoryNames).getBuckets().get(i).getKeyAsString());
    }
    productRelatedInfo.setProductCategoryNames(productCategoryNameList);
    //設定引數
    Aggregation productAttrs = aggregationMap.get("allAttrValues");
    List<? extends Terms.Bucket> attrIds = ((ParsedStringTerms) ((ParsedFilter) ((ParsedNested) productAttrs).getAggregations().get("productAttrs")).getAggregations().get("attrIds")).getBuckets();
    List<EsProductRelatedInfo.ProductAttr> attrList = new ArrayList<>();
    for (Terms.Bucket attrId : attrIds) {
        EsProductRelatedInfo.ProductAttr attr = new EsProductRelatedInfo.ProductAttr();
        attr.setAttrId(Long.parseLong((String) attrId.getKey()));
        List<String> attrValueList = new ArrayList<>();
        List<? extends Terms.Bucket> attrValues = ((ParsedStringTerms) attrId.getAggregations().get("attrValues")).getBuckets();
        List<? extends Terms.Bucket> attrNames = ((ParsedStringTerms) attrId.getAggregations().get("attrNames")).getBuckets();
        for (Terms.Bucket attrValue : attrValues) {
            attrValueList.add(attrValue.getKeyAsString());
        }
        attr.setAttrValues(attrValueList);
        if(!CollectionUtils.isEmpty(attrNames)){
            String attrName = attrNames.get(0).getKeyAsString();
            attr.setAttrName(attrName);
        }
        attrList.add(attr);
    }
    productRelatedInfo.setProductAttrs(attrList);
    return productRelatedInfo;
}

} ```

總結

今天將之前的使用Spring Data的商品搜尋案例使用Easy-Es改寫了一下,確實使用Easy-Es更簡單,但是對於複雜的聚合搜尋功能,兩者都需要使用原生的RestHighLevelClient用法來實現。使用Easy-Es來操作ES確實足夠優雅,它類似MyBatis-Plus的用法能大大降低我們的學習成本,快速完成開發工作!

參考資料

官方文件:http://www.easy-es.cn/

專案原始碼地址

http://github.com/macrozheng/mall-learning/tree/master/mall-tiny-easyes

「其他文章」