【微服务】ES使用实战·黑马旅游(五)
🚗Es学习·第五站~
🚩Es学习起始站:【微服务】Elasticsearch概述&环境搭建(一)
🚩本文已收录至专栏:微服务探索之旅
👍希望您能有所收获
一.引入
综合前几站所学,我们已经对Elasticsearch的使用有了一定的了解,接下来让我们一起通过一个综合实战案例来复习前几站所学内容,体会在实际生产中的作用。
我们一起实现如下功能:
- 酒店搜索和分页
- 酒店结果过滤
- 我周边的酒店
- 酒店竞价排名
- 数据聚合筛选选项
- 搜索框自动补全
- 酒店数据的同步
二.环境搭建
-
按照第一站的学习部署Elasticsearch并启动运行。
-
按照第二站的学习中的如下步骤,初始化测试项目并在Es导入数据。
-
使用Elasticsearch,肯定离不开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"))); }
三.酒店搜索和分页
(1) 需求分析
在项目的首页,有一个大大的搜索框,还有分页按钮:
点击搜索按钮,可以看到浏览器控制台发出了请求:
请求参数如下:
由此可以知道,这个请求的信息如下:
- 请求方式:POST
- 请求路径:/hotel/list
- 请求参数:JSON对象,包含4个字段:
- key:搜索关键字
- page:页码
- size:每页大小
- sortBy:排序,目前暂不实现
- 返回值:分页查询,需要返回分页结果PageResult,包含两个属性:
total
:总条数List<HotelDoc>
:当前页的数据
因此,我们实现业务的流程如下:
- 定义实体类,接收请求参数的JSON对象
- 编写controller,接收页面的请求
- 编写业务实现,利用RestHighLevelClient实现搜索、分页
(2) 定义实体类
实体类有两个,一个是前端的请求参数实体,一个是服务端应该返回的响应结果实体。
- 请求参数实体类
由前端请求的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;
}
- 响应结果实体
因为是分页查询,所以我们需要返回的分页结果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;}
}
(3) 定义controller
定义一个HotelController,结合前端请求我们可以分析出如下结构:
- 请求方式:Post
- 请求路径:/hotel/list
- 请求参数:对象,类型为RequestParam
- 返回值:PageResult,包含两个属性
Long total
:总条数List<HotelDoc> hotels
:酒店数据
因此,我们在cn.itcast.hotel.web
中定义HotelController:
@RestController
@RequestMapping("/hotel")
public class HotelController {@Autowiredprivate IHotelService hotelService;// 搜索酒店数据并分页@PostMapping("/list")public PageResult search(@RequestBody RequestParams params){return hotelService.search(params);}
}
我们在controller调用了search,但并没有实现该方法,因此下面我们就在IHotelService中定义方法,再去实现业务逻辑。
在cn.itcast.hotel.service
中的IHotelService
接口中定义一个方法:
/*** 根据关键字搜索酒店信息并分页* @param params 请求参数对象,包含用户输入的关键字 * @return 酒店文档列表*/
PageResult search(RequestParams params);
(4) 实现搜索业务
在cn.itcast.hotel.service.impl
中的HotelService
中实现search方法:
@Override
public PageResult search(RequestParams params) {try {// 1.准备RequestSearchRequest request = new SearchRequest("hotel");// 2.准备DSL// 2.1.query查询String key = params.getKey();if (key == null || "".equals(key)) {boolQuery.must(QueryBuilders.matchAllQuery());} else {boolQuery.must(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) {// 获取文档sourceString json = hit.getSourceAsString();// 反序列化HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class);// 放入集合hotels.add(hotelDoc);}// 4.4.封装返回return new PageResult(total, hotels);
}
- 重启测试接口,我们可以看到如下效果
可以看到我们已经成功的实现了搜索和分页功能。如需详细了解搜索与分页语法可以看第三站学习中的全文检索查询内容~
四.酒店结果过滤
(1) 需求分析
我们可以在页面搜索框下面,看到一些过滤项:
点击过滤项可以看到发送的请求与搜索相同,但是传递的参数却多了几个。
传递的参数如图:
根据页面展示我们可以推断包含的过滤条件有:
- brand:品牌值
- city:城市
- minPrice~maxPrice:价格范围
- starName:星级
(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;
}
(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.构建BooleanQueryBoolQueryBuilder 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.放入sourcerequest.source().query(boolQuery);
}
重启测试接口,我们可以看到如下效果:
可以看到我们已经成功实现了结果过滤功能。如需详细了解结果过滤语法可以看第三站学习的精确查询和复合查询中的布尔查询内容~
五.我周边的酒店
(1) 需求分析
在酒店列表页的右侧,有一个小地图,点击地图的定位按钮,地图会找到你所在的位置:
并且,在前端会通过搜索接口发起查询请求,将坐标发送到服务端:
我们要做的事情就是基于这个location坐标,然后按照距离对周围酒店排序。实现思路如下:
- 修改RequestParams参数,接收location字段
- 修改search方法业务逻辑,如果location有值,添加根据geo_distance排序的功能
(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;
}
(3) 实现距离排序
由于与搜索接口相同,只需要在HotelService的
search方法中,添加一个排序功能即可:
完整代码如下:
@Override
public PageResult search(RequestParams params) {try {// 1.准备RequestSearchRequest request = new SearchRequest("hotel");// 2.准备DSL// 2.1.querybuildBasicQuery(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) 显示排序距离
重启服务后,测试我的酒店功能:
发现确实可以实现对我附近酒店的排序,不过并没有看到酒店到底距离我多远,这该怎么办?
排序完成后,页面还要获取我附近每个酒店的具体距离值,这个值在响应结果中是独立的:
因此,我们在结果解析阶段,除了解析source部分以外,还要得到sort部分,也就是排序的距离,然后放到响应结果中。
我们要做两件事:
- 修改HotelDoc,添加排序距离字段,用于页面显示
- 修改HotelService类中的handleResponse方法,添加对sort值的获取
- 修改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();}
}
- 修改HotelService中的handleResponse方法,设置距离
重启后测试,发现页面能成功显示距离了:
可以看到我们已经成功实现了查询附加的酒店功能。如需详细了解地理查询语法可以看第三站学习的地理坐标查询内容~
六.酒店竞价排名
(1) 需求分析
搜索内容时,我们常常可以看到位于顶部的是广告。接下来我们实现指定酒店在搜索结果中排名靠前,效果如图:
页面会给指定的酒店添加广告标记。
那怎样才能让指定的酒店排名置顶呢?
我们之前学习过的function_score查询可以影响算分,算分高了,自然排名也就高了。
因此我们需要给这些酒店添加一个标记,这样在过滤条件中就可以根据这个标记来判断,是否要提高算分。
比如,我们给酒店添加一个字段:isAD,Boolean类型:
- true:是广告
- false:不是广告
这样function_score包含3个要素就很好确定了:
- 过滤条件:判断isAD 是否为true
- 算分函数:这里我们可以用最简单暴力的weight,固定加权值
- 加权方式:可以用默认的相乘,大大提高算分
因此,业务的实现步骤包括:
-
给HotelDoc类添加isAD字段,Boolean类型
-
挑选几个你喜欢的酒店,给它的文档数据添加isAD字段,值为true
-
修改search方法,添加function score功能,给isAD值为true的酒店增加权重
(2) 修改实体类
给cn.itcast.hotel.pojo
包下的HotelDoc类添加isAD字段:
(3) 添加广告标记
接下来,作为测试效果,我们挑几个酒店,在kinbana中添加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) 实现广告靠前
接下来我们就要修改查询条件了。之前是用的boolean 查询,现在要改成function_socre
查询。
我们可以将之前写的boolean查询放到算分查询中,然后添加过滤条件、算分函数、加权模式。所以原来的代码依然可以沿用。
修改HotelService
类中的buildBasicQuery
方法,添加算分函数查询:
private void buildBasicQuery(RequestParams params, SearchRequest request) {// 1.构建BooleanQueryBoolQueryBuilder 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);
}
重启测试接口,我们可以看到如下效果:
可以看到我们已经成功实现了广告置顶功能。如需详细了解算分语法可以看第三站学习的复合查询中算分函数查询内容~
七.过滤选项展示
(1) 需求分析
搜索页面的品牌、城市等信息的选项不应该是在页面写死,而应该通过聚合索引库中的酒店数据得来的:
上述,页面的城市列表、星级列表、品牌列表都是写死的,并不会随着搜索结果的变化而变化。当用户搜索条件改变时,搜索结果会跟着变化,过滤选项也应该跟着变化。
例如:用户搜索“东方明珠”,那搜索的酒店肯定是在上海东方明珠附近,因此,城市只能是上海,此时城市列表中就不应该显示北京、深圳、杭州这些信息了。
也就是说,搜索结果中包含哪些城市,页面就应该列出哪些城市;搜索结果中包含哪些品牌,页面就应该列出哪些品牌。
使用聚合功能,利用Bucket聚合,对搜索结果中的文档基于品牌分组、基于城市分组,就能得知包含哪些品牌、哪些城市了。
点击过滤选项,查看浏览器可以发现,前端其实已经发出了这样的一个请求:
请求参数与搜索文档的参数完全一致。
返回值类型就是页面要展示的最终结果:
结果是一个Map结构:
- key是字符串,城市、星级、品牌、价格
- value是集合,例如多个城市的名称
(2) 定义controller
在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方法,尚未实现。
在IHotelService
中定义新方法:
Map<String, List<String>> filters(RequestParams params);
(3) 实现选项展示
在HotelService
中实现该方法:
@Override
public Map<String, List<String>> filters(RequestParams params) {try {// 1.准备RequestSearchRequest request = new SearchRequest("hotel");// 2.准备DSL// 2.1.querybuildBasicQuery(params, request);// 2.2.设置sizerequest.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.获取bucketsList<? extends Terms.Bucket> buckets = brandTerms.getBuckets();// 4.3.遍历List<String> brandList = new ArrayList<>();for (Terms.Bucket bucket : buckets) {// 4.4.获取keyString key = bucket.getKeyAsString();brandList.add(key);}return brandList;
}
重启测试接口,我们可以看到如下效果,选项少了很多且动态变化:
可以看到我们已经成功实现了过滤选项展示功能。如需详细了解数据聚合语法可以看第四站学习的数据聚合部分内容~
八.搜索框自动补全
(1) 需求分析
为了更好的用户体验,当用户在搜索框搜索内容时,我们可以给出相似选项提示,效果如下:
查看前端页面,可以发现当我们在输入框键入时,前端会发起ajax请求:
返回值是补全词条的集合,类型为List<String>
,请求参数key在url中
(2) 按照配置拼音分词器
要实现根据字母做补全,就必须对文档按照拼音分词,这时就需要自己安装并配置拼音分词器。
此处不再赘述,可根据第四站学习的自动补全中拼音分词器使用来配置。
(3) 修改索引库结构
在之前的使用中,我们创建索引库并未提前设置拼音分词器。我们知道索引库是无法修改的,因此只能先删除然后重新创建。
此外,我们还需要添加一个completion
类型字段,用来做自动补全,将brand、suggestion、city等都放进去,作为自动补全的提示。
在kinbana中进行如下操作:
- 先删除酒店数据索引库
DELETE /hotel
- 重新创建索引库
// 酒店数据索引库
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"}}}
}
(4) 修改实体
我们需要在HotelDoc中添加一个字段,用来做自动补全,内容可以是酒店品牌、城市、商圈等信息。按照自动补全字段的要求,最好是这些字段的数组。
因此我们在HotelDoc中添加一个字段,类型为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();// 组装suggestionif(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);}}
}
(5) 重新导入数据
由于我们删除了索引库,因此之前导入的数据也被清空了。
因此需要重新执行第二站学习中编写的导入数据功能,
再次查询可以看到新的酒店数据中包含了suggestion字段:
(6) 定义controller
- 在
HotelController
中添加新接口,接收前端请求:
@GetMapping("suggestion")
public List<String> getSuggestions(@RequestParam("key") String prefix) {return hotelService.getSuggestions(prefix);
}
- 在
IhotelService
中添加方法:
List<String> getSuggestions(String prefix);
(7) 实现搜索框自动补全
在HotelService
中实现方法:
@Override
public List<String> getSuggestions(String prefix) {try {// 1.准备RequestSearchRequest request = new SearchRequest("hotel");// 2.准备自动补全代码request.source().suggest(new SuggestBuilder().addSuggestion("suggestions",SuggestBuilderspletionSuggestion("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.获取optionsList<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);}
}
重启测试接口,我们可以看到如下效果,根据输入拼音进行了一定提示:
可以看到我们已经成功实现了搜索框自动补全功能。如需详细了解自动补全语法可以看第四站学习的自动补全部分内容~
九.数据同步
(1) 需求分析
我们知道es中的数据来自于mysql数据库,因此mysql数据发生改变时,es也必须跟着改变,否则会导致数据不一致问题,这个就是elasticsearch与mysql之间的数据同步。
在第四站学习的学习中,我们学习到了三种实现es与mysql之间数据同步的解决方案,接下来让我们一起实现通过实现异步通知。
思路分析:
当酒店数据发生增、删、改时,要求对elasticsearch中数据也要完成相同操作。
步骤:
-
导入下述资料提供的hotel-admin项目,启动并测试酒店数据的CRUD
-
声明exchange、queue、RoutingKey
-
在hotel-admin中的增、删、改业务中完成消息发送
-
在hotel-demo中完成消息监听,并更新elasticsearch中数据
-
启动并测试数据同步功能
(2) 搭建初始环境
导入资料提供的hotel-admin项目:=vsfs
运行后,访问 http://localhost:8099,可以看到如下界面
查看hotel-admin项目的HotelController可以看到其中包含了酒店的CRUD功能:
在hotel-admin、hotel-demo中引入rabbitmq的依赖:
<!--amqp-->
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
(3) 搭建RabitMQ使用环境
MQ整体结构如图:
RabbitMQ详细使用介绍:点击跳转
(3.1) 声明队列交换机名称
在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.2) 声明队列交换机
在hotel-demo中,定义config配置类,声明队列、交换机:
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 {// 声明交换机@Beanpublic TopicExchange topicExchange(){return new TopicExchange(MqConstants.HOTEL_EXCHANGE, true, false);}// 声明队列@Beanpublic Queue insertQueue(){return new Queue(MqConstants.HOTEL_INSERT_QUEUE, true);}// 声明队列@Beanpublic Queue deleteQueue(){return new Queue(MqConstants.HOTEL_DELETE_QUEUE, true);}// 绑定队列到交换机@Beanpublic Binding insertQueueBinding(){return BindingBuilder.bind(insertQueue()).to(topicExchange()).with(MqConstants.HOTEL_INSERT_KEY);}// 绑定队列到交换机@Beanpublic Binding deleteQueueBinding(){return BindingBuilder.bind(deleteQueue()).to(topicExchange()).with(MqConstants.HOTEL_DELETE_KEY);}
}
(4) 发送MQ消息
在hotel-admin
中的增、删、改业务中分别发送MQ消息:
(5) 接收MQ消息
在hotel-demo
接收MQ中的消息,要做的事情包括:
- 新增消息:根据传递的hotel的id查询hotel信息,然后新增一条数据到索引库
- 删除消息:根据传递的hotel的id删除索引库中的一条数据
- 首先在hotel-demo的
IHotelService
中新增新增、删除业务
void deleteById(Long id);void insertById(Long id);
- 在hotel-demo的HotelService中实现业务:
@Override
public void deleteById(Long id) {try {// 1.准备RequestDeleteRequest 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);}
}
- 编写监听器
在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 {@Autowiredprivate 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);}
}
(6) 测试
重启两个项目进行测试
-
我们在admin服务中修改如下酒店的价格
-
修改酒店价格
-
刷新网页,可以看到我们的修改已经生效
如此表明我们已经实现了两个服务之间的数据同步
【微服务】ES使用实战·黑马旅游(五)
🚗Es学习·第五站~
🚩Es学习起始站:【微服务】Elasticsearch概述&环境搭建(一)
🚩本文已收录至专栏:微服务探索之旅
👍希望您能有所收获
一.引入
综合前几站所学,我们已经对Elasticsearch的使用有了一定的了解,接下来让我们一起通过一个综合实战案例来复习前几站所学内容,体会在实际生产中的作用。
我们一起实现如下功能:
- 酒店搜索和分页
- 酒店结果过滤
- 我周边的酒店
- 酒店竞价排名
- 数据聚合筛选选项
- 搜索框自动补全
- 酒店数据的同步
二.环境搭建
-
按照第一站的学习部署Elasticsearch并启动运行。
-
按照第二站的学习中的如下步骤,初始化测试项目并在Es导入数据。
-
使用Elasticsearch,肯定离不开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"))); }
三.酒店搜索和分页
(1) 需求分析
在项目的首页,有一个大大的搜索框,还有分页按钮:
点击搜索按钮,可以看到浏览器控制台发出了请求:
请求参数如下:
由此可以知道,这个请求的信息如下:
- 请求方式:POST
- 请求路径:/hotel/list
- 请求参数:JSON对象,包含4个字段:
- key:搜索关键字
- page:页码
- size:每页大小
- sortBy:排序,目前暂不实现
- 返回值:分页查询,需要返回分页结果PageResult,包含两个属性:
total
:总条数List<HotelDoc>
:当前页的数据
因此,我们实现业务的流程如下:
- 定义实体类,接收请求参数的JSON对象
- 编写controller,接收页面的请求
- 编写业务实现,利用RestHighLevelClient实现搜索、分页
(2) 定义实体类
实体类有两个,一个是前端的请求参数实体,一个是服务端应该返回的响应结果实体。
- 请求参数实体类
由前端请求的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;
}
- 响应结果实体
因为是分页查询,所以我们需要返回的分页结果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;}
}
(3) 定义controller
定义一个HotelController,结合前端请求我们可以分析出如下结构:
- 请求方式:Post
- 请求路径:/hotel/list
- 请求参数:对象,类型为RequestParam
- 返回值:PageResult,包含两个属性
Long total
:总条数List<HotelDoc> hotels
:酒店数据
因此,我们在cn.itcast.hotel.web
中定义HotelController:
@RestController
@RequestMapping("/hotel")
public class HotelController {@Autowiredprivate IHotelService hotelService;// 搜索酒店数据并分页@PostMapping("/list")public PageResult search(@RequestBody RequestParams params){return hotelService.search(params);}
}
我们在controller调用了search,但并没有实现该方法,因此下面我们就在IHotelService中定义方法,再去实现业务逻辑。
在cn.itcast.hotel.service
中的IHotelService
接口中定义一个方法:
/*** 根据关键字搜索酒店信息并分页* @param params 请求参数对象,包含用户输入的关键字 * @return 酒店文档列表*/
PageResult search(RequestParams params);
(4) 实现搜索业务
在cn.itcast.hotel.service.impl
中的HotelService
中实现search方法:
@Override
public PageResult search(RequestParams params) {try {// 1.准备RequestSearchRequest request = new SearchRequest("hotel");// 2.准备DSL// 2.1.query查询String key = params.getKey();if (key == null || "".equals(key)) {boolQuery.must(QueryBuilders.matchAllQuery());} else {boolQuery.must(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) {// 获取文档sourceString json = hit.getSourceAsString();// 反序列化HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class);// 放入集合hotels.add(hotelDoc);}// 4.4.封装返回return new PageResult(total, hotels);
}
- 重启测试接口,我们可以看到如下效果
可以看到我们已经成功的实现了搜索和分页功能。如需详细了解搜索与分页语法可以看第三站学习中的全文检索查询内容~
四.酒店结果过滤
(1) 需求分析
我们可以在页面搜索框下面,看到一些过滤项:
点击过滤项可以看到发送的请求与搜索相同,但是传递的参数却多了几个。
传递的参数如图:
根据页面展示我们可以推断包含的过滤条件有:
- brand:品牌值
- city:城市
- minPrice~maxPrice:价格范围
- starName:星级
(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;
}
(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.构建BooleanQueryBoolQueryBuilder 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.放入sourcerequest.source().query(boolQuery);
}
重启测试接口,我们可以看到如下效果:
可以看到我们已经成功实现了结果过滤功能。如需详细了解结果过滤语法可以看第三站学习的精确查询和复合查询中的布尔查询内容~
五.我周边的酒店
(1) 需求分析
在酒店列表页的右侧,有一个小地图,点击地图的定位按钮,地图会找到你所在的位置:
并且,在前端会通过搜索接口发起查询请求,将坐标发送到服务端:
我们要做的事情就是基于这个location坐标,然后按照距离对周围酒店排序。实现思路如下:
- 修改RequestParams参数,接收location字段
- 修改search方法业务逻辑,如果location有值,添加根据geo_distance排序的功能
(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;
}
(3) 实现距离排序
由于与搜索接口相同,只需要在HotelService的
search方法中,添加一个排序功能即可:
完整代码如下:
@Override
public PageResult search(RequestParams params) {try {// 1.准备RequestSearchRequest request = new SearchRequest("hotel");// 2.准备DSL// 2.1.querybuildBasicQuery(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) 显示排序距离
重启服务后,测试我的酒店功能:
发现确实可以实现对我附近酒店的排序,不过并没有看到酒店到底距离我多远,这该怎么办?
排序完成后,页面还要获取我附近每个酒店的具体距离值,这个值在响应结果中是独立的:
因此,我们在结果解析阶段,除了解析source部分以外,还要得到sort部分,也就是排序的距离,然后放到响应结果中。
我们要做两件事:
- 修改HotelDoc,添加排序距离字段,用于页面显示
- 修改HotelService类中的handleResponse方法,添加对sort值的获取
- 修改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();}
}
- 修改HotelService中的handleResponse方法,设置距离
重启后测试,发现页面能成功显示距离了:
可以看到我们已经成功实现了查询附加的酒店功能。如需详细了解地理查询语法可以看第三站学习的地理坐标查询内容~
六.酒店竞价排名
(1) 需求分析
搜索内容时,我们常常可以看到位于顶部的是广告。接下来我们实现指定酒店在搜索结果中排名靠前,效果如图:
页面会给指定的酒店添加广告标记。
那怎样才能让指定的酒店排名置顶呢?
我们之前学习过的function_score查询可以影响算分,算分高了,自然排名也就高了。
因此我们需要给这些酒店添加一个标记,这样在过滤条件中就可以根据这个标记来判断,是否要提高算分。
比如,我们给酒店添加一个字段:isAD,Boolean类型:
- true:是广告
- false:不是广告
这样function_score包含3个要素就很好确定了:
- 过滤条件:判断isAD 是否为true
- 算分函数:这里我们可以用最简单暴力的weight,固定加权值
- 加权方式:可以用默认的相乘,大大提高算分
因此,业务的实现步骤包括:
-
给HotelDoc类添加isAD字段,Boolean类型
-
挑选几个你喜欢的酒店,给它的文档数据添加isAD字段,值为true
-
修改search方法,添加function score功能,给isAD值为true的酒店增加权重
(2) 修改实体类
给cn.itcast.hotel.pojo
包下的HotelDoc类添加isAD字段:
(3) 添加广告标记
接下来,作为测试效果,我们挑几个酒店,在kinbana中添加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) 实现广告靠前
接下来我们就要修改查询条件了。之前是用的boolean 查询,现在要改成function_socre
查询。
我们可以将之前写的boolean查询放到算分查询中,然后添加过滤条件、算分函数、加权模式。所以原来的代码依然可以沿用。
修改HotelService
类中的buildBasicQuery
方法,添加算分函数查询:
private void buildBasicQuery(RequestParams params, SearchRequest request) {// 1.构建BooleanQueryBoolQueryBuilder 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);
}
重启测试接口,我们可以看到如下效果:
可以看到我们已经成功实现了广告置顶功能。如需详细了解算分语法可以看第三站学习的复合查询中算分函数查询内容~
七.过滤选项展示
(1) 需求分析
搜索页面的品牌、城市等信息的选项不应该是在页面写死,而应该通过聚合索引库中的酒店数据得来的:
上述,页面的城市列表、星级列表、品牌列表都是写死的,并不会随着搜索结果的变化而变化。当用户搜索条件改变时,搜索结果会跟着变化,过滤选项也应该跟着变化。
例如:用户搜索“东方明珠”,那搜索的酒店肯定是在上海东方明珠附近,因此,城市只能是上海,此时城市列表中就不应该显示北京、深圳、杭州这些信息了。
也就是说,搜索结果中包含哪些城市,页面就应该列出哪些城市;搜索结果中包含哪些品牌,页面就应该列出哪些品牌。
使用聚合功能,利用Bucket聚合,对搜索结果中的文档基于品牌分组、基于城市分组,就能得知包含哪些品牌、哪些城市了。
点击过滤选项,查看浏览器可以发现,前端其实已经发出了这样的一个请求:
请求参数与搜索文档的参数完全一致。
返回值类型就是页面要展示的最终结果:
结果是一个Map结构:
- key是字符串,城市、星级、品牌、价格
- value是集合,例如多个城市的名称
(2) 定义controller
在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方法,尚未实现。
在IHotelService
中定义新方法:
Map<String, List<String>> filters(RequestParams params);
(3) 实现选项展示
在HotelService
中实现该方法:
@Override
public Map<String, List<String>> filters(RequestParams params) {try {// 1.准备RequestSearchRequest request = new SearchRequest("hotel");// 2.准备DSL// 2.1.querybuildBasicQuery(params, request);// 2.2.设置sizerequest.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.获取bucketsList<? extends Terms.Bucket> buckets = brandTerms.getBuckets();// 4.3.遍历List<String> brandList = new ArrayList<>();for (Terms.Bucket bucket : buckets) {// 4.4.获取keyString key = bucket.getKeyAsString();brandList.add(key);}return brandList;
}
重启测试接口,我们可以看到如下效果,选项少了很多且动态变化:
可以看到我们已经成功实现了过滤选项展示功能。如需详细了解数据聚合语法可以看第四站学习的数据聚合部分内容~
八.搜索框自动补全
(1) 需求分析
为了更好的用户体验,当用户在搜索框搜索内容时,我们可以给出相似选项提示,效果如下:
查看前端页面,可以发现当我们在输入框键入时,前端会发起ajax请求:
返回值是补全词条的集合,类型为List<String>
,请求参数key在url中
(2) 按照配置拼音分词器
要实现根据字母做补全,就必须对文档按照拼音分词,这时就需要自己安装并配置拼音分词器。
此处不再赘述,可根据第四站学习的自动补全中拼音分词器使用来配置。
(3) 修改索引库结构
在之前的使用中,我们创建索引库并未提前设置拼音分词器。我们知道索引库是无法修改的,因此只能先删除然后重新创建。
此外,我们还需要添加一个completion
类型字段,用来做自动补全,将brand、suggestion、city等都放进去,作为自动补全的提示。
在kinbana中进行如下操作:
- 先删除酒店数据索引库
DELETE /hotel
- 重新创建索引库
// 酒店数据索引库
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"}}}
}
(4) 修改实体
我们需要在HotelDoc中添加一个字段,用来做自动补全,内容可以是酒店品牌、城市、商圈等信息。按照自动补全字段的要求,最好是这些字段的数组。
因此我们在HotelDoc中添加一个字段,类型为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();// 组装suggestionif(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);}}
}
(5) 重新导入数据
由于我们删除了索引库,因此之前导入的数据也被清空了。
因此需要重新执行第二站学习中编写的导入数据功能,
再次查询可以看到新的酒店数据中包含了suggestion字段:
(6) 定义controller
- 在
HotelController
中添加新接口,接收前端请求:
@GetMapping("suggestion")
public List<String> getSuggestions(@RequestParam("key") String prefix) {return hotelService.getSuggestions(prefix);
}
- 在
IhotelService
中添加方法:
List<String> getSuggestions(String prefix);
(7) 实现搜索框自动补全
在HotelService
中实现方法:
@Override
public List<String> getSuggestions(String prefix) {try {// 1.准备RequestSearchRequest request = new SearchRequest("hotel");// 2.准备自动补全代码request.source().suggest(new SuggestBuilder().addSuggestion("suggestions",SuggestBuilderspletionSuggestion("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.获取optionsList<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);}
}
重启测试接口,我们可以看到如下效果,根据输入拼音进行了一定提示:
可以看到我们已经成功实现了搜索框自动补全功能。如需详细了解自动补全语法可以看第四站学习的自动补全部分内容~
九.数据同步
(1) 需求分析
我们知道es中的数据来自于mysql数据库,因此mysql数据发生改变时,es也必须跟着改变,否则会导致数据不一致问题,这个就是elasticsearch与mysql之间的数据同步。
在第四站学习的学习中,我们学习到了三种实现es与mysql之间数据同步的解决方案,接下来让我们一起实现通过实现异步通知。
思路分析:
当酒店数据发生增、删、改时,要求对elasticsearch中数据也要完成相同操作。
步骤:
-
导入下述资料提供的hotel-admin项目,启动并测试酒店数据的CRUD
-
声明exchange、queue、RoutingKey
-
在hotel-admin中的增、删、改业务中完成消息发送
-
在hotel-demo中完成消息监听,并更新elasticsearch中数据
-
启动并测试数据同步功能
(2) 搭建初始环境
导入资料提供的hotel-admin项目:=vsfs
运行后,访问 http://localhost:8099,可以看到如下界面
查看hotel-admin项目的HotelController可以看到其中包含了酒店的CRUD功能:
在hotel-admin、hotel-demo中引入rabbitmq的依赖:
<!--amqp-->
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
(3) 搭建RabitMQ使用环境
MQ整体结构如图:
RabbitMQ详细使用介绍:点击跳转
(3.1) 声明队列交换机名称
在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.2) 声明队列交换机
在hotel-demo中,定义config配置类,声明队列、交换机:
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 {// 声明交换机@Beanpublic TopicExchange topicExchange(){return new TopicExchange(MqConstants.HOTEL_EXCHANGE, true, false);}// 声明队列@Beanpublic Queue insertQueue(){return new Queue(MqConstants.HOTEL_INSERT_QUEUE, true);}// 声明队列@Beanpublic Queue deleteQueue(){return new Queue(MqConstants.HOTEL_DELETE_QUEUE, true);}// 绑定队列到交换机@Beanpublic Binding insertQueueBinding(){return BindingBuilder.bind(insertQueue()).to(topicExchange()).with(MqConstants.HOTEL_INSERT_KEY);}// 绑定队列到交换机@Beanpublic Binding deleteQueueBinding(){return BindingBuilder.bind(deleteQueue()).to(topicExchange()).with(MqConstants.HOTEL_DELETE_KEY);}
}
(4) 发送MQ消息
在hotel-admin
中的增、删、改业务中分别发送MQ消息:
(5) 接收MQ消息
在hotel-demo
接收MQ中的消息,要做的事情包括:
- 新增消息:根据传递的hotel的id查询hotel信息,然后新增一条数据到索引库
- 删除消息:根据传递的hotel的id删除索引库中的一条数据
- 首先在hotel-demo的
IHotelService
中新增新增、删除业务
void deleteById(Long id);void insertById(Long id);
- 在hotel-demo的HotelService中实现业务:
@Override
public void deleteById(Long id) {try {// 1.准备RequestDeleteRequest 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);}
}
- 编写监听器
在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 {@Autowiredprivate 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);}
}
(6) 测试
重启两个项目进行测试
-
我们在admin服务中修改如下酒店的价格
-
修改酒店价格
-
刷新网页,可以看到我们的修改已经生效
如此表明我们已经实现了两个服务之间的数据同步
发布评论