【主流技術】詳解 Spring Boot 2.7.x 整合 ElasticSearch7.x 全過程(二)

2023-11-22 18:00:26

前言

ElasticSearch 簡稱 es,是一個開源的高擴充套件的分散式全文檢索引擎,目前最新版本已經到了8.11.x了。

它可以近乎實時的儲存、檢索資料,且其擴充套件性很好,是企業級應用中較為常見的檢索技術。

下面主要記錄學習 ElasticSearch7.x 的一些基本結構、在Spring Boot 專案裡基本應用的過程,在這裡與大家作分享交流。

一、新增依賴

這裡參照的依賴是 starter-data-elasticsearch,版本應與 Spring Boot(我是2.7.2)的版本一致,並不是 Elasticsearch 的版本。

<!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-data-elasticsearch -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-elasticsearch</artifactId>
    <version>2.7.2</version>
</dependency>

二、 yml 設定

spring:
  elasticsearch:
    uris: http://遠端主機的公網IP:9200
    username: 自己的使用者名稱
    password: 自己的密碼

使用 Docker 安裝的 Elasticsearch 設定賬號/密碼教學:https://blog.csdn.net/qq_38669698/article/details/130529829

因為 ES 設定了密碼,所以 Kibana 的設定也需要修改:https://blog.csdn.net/weixin_45956631/article/details/130636880


三、注入依賴

  1. (推薦)ElasticsearchRestTemplate 類來源於 org.springframework.data.elasticsearch.core 包,封裝了 Elasticsearch 的 RESTful API,使用起來很便捷。

    //直接引入即可,無需額外的 Bean 設定和序列化設定
    @Resource
    private ElasticsearchRestTemplate elasticTemplate;
    
  2. (推薦)ElasticsearchRepository 介面來源於 org.springframework.data.elasticsearch.repository 包, 該介面用於簡化對 Elasticsearch 中資料的操作。

    public interface ArticleRepository extends ElasticsearchRepository<ESArticle, String>{}
    

    注:ESArticle 為實體類,String 表示唯一 Id 的資料型別。

  3. (不推薦)在 Elasticsearch 7.15版本之後,官方已將它的高階使用者端 RestHighLevelClient 標記為棄用狀態,之後的版本會推薦新的 RestClient。

    經過筆者對比實踐,無論是新/舊使用者端,在 Spring Boot 專案中都沒有上面前兩個使用起來便捷。但值得注意的是,很多企業以前的專案都會使用舊的 RestHighLevelClient 來寫業務。

    @Resource
    private RestHighLevelClient highLevelClient;
    
    @Resource
    private RestClient restClient;
    

四、CRUD 常用 API

  • ES 實體類

    和 MySQL、MongoDB 在 Spring 中的實體類一樣,需要將欄位和類屬性進行對映,同樣還可以使用註解進行簡單設定。

    以下是文章 ESArticle 的實體類,屬性包含標題、內容、標籤、點贊數/收藏數等:

    @Data
    @Document(indexName = "article")
    @EqualsAndHashCode(callSuper = true)
    public class ESArticle extends BaseEntity implements Serializable {
        
        private static final String DATE_TIME_PATTERN = "yyyy-MM-dd HH:mm:ss";
    
        /**
         * 唯一標識 id
         */
        @Id
        @Field(type = FieldType.Text)
        private String id;
    
        /**
         * 標題,欄位型別為 Text,沒有 String 型別;分詞型別為 ik 分詞器的最細顆粒度劃分法。
         */
        @Field(type = FieldType.Text, analyzer = "ik_max_word")
        private String title;
    
        /**
         * 內容
         */
        @Field(type = FieldType.Text, analyzer = "ik_max_word")
        private String content;
    
        /**
         * 標籤列表
         */
        private List<String> tags;
    
        /**
         * 點贊數
         */
        private Integer thumbNum;
    
        /**
         * 收藏數
         */
        private Integer favourNum;
    
        /**
         * 建立使用者 id
         */
        @Field(type = FieldType.Text)
        private String userId;
    
        /**
         * 建立時間,單獨儲存,欄位型別為 Date ,自定義格式
         */
        @Field(store = true, type = FieldType.Date, format = {}, pattern = DATE_TIME_PATTERN)
        private Date createTime;
    
        /**
         * 更新時間,單獨儲存,欄位型別為 Date ,自定義格式
         */
        @Field(store = true, type = FieldType.Date, format = {}, pattern = DATE_TIME_PATTERN)
        private Date updateTime;
    
        /**
         * 是否刪除
         */
        private Integer isDelete;
    }
    
  • documents 操作

    documents 的概念和 MySQL 中的行類似,指的是一條條的記錄,但是 ES 裡所有的資料都是 JSON 格式的,所以看起來就像是一個個檔案了。

    以下簡單的 CRUD 都由 ArticleRepository 來完成,下一小節複雜的查詢交給 ElasticsearchRestTemplate 來完成。

    • 新增(批次)

          @Resource
          private ArticleMapper articleMapper;
      
          @Resource
          private ArticleRepository articleRepository;
      
          //todo: ES裡的資料來源於資料庫,需要做遷移,業務資料不會直接寫進資料庫
          //todo: 有全量和增量兩種方式做資料遷移,或者引入第三方框架處理
          //todo: 此處暫不做資料遷移展示,就直接往 ES 裡寫,然後就當 ES 裡已經有資料了,再做 CRUD 以及查詢
          @Override
          public Boolean addDocuments(){
              LambdaQueryWrapper<Article> wrapper = new LambdaQueryWrapper<>();
              List<Article> articleList = articleMapper.selectList(wrapper);
              if (CollectionUtils.isNotEmpty(articleList)){
                  // 這裡是兩個實體的屬性轉換,這裡不過多展開講
                  List<ESArticle> esArticleList = articleList.stream().map(ESArticle::dbToEs).collect(Collectors.toList());
                  articleRepository.saveAll(esArticleList);
                  return Boolean.TRUE;
              }
              return Boolean.FALSE;
          }
      
    • 修改(更新)

          //todo: 還可以使用 elasticTemplate 的 update() 來進行更新,不過一般沒有單獨針對 es 的資料更新需求    
          @Override
          public Boolean updateDocuments(){
              ESArticle esArticle = articleRepository.findById("18094375634670546").orElse(null);
              if (Objects.nonNull(esArticle)){
                  esArticle.setTitle("測試修改標題更新操作");
                  articleRepository.save(esArticle);
                  return Boolean.TRUE;
              }
              return Boolean.FALSE;
          }
      
    • 獲取

          @Override
          public List<ESArticle> getESDocuments(){
              List<ESArticle> list = Lists.newArrayList();
              Iterable<ESArticle> esArticleList = this.articleRepository.findAll(Sort.by(Sort.Order.desc("id")));
              esArticleList.forEach(list::add);
              return list;
          }
      
    • 刪除

          @Override
          public Boolean deleteESDocuments(){
              //如果存在該條 document 則繼續刪除
              if (this.articleRepository.existsById("18094375634670546")){
                  this.articleRepository.deleteById("18094375634670546");
                  return Boolean.TRUE;
              }
              return Boolean.FALSE;
          }
      
  • 常見條件查詢(重點)

    以下會詳細地演示一下 BoolQueryBuilder 條件構造、常見 QueryBuilders 的方法等多條件複雜查詢場景:

        //todo: 企業專案中真正的複雜條件查詢
        @Override
        public PageInfo<ESArticle> testSearchFromES(ArticleSearchDTO articleSearchDTO){
            //完整的合法 id
            String id = articleSearchDTO.getId();
            //非法 id
            String notId = articleSearchDTO.getNotId();
            //搜尋方塊輸入的內容(實際會從標籤/內容/標題中查詢)
            String searchText = articleSearchDTO.getSearchWord();
            //單獨在標題中查詢
            String title = articleSearchDTO.getTitle();
            //單獨在內容中查詢
            String content = articleSearchDTO.getContent();
            //單獨在標籤中查詢(全部標籤)
            List<String> tagList = articleSearchDTO.getTags();
            //任意標籤
            List<String> orTagList = articleSearchDTO.getOrTags();
            //按照建立者的 userId 查詢
            String userId = articleSearchDTO.getUserId();
            // 布林查詢初始化
            BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();
            // 過濾,首先被刪除的就不要了
            boolQueryBuilder.filter(QueryBuilders.termQuery(this.fn.fnToFieldName(ESArticle::getIsDelete), NumberUtils.INTEGER_ZERO));
            //如果輸入的是 id 那麼就不對 id 分詞,然後過濾掉不符合該 id 的其它檔案
            if (StringUtils.isNotBlank(id)) {
                boolQueryBuilder.filter(QueryBuilders.termQuery("id", id));
            }
            //如果輸入的是非法 id 那麼什麼也查不到,取反(也就是所有)返回
            if (StringUtils.isNotBlank(notId)) {
                boolQueryBuilder.mustNot(QueryBuilders.termQuery("id", notId));
            }
            //建立者 userId 也不分詞,過濾掉不匹配的
            if (StringUtils.isNotBlank(userId)) {
                boolQueryBuilder.filter(QueryBuilders.termQuery("createId", userId));
            }
            // 必須包含所有標籤
            if (CollectionUtils.isNotEmpty(tagList)) {
                for (String tag : tagList) {
                    boolQueryBuilder.filter(QueryBuilders.termQuery("tags", tag));
                }
            }
            // 包含任何一個標籤即可
            if (CollectionUtils.isNotEmpty(orTagList)) {
                BoolQueryBuilder orTagBoolQueryBuilder = QueryBuilders.boolQuery();
                // DB 實體中 tag 欄位為 String,而 ES 實體該欄位的型別為 List,所以做迴圈遍歷
                for (String tag : orTagList) {
                    orTagBoolQueryBuilder.should(QueryBuilders.termQuery("tags", tag)).minimumShouldMatch(1);
                }
                //filter 可以結合 bool 做更復雜的過濾
                boolQueryBuilder.filter(orTagBoolQueryBuilder);
            }
            // 按關鍵詞檢索(主要的搜尋方塊,關鍵詞會在兩個欄位裡匹配)
            if (StringUtils.isNotBlank(searchText)) {
                boolQueryBuilder.should(QueryBuilders.matchQuery("title", searchText));
                boolQueryBuilder.should(QueryBuilders.matchQuery("content", searchText));
                boolQueryBuilder.minimumShouldMatch(1);
            }
            // 單獨按標題檢索
            if (StringUtils.isNotBlank(title)) {
                boolQueryBuilder.should(QueryBuilders.matchQuery("title", title));
            }
            // 單獨按內容檢索
            if (StringUtils.isNotBlank(content)) {
                boolQueryBuilder.should(QueryBuilders.matchQuery("content", content));
            }
        }
    
  • 分頁查詢

    Spring Data 自帶的分頁方案,即 PageRequest 物件:

            // 分頁引數:起始頁為 0
            long current = articleSearchDTO.getCurrent() - 1;
            long pageSize = articleSearchDTO.getPageSize();
            PageRequest pageRequest = PageRequest.of((int) current, (int) pageSize);
    
  • 排序

    設定了按條件排序則以排序欄位為準來返回,沒設定排序則預設按照分數,即匹配度返回:

            // 排序欄位,可以支援多個
            String sortField = articleSearchDTO.getSortField();
            SortBuilder<?> sortBuilder = SortBuilders.scoreSort();
            if (StringUtils.isNotBlank(sortField)) {
                sortBuilder = SortBuilders.fieldSort(sortField).order(SortOrder.DESC);
            }
    
  • 構造查詢

    將所有的條件放進 NativeSearchQueryBuilder 物件,並呼叫elasticTemplate.search()方法,最後放入PageInfo(這裡引入的是com.github.pagehelper)物件返回:

            // 構造查詢
            NativeSearchQuery searchQuery = new NativeSearchQueryBuilder()
                    .withQuery(boolQueryBuilder)
                    .withSorts(sortBuilder)
                    .withPageable(pageRequest).build();
            // 獲取查詢物件的結果:放入所有條件,指定索引實體
            SearchHits<ESArticle> searchHits = elasticTemplate.search(searchQuery, ESArticle.class);
            //todo: 先以 ES 的資料為準,後期資料遷移再考慮使用 MySQL 的資料來源
            //初始化 page 物件
            PageInfo<ESArticle> pageInfo = new PageInfo<>();
            pageInfo.setList(searchHits.getSearchHits().stream().map(SearchHit::getContent).collect(Collectors.toList()));
            pageInfo.setTotal(searchHits.getTotalHits());
            System.out.println(pageInfo);
            return pageInfo;
    
  • 測試呼叫
        @Test
        public void testSearchFromES(){
            ArticleSearchDTO articleSearchDTO = new ArticleSearchDTO();
            articleSearchDTO.setId("18094375634670546");
            //articleSearchDTO.setSearchWord("是");
            //articleSearchDTO.setTitle("標題");
            //articleSearchDTO.setTags(Collections.singletonList("es"));
            //articleSearchDTO.setSortField("createTime");
            esTestService.testSearchFromES(articleSearchDTO);
        }
    

測試資料如下圖所示:


五、文章小結

使用 ElasticSearch 實現全文檢索的過程並不複雜,只要在業務需要的地方建立 ElasticSearch 索引,將資料放入索引中,就可以使用 ElasticSearch 整合在 Spring Boot 中對搜尋物件進行查詢操作了。

無論是建立索引、精準匹配、還是欄位高亮等操作,其本質上還是一個物件導向的過程。和 Java 中的其它「物件」一樣,只要靈活運用這些「物件」的使用規則和特性,就可以滿足業務上的需求。

關於 ElasticSearch7.x 的基本結構和在 Spring Boot 專案中的整合應用就和大家分享到這裡。如有錯誤和不足,還期待大家的指正與交流。

參考檔案:

  1. ElasticSearch 官方查詢 API 檔案:https://www.elastic.co/guide/en/elasticsearch/reference/current/search.html
  2. Spring Data ElasticSearch 官方:https://docs.spring.io/spring-data/redis/docs/2.6.10/api/