1.数据聚合聚合（aggregations）可以让我们极其方便的实现对数据的统计、分析、运算。例如：什么品牌的手机最受欢迎？这些手机的平均价格、最高价格、最低价格？这些手机每月的销售情况如何？实现这些统计功能的比数据库的sql要方便的多，而且查询速度非常快，可以实现近实时搜索效果。1.1.聚合的种类聚合常见的有三类：桶（Bucket）聚合：用来对文档做分组TermAggregation：按照文档字段值分组，例如按照品牌值分组、按照国家分组Date Histogram：按照日期阶梯分组，例如一周为一组，或者一月为一组度量（Metric）聚合：用以计算一些值，比如：最大值、最小值、平均值等Avg：求平均值Max：求最大值Min：求最小值Stats：同时求max、min、avg、sum等管道（pipeline）聚合：其它聚合的结果为基础做聚合注意：参加聚合的字段必须是keyword、日期、数值、布尔类型1.2.DSL实现聚合现在，我们要统计所有数据中的酒店品牌有几种，其实就是按照品牌对数据分组。此时可以根据酒店品牌的名称做聚合，也就是Bucket聚合。1.2.1.Bucket聚合语法语法如下：GET /hotel/_search { "size": 0, // 设置size为0，结果中不包含文档，只包含聚合结果 "aggs": { // 定义聚合 "brandAgg": { //给聚合起个名字 "terms": { // 聚合的类型，按照品牌值聚合，所以选择term "field": "brand", // 参与聚合的字段 "size": 20 // 希望获取的聚合结果数量 } } } }结果如图：1.2.2.聚合结果排序默认情况下，Bucket聚合会统计Bucket内的文档数量，记为_count，并且按照_count降序排序。我们可以指定order属性，自定义聚合的排序方式：GET /hotel/_search { "size": 0, "aggs": { "brandAgg": { "terms": { "field": "brand", "order": { "_count": "asc" // 按照_count升序排列 }, "size": 20 } } } }1.2.3.限定聚合范围默认情况下，Bucket聚合是对索引库的所有文档做聚合，但真实场景下，用户会输入搜索条件，因此聚合必须是对搜索结果聚合。那么聚合必须添加限定条件。我们可以限定要聚合的文档范围，只要添加query条件即可：GET /hotel/_search { "query": { "range": { "price": { "lte": 200 // 只对200元以下的文档聚合 } } }, "size": 0, "aggs": { "brandAgg": { "terms": { "field": "brand", "size": 20 } } } }这次，聚合得到的品牌明显变少了：1.2.4.Metric聚合语法上节课，我们对酒店按照品牌分组，形成了一个个桶。现在我们需要对桶内的酒店做运算，获取每个品牌的用户评分的min、max、avg等值。这就要用到Metric聚合了，例如stat聚合：就可以获取min、max、avg等结果。语法如下：GET /hotel/_search { "size": 0, "aggs": { "brandAgg": { "terms": { "field": "brand", "size": 20 }, "aggs": { // 是brands聚合的子聚合，也就是分组后对每组分别计算 "score_stats": { // 聚合名称 "stats": { // 聚合类型，这里stats可以计算min、max、avg等 "field": "score" // 聚合字段，这里是score } } } } } }这次的score_stats聚合是在brandAgg的聚合内部嵌套的子聚合。因为我们需要在每个桶分别计算。另外，我们还可以给聚合结果做个排序，例如按照每个桶的酒店平均分做排序：1.2.5.小结aggs代表聚合，与query同级，此时query的作用是？限定聚合的的文档范围聚合必须的三要素：聚合名称聚合类型聚合字段聚合可配置属性有：size：指定聚合结果数量order：指定聚合结果排序方式field：指定聚合字段1.3.RestAPI实现聚合1.3.1.API语法聚合条件与query条件同级别，因此需要使用request.source()来指定聚合条件。聚合条件的语法：聚合的结果也与查询结果不同，API也比较特殊。不过同样是JSON逐层解析：1.3.2.业务需求需求：搜索页面的品牌、城市等信息不应该是在页面写死，而是通过聚合索引库中的酒店数据得来的：分析：目前，页面的城市列表、星级列表、品牌列表都是写死的，并不会随着搜索结果的变化而变化。但是用户搜索条件改变时，搜索结果会跟着变化。例如：用户搜索“东方明珠”，那搜索的酒店肯定是在上海东方明珠附近，因此，城市只能是上海，此时城市列表中就不应该显示北京、深圳、杭州这些信息了。也就是说，搜索结果中包含哪些城市，页面就应该列出哪些城市；搜索结果中包含哪些品牌，页面就应该列出哪些品牌。如何得知搜索结果中包含哪些品牌？如何得知搜索结果中包含哪些城市？使用聚合功能，利用Bucket聚合，对搜索结果中的文档基于品牌分组、基于城市分组，就能得知包含哪些品牌、哪些城市了。因为是对搜索结果聚合，因此聚合是限定范围的聚合，也就是说聚合的限定条件跟搜索文档的条件一致。查看浏览器可以发现，前端其实已经发出了这样的一个请求：请求参数与搜索文档的参数完全一致。返回值类型就是页面要展示的最终结果：结果是一个Map结构：key是字符串，城市、星级、品牌、价格value是集合，例如多个城市的名称1.3.3.业务实现在cn.itcast.hotel.web包的HotelController中添加一个方法，遵循下面的要求：请求方式：POST请求路径：/hotel/filters请求参数：RequestParams，与搜索文档的参数一致返回值类型：Map<String, List<String>>代码： @PostMapping("filters") public Map<String, List<String>> getFilters(@RequestBody RequestParams params){ return hotelService.getFilters(params); }这里调用了IHotelService中的getFilters方法，尚未实现。在cn.itcast.hotel.service.IHotelService中定义新方法：Map<String, List<String>> filters(RequestParams params);在cn.itcast.hotel.service.impl.HotelService中实现该方法：@Override public Map<String, List<String>> filters(RequestParams params) { try { // 1.准备Request SearchRequest request = new SearchRequest("hotel"); // 2.准备DSL // 2.1.query buildBasicQuery(params, request); // 2.2.设置size request.source().size(0); // 2.3.聚合 buildAggregation(request); // 3.发出请求 SearchResponse response = client.search(request, RequestOptions.DEFAULT); // 4.解析结果 Map<String, List<String>> result = new HashMap<>(); Aggregations aggregations = response.getAggregations(); // 4.1.根据品牌名称，获取品牌结果 List<String> brandList = getAggByName(aggregations, "brandAgg"); result.put("品牌", brandList); // 4.2.根据品牌名称，获取品牌结果 List<String> cityList = getAggByName(aggregations, "cityAgg"); result.put("城市", cityList); // 4.3.根据品牌名称，获取品牌结果 List<String> starList = getAggByName(aggregations, "starAgg"); result.put("星级", starList); return result; } catch (IOException e) { throw new RuntimeException(e); } } private void buildAggregation(SearchRequest request) { request.source().aggregation(AggregationBuilders .terms("brandAgg") .field("brand") .size(100) ); request.source().aggregation(AggregationBuilders .terms("cityAgg") .field("city") .size(100) ); request.source().aggregation(AggregationBuilders .terms("starAgg") .field("starName") .size(100) ); } private List<String> getAggByName(Aggregations aggregations, String aggName) { // 4.1.根据聚合名称获取聚合结果 Terms brandTerms = aggregations.get(aggName); // 4.2.获取buckets List<? extends Terms.Bucket> buckets = brandTerms.getBuckets(); // 4.3.遍历 List<String> brandList = new ArrayList<>(); for (Terms.Bucket bucket : buckets) { // 4.4.获取key String key = bucket.getKeyAsString(); brandList.add(key); } return brandList; }2.自动补全当用户在搜索框输入字符时，我们应该提示出与该字符有关的搜索项，如图：这种根据用户输入的字母，提示完整词条的功能，就是自动补全了。因为需要根据拼音字母来推断，因此要用到拼音分词功能。2.1.拼音分词器要实现根据字母做补全，就必须对文档按照拼音分词。在GitHub上恰好有elasticsearch的拼音分词插件。地址：https://github.com/medcl/elasticsearch-analysis-pinyin课前资料中也提供了拼音分词器的安装包：安装方式与IK分词器一样，分三步：①解压②上传到虚拟机中，elasticsearch的plugin目录③重启elasticsearch④测试详细安装步骤可以参考IK分词器的安装过程。测试用法如下：POST /_analyze { "text": "如家酒店还不错", "analyzer": "pinyin" }结果：2.2.自定义分词器默认的拼音分词器会将每个汉字单独分为拼音，而我们希望的是每个词条形成一组拼音，需要对拼音分词器做个性化定制，形成自定义分词器。elasticsearch中分词器（analyzer）的组成包含三部分：character filters：在tokenizer之前对文本进行处理。例如删除字符、替换字符tokenizer：将文本按照一定的规则切割成词条（term）。例如keyword，就是不分词；还有ik_smarttokenizer filter：将tokenizer输出的词条做进一步处理。例如大小写转换、同义词处理、拼音处理等文档分词时会依次由这三部分来处理文档：声明自定义分词器的语法如下：PUT /test { "settings": { "analysis": { "analyzer": { // 自定义分词器 "my_analyzer": { // 分词器名称 "tokenizer": "ik_max_word", "filter": "py" } }, "filter": { // 自定义tokenizer filter "py": { // 过滤器名称 "type": "pinyin", // 过滤器类型，这里是pinyin "keep_full_pinyin": false, "keep_joined_full_pinyin": true, "keep_original": true, "limit_first_letter_length": 16, "remove_duplicated_term": true, "none_chinese_pinyin_tokenize": false } } } }, "mappings": { "properties": { "name": { "type": "text", "analyzer": "my_analyzer", "search_analyzer": "ik_smart" } } } }测试：总结：如何使用拼音分词器？①下载pinyin分词器②解压并放到elasticsearch的plugin目录③重启即可如何自定义分词器？①创建索引库时，在settings中配置，可以包含三部分②character filter③tokenizer④filter拼音分词器注意事项？为了避免搜索到同音字，搜索时不要使用拼音分词器2.3.自动补全查询elasticsearch提供了Completion Suggester查询来实现自动补全功能。这个查询会匹配以用户输入内容开头的词条并返回。为了提高补全查询的效率，对于文档中字段的类型有一些约束：参与补全查询的字段必须是completion类型。字段的内容一般是用来补全的多个词条形成的数组。比如，一个这样的索引库：// 创建索引库 PUT test { "mappings": { "properties": { "title":{ "type": "completion" } } } }然后插入下面的数据：// 示例数据 POST test/_doc { "title": ["Sony", "WH-1000XM3"] } POST test/_doc { "title": ["SK-II", "PITERA"] } POST test/_doc { "title": ["Nintendo", "switch"] }查询的DSL语句如下：// 自动补全查询 GET /test/_search { "suggest": { "title_suggest": { "text": "s", // 关键字 "completion": { "field": "title", // 补全查询的字段 "skip_duplicates": true, // 跳过重复的 "size": 10 // 获取前10条结果 } } } }2.4.实现酒店搜索框自动补全现在，我们的hotel索引库还没有设置拼音分词器，需要修改索引库中的配置。但是我们知道索引库是无法修改的，只能删除然后重新创建。另外，我们需要添加一个字段，用来做自动补全，将brand、suggestion、city等都放进去，作为自动补全的提示。因此，总结一下，我们需要做的事情包括：修改hotel索引库结构，设置自定义拼音分词器修改索引库的name、all字段，使用自定义分词器索引库添加一个新字段suggestion，类型为completion类型，使用自定义的分词器给HotelDoc类添加suggestion字段，内容包含brand、business重新导入数据到hotel库2.4.1.修改酒店映射结构代码如下：// 酒店数据索引库 PUT /hotel { "settings": { "analysis": { "analyzer": { "text_anlyzer": { "tokenizer": "ik_max_word", "filter": "py" }, "completion_analyzer": { "tokenizer": "keyword", "filter": "py" } }, "filter": { "py": { "type": "pinyin", "keep_full_pinyin": false, "keep_joined_full_pinyin": true, "keep_original": true, "limit_first_letter_length": 16, "remove_duplicated_term": true, "none_chinese_pinyin_tokenize": false } } } }, "mappings": { "properties": { "id":{ "type": "keyword" }, "name":{ "type": "text", "analyzer": "text_anlyzer", "search_analyzer": "ik_smart", "copy_to": "all" }, "address":{ "type": "keyword", "index": false }, "price":{ "type": "integer" }, "score":{ "type": "integer" }, "brand":{ "type": "keyword", "copy_to": "all" }, "city":{ "type": "keyword" }, "starName":{ "type": "keyword" }, "business":{ "type": "keyword", "copy_to": "all" }, "location":{ "type": "geo_point" }, "pic":{ "type": "keyword", "index": false }, "all":{ "type": "text", "analyzer": "text_anlyzer", "search_analyzer": "ik_smart" }, "suggestion":{ "type": "completion", "analyzer": "completion_analyzer" } } } }2.4.2.修改HotelDoc实体HotelDoc中要添加一个字段，用来做自动补全，内容可以是酒店品牌、城市、商圈等信息。按照自动补全字段的要求，最好是这些字段的数组。因此我们在HotelDoc中添加一个suggestion字段，类型为List<String>，然后将brand、city、business等信息放到里面。代码如下：package cn.itcast.hotel.pojo; import lombok.Data; import lombok.NoArgsConstructor; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; @Data @NoArgsConstructor public class HotelDoc { private Long id; private String name; private String address; private Integer price; private Integer score; private String brand; private String city; private String starName; private String business; private String location; private String pic; private Object distance; private Boolean isAD; private List<String> suggestion; public HotelDoc(Hotel hotel) { this.id = hotel.getId(); this.name = hotel.getName(); this.address = hotel.getAddress(); this.price = hotel.getPrice(); this.score = hotel.getScore(); this.brand = hotel.getBrand(); this.city = hotel.getCity(); this.starName = hotel.getStarName(); this.business = hotel.getBusiness(); this.location = hotel.getLatitude() + ", " + hotel.getLongitude(); this.pic = hotel.getPic(); // 组装suggestion if(this.business.contains("/")){ // business有多个值，需要切割 String[] arr = this.business.split("/"); // 添加元素 this.suggestion = new ArrayList<>(); this.suggestion.add(this.brand); Collections.addAll(this.suggestion, arr); }else { this.suggestion = Arrays.asList(this.brand, this.business); } } }2.4.3.重新导入重新执行之前编写的导入数据功能，可以看到新的酒店数据中包含了suggestion：2.4.4.自动补全查询的JavaAPI之前我们学习了自动补全查询的DSL，而没有学习对应的JavaAPI，这里给出一个示例：而自动补全的结果也比较特殊，解析的代码如下：2.4.5.实现搜索框自动补全查看前端页面，可以发现当我们在输入框键入时，前端会发起ajax请求：返回值是补全词条的集合，类型为List<String>1）在cn.itcast.hotel.web包下的HotelController中添加新接口，接收新的请求：@GetMapping("suggestion") public List<String> getSuggestions(@RequestParam("key") String prefix) { return hotelService.getSuggestions(prefix); }2）在cn.itcast.hotel.service包下的IhotelService中添加方法：List<String> getSuggestions(String prefix);3）在cn.itcast.hotel.service.impl.HotelService中实现该方法：@Override public List<String> getSuggestions(String prefix) { try { // 1.准备Request SearchRequest request = new SearchRequest("hotel"); // 2.准备DSL request.source().suggest(new SuggestBuilder().addSuggestion( "suggestions", SuggestBuilders.completionSuggestion("suggestion") .prefix(prefix) .skipDuplicates(true) .size(10) )); // 3.发起请求 SearchResponse response = client.search(request, RequestOptions.DEFAULT); // 4.解析结果 Suggest suggest = response.getSuggest(); // 4.1.根据补全查询名称，获取补全结果 CompletionSuggestion suggestions = suggest.getSuggestion("suggestions"); // 4.2.获取options List<CompletionSuggestion.Entry.Option> options = suggestions.getOptions(); // 4.3.遍历 List<String> list = new ArrayList<>(options.size()); for (CompletionSuggestion.Entry.Option option : options) { String text = option.getText().toString(); list.add(text); } return list; } catch (IOException e) { throw new RuntimeException(e); } }3.数据同步elasticsearch中的酒店数据来自于mysql数据库，因此mysql数据发生改变时，elasticsearch也必须跟着改变，这个就是elasticsearch与mysql之间的数据同步。3.1.思路分析常见的数据同步方案有三种：同步调用异步通知监听binlog3.1.1.同步调用方案一：同步调用基本步骤如下：hotel-demo对外提供接口，用来修改elasticsearch中的数据酒店管理服务在完成数据库操作后，直接调用hotel-demo提供的接口，3.1.2.异步通知方案二：异步通知流程如下：hotel-admin对mysql数据库数据完成增、删、改后，发送MQ消息hotel-demo监听MQ，接收到消息后完成elasticsearch数据修改3.1.3.监听binlog方案三：监听binlog流程如下：给mysql开启binlog功能mysql完成增、删、改操作都会记录在binlog中hotel-demo基于canal监听binlog变化，实时更新elasticsearch中的内容3.1.4.选择方式一：同步调用优点：实现简单，粗暴缺点：业务耦合度高方式二：异步通知优点：低耦合，实现难度一般缺点：依赖mq的可靠性方式三：监听binlog优点：完全解除服务间耦合缺点：开启binlog增加数据库负担、实现复杂度高3.2.实现数据同步3.2.1.思路利用课前资料提供的hotel-admin项目作为酒店管理的微服务。当酒店数据发生增、删、改时，要求对elasticsearch中数据也要完成相同操作。步骤：导入课前资料提供的hotel-admin项目，启动并测试酒店数据的CRUD声明exchange、queue、RoutingKey在hotel-admin中的增、删、改业务中完成消息发送在hotel-demo中完成消息监听，并更新elasticsearch中数据启动并测试数据同步功能3.2.2.导入demo导入课前资料提供的hotel-admin项目：运行后，访问 http://localhost:8099其中包含了酒店的CRUD功能：3.2.3.声明交换机、队列MQ结构如图：1）引入依赖在hotel-admin、hotel-demo中引入rabbitmq的依赖： <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-amqp</artifactId> </dependency>2）声明队列交换机名称在hotel-admin和hotel-demo中的cn.itcast.hotel.constatnts包下新建一个类MqConstants：package cn.itcast.hotel.constatnts; public class MqConstants { /** * 交换机 */ public final static String HOTEL_EXCHANGE = "hotel.topic"; /** * 监听新增和修改的队列 */ public final static String HOTEL_INSERT_QUEUE = "hotel.insert.queue"; /** * 监听删除的队列 */ public final static String HOTEL_DELETE_QUEUE = "hotel.delete.queue"; /** * 新增或修改的RoutingKey */ public final static String HOTEL_INSERT_KEY = "hotel.insert"; /** * 删除的RoutingKey */ public final static String HOTEL_DELETE_KEY = "hotel.delete"; }3）声明队列交换机在hotel-demo中，定义配置类，声明队列、交换机：package cn.itcast.hotel.config; import cn.itcast.hotel.constants.MqConstants; import org.springframework.amqp.core.Binding; import org.springframework.amqp.core.BindingBuilder; import org.springframework.amqp.core.Queue; import org.springframework.amqp.core.TopicExchange; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class MqConfig { @Bean public TopicExchange topicExchange(){ return new TopicExchange(MqConstants.HOTEL_EXCHANGE, true, false); } @Bean public Queue insertQueue(){ return new Queue(MqConstants.HOTEL_INSERT_QUEUE, true); } @Bean public Queue deleteQueue(){ return new Queue(MqConstants.HOTEL_DELETE_QUEUE, true); } @Bean public Binding insertQueueBinding(){ return BindingBuilder.bind(insertQueue()).to(topicExchange()).with(MqConstants.HOTEL_INSERT_KEY); } @Bean public Binding deleteQueueBinding(){ return BindingBuilder.bind(deleteQueue()).to(topicExchange()).with(MqConstants.HOTEL_DELETE_KEY); } }3.2.4.发送MQ消息在hotel-admin中的增、删、改业务中分别发送MQ消息：3.2.5.接收MQ消息hotel-demo接收到MQ消息要做的事情包括：新增消息：根据传递的hotel的id查询hotel信息，然后新增一条数据到索引库删除消息：根据传递的hotel的id删除索引库中的一条数据1）首先在hotel-demo的cn.itcast.hotel.service包下的IHotelService中新增新增、删除业务void deleteById(Long id); void insertById(Long id);2）给hotel-demo中的cn.itcast.hotel.service.impl包下的HotelService中实现业务：@Override public void deleteById(Long id) { try { // 1.准备Request DeleteRequest request = new DeleteRequest("hotel", id.toString()); // 2.发送请求 client.delete(request, RequestOptions.DEFAULT); } catch (IOException e) { throw new RuntimeException(e); } } @Override public void insertById(Long id) { try { // 0.根据id查询酒店数据 Hotel hotel = getById(id); // 转换为文档类型 HotelDoc hotelDoc = new HotelDoc(hotel); // 1.准备Request对象 IndexRequest request = new IndexRequest("hotel").id(hotel.getId().toString()); // 2.准备Json文档 request.source(JSON.toJSONString(hotelDoc), XContentType.JSON); // 3.发送请求 client.index(request, RequestOptions.DEFAULT); } catch (IOException e) { throw new RuntimeException(e); } }3）编写监听器在hotel-demo中的cn.itcast.hotel.mq包新增一个类：package cn.itcast.hotel.mq; import cn.itcast.hotel.constants.MqConstants; import cn.itcast.hotel.service.IHotelService; import org.springframework.amqp.rabbit.annotation.RabbitListener; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @Component public class HotelListener { @Autowired private IHotelService hotelService; /** * 监听酒店新增或修改的业务 * @param id 酒店id */ @RabbitListener(queues = MqConstants.HOTEL_INSERT_QUEUE) public void listenHotelInsertOrUpdate(Long id){ hotelService.insertById(id); } /** * 监听酒店删除的业务 * @param id 酒店id */ @RabbitListener(queues = MqConstants.HOTEL_DELETE_QUEUE) public void listenHotelDelete(Long id){ hotelService.deleteById(id); } }4.集群单机的elasticsearch做数据存储，必然面临两个问题：海量数据存储问题、单点故障问题。海量数据存储问题：将索引库从逻辑上拆分为N个分片（shard），存储到多个节点单点故障问题：将分片数据在不同节点备份（replica ）ES集群相关概念:集群（cluster）：一组拥有共同的 cluster name 的 节点。节点（node) ：集群中的一个 Elasticearch 实例分片（shard）：索引可以被拆分为不同的部分进行存储，称为分片。在集群环境下，一个索引的不同分片可以拆分到不同的节点中解决问题：数据量太大，单点存储量有限的问题。此处，我们把数据分成3片：shard0、shard1、shard2主分片（Primary shard）：相对于副本分片的定义。副本分片（Replica shard）每个主分片可以有一个或者多个副本，数据和主分片一样。数据备份可以保证高可用，但是每个分片备份一份，所需要的节点数量就会翻一倍，成本实在是太高了！为了在高可用和成本间寻求平衡，我们可以这样做：首先对数据分片，存储到不同节点然后对每个分片进行备份，放到对方节点，完成互相备份这样可以大大减少所需要的服务节点数量，如图，我们以3分片，每个分片备份一份为例：现在，每个分片都有1个备份，存储在3个节点：node0：保存了分片0和1node1：保存了分片0和2node2：保存了分片1和24.1.搭建ES集群参考课前资料的文档：其中的第四章节：4.2.集群脑裂问题4.2.1.集群职责划分elasticsearch中集群节点有不同的职责划分：默认情况下，集群中的任何一个节点都同时具备上述四种角色。但是真实的集群一定要将集群职责分离：master节点：对CPU要求高，但是内存要求第data节点：对CPU和内存要求都高coordinating节点：对网络带宽、CPU要求高职责分离可以让我们根据不同节点的需求分配不同的硬件去部署。而且避免业务之间的互相干扰。一个典型的es集群职责划分如图：4.2.2.脑裂问题脑裂是因为集群中的节点失联导致的。例如一个集群中，主节点与其它节点失联：此时，node2和node3认为node1宕机，就会重新选主：当node3当选后，集群继续对外提供服务，node2和node3自成集群，node1自成集群，两个集群数据不同步，出现数据差异。当网络恢复后，因为集群中有两个master节点，集群状态的不一致，出现脑裂的情况：解决脑裂的方案是，要求选票超过 ( eligible节点数量 + 1 ）/ 2 才能当选为主，因此eligible节点数量最好是奇数。对应配置项是discovery.zen.minimum_master_nodes，在es7.0以后，已经成为默认配置，因此一般不会发生脑裂问题例如：3个节点形成的集群，选票必须超过 （3 + 1） / 2 ，也就是2票。node3得到node2和node3的选票，当选为主。node1只有自己1票，没有当选。集群中依然只有1个主节点，没有出现脑裂。4.2.3.小结master eligible节点的作用是什么？参与集群选主主节点可以管理集群状态、管理分片信息、处理创建和删除索引库的请求data节点的作用是什么？数据的CRUDcoordinator节点的作用是什么？路由请求到其它节点合并查询到的结果，返回给用户4.3.集群分布式存储当新增文档时，应该保存到不同分片，保证数据均衡，那么coordinating node如何确定数据该存储到哪个分片呢？4.3.1.分片存储测试插入三条数据：测试可以看到，三条数据分别在不同分片：结果：4.3.2.分片存储原理elasticsearch会通过hash算法来计算文档应该存储到哪个分片：说明：_routing默认是文档的id算法与分片数量有关，因此索引库一旦创建，分片数量不能修改！新增文档的流程如下：解读：1）新增一个id=1的文档2）对id做hash运算，假如得到的是2，则应该存储到shard-23）shard-2的主分片在node3节点，将数据路由到node34）保存文档5）同步给shard-2的副本replica-2，在node2节点6）返回结果给coordinating-node节点4.4.集群分布式查询elasticsearch的查询分成两个阶段：scatter phase：分散阶段，coordinating node会把请求分发到每一个分片gather phase：聚集阶段，coordinating node汇总data node的搜索结果，并处理为最终结果集返回给用户4.5.集群故障转移集群的master节点会监控集群中的节点状态，如果发现有节点宕机，会立即将宕机节点的分片数据迁移到其它节点，确保数据安全，这个叫做故障转移。1）例如一个集群结构如图：现在，node1是主节点，其它两个节点是从节点。2）突然，node1发生了故障：宕机后的第一件事，需要重新选主，例如选中了node2：node2成为主节点后，会检测集群监控状态，发现：shard-1、shard-0没有副本节点。因此需要将node1上的数据迁移到node2、node3：
0.学习目标在昨天的学习中，我们已经导入了大量数据到elasticsearch中，实现了elasticsearch的数据存储功能。但elasticsearch最擅长的还是搜索和数据分析。所以今天，我们研究下elasticsearch的数据搜索功能。我们会分别使用DSL和RestClient实现搜索。1.DSL查询文档elasticsearch的查询依然是基于JSON风格的DSL来实现的。1.1.DSL查询分类Elasticsearch提供了基于JSON的DSL（Domain Specific Language）来定义查询。常见的查询类型包括：查询所有：查询出所有数据，一般测试用。例如：match_all全文检索（full text）查询：利用分词器对用户输入内容分词，然后去倒排索引库中匹配。例如：match_querymulti_match_query精确查询：根据精确词条值查找数据，一般是查找keyword、数值、日期、boolean等类型字段。例如：idsrangeterm地理（geo）查询：根据经纬度查询。例如：geo_distancegeo_bounding_box复合（compound）查询：复合查询可以将上述各种查询条件组合起来，合并查询条件。例如：boolfunction_score查询的语法基本一致：GET /indexName/_search { "query": { "查询类型": { "查询条件": "条件值" } } }我们以查询所有为例，其中：查询类型为match_all没有查询条件// 查询所有 GET /indexName/_search { "query": { "match_all": { } } }其它查询无非就是查询类型、查询条件的变化。1.2.全文检索查询1.2.1.使用场景全文检索查询的基本流程如下：对用户搜索的内容做分词，得到词条根据词条去倒排索引库中匹配，得到文档id根据文档id找到文档，返回给用户比较常用的场景包括：商城的输入框搜索百度输入框搜索例如京东：因为是拿着词条去匹配，因此参与搜索的字段也必须是可分词的text类型的字段。1.2.2.基本语法常见的全文检索查询包括：match查询：单字段查询multi_match查询：多字段查询，任意一个字段符合条件就算符合查询条件match查询语法如下：GET /indexName/_search { "query": { "match": { "FIELD": "TEXT" } } }mulit_match语法如下：GET /indexName/_search { "query": { "multi_match": { "query": "TEXT", "fields": ["FIELD1", " FIELD12"] } } }1.2.3.示例match查询示例：multi_match查询示例：可以看到，两种查询结果是一样的，为什么？因为我们将brand、name、business值都利用copy_to复制到了all字段中。因此你根据三个字段搜索，和根据all字段搜索效果当然一样了。但是，搜索字段越多，对查询性能影响越大，因此建议采用copy_to，然后单字段查询的方式。1.2.4.总结match和multi_match的区别是什么？match：根据一个字段查询multi_match：根据多个字段查询，参与查询字段越多，查询性能越差1.3.精准查询精确查询一般是查找keyword、数值、日期、boolean等类型字段。所以不会对搜索条件分词。常见的有：term：根据词条精确值查询range：根据值的范围查询1.3.1.term查询因为精确查询的字段搜是不分词的字段，因此查询的条件也必须是不分词的词条。查询时，用户输入的内容跟自动值完全匹配时才认为符合条件。如果用户输入的内容过多，反而搜索不到数据。语法说明：// term查询 GET /indexName/_search { "query": { "term": { "FIELD": { "value": "VALUE" } } } }示例：当我搜索的是精确词条时，能正确查询出结果：但是，当我搜索的内容不是词条，而是多个词语形成的短语时，反而搜索不到：1.3.2.range查询范围查询，一般应用在对数值类型做范围过滤的时候。比如做价格范围过滤。基本语法：// range查询 GET /indexName/_search { "query": { "range": { "FIELD": { "gte": 10, // 这里的gte代表大于等于，gt则代表大于 "lte": 20 // lte代表小于等于，lt则代表小于 } } } }示例：1.3.3.总结精确查询常见的有哪些？term查询：根据词条精确匹配，一般搜索keyword类型、数值类型、布尔类型、日期类型字段range查询：根据数值范围查询，可以是数值、日期的范围1.4.地理坐标查询所谓的地理坐标查询，其实就是根据经纬度查询，官方文档：https://www.elastic.co/guide/en/elasticsearch/reference/current/geo-queries.html常见的使用场景包括：携程：搜索我附近的酒店滴滴：搜索我附近的出租车微信：搜索我附近的人附近的酒店：附近的车：1.4.1.矩形范围查询矩形范围查询，也就是geo_bounding_box查询，查询坐标落在某个矩形范围的所有文档：查询时，需要指定矩形的左上、右下两个点的坐标，然后画出一个矩形，落在该矩形内的都是符合条件的点。语法如下：// geo_bounding_box查询 GET /indexName/_search { "query": { "geo_bounding_box": { "FIELD": { "top_left": { // 左上点 "lat": 31.1, "lon": 121.5 }, "bottom_right": { // 右下点 "lat": 30.9, "lon": 121.7 } } } } }这种并不符合“附近的人”这样的需求，所以我们就不做了。1.4.2.附近查询附近查询，也叫做距离查询（geo_distance）：查询到指定中心点小于某个距离值的所有文档。换句话来说，在地图上找一个点作为圆心，以指定距离为半径，画一个圆，落在圆内的坐标都算符合条件：语法说明：// geo_distance 查询 GET /indexName/_search { "query": { "geo_distance": { "distance": "15km", // 半径 "FIELD": "31.21,121.5" // 圆心 } } }示例：我们先搜索陆家嘴附近15km的酒店：发现共有47家酒店。然后把半径缩短到3公里：可以发现，搜索到的酒店数量减少到了5家。1.5.复合查询复合（compound）查询：复合查询可以将其它简单查询组合起来，实现更复杂的搜索逻辑。常见的有两种：fuction score：算分函数查询，可以控制文档相关性算分，控制文档排名bool query：布尔查询，利用逻辑关系组合多个其它的查询，实现复杂搜索1.5.1.相关性算分当我们利用match查询时，文档结果会根据与搜索词条的关联度打分（_score），返回结果时按照分值降序排列。例如，我们搜索 "虹桥如家"，结果如下：[ { "_score" : 17.850193, "_source" : { "name" : "虹桥如家酒店真不错", } }, { "_score" : 12.259849, "_source" : { "name" : "外滩如家酒店真不错", } }, { "_score" : 11.91091, "_source" : { "name" : "迪士尼如家酒店真不错", } } ]在elasticsearch中，早期使用的打分算法是TF-IDF算法，公式如下：在后来的5.1版本升级中，elasticsearch将算法改进为BM25算法，公式如下：TF-IDF算法有一各缺陷，就是词条频率越高，文档得分也会越高，单个词条对文档影响较大。而BM25则会让单个词条的算分有一个上限，曲线更加平滑：小结：elasticsearch会根据词条和文档的相关度做打分，算法由两种：TF-IDF算法BM25算法，elasticsearch5.1版本后采用的算法1.5.2.算分函数查询根据相关度打分是比较合理的需求，但合理的不一定是产品经理需要的。以百度为例，你搜索的结果中，并不是相关度越高排名越靠前，而是谁掏的钱多排名就越靠前。如图：要想认为控制相关性算分，就需要利用elasticsearch中的function score 查询了。1）语法说明function score 查询中包含四部分内容：原始查询条件：query部分，基于这个条件搜索文档，并且基于BM25算法给文档打分，原始算分（query score)过滤条件：filter部分，符合该条件的文档才会重新算分算分函数：符合filter条件的文档要根据这个函数做运算，得到的函数算分（function score），有四种函数weight：函数结果是常量field_value_factor：以文档中的某个字段值作为函数结果random_score：以随机数作为函数结果script_score：自定义算分函数算法运算模式：算分函数的结果、原始查询的相关性算分，两者之间的运算方式，包括：multiply：相乘replace：用function score替换query score其它，例如：sum、avg、max、minfunction score的运行流程如下：1）根据原始条件查询搜索文档，并且计算相关性算分，称为原始算分（query score）2）根据过滤条件，过滤文档3）符合过滤条件的文档，基于算分函数运算，得到函数算分（function score）4）将原始算分（query score）和函数算分（function score）基于运算模式做运算，得到最终结果，作为相关性算分。因此，其中的关键点是：过滤条件：决定哪些文档的算分被修改算分函数：决定函数算分的算法运算模式：决定最终算分结果2）示例需求：给“如家”这个品牌的酒店排名靠前一些翻译一下这个需求，转换为之前说的四个要点：原始条件：不确定，可以任意变化过滤条件：brand = "如家"算分函数：可以简单粗暴，直接给固定的算分结果，weight运算模式：比如求和因此最终的DSL语句如下：GET /hotel/_search { "query": { "function_score": { "query": { .... }, // 原始查询，可以是任意条件 "functions": [ // 算分函数 { "filter": { // 满足的条件，品牌必须是如家 "term": { "brand": "如家" } }, "weight": 2 // 算分权重为2 } ], "boost_mode": "sum" // 加权模式，求和 } } }测试，在未添加算分函数时，如家得分如下：添加了算分函数后，如家得分就提升了：3）小结function score query定义的三要素是什么？过滤条件：哪些文档要加分算分函数：如何计算function score加权方式：function score 与 query score如何运算1.5.3.布尔查询布尔查询是一个或多个查询子句的组合，每一个子句就是一个子查询。子查询的组合方式有：must：必须匹配每个子查询，类似“与”should：选择性匹配子查询，类似“或”must_not：必须不匹配，不参与算分，类似“非”filter：必须匹配，不参与算分比如在搜索酒店时，除了关键字搜索外，我们还可能根据品牌、价格、城市等字段做过滤：每一个不同的字段，其查询的条件、方式都不一样，必须是多个不同的查询，而要组合这些查询，就必须用bool查询了。需要注意的是，搜索时，参与打分的字段越多，查询的性能也越差。因此这种多条件查询时，建议这样做：搜索框的关键字搜索，是全文检索查询，使用must查询，参与算分其它过滤条件，采用filter查询。不参与算分1）语法示例：GET /hotel/_search { "query": { "bool": { "must": [ {"term": {"city": "上海" }} ], "should": [ {"term": {"brand": "皇冠假日" }}, {"term": {"brand": "华美达" }} ], "must_not": [ { "range": { "price": { "lte": 500 } }} ], "filter": [ { "range": {"score": { "gte": 45 } }} ] } } }2）示例需求：搜索名字包含“如家”，价格不高于400，在坐标31.21,121.5周围10km范围内的酒店。分析：名称搜索，属于全文检索查询，应该参与算分。放到must中价格不高于400，用range查询，属于过滤条件，不参与算分。放到must_not中周围10km范围内，用geo_distance查询，属于过滤条件，不参与算分。放到filter中3）小结bool查询有几种逻辑关系？must：必须匹配的条件，可以理解为“与”should：选择性匹配的条件，可以理解为“或”must_not：必须不匹配的条件，不参与打分filter：必须匹配的条件，不参与打分2.搜索结果处理搜索的结果可以按照用户指定的方式去处理或展示。2.1.排序elasticsearch默认是根据相关度算分（_score）来排序，但是也支持自定义方式对搜索结果排序。可以排序字段类型有：keyword类型、数值类型、地理坐标类型、日期类型等。2.1.1.普通字段排序keyword、数值、日期类型排序的语法基本一致。语法：GET /indexName/_search { "query": { "match_all": {} }, "sort": [ { "FIELD": "desc" // 排序字段、排序方式ASC、DESC } ] }排序条件是一个数组，也就是可以写多个排序条件。按照声明的顺序，当第一个条件相等时，再按照第二个条件排序，以此类推示例：需求描述：酒店数据按照用户评价（score)降序排序，评价相同的按照价格(price)升序排序2.1.2.地理坐标排序地理坐标排序略有不同。语法说明：GET /indexName/_search { "query": { "match_all": {} }, "sort": [ { "_geo_distance" : { "FIELD" : "纬度，经度", // 文档中geo_point类型的字段名、目标坐标点 "order" : "asc", // 排序方式 "unit" : "km" // 排序的距离单位 } } ] }这个查询的含义是：指定一个坐标，作为目标点计算每一个文档中，指定字段（必须是geo_point类型）的坐标 到目标点的距离是多少根据距离排序示例：需求描述：实现对酒店数据按照到你的位置坐标的距离升序排序提示：获取你的位置的经纬度的方式：https://lbs.amap.com/demo/jsapi-v2/example/map/click-to-get-lnglat/假设我的位置是：31.034661，121.612282，寻找我周围距离最近的酒店。2.2.分页elasticsearch 默认情况下只返回top10的数据。而如果要查询更多数据就需要修改分页参数了。elasticsearch中通过修改from、size参数来控制要返回的分页结果：from：从第几个文档开始size：总共查询几个文档类似于mysql中的limit ?, ?2.2.1.基本的分页分页的基本语法如下：GET /hotel/_search { "query": { "match_all": {} }, "from": 0, // 分页开始的位置，默认为0 "size": 10, // 期望获取的文档总数 "sort": [ {"price": "asc"} ] }2.2.2.深度分页问题现在，我要查询990~1000的数据，查询逻辑要这么写：GET /hotel/_search { "query": { "match_all": {} }, "from": 990, // 分页开始的位置，默认为0 "size": 10, // 期望获取的文档总数 "sort": [ {"price": "asc"} ] }这里是查询990开始的数据，也就是 第990~第1000条 数据。不过，elasticsearch内部分页时，必须先查询 0~1000条，然后截取其中的990 ~ 1000的这10条：查询TOP1000，如果es是单点模式，这并无太大影响。但是elasticsearch将来一定是集群，例如我集群有5个节点，我要查询TOP1000的数据，并不是每个节点查询200条就可以了。因为节点A的TOP200，在另一个节点可能排到10000名以外了。因此要想获取整个集群的TOP1000，必须先查询出每个节点的TOP1000，汇总结果后，重新排名，重新截取TOP1000。那如果我要查询9900~10000的数据呢？是不是要先查询TOP10000呢？那每个节点都要查询10000条？汇总到内存中？当查询分页深度较大时，汇总数据过多，对内存和CPU会产生非常大的压力，因此elasticsearch会禁止from+ size 超过10000的请求。针对深度分页，ES提供了两种解决方案，官方文档：search after：分页时需要排序，原理是从上一次的排序值开始，查询下一页数据。官方推荐使用的方式。scroll：原理将排序后的文档id形成快照，保存在内存。官方已经不推荐使用。2.2.3.小结分页查询的常见实现方案以及优缺点：from + size：优点：支持随机翻页缺点：深度分页问题，默认查询上限（from + size）是10000场景：百度、京东、谷歌、淘宝这样的随机翻页搜索after search：优点：没有查询上限（单次查询的size不超过10000）缺点：只能向后逐页查询，不支持随机翻页场景：没有随机翻页需求的搜索，例如手机向下滚动翻页scroll：优点：没有查询上限（单次查询的size不超过10000）缺点：会有额外内存消耗，并且搜索结果是非实时的场景：海量数据的获取和迁移。从ES7.1开始不推荐，建议用 after search方案。2.3.高亮2.3.1.高亮原理什么是高亮显示呢？我们在百度，京东搜索时，关键字会变成红色，比较醒目，这叫高亮显示：高亮显示的实现分为两步：1）给文档中的所有关键字都添加一个标签，例如标签2）页面给标签编写CSS样式2.3.2.实现高亮高亮的语法：GET /hotel/_search { "query": { "match": { "FIELD": "TEXT" // 查询条件，高亮一定要使用全文检索查询 } }, "highlight": { "fields": { // 指定要高亮的字段 "FIELD": { "pre_tags": "", // 用来标记高亮字段的前置标签 "post_tags": "" // 用来标记高亮字段的后置标签 } } } }注意：高亮是对关键字高亮，因此搜索条件必须带有关键字，而不能是范围这样的查询。默认情况下，高亮的字段，必须与搜索指定的字段一致，否则无法高亮如果要对非搜索字段高亮，则需要添加一个属性：required_field_match=false示例：2.4.总结查询的DSL是一个大的JSON对象，包含下列属性：query：查询条件from和size：分页条件sort：排序条件highlight：高亮条件示例：3.RestClient查询文档文档的查询同样适用昨天学习的 RestHighLevelClient对象，基本步骤包括：1）准备Request对象2）准备请求参数3）发起请求4）解析响应3.1.快速入门我们以match_all查询为例3.1.1.发起查询请求代码解读：第一步，创建SearchRequest对象，指定索引库名第二步，利用request.source()构建DSL，DSL中可以包含查询、分页、排序、高亮等query()：代表查询条件，利用QueryBuilders.matchAllQuery()构建一个match_all查询的DSL第三步，利用client.search()发送请求，得到响应这里关键的API有两个，一个是request.source()，其中包含了查询、排序、分页、高亮等所有功能：另一个是QueryBuilders，其中包含match、term、function_score、bool等各种查询：3.1.2.解析响应响应结果的解析：elasticsearch返回的结果是一个JSON字符串，结构包含：hits：命中的结果total：总条数，其中的value是具体的总条数值max_score：所有结果中得分最高的文档的相关性算分hits：搜索结果的文档数组，其中的每个文档都是一个json对象_source：文档中的原始数据，也是json对象因此，我们解析响应结果，就是逐层解析JSON字符串，流程如下：SearchHits：通过response.getHits()获取，就是JSON中的最外层的hits，代表命中的结果SearchHits#getTotalHits().value：获取总条数信息SearchHits#getHits()：获取SearchHit数组，也就是文档数组SearchHit#getSourceAsString()：获取文档结果中的_source，也就是原始的json文档数据3.1.3.完整代码完整代码如下：@Test void testMatchAll() throws IOException { // 1.准备Request SearchRequest request = new SearchRequest("hotel"); // 2.准备DSL request.source() .query(QueryBuilders.matchAllQuery()); // 3.发送请求 SearchResponse response = client.search(request, RequestOptions.DEFAULT); // 4.解析响应 handleResponse(response); } private void handleResponse(SearchResponse response) { // 4.解析响应 SearchHits searchHits = response.getHits(); // 4.1.获取总条数 long total = searchHits.getTotalHits().value; System.out.println("共搜索到" + total + "条数据"); // 4.2.文档数组 SearchHit[] hits = searchHits.getHits(); // 4.3.遍历 for (SearchHit hit : hits) { // 获取文档source String json = hit.getSourceAsString(); // 反序列化 HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class); System.out.println("hotelDoc = " + hotelDoc); } }3.1.4.小结查询的基本步骤是：创建SearchRequest对象准备Request.source()，也就是DSL。① QueryBuilders来构建查询条件② 传入Request.source() 的 query() 方法发送请求，得到结果解析结果（参考JSON结果，从外到内，逐层解析）3.2.match查询全文检索的match和multi_match查询与match_all的API基本一致。差别是查询条件，也就是query的部分。因此，Java代码上的差异主要是request.source().query()中的参数了。同样是利用QueryBuilders提供的方法：而结果解析代码则完全一致，可以抽取并共享。完整代码如下：@Test void testMatch() throws IOException { // 1.准备Request SearchRequest request = new SearchRequest("hotel"); // 2.准备DSL request.source() .query(QueryBuilders.matchQuery("all", "如家")); // 3.发送请求 SearchResponse response = client.search(request, RequestOptions.DEFAULT); // 4.解析响应 handleResponse(response); }3.3.精确查询精确查询主要是两者：term：词条精确匹配range：范围查询与之前的查询相比，差异同样在查询条件，其它都一样。查询条件构造的API如下：3.4.布尔查询布尔查询是用must、must_not、filter等方式组合其它查询，代码示例如下：可以看到，API与其它查询的差别同样是在查询条件的构建，QueryBuilders，结果解析等其他代码完全不变。完整代码如下：@Test void testBool() throws IOException { // 1.准备Request SearchRequest request = new SearchRequest("hotel"); // 2.准备DSL // 2.1.准备BooleanQuery BoolQueryBuilder boolQuery = QueryBuilders.boolQuery(); // 2.2.添加term boolQuery.must(QueryBuilders.termQuery("city", "杭州")); // 2.3.添加range boolQuery.filter(QueryBuilders.rangeQuery("price").lte(250)); request.source().query(boolQuery); // 3.发送请求 SearchResponse response = client.search(request, RequestOptions.DEFAULT); // 4.解析响应 handleResponse(response); }3.5.排序、分页搜索结果的排序和分页是与query同级的参数，因此同样是使用request.source()来设置。对应的API如下：完整代码示例：@Test void testPageAndSort() throws IOException { // 页码，每页大小 int page = 1, size = 5; // 1.准备Request SearchRequest request = new SearchRequest("hotel"); // 2.准备DSL // 2.1.query request.source().query(QueryBuilders.matchAllQuery()); // 2.2.排序 sort request.source().sort("price", SortOrder.ASC); // 2.3.分页 from、size request.source().from((page - 1) * size).size(5); // 3.发送请求 SearchResponse response = client.search(request, RequestOptions.DEFAULT); // 4.解析响应 handleResponse(response); }3.6.高亮高亮的代码与之前代码差异较大，有两点：查询的DSL：其中除了查询条件，还需要添加高亮条件，同样是与query同级。结果解析：结果除了要解析_source文档数据，还要解析高亮结果3.6.1.高亮请求构建高亮请求的构建API如下：上述代码省略了查询条件部分，但是大家不要忘了：高亮查询必须使用全文检索查询，并且要有搜索关键字，将来才可以对关键字高亮。完整代码如下：@Test void testHighlight() throws IOException { // 1.准备Request SearchRequest request = new SearchRequest("hotel"); // 2.准备DSL // 2.1.query request.source().query(QueryBuilders.matchQuery("all", "如家")); // 2.2.高亮 request.source().highlighter(new HighlightBuilder().field("name").requireFieldMatch(false)); // 3.发送请求 SearchResponse response = client.search(request, RequestOptions.DEFAULT); // 4.解析响应 handleResponse(response); }3.6.2.高亮结果解析高亮的结果与查询的文档结果默认是分离的，并不在一起。因此解析高亮的代码需要额外处理：代码解读：第一步：从结果中获取source。hit.getSourceAsString()，这部分是非高亮结果，json字符串。还需要反序列为HotelDoc对象第二步：获取高亮结果。hit.getHighlightFields()，返回值是一个Map，key是高亮字段名称，值是HighlightField对象，代表高亮值第三步：从map中根据高亮字段名称，获取高亮字段值对象HighlightField第四步：从HighlightField中获取Fragments，并且转为字符串。这部分就是真正的高亮字符串了第五步：用高亮的结果替换HotelDoc中的非高亮结果完整代码如下：private void handleResponse(SearchResponse response) { // 4.解析响应 SearchHits searchHits = response.getHits(); // 4.1.获取总条数 long total = searchHits.getTotalHits().value; System.out.println("共搜索到" + total + "条数据"); // 4.2.文档数组 SearchHit[] hits = searchHits.getHits(); // 4.3.遍历 for (SearchHit hit : hits) { // 获取文档source String json = hit.getSourceAsString(); // 反序列化 HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class); // 获取高亮结果 Map<String, HighlightField> highlightFields = hit.getHighlightFields(); if (!CollectionUtils.isEmpty(highlightFields)) { // 根据字段名获取高亮结果 HighlightField highlightField = highlightFields.get("name"); if (highlightField != null) { // 获取高亮值 String name = highlightField.getFragments()[0].string(); // 覆盖非高亮结果 hotelDoc.setName(name); } } System.out.println("hotelDoc = " + hotelDoc); } }4.黑马旅游案例下面，我们通过黑马旅游的案例来实战演练下之前学习的知识。我们实现四部分功能：酒店搜索和分页酒店结果过滤我周边的酒店酒店竞价排名启动我们提供的hotel-demo项目，其默认端口是8089，访问http://localhost:8090，就能看到项目页面了：4.1.酒店搜索和分页案例需求：实现黑马旅游的酒店搜索功能，完成关键字搜索和分页4.1.1.需求分析在项目的首页，有一个大大的搜索框，还有分页按钮：点击搜索按钮，可以看到浏览器控制台发出了请求：请求参数如下：由此可以知道，我们这个请求的信息如下：请求方式：POST请求路径：/hotel/list请求参数：JSON对象，包含4个字段：key：搜索关键字page：页码size：每页大小sortBy：排序，目前暂不实现返回值：分页查询，需要返回分页结果PageResult，包含两个属性：total：总条数List<HotelDoc>：当前页的数据因此，我们实现业务的流程如下：步骤一：定义实体类，接收请求参数的JSON对象步骤二：编写controller，接收页面的请求步骤三：编写业务实现，利用RestHighLevelClient实现搜索、分页4.1.2.定义实体类实体类有两个，一个是前端的请求参数实体，一个是服务端应该返回的响应结果实体。1）请求参数前端请求的json结构如下：{ "key": "搜索关键字", "page": 1, "size": 3, "sortBy": "default" }因此，我们在cn.itcast.hotel.pojo包下定义一个实体类：package cn.itcast.hotel.pojo; import lombok.Data; @Data public class RequestParams { private String key; private Integer page; private Integer size; private String sortBy; }2）返回值分页查询，需要返回分页结果PageResult，包含两个属性：total：总条数List<HotelDoc>：当前页的数据因此，我们在cn.itcast.hotel.pojo中定义返回结果：package cn.itcast.hotel.pojo; import lombok.Data; import java.util.List; @Data public class PageResult { private Long total; private List<HotelDoc> hotels; public PageResult() { } public PageResult(Long total, List<HotelDoc> hotels) { this.total = total; this.hotels = hotels; } }4.1.3.定义controller定义一个HotelController，声明查询接口，满足下列要求：请求方式：Post请求路径：/hotel/list请求参数：对象，类型为RequestParam返回值：PageResult，包含两个属性Long total：总条数List<HotelDoc> hotels：酒店数据因此，我们在cn.itcast.hotel.web中定义HotelController：@RestController @RequestMapping("/hotel") public class HotelController { @Autowired private IHotelService hotelService; // 搜索酒店数据 @PostMapping("/list") public PageResult search(@RequestBody RequestParams params){ return hotelService.search(params); } }4.1.4.实现搜索业务我们在controller调用了IHotelService，并没有实现该方法，因此下面我们就在IHotelService中定义方法，并且去实现业务逻辑。1）在cn.itcast.hotel.service中的IHotelService接口中定义一个方法：/** * 根据关键字搜索酒店信息 * @param params 请求参数对象，包含用户输入的关键字 * @return 酒店文档列表 */ PageResult search(RequestParams params);2）实现搜索业务，肯定离不开RestHighLevelClient，我们需要把它注册到Spring中作为一个Bean。在cn.itcast.hotel中的HotelDemoApplication中声明这个Bean：@Bean public RestHighLevelClient client(){ return new RestHighLevelClient(RestClient.builder( HttpHost.create("http://192.168.150.101:9200") )); }3）在cn.itcast.hotel.service.impl中的HotelService中实现search方法：@Override public PageResult search(RequestParams params) { try { // 1.准备Request SearchRequest request = new SearchRequest("hotel"); // 2.准备DSL // 2.1.query String key = params.getKey(); if (key == null || "".equals(key)) { request.source().query(QueryBuilders.matchAllQuery()); } else { request.source().query(QueryBuilders.matchQuery("all", key)); } // 2.2.分页 int page = params.getPage(); int size = params.getSize(); request.source().from((page - 1) * size).size(size); // 3.发送请求 SearchResponse response = client.search(request, RequestOptions.DEFAULT); // 4.解析响应 return handleResponse(response); } catch (IOException e) { throw new RuntimeException(e); } } // 结果解析 private PageResult handleResponse(SearchResponse response) { // 4.解析响应 SearchHits searchHits = response.getHits(); // 4.1.获取总条数 long total = searchHits.getTotalHits().value; // 4.2.文档数组 SearchHit[] hits = searchHits.getHits(); // 4.3.遍历 List<HotelDoc> hotels = new ArrayList<>(); for (SearchHit hit : hits) { // 获取文档source String json = hit.getSourceAsString(); // 反序列化 HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class); // 放入集合 hotels.add(hotelDoc); } // 4.4.封装返回 return new PageResult(total, hotels); }4.2.酒店结果过滤需求：添加品牌、城市、星级、价格等过滤功能4.2.1.需求分析在页面搜索框下面，会有一些过滤项：传递的参数如图：包含的过滤条件有：brand：品牌值city：城市minPrice~maxPrice：价格范围starName：星级我们需要做两件事情：修改请求参数的对象RequestParams，接收上述参数修改业务逻辑，在搜索条件之外，添加一些过滤条件4.2.2.修改实体类修改在cn.itcast.hotel.pojo包下的实体类RequestParams：@Data public class RequestParams { private String key; private Integer page; private Integer size; private String sortBy; // 下面是新增的过滤条件参数 private String city; private String brand; private String starName; private Integer minPrice; private Integer maxPrice; }4.2.3.修改搜索业务在HotelService的search方法中，只有一个地方需要修改：requet.source().query( ... )其中的查询条件。在之前的业务中，只有match查询，根据关键字搜索，现在要添加条件过滤，包括：品牌过滤：是keyword类型，用term查询星级过滤：是keyword类型，用term查询价格过滤：是数值类型，用range查询城市过滤：是keyword类型，用term查询多个查询条件组合，肯定是boolean查询来组合：关键字搜索放到must中，参与算分其它过滤条件放到filter中，不参与算分因为条件构建的逻辑比较复杂，这里先封装为一个函数：buildBasicQuery的代码如下：private void buildBasicQuery(RequestParams params, SearchRequest request) { // 1.构建BooleanQuery BoolQueryBuilder boolQuery = QueryBuilders.boolQuery(); // 2.关键字搜索 String key = params.getKey(); if (key == null || "".equals(key)) { boolQuery.must(QueryBuilders.matchAllQuery()); } else { boolQuery.must(QueryBuilders.matchQuery("all", key)); } // 3.城市条件 if (params.getCity() != null && !params.getCity().equals("")) { boolQuery.filter(QueryBuilders.termQuery("city", params.getCity())); } // 4.品牌条件 if (params.getBrand() != null && !params.getBrand().equals("")) { boolQuery.filter(QueryBuilders.termQuery("brand", params.getBrand())); } // 5.星级条件 if (params.getStarName() != null && !params.getStarName().equals("")) { boolQuery.filter(QueryBuilders.termQuery("starName", params.getStarName())); } // 6.价格 if (params.getMinPrice() != null && params.getMaxPrice() != null) { boolQuery.filter(QueryBuilders .rangeQuery("price") .gte(params.getMinPrice()) .lte(params.getMaxPrice()) ); } // 7.放入source request.source().query(boolQuery); }4.3.我周边的酒店需求：我附近的酒店4.3.1.需求分析在酒店列表页的右侧，有一个小地图，点击地图的定位按钮，地图会找到你所在的位置：并且，在前端会发起查询请求，将你的坐标发送到服务端：我们要做的事情就是基于这个location坐标，然后按照距离对周围酒店排序。实现思路如下：修改RequestParams参数，接收location字段修改search方法业务逻辑，如果location有值，添加根据geo_distance排序的功能4.3.2.修改实体类修改在cn.itcast.hotel.pojo包下的实体类RequestParams：package cn.itcast.hotel.pojo; import lombok.Data; @Data public class RequestParams { private String key; private Integer page; private Integer size; private String sortBy; private String city; private String brand; private String starName; private Integer minPrice; private Integer maxPrice; // 我当前的地理坐标 private String location; }4.3.3.距离排序API我们以前学习过排序功能，包括两种：普通字段排序地理坐标排序我们只讲了普通字段排序对应的java写法。地理坐标排序只学过DSL语法，如下：GET /indexName/_search { "query": { "match_all": {} }, "sort": [ { "price": "asc" }, { "_geo_distance" : { "FIELD" : "纬度，经度", "order" : "asc", "unit" : "km" } } ] }对应的java代码示例：4.3.4.添加距离排序在cn.itcast.hotel.service.impl的HotelService的search方法中，添加一个排序功能：完整代码：@Override public PageResult search(RequestParams params) { try { // 1.准备Request SearchRequest request = new SearchRequest("hotel"); // 2.准备DSL // 2.1.query buildBasicQuery(params, request); // 2.2.分页 int page = params.getPage(); int size = params.getSize(); request.source().from((page - 1) * size).size(size); // 2.3.排序 String location = params.getLocation(); if (location != null && !location.equals("")) { request.source().sort(SortBuilders .geoDistanceSort("location", new GeoPoint(location)) .order(SortOrder.ASC) .unit(DistanceUnit.KILOMETERS) ); } // 3.发送请求 SearchResponse response = client.search(request, RequestOptions.DEFAULT); // 4.解析响应 return handleResponse(response); } catch (IOException e) { throw new RuntimeException(e); } }4.3.5.排序距离显示重启服务后，测试我的酒店功能：发现确实可以实现对我附近酒店的排序，不过并没有看到酒店到底距离我多远，这该怎么办？排序完成后，页面还要获取我附近每个酒店的具体距离值，这个值在响应结果中是独立的：因此，我们在结果解析阶段，除了解析source部分以外，还要得到sort部分，也就是排序的距离，然后放到响应结果中。我们要做两件事：修改HotelDoc，添加排序距离字段，用于页面显示修改HotelService类中的handleResponse方法，添加对sort值的获取1）修改HotelDoc类，添加距离字段package cn.itcast.hotel.pojo; import lombok.Data; import lombok.NoArgsConstructor; @Data @NoArgsConstructor public class HotelDoc { private Long id; private String name; private String address; private Integer price; private Integer score; private String brand; private String city; private String starName; private String business; private String location; private String pic; // 排序时的 距离值 private Object distance; public HotelDoc(Hotel hotel) { this.id = hotel.getId(); this.name = hotel.getName(); this.address = hotel.getAddress(); this.price = hotel.getPrice(); this.score = hotel.getScore(); this.brand = hotel.getBrand(); this.city = hotel.getCity(); this.starName = hotel.getStarName(); this.business = hotel.getBusiness(); this.location = hotel.getLatitude() + ", " + hotel.getLongitude(); this.pic = hotel.getPic(); } }2）修改HotelService中的handleResponse方法重启后测试，发现页面能成功显示距离了：4.4.酒店竞价排名需求：让指定的酒店在搜索结果中排名置顶4.4.1.需求分析要让指定酒店在搜索结果中排名置顶，效果如图：页面会给指定的酒店添加广告标记。那怎样才能让指定的酒店排名置顶呢？我们之前学习过的function_score查询可以影响算分，算分高了，自然排名也就高了。而function_score包含3个要素：过滤条件：哪些文档要加分算分函数：如何计算function score加权方式：function score 与 query score如何运算这里的需求是：让指定酒店排名靠前。因此我们需要给这些酒店添加一个标记，这样在过滤条件中就可以根据这个标记来判断，是否要提高算分。比如，我们给酒店添加一个字段：isAD，Boolean类型：true：是广告false：不是广告这样function_score包含3个要素就很好确定了：过滤条件：判断isAD 是否为true算分函数：我们可以用最简单暴力的weight，固定加权值加权方式：可以用默认的相乘，大大提高算分因此，业务的实现步骤包括：给HotelDoc类添加isAD字段，Boolean类型挑选几个你喜欢的酒店，给它的文档数据添加isAD字段，值为true修改search方法，添加function score功能，给isAD值为true的酒店增加权重4.4.2.修改HotelDoc实体给cn.itcast.hotel.pojo包下的HotelDoc类添加isAD字段：4.4.3.添加广告标记接下来，我们挑几个酒店，添加isAD字段，设置为true：POST /hotel/_update/1902197537 { "doc": { "isAD": true } } POST /hotel/_update/2056126831 { "doc": { "isAD": true } } POST /hotel/_update/1989806195 { "doc": { "isAD": true } } POST /hotel/_update/2056105938 { "doc": { "isAD": true } }4.4.4.添加算分函数查询接下来我们就要修改查询条件了。之前是用的boolean 查询，现在要改成function_socre查询。function_score查询结构如下：对应的JavaAPI如下：我们可以将之前写的boolean查询作为原始查询条件放到query中，接下来就是添加过滤条件、算分函数、加权模式了。所以原来的代码依然可以沿用。修改cn.itcast.hotel.service.impl包下的HotelService类中的buildBasicQuery方法，添加算分函数查询：private void buildBasicQuery(RequestParams params, SearchRequest request) { // 1.构建BooleanQuery BoolQueryBuilder boolQuery = QueryBuilders.boolQuery(); // 关键字搜索 String key = params.getKey(); if (key == null || "".equals(key)) { boolQuery.must(QueryBuilders.matchAllQuery()); } else { boolQuery.must(QueryBuilders.matchQuery("all", key)); } // 城市条件 if (params.getCity() != null && !params.getCity().equals("")) { boolQuery.filter(QueryBuilders.termQuery("city", params.getCity())); } // 品牌条件 if (params.getBrand() != null && !params.getBrand().equals("")) { boolQuery.filter(QueryBuilders.termQuery("brand", params.getBrand())); } // 星级条件 if (params.getStarName() != null && !params.getStarName().equals("")) { boolQuery.filter(QueryBuilders.termQuery("starName", params.getStarName())); } // 价格 if (params.getMinPrice() != null && params.getMaxPrice() != null) { boolQuery.filter(QueryBuilders .rangeQuery("price") .gte(params.getMinPrice()) .lte(params.getMaxPrice()) ); } // 2.算分控制 FunctionScoreQueryBuilder functionScoreQuery = QueryBuilders.functionScoreQuery( // 原始查询，相关性算分的查询 boolQuery, // function score的数组 new FunctionScoreQueryBuilder.FilterFunctionBuilder[]{ // 其中的一个function score 元素 new FunctionScoreQueryBuilder.FilterFunctionBuilder( // 过滤条件 QueryBuilders.termQuery("isAD", true), // 算分函数 ScoreFunctionBuilders.weightFactorFunction(10) ) }); request.source().query(functionScoreQuery); }