迎着风跑  

Spring Boot 整合 ElasticSearch

ElasticSearch 是一个开源的搜索引擎,建立在一个全文搜索引擎库 Apache Lucene 基础之上。(Lucene 可以说是当下最先进、高性能、全功能的搜索引擎库。)

ElasticSearch 使用 Java 编写的,它的内部使用的是 Lucene 做索引与搜索,它的目的是使全文检索变得简单(因为 Lucene 只是个库),通过隐藏 Lucene 的复杂性,取而代之提供了一套简单一致的 RESTful API 。

然而,ElasticSearch 不仅仅是 Lucene 的一层『壳子』,并且也不仅仅只是一个全文搜索引擎,更多的时候它被当作 NoSQL 数据库来使用。

#1. 版本问题

Spring Data ElasticSearch 和 ElasticSearch 是有对应关系的,不同的版本之间不兼容。

具体版本对应关键见最后。

简而言之:

  1. spring-boot 2.1.x ,仅支持 ES6 ;

  2. pring-boot 2.2.x ,同时支持 ES6 和 ES7 。

#2. 回顾 Elasticsearch 搜索功能

Elastic Search 搜索没命中的原因无非关系到三点:

  1. 中文分词器

  2. document 的字段类型

  3. 执行的是 term 查询,还是 match 查询

#中文分词器的影响

text 类型的字符串数据存储进 Elastic Search 时,会被分词器分词。如果没有指定分词器,那么 Elastic Search 使用的就是默认的 Standard 分词器。

以 张三丰 为例,『默认的分词器』会将其分为  和 (注意,甚至都没有 张三丰!),此时,你以 张三丰 作为条件,进行 term 查询,Elastic Search 会告诉你:没有。因为  和  都不是 张三丰 。

#document 的字段类型

Elastic Search 中的字符串类型(以前有 String,现在细)分为 text 和 keyword 两种 。

text 类型的字符串数据会被分词器分词,细化为多个 term;而 keyword 类型的字符串数据,则不会被分词器分词,或者说整体就是有且仅有的一个 term 。当然,至于 string 被分词器分为 term 的结果科不科学?给不给力?合不合理?那就是另一码事了,见上文。

以 张三丰 为例(假设使用了 IK 中文分词器),以 text 类型存储时,会被分词器分为 张三丰张三三丰 这好几个 term,也就是说,但凡以上述的哪个 term 作为关键字(例如,张三进行搜索,都能定位到这条数据。

但是 张三丰 以 keyword 类型存储时,分词器不参与其中工作,张三丰 就是一个整体,张三三丰 跟它半毛钱关系都没有。

#term 查询和 match 查询

进行查询时,你提供的查询的关键字/线索,也有可能被分词器进行分词,这取决于你进行的是 term 查询,还是 match 查询。

你进行 term 查询时,你提供的线索,例如 张三丰不会被分词器分词,也即是说,Elastic Search 就是实实在在地拿 张三丰 作为条件,去数据库中查找数据。

简单来说,你提供的一个查询线索就是一个查询线索。

你进行 match 查询时,你提供的线索,还是 张三丰,分词器会对其分词,也就是说,你以为你是在以 张三丰 为线索进行查询?不,实际上 Elastic Search 是分别以 张三丰张三三丰 这几个,去数据库中查了好几次。

简单来说,你以为你提供的是一个查询线索,实际上是好几个。这样的查询命中自然就高了(至于命中的是不是你想要的,那就是另一个问题了)

想知道中文字符串被分词器解析出几个词项,可以通过下面命令简介知道:

POST _analyze
{
    "analyzer" : "ik_max_word 或 ik_smart",
    "text" : "内容"
}

# 查看某个字段的分词结果
GET 索引名/_doc/指定ID/_termvectors?fields=字段名
Copied!

#3. Spring Boot 集成 ElasticSearch

spring-boot-es-01

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>

<dependency><!-- 因为 spring-data-es 要发出 HTTP 请求,所以还需要 spring web 的包 -->
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
Copied!
  • 老版本配置方式(已被废弃,不再推荐使用)

    略。

  • 新版本配置方式(推荐使用)

    新的配置方式使用的是 High Level REST Client 的方式来替代之前的 Transport Client 方式,使用的是 HTTP 请求,和 Kibana 一样使用的是 Elasticsearch 的 9200 端口。

    这种配置方案中,你使用的不是配置文件,而是自定义配置类:

    /**
     * 你也可以不继承 AbstractElasticsearchConfiguration 类,而将 ESConfig 写成一般的配置类的型式。
     * 不过继承 AbstractElasticsearchConfiguration 好处在于,它已经帮我们配置好了 elasticsearchTemplate 直接使用。
     * 
     * 在配置文件中配置 spring.elasticsearch.rest.uris=http://localhost:9200
     */
    @Configuration
    public class ESConfig extends AbstractElasticsearchConfiguration {
    
        @Override
        public RestHighLevelClient elasticsearchClient() {
            ClientConfiguration clientConfiguration = ClientConfiguration.builder()
                .connectedTo("localhost:9200")
                .build();
            return RestClients.create(clientConfiguration).rest();
        }
    }
    
    Copied!

    另外,由于 High Level REST Client 的方式是向 Elasticsearch 发出 HTTP 请求,因此无论你用不用得到 spring webmvc,spring-data-elasticsearch 它是一定要用的,所以,这就是为什么你的 pom 依赖中还要加上 spring-webmvc 的依赖。

Elasticsearch 中的 PO 类:

@Document(indexName = "books", shards = 1, replicas = 0)
public class Book {

    @Id
    @Field(name="id", type = FieldType.Keyword)
    private String id;

    @Field(name="title", type = FieldType.Text, analyzer = "ik_smart")
    private String title;

    @Field(name="language", type = FieldType.Keyword)
    private String language;

    @Field(name="author", type = FieldType.Keyword)
    private String author;

    @Field(name="price", type = FieldType.Float)
    private Float price;

    @Field(name = "publish_date", type = FieldType.Date, format = DateFormat.custom, pattern = "yyyy-MM-dd")
    private String publishDate;

    @Field(name="description", type = FieldType.Text, analyzer = "ik_max_word")
    private String description;

    // getter / setter

}
Copied!
  • @Document :注解会对实体中的所有属性建立索引;

  • indexName = "books" :表示创建一个名称为 "books" 的索引;

  • shards = 1 : 表示只使用一个分片;

  • replicas = 0 : 表示不使用复制备份;

  • @Field(type = FieldType.Keyword) : 用以指定字段的数据类型。

#4. 创建操作的 Repository

@Repository
public interface CustomerRepository extends ElasticsearchRepository<Customer, String> {

    // SearchHits<Customer> findByAddress(String address); 
    List<Customer> findByAddress(String address); 

    Page<Customer> findByAddress(String address, Pageable pageable);

    Customer findByUserName(String userName);

    int deleteByUserName(String userName);

    // 更多示例见笔记的最后
}
Copied!

spring-boot-es-02

我们自定义的 CustomerRepository 接口,从它的祖先们那里继承了大量的现成的方法,除此之外,它还可以按 spring data 的规则定义特定的方法。

#5. 测试 CustomerRepository

@RunWith(SpringJUnit4ClassRunner.class)
@SpringBootTest
public class CustomerRepositoryTest {

    @Autowired
    private CustomerRepository repository;

    @Test
    public void svaeCustomers() {
        repository.save(new Customer("Alice", "湖北武汉", 13));
        repository.save(new Customer("Bob", "湖北咸宁", 23));
        repository.save(new Customer("Neo", "湖北黄石", 30));
        repository.save(new Customer("Summer", "湖北襄阳", 22));
        repository.save(new Customer("Tommy", "湖北宜昌", 22));
        repository.save(new Customer("Jerry", "湖北荆州", 22));
        repository.save(new Customer("关羽", "大意失荆州", 22));
    }
}
Copied!

很显然,上面执行的是插入操作(在此之前还执行了建库建表操作)

注意:由于我们没有指定 id 属性的值,这意味着,我们希望由 Elasticsearch 来为每一个文档的 id 属性赋值。

不过 JavaBean 中的这个 id 属性是 ES 文档(Document)的元属性 _id ,而不是 id 。从 Kibana 中可以看到 id 属性的值是 null 。

如果我们指定了 id 属性值,那么,文档的原属性 _id 和我们自己的 id 属性值是一样的,都是这个我们指定的这个值。

  • 查询所有

    for (Customer customer : repository.findAll()) {
        log.info("{}", customer);
    }
    
    Copied!
  • 删除

    repository.deleteAll();
    repository.deleteByUserName("neo"); // 注意,大小写敏感。
    
    Copied!
  • 修改

    Customer customer = repository.findByUserName("Summer");
    log.info("{}", customer);
    customer.setAddress("湖北武汉武昌区");
    repository.save(customer);
    Customer other = repository.findByUserName("Summer");
    log.info("{}", other);
    
    Copied!

#6. 高级查询

#分页查询

Spring Data 自带的分页方案:

Pageable pageable = PageRequest.of(0, 2);

Page<Book> books = bookDao.findAll(pageable);

log.info("总共有 {} 条数据", books.getTotalElements());
log.info("总共有 {} 页", books.getTotalPages());
log.info("本页的页号是 {}", books.getNumber());
log.info("本页的页面大小 {}", books.getSize());
log.info("本页的实际数量 {}", books.getContent().size());

// 本页的内容:List<Book>
books.getContent().forEach((item) -> log.debug("{}", item));
Copied!

#排序

Spring Data 自带了排序方案:

Sort sort  =  Sort.by(Sort.Direction.DESC, "price");

Iterable<Book> books = bookDao.findAll(sort);

books.forEach((book) -> log.info("{}", book));
Copied!

#使用 @Query 注解

你可以在 Repository 接口的方法上标注 @Query 注解,并明确指定执行怎样的查询:

@Query("{ \"match\" : { \"description\" : \"?0\" }}")
SearchHits<Book> xxx(String content);
Copied!

注意:

  • @Query 注解的字符串内容是 { ... } 形式的,不要忘记了大括号。

  • @Query 注解的字符串内容中没有 "query",不要多复制了东西;

假设你传入的 content 参数值为 java ,那么调用上述方法等价于执行了下面的查询:

{
  "query": {
    "match": { "description": "java" }
  }
}
Copied!

#term 查询

// 不建议使用。官方建议使用上述的 @Query 替代。
QueryBuilder queryBuilder = QueryBuilders.termQuery("description", "java");

Iterable<Book> it = bookDao.search(queryBuilder);
it.forEach((item) -> log.info("{}", item));
Copied!

#match 查询

// 不建议使用。官方建议使用上述的 @Query 替代。
QueryBuilder queryBuilder = QueryBuilders.matchQuery("description", "java编程");

Iterable<Book> it = bookDao.search(queryBuilder);
it.forEach((item) -> log.info("{}", item));
Copied!

#多条件查询

  • QueryBuilders.boolQuery()
  • QueryBuilders.boolQuery().must():相当于 and
  • QueryBuilders.boolQuery().should():相当于 or
  • QueryBuilders.boolQuery().mustNot():相当于 not

注意,合理的 and 和 or 的嵌套关系,有助于你理清逻辑。建议像 Mybatis 的 Example 对象学习,组织成 (... and ...) or (... and ...) 的形式。

// 不建议使用。官方建议使用上述的 @Query 替代。

QueryBuilder query1 = QueryBuilders.termQuery("", "");
QueryBuilder query2 = QueryBuilders.termQuery("", "");
QueryBuilder query3 = QueryBuilders.termQuery("", "");
QueryBuilder query4 = QueryBuilders.termQuery("", "");

// ... and ...
QueryBuilder all = QueryBuilders.boolQuery()
    .must(query1)
    .must(query2);

// ... or ...
QueryBuilder all = QueryBuilders.boolQuery()
    .should(query1)
    .should(query2);

// (... and ...) or (... and ...)
QueryBuilder all = QueryBuilders.boolQuery()
    .should(
        QueryBuilders.boolQuery()
        .must(query1)
        .must(query2))
    .should(
        QueryBuilders.boolQuery()
        .must(query3)
        .must(query4));

Copied!

#7. 附-版本对应关系

Spring Data ElasticSearch 和 ElasticSearch 是有对应关系的,不同的版本之间不兼容。

官网描述的对应关系如下表:

Spring BootSpring Data ElasticsearchElasticsearch
2.2.x 3.2.x 6.8.4
2.1.x 3.1.x 6.2.2
2.0.x 3.0.x 5.5.0

注意,Spring Boot(Spring Data Elasticsearch)和 Elasticsearch 的版本匹配问题是网上反映较多的问题一定要注意。以上是官网列出的版本对应关系,但是实施情况好像也并非如此,实际情况比较混乱。

总体而言规则如下:

spring-data-es / spring-bootES7ES6ES5
3.2.x / 2.2.x 支持 支持 不支持
3.1.x / 2.1.x 不支持 支持 不支持
3.0.x / 2.0.x 不支持 支持 支持

#8. 示例

  • And: findByNameAndPrice

    { 
      "query" : { 
        "bool" : { 
          "must" : [ 
            { "query_string" : { "query" : "?", "fields" : [ "name" ] } }, 
            { "query_string" : { "query" : "?", "fields" : [ "price" ] } } 
          ] 
        } 
      }
    }
    
    Copied!
  • Or:findByNameOrPrice

    { 
      "query" : { 
        "bool" : { 
          "should" : [ 
            { "query_string" : { "query" : "?", "fields" : [ "name" ] } }, 
            { "query_string" : { "query" : "?", "fields" : [ "price" ] } } 
          ] 
        } 
      }
    }
    
    Copied!
  • Is: findByName

    { 
      "query" : { 
        "bool" : { 
          "must" : [ 
            { "query_string" : { "query" : "?", "fields" : [ "name" ] } } 
          ] 
        } 
      }
    }
    
    Copied!
  • Not: findByNameNot

    { 
        "query" : { 
            "bool" : { 
                "must_not" : [ { 
                    "query_string" : { "query" : "?", "fields" : [ "name" ] } 
                } ] 
            } 
        }
    }
    
    Copied!
  • Between: findByPriceBetween

    { 
        "query" : { 
            "bool" : { 
                "must" : [ {
                    "range" : {
                        "price" : {"from" : ?, "to" : ?, "include_lower" : true, "include_upper" : true } 
                    } 
                } ] 
            } 
        }
    }
    
    Copied!
  • LessThan: findByPriceLessThan

    { 
        "query" : { 
            "bool" : { 
                "must" : [ {
                    "range" : {
                        "price" : {"from" : null, "to" : ?, "include_lower" : true, "include_upper" : false } 
                    } 
                } ] 
            } 
        }
    }
    
    Copied!
  • LessThanEqual: findByPriceLessThanEqual

    { 
        "query" : { 
            "bool" : { 
                "must" : [ {
                    "range" : {
                        "price" : {"from" : null, "to" : ?, "include_lower" : true, "include_upper" : true } 
                    } 
                } ] 
            } 
        }
    }
    
    Copied!
  • GreaterThan: findByPriceGreaterThan

    { 
        "query" : { 
            "bool" : { 
                "must" : [ {
                    "range" : {
                        "price" : {"from" : ?, "to" : null, "include_lower" : false, "include_upper" : true } 
                    } 
                } ] 
            } 
        }
    }
    
    Copied!
  • GreaterThanEqual: findByPriceGreaterThan

    { 
        "query" : { 
            "bool" : { 
                "must" : [ {
                    "range" : {
                        "price" : {"from" : ?, "to" : null, "include_lower" : true, "include_upper" : true } 
                    } 
                } ] 
            } 
        }
    }
    
    Copied!
  • Before: findByPriceBefore

    { 
        "query" : { 
            "bool" : { 
                "must" : [ {
                    "range" : {
                        "price" : {"from" : null, "to" : ?, "include_lower" : true, "include_upper" : true } 
                    } 
                } ] 
            } 
        }
    }
    
    Copied!
  • After: findByPriceAfter

    { 
        "query" : { 
            "bool" : { 
                "must" : [ {
                    "range" : {
                        "price" : {"from" : ?, "to" : null, "include_lower" : true, "include_upper" : true } 
                    } 
                } ] 
            } 
        }
    }
    
    Copied!
  • Like: findByNameLike

    { 
        "query" : { 
            "bool" : { 
                "must" : [ { 
                    "query_string" : { "query" : "?*", "fields" : [ "name" ] }, 
                    "analyze_wildcard": true 
                } ] 
            } 
        }
    }
    
    Copied!
  • StartingWith: findByNameStartingWith

    { 
        "query" : { 
            "bool" : { 
                "must" : [ { 
                    "query_string" : { "query" : "?*", "fields" : [ "name" ] }, 
                    "analyze_wildcard": true 
                } ] 
            } 
        }
    }
    
    Copied!
  • EndingWith: findByNameEndingWith

    { 
        "query" : { 
            "bool" : { 
                "must" : [ { 
                    "query_string" : { "query" : "*?", "fields" : [ "name" ] }, 
                    "analyze_wildcard": true 
                } ] 
            } 
        }
    }
    
    Copied!
  • Contains/Containing: findByNameContaining

    { 
        "query" : { 
            "bool" : { 
                "must" : [ { 
                    "query_string" : { "query" : "*?*", "fields" : [ "name" ] }, 
                    "analyze_wildcard": true 
                } ] 
            } 
        }
    }
    
    Copied!
  • In (when annotated as FieldType.Keyword): findByNameIn(Collection<String>names)

    { 
        "query" : { 
            "bool" : { 
                "must" : [ {
                    "bool" : {
                        "must" : [ {
                            "terms" : {"name" : ["?","?"]}
                        } ] 
                    } 
                } ] 
            } 
        }
    }
    
    Copied!
  • In: findByNameIn(Collection<String>names)

    { "query": {"bool": {"must": [{"query_string":{"query": "\"?\" \"?\"", "fields": ["name"]}}]}}}
    
    Copied!
  • NotIn (when annotated as FieldType.Keyword): findByNameNotIn(Collection<String>names)

    { 
        "query" : { 
            "bool" : { 
                "must" : [ {
                    "bool" : {
                        "must_not" : [ {
                            "terms" : {"name" : ["?","?"]}
                        } ] 
                    } 
                } ] 
            } 
        }
    }
    
    Copied!
  • NotIn: findByNameNotIn(Collection<String>names)

    {
        "query": {
            "bool": {
                "must": [{
                    "query_string": {
                        "query": "NOT(\"?\" \"?\")", "fields": ["name"]
                    }
                }]
            }
        }
    }
    
    Copied!
  • Near: findByStoreNear

    暂不支持

  • True: findByAvailableTrue

    { 
        "query" : { 
            "bool" : { 
                "must" : [ { 
                    "query_string" : { "query" : "true", "fields" : [ "available" ] } 
                } ] 
            } 
        }
    }
    
    Copied!
  • False: findByAvailableFalse

    { 
        "query" : { 
            "bool" : { 
                "must" : [ { 
                    "query_string" : { "query" : "false", "fields" : [ "available" ] } 
                } ] 
            } 
        }
    }
    
    Copied!
  • OrderBy: findByAvailableTrueOrderByNameDesc

    { 
        "query" : { 
            "bool" : { 
                "must" : [ { 
                    "query_string" : { "query" : "true", "fields" : [ "available" ] } 
                } ] 
            } 
        }, 
        "sort":[{
            "name":{"order":"desc"}
        }] 
    }
    
     
posted on 2021-12-08 20:17  迎着风跑  阅读(578)  评论(0编辑  收藏  举报