代码请见: https://gitee.com/lhwebsite/es_practice_hotels
1 需求分析
实现四部分功能:
- 酒店搜索和分页
- 酒店结果过滤
- 我周边的酒店
- 酒店竞价排名
- 实现品牌城市星级价格的聚合
2 酒店搜索和分页
2.1 请求和响应分析
由上我们可知:
- 请求方式:POST
- 请求路径:/hotel/list
- 请求参数:JSON对象,包含4个字段:
- key:搜索关键字
- page:页码
- size:每页大小
- sortBy:排序,目前暂不实现
再分析下响应信息:
返回值:分页查询,需要返回分页结果PageResult,包含两个属性:
- total:总条数
- hotels:当前页的数据
因此,我们实现业务的流程如下:
- 步骤一:定义实体类,接收请求参数的JSON对象
- 步骤二:编写controller,接收页面的请求
- 步骤三:编写业务实现,利用RestHighLevelClient实现搜索、分页
2.2 定义实体类,接收请求参数的JSON对象
request请求pojo类:
@Data
public class requestParams {
private String key;
private Integer page;
private Integer size;
private String sortBy;
}
reponse响应pojo类:
@Data
@NoArgsConstructor
@AllArgsConstructor
public class PageResult {
private Long total;
private List<HotelDoc> hotels;
}
2.3 编写controller,接收页面的请求
@RestController
@RequestMapping("hotel")
public class HotelController {
@Autowired
private IHotelService hotelService;
@PostMapping("list")
public PageResult search(@RequestBody RequestParams params) {
return hotelService.search(params);
}
}
2.4 编写业务实现,利用RestHighLevelClient实现搜索、分页
@Service
public class HotelService extends ServiceImpl<HotelMapper, Hotel> implements IHotelService {
@Autowired
private RestHighLevelClient restHighLevelClient;
@Override
public PageResult searchPageInfo(RequestParams params) throws IOException {
//1 构建搜索请求对象
SearchRequest request = new SearchRequest("hotel");
//2 构建查询条件
if(params.getKey() == null){
//如果关键字为空 则无条件查询
request.source().query(QueryBuilders.matchAllQuery());
}else{
request.source().query(QueryBuilders.matchQuery("all",params.getKey()));
}
// 3 构建分页
Integer page = params.getPage()==null?1:params.getPage();
Integer pageSize = params.getSize()==null?1:params.getSize();
request.source().from((page - 1)*pageSize).size(pageSize);
//4 发起请求
SearchResponse response = restHighLevelClient.search(request, RequestOptions.DEFAULT);
//5 解析response
return handerResult(response);
}
private PageResult handerResult(SearchResponse response) {
//1 判断是不是null
if(response == null){
return null;
}
//2 解析数据
SearchHits hits = response.getHits();
//3 获取命中的文档数
long total = hits.getTotalHits().value;
//4 获取命中查询的内容
SearchHit[] hitsArray = hits.getHits();
List<HotelDoc> docs = new ArrayList<>();
if(hitsArray.length > 0){
for (SearchHit hit : hitsArray) {
String jsonData = hit.getSourceAsString();
HotelDoc hotelDoc = JSON.parseObject(jsonData, HotelDoc.class);
docs.add(hotelDoc);
}
}
//5 组装pageResult
return new PageResult(total, docs);
}
}
3. 酒店结果过滤
3.1 请求和响应分析
需求:添加品牌、城市、星级、价格等过滤功能
在页面搜索框下面,会有一些过滤项:
前端进入f12查看request和response
包含的过滤条件有:
- brand:品牌值
- city:城市
- minPrice~maxPrice:价格范围
- starName:星级
我们需要做两件事情:
- 修改请求参数的对象RequestParams,接收上述参数
- 修改业务逻辑,在搜索条件之外,添加一些过滤条件
3.2 修改请求参数的对象RequestParams
3.2 修改业务逻辑,在搜索条件之外,添加一些过滤条件
对业务层进行修改,使用bool查询进行组合查询条件:
- 品牌过滤:是keyword类型,用term查询
- 星级过滤:是keyword类型,用term查询
- 价格过滤:是数值类型,用range查询
- 城市过滤:是keyword类型,用term查询
- 关键字搜索放到must中,参与算分
- 其它过滤条件放到filter中,不参与算分
为了提高代码可阅读性,我把bool过滤查询封装到了一个函数:
/**
* bool多条件过滤查询构建方法
* @param request
* @param params
*/
private void buildBasicSearch(SearchRequest request, RequestParams params) {
BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();
//1 设置must全文过滤条件
if(params.getKey() == null){
//如果关键字为空 则无条件查询
boolQueryBuilder.must(QueryBuilders.matchAllQuery());
}else{
boolQueryBuilder.must(QueryBuilders.matchQuery("all",params.getKey()));
}
//2 设置filter过滤条件
if(params.getBrand() != null){
boolQueryBuilder.filter(QueryBuilders.termQuery("brand",params.getBrand()));
}
if(params.getCity() != null){
boolQueryBuilder.filter(QueryBuilders.termQuery("city",params.getCity()));
}
if(params.getStarName() != null){
boolQueryBuilder.filter(QueryBuilders.termQuery("starName",params.getStarName()));
}
if(params.getMinPrice() != null && params.getMaxPrice() != null){
boolQueryBuilder.filter(QueryBuilders.rangeQuery("price").gte(params.getMinPrice()).lte(params.getMaxPrice()));
}
request.source().query(boolQueryBuilder);
}
4.实现 我周边的酒店
4.1 请求和响应分析
在酒店列表页的右侧,有一个小地图,点击地图的定位按钮,地图会找到你所在的位置:
并且,在前端会发起查询请求,将你的坐标发送到服务端:
所以需求就是基于这个location坐标,然后按照距离对周围酒店排序。实现思路如下:
- 修改RequestParams参数,接收location字段
- 修改search方法业务逻辑,如果location有值,添加根据geo_distance排序的功能
4.2 修改RequestParams参数,接收location字段
@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 修改search方法,完成距离排序
业务层修改代码:距离排序规则 由近到远排序
if(params.getLocation() != null){
//距离排序规则 由近到远排序
request.source().sort(SortBuilders.geoDistanceSort("location",new GeoPoint(params.getLocation()))
.order(SortOrder.ASC).unit(DistanceUnit.KILOMETERS));
}
4.4 排序距离显示
实现以下功能:
这个实现也很简单,之前学习es时,在es终端输入这个距离排序,得到的结果是:
因此,我们在结果解析阶段,除了解析source部分以外,还要得到sort部分,也就是排序的距离,然后放到响应结果中。
我们要做两件事:
- 修改HotelDoc,添加排序距离字段,用于页面显示
- 修改HotelService类中的handleResponse方法,添加对sort值的获取
首先查看前端页面:
这里前端接受的是一个叫做distance的值(且保留两位小数),因此,HotelDoc实体类中应该添加一个distance成员变量
之后修改业务层代码:
//获取距离
Object[] sortValues = hit.getSortValues();
if(sortValues.length > 0){
hotelDoc.setDistance(sortValues[0]);
}
5 酒店竞价排名
5.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的酒店增加权重
5.2 修改Hoteldoc实体类 以及 es添加doc属性
修改实体类:
给几个酒店的doc添加isAD标签
首先,我们es原来索引的mapping中没有isAD,那么怎么进行添加?
其实不用改动mapping,es相对于mysql这中关系型数据库有个很强大的特性:我们只需要给某个索引doc添加isAD属性,那么hotel的索引mapping自动会检测到并添加isAD的mapping属性。如下所示:
POST /hotel/_update/2359697
{
"doc": {
"isAD":true
}
}
之后查一下hotel的mapping
5.3 修改业务层代码
首先我们回一下算分查询的语法把:
那么根据es的语法,修改代码:
FunctionScoreQueryBuilder functionScoreQueryBuilder = QueryBuilders.functionScoreQuery(boolQueryBuilder,
new FunctionScoreQueryBuilder.FilterFunctionBuilder[]{
new FunctionScoreQueryBuilder.FilterFunctionBuilder(
QueryBuilders.termQuery("isAD","true"),
ScoreFunctionBuilders.weightFactorFunction(20f)
)
});
最终达到这种效果:
6 实现品牌城市星级价格的聚合
6.1 什么意思
搜索页面的品牌、城市等信息不应该是在页面写死,而是通过聚合索引库中的酒店数据得来的
那么如何解决这个问题呢?
使用聚合功能,利用Bucket聚合,对搜索结果中的文档基于品牌分组、基于城市分组,就能得知包含哪些品牌、哪些城市了,之后在选项中显示包含的结果。
因为是对搜索结果聚合,因此聚合是限定范围的聚合,也就是说聚合的限定条件跟搜索文档的条件一致。
6.2 请求和响应分析
首先,这个功能是通过filters接口实现的:
那么,我们从前端页面看一下这个request和response的参数:
其中request和上面是一样的
response大概的样子:
6.3 Controller层实现
要求:
- 请求方式:
POST
- 请求路径:
/hotel/filters
- 请求参数:
RequestParams
,与搜索文档的参数一致 - 返回值类型:
Map<String, List<String>>
@PostMapping("/filters")
public Map<String, List<String>> getFilters(@RequestBody RequestParams params){
return hotelService.getFilters(params);
}
6.4 业务层实现
service代码如下:
/**
* 根据传入的条件 动态过滤出品牌星级城市价格等信息
* @param params
* @return
*/
@Override
public Map<String, List<String>> getFilters(RequestParams params) throws IOException {
SearchRequest request = new SearchRequest("hotel");
request.source().size(0);
buildBasicSearch(request,params);
//在上面条件的基础上构建聚合:brand city starName
buildAggs(request);
//发起请求
SearchResponse response = restHighLevelClient.search(request, RequestOptions.DEFAULT);
//解析聚合数据
List<String> cities = getBuckNames(response,"brandAggs");
List<String> brands = getBuckNames(response,"cityAggs");
List<String> starNames = getBuckNames(response,"starNameAggs");
//组装为响应结果
Map<String,List<String>> info = new HashMap<>();
info.put("city",cities);
info.put("brand",brands);
info.put("starName",starNames);
return info;
}
封装了两个函数
buildAggs:
/**
* 构建brand city starName的聚合
* @param request
*/
private void buildAggs(SearchRequest request) {
request.source().aggregation(AggregationBuilders
.terms("cityAggs").field("city").size(20));
request.source().aggregation(AggregationBuilders
.terms("brandAggs").field("brand").size(20));
request.source().aggregation(AggregationBuilders
.terms("starNameAggs").field("starName").size(20));
}
getBuckNames:
/**
* 根据response和聚合名称获取桶的数据
* @param response
* @param aggName
* @return
*/
private List<String> getBuckNames(SearchResponse response, String aggName) {
List<String> result = new ArrayList<>();
Aggregations aggregations = response.getAggregations();
Terms aggregation = aggregations.get(aggName);
if(aggregation == null || aggregation.getBuckets().size() == 0){
return result;
}
List<? extends Terms.Bucket> buckets = aggregation.getBuckets();
for (Terms.Bucket bucket : buckets) {
String key = bucket.getKeyAsString();
result.add(key);
}
return result;
}
7 实现搜索的自动补全
7.1 自动补全功能思路
- 修改hotel索引库结构,设置自定义拼音分词器
- 修改索引库的name、all字段,使用自定义分词器
- 索引库添加一个新字段suggestion,类型为completion类型,使用自定义的分词器
- 给HotelDoc类添加suggestion字段,内容包含brand、business
- 重新导入数据到hotel库
- RestAPI实现自动补全
7.2 修改索引库
对于hotel索引库,进行了以下修改:
- 定义了两个分词器analyzer,text_anlyzerr是分词+拼音过滤 ,completion_analyzer是单纯进行拼音过滤
- 构建倒排索引时使用text_anlyzerr,查询时使用ik_smart
- 添加了一个suggestion字段,使用自定义的分词器,包含brand、suggestion、city等信息,作为自动补全的提示
// 酒店数据索引库
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"
}
}
}
}
7.3 修改HotelDoc类
由于这个bussiness属性可能包含多个(用‘/’间隔),所以在构造方法要进行一些处理,将bussiness的每个值形成独立的词条
@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();
//维护当前文档搜索提示的关键字
if (this.business.contains("/")){
String[] arrs = this.business.split("/");
this.suggestion = new ArrayList<>();
this.suggestion.add(this.brand);
Collections.addAll(this.suggestion,arrs);
}else{
this.suggestion = Arrays.asList(this.brand,this.business);
}
}
}
之后重新运行插入文档的代码即可
再来复习下批量导入doc
@Test
public void batchTest() throws IOException {
//1 获取所有的酒店数据
List<Hotel> hotels = hotelMapper.selectList(null);
//2转化为文档对象 转化为IndexRequest对象
//3 添加到bulk对象
BulkRequest bulkRequest = new BulkRequest("hotel");
hotels.stream().forEach(dbHotel->{
IndexRequest index = new IndexRequest("hotel").id(dbHotel.getId().toString()).source(JSON.toJSONString(new HotelDoc(dbHotel)), XContentType.JSON);
bulkRequest.add(index);
});
BulkResponse result = restHighLevelClient.bulk(bulkRequest, RequestOptions.DEFAULT);
System.out.println(result);
}
}
运行成功后查询下doc
7.4 分析前端request和response
当我们在前端的搜索栏,输入拼音,会发出这样的请求:
接受的响应根据查看前端代码得知应该是一个list集合:
7.5 RestAPI实现自动补全
首先,我们来写一下,自动补全查询的语句:
GET /hotel/_search
{
"suggest": {
"mySuggestion": {
"text": "h", //查询关键字
"completion":{
"field":"suggestion", //补全字段
"skip_duplicates":true, //跳过重复的内容
"size":10 //获取前十条结果
}
}
}
}
那么再来看下RestAPi:
再来看下如何获取返回结果:
因此得到最终业务层代码:
@Override
public List<String> getSuggestion(String key) throws IOException {
SearchRequest request = new SearchRequest("hotel");
request.source().suggest(
new SuggestBuilder().addSuggestion(
"hotelSuggestion",
SuggestBuilders.completionSuggestion("suggestion")
.prefix(key)
.skipDuplicates(true)
.size(5)
)
);
SearchResponse response = restHighLevelClient.search(request, RequestOptions.DEFAULT);
//解析
Suggest suggest = response.getSuggest();
CompletionSuggestion hotelSuggestion = suggest.getSuggestion("hotelSuggestion");
List<String> sug = new ArrayList<>();
if(hotelSuggestion == null || hotelSuggestion.getOptions() == null){
return sug;
}else {
List<CompletionSuggestion.Entry.Option> options = hotelSuggestion.getOptions();
for (CompletionSuggestion.Entry.Option option : options) {
String suggestion = option.getText().toString();
sug.add(suggestion);
}
return sug;
}
}
最终得到以下效果: