下面,我们通过酒店搜索案例来实战演练下之前学习的知识。

我们实现四部分功能:

  • 酒店搜索和分页
  • 酒店结果过滤
  • 我周边的酒店
  • 酒店竞价排名

启动 hotel-demo 项目,其默认端口是 8089,访问 http://localhost:8090,就能看到项目页面了。

酒店搜索和分页

案例需求:实现黑马旅游的酒店搜索功能,完成关键字搜索和分页

需求分析

  • 请求方式:POST
  • 请求路径:/hotel/list
  • 请求参数:JSON 对象,包含4个字段:
    • key:搜索关键字
    • page:页码
    • size:每页大小
    • sortBy:排序,目前暂不实现
  • 返回值:分页查询,需要返回分页结果 PageResult,包含两个属性:
    • total:总条数
    • List<HotelDoc>:当前页的数据

因此,我们实现业务的流程如下:

  • 步骤一:定义实体类,接收请求参数的 JSON 对象
  • 步骤二:编写 controller,接收页面的请求
  • 步骤三:编写业务实现,利用 RestHighLevelClient 实现搜索、分页

定义实体类

实体类有两个,一个是前端的请求参数实体,一个是服务端应该返回的响应结果实体。

① 请求参数,前端请求的 JSON 结构如下:

  1. {
  2. "key": "搜索关键字",
  3. "page": 1,
  4. "size": 3,
  5. "sortBy": "default"
  6. }

因此,我们在 cn.itcast.hotel.pojo 包下定义一个实体类:

  1. package cn.itcast.hotel.pojo;
  2. import lombok.Data;
  3. @Data
  4. public class RequestParams {
  5. private String key;
  6. private Integer page;
  7. private Integer size;
  8. private String sortBy;
  9. }

② 返回值,分页查询,需要返回分页结果 PageResult,包含两个属性:

  • total :总条数
  • List<HotelDoc> :当前页的数据

因此,我们在 cn.itcast.hotel.pojo 中定义返回结果:

  1. package cn.itcast.hotel.pojo;
  2. import lombok.Data;
  3. import java.util.List;
  4. @Data
  5. public class PageResult {
  6. private Long total;
  7. private List<HotelDoc> hotels;
  8. public PageResult() {
  9. }
  10. public PageResult(Long total, List<HotelDoc> hotels) {
  11. this.total = total;
  12. this.hotels = hotels;
  13. }
  14. }

定义 controller

定义一个 HotelController,声明查询接口,满足下列要求:

  • 请求方式:Post
  • 请求路径:/hotel/list
  • 请求参数:对象,类型为 RequestParam
  • 返回值:PageResult,包含两个属性
    • Long total:总条数
    • List<HotelDoc> hotels:酒店数据

因此,我们在 cn.itcast.hotel.web 中定义 HotelController:

  1. @RestController
  2. @RequestMapping("/hotel")
  3. public class HotelController {
  4. @Autowired
  5. private IHotelService hotelService;
  6. // 搜索酒店数据
  7. @PostMapping("/list")
  8. public PageResult search(@RequestBody RequestParams params){
  9. return hotelService.search(params);
  10. }
  11. }

实现搜索业务

我们在 controller 调用了 IHotelService,并没有实现该方法,因此下面我们就在 IHotelService 中定义方法,并且去实现业务逻辑。

① 在 cn.itcast.hotel.service 中的 IHotelService 接口中定义一个方法:

  1. /**
  2. * 根据关键字搜索酒店信息
  3. * @param params 请求参数对象,包含用户输入的关键字
  4. * @return 酒店文档列表
  5. */
  6. PageResult search(RequestParams params);

② 实现搜索业务,肯定离不开 RestHighLevelClient,我们需要把它注册到 Spring 中作为一个 Bean。在 cn.itcast.hotel 中的 HotelDemoApplication 中声明这个 Bean:

  1. @Bean
  2. public RestHighLevelClient client() {
  3. return new RestHighLevelClient(RestClient.builder(HttpHost.create("http://halo:9200")));
  4. }

③ 在 cn.itcast.hotel.service.impl 中的 HotelService 中实现 search 方法:

  1. @Autowired
  2. private RestHighLevelClient client;
  3. @Override
  4. public PageResult search(RequestParams params) {
  5. try {
  6. // 1.准备Request
  7. SearchRequest request = new SearchRequest("hotel");
  8. // 2.准备DSL
  9. // 2.1.query
  10. String key = params.getKey();
  11. if (key == null || "".equals(key)) {
  12. request.source().query(QueryBuilders.matchAllQuery());
  13. } else {
  14. request.source().query(QueryBuilders.matchQuery("all", key));
  15. }
  16. // 2.2.分页
  17. int page = params.getPage();
  18. int size = params.getSize();
  19. request.source().from((page - 1) * size).size(size);
  20. // 3.发送请求
  21. SearchResponse response = client.search(request, RequestOptions.DEFAULT);
  22. // 4.解析响应
  23. return handleResponse(response);
  24. } catch (IOException e) {
  25. throw new RuntimeException(e);
  26. }
  27. }
  28. // 结果解析
  29. private PageResult handleResponse(SearchResponse response) {
  30. // 4.解析响应
  31. SearchHits searchHits = response.getHits();
  32. // 4.1.获取总条数
  33. long total = searchHits.getTotalHits().value;
  34. // 4.2.文档数组
  35. SearchHit[] hits = searchHits.getHits();
  36. // 4.3.遍历
  37. List<HotelDoc> hotels = new ArrayList<>();
  38. for (SearchHit hit : hits) {
  39. // 获取文档source
  40. String json = hit.getSourceAsString();
  41. // 反序列化
  42. HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class);
  43. // 放入集合
  44. hotels.add(hotelDoc);
  45. }
  46. // 4.4.封装返回
  47. return new PageResult(total, hotels);
  48. }

酒店结果过滤

需求:添加品牌、城市、星级、价格等过滤功能

需求分析

包含的过滤条件有:

  • brand:品牌值
  • city:城市
  • minPrice~maxPrice:价格范围
  • starName:星级

我们需要做两件事情:

  • 修改请求参数的对象 RequestParams,接收上述参数
  • 修改业务逻辑,在搜索条件之外,添加一些过滤条件

修改实体类

修改在 cn.itcast.hotel.pojo 包下的实体类 RequestParams:

  1. @Data
  2. public class RequestParams {
  3. private String key;
  4. private Integer page;
  5. private Integer size;
  6. private String sortBy;
  7. // 下面是新增的过滤条件参数
  8. private String city;
  9. private String brand;
  10. private String starName;
  11. private Integer minPrice;
  12. private Integer maxPrice;
  13. }

修改搜索业务

在 HotelService 的 search 方法中,只有一个地方需要修改:requet.source().query( ... ) 其中的查询条件。

在之前的业务中,只有 match 查询,根据关键字搜索,现在要添加条件过滤,包括:

  • 品牌过滤:是 keyword 类型,用 term 查询
  • 星级过滤:是 keyword 类型,用 term 查询
  • 价格过滤:是数值类型,用 range 查询
  • 城市过滤:是 keyword 类型,用 term 查询

多个查询条件组合,肯定是 boolean 查询来组合:

  • 关键字搜索放到 must 中,参与算分
  • 其它过滤条件放到 filter 中,不参与算分

因为条件构建的逻辑比较复杂,这里封装为一个函数,getBoolQueryBuilder 的代码如下:

  1. private BoolQueryBuilder getBoolQueryBuilder(RequestParams params) {
  2. BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
  3. String key = params.getKey();
  4. if (key == null || "".equals(key)) {
  5. boolQuery.must(QueryBuilders.matchAllQuery());
  6. } else {
  7. boolQuery.must(QueryBuilders.matchQuery("all", key));
  8. }
  9. // 条件过滤
  10. // 城市条件
  11. if (params.getCity() != null && !params.getCity().equals("")) {
  12. boolQuery.filter(QueryBuilders.termQuery("city", params.getCity()));
  13. }
  14. // 品牌条件
  15. if (params.getBrand() != null && !params.getBrand().equals("")) {
  16. boolQuery.filter(QueryBuilders.termQuery("brand", params.getBrand()));
  17. }
  18. // 星级
  19. if (params.getStarName() != null && !params.getStarName().equals("")) {
  20. boolQuery.filter(QueryBuilders.termQuery("starName", params.getStarName()));
  21. }
  22. // 价格
  23. if (params.getMinPrice() != null && params.getMaxPrice() != null) {
  24. boolQuery.filter(QueryBuilders.rangeQuery("price")
  25. .gte(params.getMinPrice()).lte(params.getMaxPrice()));
  26. }
  27. return boolQuery;
  28. }

我周边的酒店

需求:我附近的酒店

需求分析

在酒店列表页的右侧,有一个小地图,点击地图的定位按钮,地图会找到你所在的位置,并且,在前端会发起查询请求,将你的坐标发送到服务端。

我们要做的事情就是基于这个 location 坐标,然后按照距离对周围酒店排序。实现思路如下:

  • 修改 RequestParams 参数,接收 location 字段
  • 修改 search 方法业务逻辑,如果 location 有值,添加根据 geo_distance 排序的功能

修改实体类

修改在 cn.itcast.hotel.pojo 包下的实体类 RequestParams:

  1. package cn.itcast.hotel.pojo;
  2. import lombok.Data;
  3. @Data
  4. public class RequestParams {
  5. private String key;
  6. private Integer page;
  7. private Integer size;
  8. private String sortBy;
  9. private String city;
  10. private String brand;
  11. private String starName;
  12. private Integer minPrice;
  13. private Integer maxPrice;
  14. // 我当前的地理坐标
  15. private String location;
  16. }

距离排序 API

我们以前学习过排序功能,包括两种:

  • 普通字段排序
  • 地理坐标排序

我们只讲了普通字段排序对应的 Java 写法。地理坐标排序只学过 DSL 语法,如下:

  1. GET /indexName/_search
  2. {
  3. "query": {
  4. "match_all": {}
  5. },
  6. "sort": [
  7. {
  8. "price": "asc"
  9. },
  10. {
  11. "_geo_distance" : {
  12. "FIELD" : "纬度,经度",
  13. "order" : "asc",
  14. "unit" : "km"
  15. }
  16. }
  17. ]
  18. }

对应 Java 代码

  1. // 价格排序
  2. request.source().sort("price", SortOrder.ASC);
  3. // 距离排序
  4. request.source().sort(SortBuilders.geoDistanceSort("location", new GeoPoint("31.21, 121.5"))
  5. .order(SortOrder.ASC).unit(DistanceUnit.KILOMETERS));

添加距离排序

cn.itcast.hotel.service.implHotelServicesearch 方法中,添加一个排序功能:

  1. @Override
  2. public PageResult search(RequestParams params) {
  3. try {
  4. // 1.准备Request
  5. SearchRequest request = new SearchRequest("hotel");
  6. // 2.准备DSL
  7. // 2.1.query
  8. // 构建 boolQuery
  9. BoolQueryBuilder boolQuery = getBoolQueryBuilder(params);
  10. request.source().query(boolQuery);
  11. // 2.2.分页
  12. int page = params.getPage();
  13. int size = params.getSize();
  14. request.source().from((page - 1) * size).size(size);
  15. // 排序
  16. String location = params.getLocation();
  17. if (location != null && !location.equals("")) {
  18. request.source().sort(SortBuilders
  19. .geoDistanceSort("location", new GeoPoint(location))
  20. .order(SortOrder.ASC)
  21. .unit(DistanceUnit.KILOMETERS));
  22. }
  23. // 3.发送请求
  24. SearchResponse response = client.search(request, RequestOptions.DEFAULT);
  25. // 4.解析响应
  26. return handleResponse(response);
  27. } catch (IOException e) {
  28. throw new RuntimeException(e);
  29. }
  30. }

排序距离显示

排序完成后,页面还要获取我附近每个酒店的具体距离值,这个值在响应结果中是独立的:

因此,我们在结果解析阶段,除了解析 source 部分以外,还要得到 sort 部分,也就是排序的距离,然后放到响应结果中。

我们要做两件事:

  • 修改 HotelDoc,添加排序距离字段,用于页面显示
  • 修改 HotelService 类中的 handleResponse 方法,添加对 sort 值的获取

① 修改HotelDoc类,添加距离字段

  1. package cn.itcast.hotel.pojo;
  2. import lombok.Data;
  3. import lombok.NoArgsConstructor;
  4. @Data
  5. @NoArgsConstructor
  6. public class HotelDoc {
  7. private Long id;
  8. private String name;
  9. private String address;
  10. private Integer price;
  11. private Integer score;
  12. private String brand;
  13. private String city;
  14. private String starName;
  15. private String business;
  16. private String location;
  17. private String pic;
  18. // 排序时的 距离值
  19. private Object distance;
  20. public HotelDoc(Hotel hotel) {
  21. this.id = hotel.getId();
  22. this.name = hotel.getName();
  23. this.address = hotel.getAddress();
  24. this.price = hotel.getPrice();
  25. this.score = hotel.getScore();
  26. this.brand = hotel.getBrand();
  27. this.city = hotel.getCity();
  28. this.starName = hotel.getStarName();
  29. this.business = hotel.getBusiness();
  30. this.location = hotel.getLatitude() + ", " + hotel.getLongitude();
  31. this.pic = hotel.getPic();
  32. }
  33. }

② 修改 HotelService 中的 handleResponse 方法

// 结果解析
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);
        // 获取排序值 - location
        Object[] sortValues = hit.getSortValues();
        if (sortValues.length > 0) {
            Object sortValue = sortValues[0];
            hotelDoc.setDistance(sortValue);
        }

        // 放入集合
        hotels.add(hotelDoc);
    }
    // 4.4.封装返回
    return new PageResult(total, hotels);
}

酒店竞价排名

需求:让指定的酒店在搜索结果中排名置顶

需求分析

要让指定酒店在搜索结果中排名置顶,页面会给指定的酒店添加广告标记。

我们之前学习过的 function_score 查询可以影响算分,算分高了,自然排名也就高了。而 function_score 包含 3 个要素:

  • 过滤条件:哪些文档要加分
  • 算分函数:如何计算 function score
  • 加权方式:function score 与 query score 如何运算

这里的需求是:让指定酒店排名靠前。因此我们需要给这些酒店添加一个标记,这样在过滤条件中就可以根据这个标记来判断,是否要提高算分。

比如,我们给酒店添加一个字段:isAD,Boolean 类型:

  • true:是广告
  • false:不是广告

这样 function_score 包含 3 个要素就很好确定了:

  • 过滤条件:判断 isAD 是否为 true
  • 算分函数:我们可以用最简单暴力的 weight,固定加权值
  • 加权方式:可以用默认的相乘,大大提高算分

因此,业务的实现步骤包括:

  1. 给 HotelDoc 类添加 isAD 字段,Boolean 类型
  2. 挑选几个你喜欢的酒店,给它的文档数据添加 isAD 字段,值为 true
  3. 修改 search方法,添加 function score 功能,给 isAD 值为 true 的酒店增加权重

修改 HotelDoc 实体

cn.itcast.hotel.pojo 包下的 HotelDoc 类添加 isAD 字段:

@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;

    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();
    }
}

添加广告标记

用 DSL 添加酒店广告标记

POST /hotel/_update/36934
{
  "doc": {
    "isAD": true
  }
}

添加算分函数查询

接下来我们就要修改查询条件了。之前是用的 boolean 查询,现在要改成 function_socre 查询。

function_score 查询结构如下:

GET /hotel/_search
{
  "query": {
    "function_score": {
      "query": {
        "match": {
          "name": "外滩"
        }
      },
      "functions": [ 
        {
          "filter": {
            "term": {
              "brand": "如家"
            }
          },
          "weight": 5
        }
      ]
    }
  }
}

对应的 JavaAPI 如下

FunctionScoreQueryBuilder functionScoreQueryBuilder = 
    QueryBuilders.functionScoreQuery(
        QueryBuilders.matchQuery("name", "外滩"),
        new FunctionScoreQueryBuilder.FilterFunctionBuilder[]{
            new FunctionScoreQueryBuilder.FilterFunctionBuilder(
                QueryBuilders.termQuery("brand", "如家"), 
                ScoreFunctionBuilders.weightFactorFunction(5)
            )
        }
    );
sourceBuilder.query(functionScoreQueryBuilder);

我们可以将之前写的 boolean 查询作为原始查询条件放到 query 中,接下来就是添加过滤条件、算分函数、加权模式了。所以原来的代码依然可以沿用。

修改 cn.itcast.hotel.service.impl 包下的 HotelService 类中的 getQueryBuilder 方法,添加算分函数查询:

private FunctionScoreQueryBuilder getQueryBuilder(RequestParams params) {
    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()));
    }

    // 算分控制
    FunctionScoreQueryBuilder functionScoreQueryBuilder =
        QueryBuilders.functionScoreQuery(
        // 原始查询,相关性算分
        boolQuery,
        // function score
        new FunctionScoreQueryBuilder.FilterFunctionBuilder[]{
            // 一个 function score 元素
            new FunctionScoreQueryBuilder.FilterFunctionBuilder(
                // 过滤条件
                QueryBuilders.termQuery("isAD", true),
                // 算分函数
                ScoreFunctionBuilders.weightFactorFunction(10)
            )
        });

    return functionScoreQueryBuilder;
}