ElasticSearch入门及JavaRestClient
JavaRestClient
上小节中我们利用ES和Kibana了解了restfu风格的ES基础操作,包括索引库和文档的CRUD
实际应用当中我们如何用代码来操作ES呢?
- ES官方提供了各种不同语言的客户端用来操作ES。这些客户端的本质就是组装DSL语句,通过HTTP请求发送给ES。
- 官方文档地址:https://www.elastic.co/guide/en/elasticsearch/client/index.html
- 其中java 的API 是JavaRestClient,它又包括两种
- Java Low Level Rest Client
- Java High Level Rest Client
- 这里学习的是Java High Level Rest Client
JavaRestClient索引库操作
预备数据
数据库中导入黑马提供的sql脚本,表名hotel-demo
字段 | 类型 | 长度 | 注释 |
---|---|---|---|
id | bigint | 20 | 酒店id |
name | varchar | 255 | 酒店名称 |
address | varchar | 255 | 酒店地址 |
price | int | 10 | 酒店价格 |
score | int | 2 | 酒店评分 |
brand | varchar | 32 | 酒店品牌 |
city | varchar | 32 | 所在城市 |
star_name | varchar | 16 | 酒店星级,1星到5星,1钻到5钻 |
business | varchar | 255 | 商圈 |
latitude | varchar | 32 | 纬度 |
longitude | varchar | 32 | 经度 |
pic | varchar | 255 | 酒店图片 |
mapping映射分析
创建索引库,最关键的是mapping映射,而mapping映射要考虑的信息包括
- 字段名?
- 字段数据类型?
- 是否参与搜索?
- 是否需要分词?
- 如果分词,分词器是什么?
其中
- 字段名、字段数据类型,可以参考数据表结构的名称和类型
- 是否参与搜索要分析业务来判断,例如图片地址,就无需参与搜索
- 是否分词要看内容,如果内容是一个整体就无需分词,反之则需要分词
- 分词器,为了提高被搜索到的概率,统一使用最细切分
ik_max_word
下面我们来分析一下酒店数据的索引库结构
id
:id的类型比较特殊,不是long
,而是keyword
,而且id后期肯定需要涉及到我们的增删改查,所以需要参与搜索默认添加索引name
:需要参与搜索,而且是text
,需要参与分词,分词器选择ik_max_wordaddress
:是字符串,但是个人感觉不需要分词(所以这里把它设为keyword),当然你也可以选择分词,个人感觉不需要参与搜索,所以index为falseprice
:类型:integer,需要参与搜索(做范围排序)score
:类型:integer,需要参与搜索(做范围排序)brand
:类型:keyword,但是不需要分词(品牌名称分词后毫无意义),所以为keyword,需要参与搜索city
:类型:keyword,分词无意义,需要参与搜索star_name
:类型:keyword,需要参与搜索business
:类型:keyword,需要参与搜索latitude
和longitude
:地理坐标在ES中比较特殊,ES中支持两种地理坐标数据类型:
geo_point:
由纬度(latitude)和经度( longitude)确定的一个点。例如:”32.8752345,120.2981576”geo_shape:
有多个geo_point组成的复杂几何图形。例如一条直线,”LINESTRING (-77.03653 38.897676,-77.009051 38.889939)”
- 所以这里应该是geo_point类型
pic
:不分词 类型:keyword,不需要参与搜索,index为false
如果手动创建语句是这样的
PUT /hotel
{
"mappings": {
"properties": {
"id": {
"type": "keyword"
},
"name": {
"type": "text",
"analyzer": "ik_max_word"
},
"address": {
"type": "keyword",
"index": false
},
"price": {
"type": "integer"
},
"score": {
"type": "integer"
},
"brand": {
"type": "keyword"
},
"city": {
"type": "keyword"
},
"starName": {
"type": "keyword"
},
"business": {
"type": "keyword"
},
"location": {
"type": "geo_point"
},
"pic": {
"type": "keyword",
"index": false
}
}
}
}
但是现在还有一个小小的问题,现在我们的name、brand、city字段都需要参与搜索,也就意味着用户在搜索的时候,会根据多个字段搜,例如:
上海虹桥希尔顿五星酒店
那么ES是根据多个字段搜效率高,还是根据一个字段搜效率高
- 显然是搜索一个字段效率高
那现在既想根据多个字段搜又想要效率高,怎么解决这个问题呢?
ES给我们提供了一种简单的解决方案
字段拷贝可以使用
copy_to
属性,将当前字段拷贝到指定字段,示例类似于组合索引
"all": { "type": "text", "analyzer": "ik_max_word" }, "brand": { "type": "keyword", "copy_to": "all" }
- 使用 copy_to 的情况下 修改DSL语句
PUT /hotel
{
"mappings": {
"properties": {
"id": {
"type": "keyword"
},
"name": {
"type": "text",
"analyzer": "ik_max_word",
"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": "ik_max_word"
}
}
}
}
初始化RestCliet
- 在
ElasticSearch
提供的API中,与ElasticSearch
一切交互都封装在一个名为RestHighLevelClient
的类中,必须先完成这个对象的初始化,建立与ES的连接
引入ES的RestHighLevelClient的依赖
<dependency> <groupId>org.elasticsearch.client</groupId> <artifactId>elasticsearch-rest-high-level-client</artifactId> </dependency>
因为SpringBoot管理的ES默认版本为7.6.2,所以我们需要覆盖默认的ES版本
<properties> <java.version>1.8</java.version> <elasticsearch.version>7.12.1</elasticsearch.version> </properties>
初始化RestHighLevelClient
RestHighLevelClient client = new RestHighLevelClient(RestClient.builder( HttpHost.create("http://192.168.150.101:9200") ));
- 但是为了单元测试方便,我们创建一个测试类HotelIndexTest,在成员变量声明一个RestHighLevelClient,然后将初始化的代码编写在
@BeforeEach
中
import org.apache.http.HttpHost;
import org.elasticsearch.client.RestClient;
import org.elasticsearch.client.RestHighLevelClient;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
public class HotelIndexTest {
private RestHighLevelClient client;
@Test
void contextLoads() {
}
@BeforeEach//表示在测试类中任何一个测试方法执行之前都先执行该注解标注的方法
public void setup() {
this.client = new RestHighLevelClient(RestClient.builder(
new HttpHost("http://192.168.101.65:9200")
));
}
@AfterEach//表示在测试类中任何一个测试方法执行之后都先执行该注解标注的方法
void tearDown() throws IOException {
this.client.close();
}
}
创建索引库
代码解读
- 创建索引库的代码如下
@Test
void testCreateHotelIndex() throws IOException {
CreateIndexRequest request = new CreateIndexRequest("hotel");
request.source(MAPPING_TEMPLATE, XContentType.JSON);
client.indices().create(request, RequestOptions.DEFAULT);
}
代码分为三部分
- 创建Request对象,因为是创建索引库的操作,因此Request是CreateIndexRequest,这一步对标DSL语句中的
PUT /hotel
- 添加请求参数,其实就是DSL的JSON参数部分,因为JSON字符很长,所以这里是定义了静态常量
MAPPING_TEMPLATE
,让代码看起来更优雅
public static final String MAPPING_TEMPLATE = "{\n" +
" \"mappings\": {\n" +
" \"properties\": {\n" +
" \"id\": {\n" +
" \"type\": \"keyword\"\n" +
" },\n" +
" \"name\": {\n" +
" \"type\": \"text\",\n" +
" \"analyzer\": \"ik_max_word\",\n" +
" \"copy_to\": \"all\"\n" +
" },\n" +
" \"address\": {\n" +
" \"type\": \"keyword\",\n" +
" \"index\": false\n" +
" },\n" +
" \"price\": {\n" +
" \"type\": \"integer\"\n" +
" },\n" +
" \"score\": {\n" +
" \"type\": \"integer\"\n" +
" },\n" +
" \"brand\": {\n" +
" \"type\": \"keyword\",\n" +
" \"copy_to\": \"all\"\n" +
" },\n" +
" \"city\": {\n" +
" \"type\": \"keyword\"\n" +
" },\n" +
" \"starName\": {\n" +
" \"type\": \"keyword\"\n" +
" },\n" +
" \"business\": {\n" +
" \"type\": \"keyword\"\n" +
" , \"copy_to\": \"all\"\n" +
" },\n" +
" \"location\": {\n" +
" \"type\": \"geo_point\"\n" +
" },\n" +
" \"pic\": {\n" +
" \"type\": \"keyword\",\n" +
" \"index\": false\n" +
" },\n" +
" \"all\":{\n" +
" \"type\": \"text\",\n" +
" \"analyzer\": \"ik_max_word\"\n" +
" }\n" +
" }\n" +
" }\n" +
"}";
- 发送请求,client.indics()方法的返回值是IndicesClient类型,封装了所有与索引库有关的方法
删除索引库
与创建索引库相比
- 请求方式由PUT变为DELETE
- 请求路径不变
- 无请求参数
所以代码的差异,主要体现在Request对象上,整体步骤没有太大变化
- 创建Request对象,这次是DeleteIndexRequest对象
- 准备请求参数,这次是无参
- 发送请求,改用delete方法
@Test
void testDeleteHotelIndex() throws IOException {
DeleteIndexRequest request = new DeleteIndexRequest("hotel");
client.indices().delete(request, RequestOptions.DEFAULT);
}
判断索引库是否存在
- 判断索引库是否存在,本质就是查询,对应的DSL是
GET /hotel
因此与删除的Java代码流程是类似的
- 创建Request对象,这次是GetIndexRequest对象
- 准备请求参数,这里是无参
- 发送请求,改用exists方法
@Test
void testGetHotelIndex() throws IOException {
GetIndexRequest request = new GetIndexRequest("hotel");
boolean exists = client.indices().exists(request, RequestOptions.DEFAULT);
System.out.println(exists ? "索引库已存在" : "索引库不存在");
}
总结
- JavaRestClient对索引库操作的流程计本类似,核心就是client.indices()方法来获取索引库的操作对象
- 索引库操作基本步骤
- 初始化RestHighLevelClient
- 创建XxxIndexRequest。Xxx是Create、Get、Delete
- 准备DSL(Create时需要,其它是无参)
- 发送请求,调用ReseHighLevelClient.indices().xxx()方法,xxx是create、exists、delete
RestClient文档操作
为了与索引库操作分离,我们再添加一个测试类,做两件事
- 初始化RestHighLevelClient
- 我们的酒店数据在数据库,需要利用IHotelService去查询,所以要注入这个接口
import cn.blog.hotel.service.IHotelService;
import org.apache.http.HttpHost;
import org.elasticsearch.client.RestClient;
import org.elasticsearch.client.RestHighLevelClient;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.io.IOException;
@SpringBootTest
public class HotelDocumentTest {
@Autowired
private IHotelService hotelService;
private RestHighLevelClient client;
@BeforeEach
void setUp() {
client = new RestHighLevelClient(RestClient.builder(
new HttpHost("http://192.168.128.130:9200")
));
}
@AfterEach
void tearDown() throws IOException {
client.close();
}
}
新增文档
我们要把数据库中的酒店数据查询出来,写入ES中
但是与我们的索引库结构存在差异
- longitude和latitude需要合并为location
- 因此我们需要定义一个新类型,与索引库结构吻合
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;
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(); // 在构造函数中,定义location
this.pic = hotel.getPic();
}
}
语法说明
- 新增文档的DSL语法如下
POST /{索引库名}/_doc/{id}
{
"name": "Jack",
"age": 21
}
- 对应的Java代码如下
@Test
void testIndexDocument() throws IOException {
IndexRequest request = new IndexRequest("indexName").id("1");
request.source("{\"name\":\"Jack\",\"age\":21}");
client.index(request, RequestOptions.DEFAULT);
}
- 可以看到与创建索引库类似,同样是三步走:
- 创建Request对象
- 准备请求参数,也就是DSL中的JSON文档
- 发送请求
- 变化的地方在于,这里直接使用client.xxx()的API,不再需要client.indices()了
完整代码
- 我们导入酒店数据,基本流程一致,但是需要考虑几点变化
- 酒店数据来自于数据库,我们需要先从数据库中查询,得到
Hotel
对象 Hotel
对象需要转换为HotelDoc
对象HotelDoc
需要序列化为json
格式
- 酒店数据来自于数据库,我们需要先从数据库中查询,得到
- 因此,代码整体步骤如下
- 根据id查询酒店数据Hotel
- 将Hotel封装为HotelDoc
- 将HotelDoc序列化为Json
- 创建IndexRequest,指定索引库名和id
- 准备请求参数,也就是Json文档
- 发送请求
在hotel-demo的HotelDocumentTest测试类中,编写单元测试
@Test
void testAddDocument() throws IOException {
// 1. 根据id查询酒店数据
Hotel hotel = hotelService.getById(61083L);
// 2. 转换为文档类型
HotelDoc hotelDoc = new HotelDoc(hotel);
// 3. 转换为Json字符串
String jsonString = JSON.toJSONString(hotelDoc);
// 4. 准备request对象
IndexRequest request = new IndexRequest("hotel").id(hotel.getId().toString());
// 5. 准备json文档
request.source(jsonString, XContentType.JSON);
// 6. 发送请求
// Validation Failed: 1: index is missing;
client.index(request, RequestOptions.DEFAULT);
}
在kibana中查询我们新增的文档,发现我们的文档主要是在_source
属性里
查询文档(id查询)
查询的DSL语句如下
GET /hotel/_doc/{id}
由于没有请求参数,所以非常简单,代码分为以下两步
- 准备Request对象
- 发送请求
- 解析结果
不过查询的目的是为了得到HotelDoc,因此难点是结果的解析,在刚刚查询的结果中,我们发现HotelDoc对象的主要内容在_source属性中,所以我们要获取这部分内容,然后将其转化为HotelDoc
@Test void testGetDocumentById() throws IOException { // 1. 准备request对象 GetRequest request = new GetRequest("hotel").id("61083"); // 2. 发送请求,得到结果 GetResponse response = client.get(request, RequestOptions.DEFAULT); // 3. 解析结果 String jsonStr = response.getSourceAsString(); HotelDoc hotelDoc = JSON.parseObject(jsonStr, HotelDoc.class); System.out.println(hotelDoc); }
修改文档
修改依旧是两种方式
- 全量修改:本质是先根据id删除,再新增
- 增量修改:修改文档中的指定字段值
在RestClient的API中,全量修改与新增的API完全一致,判断的依据是ID
- 若新增时,ID已经存在,则修改(删除再新增)
- 若新增时,ID不存在,则新增
这里就主要讲增量修改,对应的DSL语句如下
POST /test001/_update/1 { "doc":{ "email":"BestApex@Apex.net", "info":"恐怖G7人--马文" } }
与之前类似,也是分为三步
准备Request对象,这次是修改,对应的就是UpdateRequest
准备参数,也就是对应的JSON文档,里面包含要修改的字段
发送请求,更新文档
@Test void testUpdateDocumentById() throws IOException { // 1. 准备request对象 UpdateRequest request = new UpdateRequest("hotel","61083"); // 2. 准备参数 request.doc( "city","北京", "price",1888); // 3. 发送请求 client.update(request,RequestOptions.DEFAULT); }
删除文档
删除的DSL语句如下
DELETE /hotel/_doc/{id}
与查询相比,仅仅是请求方式由DELETE变为GET,不难猜想对应的Java依旧是三步走
准备Request对象,因为是删除,所以是DeleteRequest对象,要指明索引库名和id
准备参数,无参
发送请求,因为是删除,所以是client.delete()方法
@Test void testDeleteDocumentById() throws IOException { // 1. 准备request对象 DeleteRequest request = new DeleteRequest("hotel","61083"); // 2. 发送请求 client.delete(request,RequestOptions.DEFAULT); }
成功删除之后,再调用查询的测试方法,返回值为null,删除成功
批量导入文档
- 之前我们都是一条一条的新增文档,但实际应用中,还是需要批量的将数据库数据导入索引库中
需求:批量查询酒店数据,然后批量导入索引库中
思路:
- 利用mybatis-plus查询酒店数据
- 将查询到的酒店数据(Hotel)转化为文档类型数据(HotelDoc)
- 利用JavaRestClient中的Bulk批处理,实现批量新增文档,示例代码如下
实现代码如下
@Test void testBulkAddDoc() throws IOException { BulkRequest request = new BulkRequest(); List<Hotel> hotels = hotelService.list(); for (Hotel hotel : hotels) { HotelDoc hotelDoc = new HotelDoc(hotel); request.add(new IndexRequest("hotel"). id(hotelDoc.getId().toString()). source(JSON.toJSONString(hotelDoc), XContentType.JSON)); } client.bulk(request, RequestOptions.DEFAULT); }
使用stream流操作可以简化代码
@Test void testBulkAddDoc() throws IOException { BulkRequest request = new BulkRequest(); hotelService.list().stream().forEach(hotel -> request.add(new IndexRequest("hotel") .id(hotel.getId().toString()) .source(JSON.toJSONString(new HotelDoc(hotel)), XContentType.JSON))); client.bulk(request, RequestOptions.DEFAULT); }
批量查询
GET /hotel/_search
小结
- 文档操作的基本步骤
- 初始化RestHighLevelClient
- 创建XxxRequest对象,Xxx是Index、Get、Update、Delete
- 准备参数(Index和Update时需要)
- 发送请求,调用RestHighLevelClient.xxx方法,xxx是index、get、update、delete
- 解析结果(Get时需要)
RestClient查询语句(非id查询)
- 文档的查询同样适用于RestHighLevelClient对象,基本步骤包括
- 准备Request对象
- 准备请求参数
- 发起请求
- 解析响应
查询全部
- 我们以match_all为例
发起查询请求
DSL语句的match_all
GET /hotel/_search { "query": { "match_all": {} } }
对应的java代码
@Test
void testMatchAll() throws IOException {
// 1. 准备Request对象,对应 GET /hotel/_search
SearchRequest request = new SearchRequest("hotel");
// 2. 组织DSL参数 对应 "query": {"match_all": {}}
request.source().query(QueryBuilders.matchAllQuery());
// 3. 发送请求,得到响应结果
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 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 hotelDoc = JSON.parseObject(json, HotelDoc.class);
System.out.println(hotelDoc);
}
}
- 代码解读
- 创建SearchRequest对象,指定索引库名
- 利用request.source()构建DSL,DSL中可以包含查询、分页、排序、高亮等
- 利用client.search()发送请求,得到响应
- 这里的关键API有两个
- 一个是request.source(),其中包含了
query
、order
、from
、size
、highlight
等所有功能 - 另一个是QueryBuilders,其中包含了
match
、term
、function_score
、bool
等各种查询
- 一个是request.source(),其中包含了
- 输出结果就是我们在kibana中看到的JSON字符串
- ES返回的结果是一个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文档数据
- SearchHits:通过response.getHits()获取,就是JSON中的最外层的hits,代表命中的结果
小结
- 查询的基本步骤是
- 创建SearchRequest对象
- 准备Request.source(),也就是DSL
- QueryBuilders来构建查询条件
- 传入Request.source()的query()方法中作为参数
- 发送请求,得到结果
- 解析结果(参考JSON结果,从外到内,逐层解析)
全文检索查询(match)
- 全文检索的match和multi_match查询与match_all的API基本一致。
- 差别是查询条件,也就是query的那部分
GET /hotel/_search
{
"query": {
"match_all": {}
}
}
GET /hotel/_search
{
"query": {
"match": {
"all": "北京"
}
}
}
GET /hotel/_search
{
"query": {
"multi_match": {
"query": "如家",
"fields": ["brand", "name"]
}
}
- 因此,Java代码上的差异主要是request.source.query()中的参数了。同样是利用QueryBuilders提供的方法
- 单字段查询:
QueryBuilders.matchQuery("all","北京")
@Test
void testMatch() throws IOException {
// 1. 准备Request对象,对应 GET /hotel/_search
SearchRequest request = new SearchRequest("hotel");
// 2. 组织DSL参数
request.source().query(QueryBuilders.matchQuery("all","北京"));
// 3. 发送请求,得到响应结果
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4. 解析响应
handleResponse(response);
}
@Test
void testMultiMatch() throws IOException {
// 1. 准备Request对象,对应 GET /hotel/_search
SearchRequest request = new SearchRequest("hotel");
// 2. 组织DSL参数
request.source().query(QueryBuilders.multiMatchQuery("如家","brand","name"));
// 3. 发送请求,得到响应结果
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4. 解析响应
handleResponse(response);
}
- 解析响应的代码都是相同的,所以这里抽取成了一个名为handleResponse的方法,使用IDEA的快捷键Ctrl+ Alt + M可以快速抽取 (注意关闭网抑云的全局热键,不然会冲突)
精确查询
- 精确查询主要是这两个
- term:词条精确匹配
- range:范围查询
- 与之前的查询相比,差异同样在查询条件,其他的都一样
- 精确匹配在北京的酒店:
QueryBuilders.termQuery("city","北京")
@Test
void testTermMatch() throws IOException {
// 1. 准备Request对象,对应 GET /hotel/_search
SearchRequest request = new SearchRequest("hotel");
// 2. 组织DSL参数
request.source().query(QueryBuilders.termQuery("city","北京"));
// 3. 发送请求,得到响应结果
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4. 解析响应
handelResponse(response);
}
- -范围查询价格在1000~2000的酒店:
QueryBuilders.rangeQuery("price").gt(1000).lt(2000)
@Test
void testRangeMatch() throws IOException {
// 1. 准备Request对象,对应 GET /hotel/_search
SearchRequest request = new SearchRequest("hotel");
// 2. 组织DSL参数
request.source().query(QueryBuilders.rangeQuery("price").gt(1000).lt(2000));
// 3. 发送请求,得到响应结果
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4. 解析响应
handelResponse(response);
}
布尔查询
- 布尔查询是用must、must_not、filter等方式组合其他查询
- 例如:查询在
上海
的华美达
或者皇冠假日
酒店,用户评分在45分
以上,价格在500~2000
@Test
void testBoolMatch() throws IOException {
// 1. 准备Request对象,对应 GET /hotel/_search
SearchRequest request = new SearchRequest("hotel");
// 2. 组织DSL参数
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
// 2.1 添加must条件
boolQuery.must(QueryBuilders.termQuery("city", "上海"));
// 2.2 添加should条件 (should有点问题,但是貌似可以用must配合termsQuery来达到should的效果)
boolQuery.must(QueryBuilders.termsQuery("brand", "华美达", "皇冠假日"));
// 2.3 添加mustNot条件
boolQuery.mustNot(QueryBuilders.rangeQuery("score").lt(45));
// 2.4 添加filter条件
boolQuery.filter(QueryBuilders.rangeQuery("price").gt(500).lt(2000));
request.source().query(boolQuery);
// 3. 发送请求,得到响应结果
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4. 解析响应
handelResponse(response);
}
排序、分页
- 搜索结果的排序和分页是与query同级的参数,因此同样是使用request.source()来设置
- 示例代码如下,ES的API都支持链式编程还挺舒服的
@Test
void testSortMatch() throws IOException {
// 1. 准备Request对象,对应 GET /hotel/_search
SearchRequest request = new SearchRequest("hotel");
// 2. 组织DSL参数
request.source().query(QueryBuilders.matchAllQuery())
.sort("price", SortOrder.ASC)
.from(0)
.size(5);
// 3. 发送请求,得到响应结果
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4. 解析响应
handelResponse(response);
}
@Test
void testPageAndSort() throws IOException {
int page = 1,size=5;
// 1. 准备Request对象,对应 GET /hotel/_search
SearchRequest request = new SearchRequest("hotel");
// 2. 组织DSL参数
request.source().query(QueryBuilders.matchAllQuery())
.sort("price", SortOrder.ASC)
.from((page-1)*size)
.size(size);
// 3. 发送请求,得到响应结果
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4. 解析响应
handelResponse(response);
}
高亮
- 高亮的代码与之前的代码差异较大,有两点
- 查询的DSL,其中除了查询条件,还需要添加高亮条件,同样是与query同级
- 结果解析,结果除了要解析_source文档,还需要解析高亮结果
高亮请求构建
高亮请求的API如下
request.source().query(QueryBuilders.matchAllQuery()) .highlighter(new HighlightBuilder() .field("name") .requireFieldMatch(false));
对应的DSL语句
"highlight": {
"fields": {
"name": {
"require_field_match": "false"
}
}
- 上述代码中省略了查询条件部分,但是千万别忘了:高亮查询必须使用全文检索查询,并且要有搜索关键字,将来才可以对关键字高亮
- 示例代码如下
@Test
void testHighLightMatch() throws IOException {
// 1. 准备Request对象,对应 GET /hotel/_search
SearchRequest request = new SearchRequest("hotel");
// 2. 组织DSL参数
request.source().query(QueryBuilders.matchQuery("all","如家"));
request.source().highlighter(new HighlightBuilder()
.field("name")
.requireFieldMatch(false));
// 3. 发送请求,得到响应结果
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4. 解析响应
handelResponse(response);
}
高亮结果解析
- 高亮的结果与查询的文档结果默认是分离的,并不是在一起
"hits" : {
"total" : {
"value" : 102,
"relation" : "eq"
},
"max_score" : null,
"hits" : [
{
"_index" : "hotel",
"_type" : "_doc",
"_id" : "1765008760",
"_score" : null,
"_source" : {
"address" : "西直门北大街49号",
"brand" : "如家",
"business" : "西直门/北京展览馆地区",
"city" : "北京",
"id" : 1765008760,
"location" : "39.945106, 116.353827",
"name" : "如家酒店(北京西直门北京北站店)",
"pic" : "https://m.tuniucdn.com/fb3/s1/2n9c/4CLwbCE9346jYn7nFsJTQXuBExTJ_w200_h200_c1_t0.jpg",
"price" : 356,
"score" : 44,
"starName" : "二钻"
},
"highlight" : {
"name" : [
"<em>如家</em>酒店(北京西直门北京北站店)"
]
},
"sort" : [
6.376497864377032,
356
]
}
- 因此解析高亮的代码需要额外处理
- 代码解读:
- 第一步:从结果中获取source。hit.getSourceAsString(),这部分是非高亮结果,json字符串。还需要反序列为HotelDoc对象
- 第二步:获取高亮结果。hit.getHighlightFields(),返回值是一个Map,key是高亮字段名称,值是HighlightField对象,代表高亮值
- 第三步:从map中根据高亮字段名称,获取高亮字段值对象HighlightField
- 第四步:从HighlightField中获取Fragments,并且转为字符串。这部分就是真正的高亮字符串了
- 第五步:用高亮的结果替换HotelDoc中的非高亮结果
- 完整代码如下
@Test
void testHighLightMatch() throws IOException {
// 1. 准备Request对象,对应 GET /hotel/_search
SearchRequest request = new SearchRequest("hotel");
// 2. 组织DSL参数
request.source().query(QueryBuilders.matchQuery("all", "如家"))
.highlighter(new HighlightBuilder()
.field("name")
.requireFieldMatch(false));
// 3. 发送请求,得到响应结果
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4. 解析响应
SearchHits searchHits = response.getHits();
TotalHits total = searchHits.getTotalHits();
System.out.println("共查询到" + total + "条数据");
SearchHit[] hits = searchHits.getHits();
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);
}
}
黑马旅游案例
- 这里只要实现四部分功能
- 酒店搜索和分页
- 酒店结果过滤
- 我周边的酒店
- 酒店竞价排名
- 启动黑马提供好的hotel-demo项目,默认端口是8089,访问http://localhost:8089/, 就能看到项目页面了
酒店搜索和分页
- 需求:实现黑马旅游的酒店搜索功能,完成关键字搜索和分页
需求分析
- 在项目首页,有一个搜索框,还有分页按钮
- 搜索框输入
上海
,页面翻到第2页,点击搜索,查看控制台发出的请求
请求网址: http://localhost:8089/hotel/list
请求方法: POST
请求参数
{key: "上海", page: 2, size: 5, sortBy: "default"}
- 由此可得
- 请求方式:POST
- 请求路径:/hotel/list
- 请求参数:JSON对象,包含4个端
key
:搜索关键字page
:页码size
:每页大小sortBy
:排序,目前暂不实现
- 返回值:分页查询,需要发挥分页结果PageResult,包含两个属性
total
:总条数List<HotelDoc>
:当页的数据
- 因此,我们实现业务的流程如下
- 定义实体类,用于接收请求参数的对象和返回响应结果的对象
- 编写controller,接收页面的请求
- 编写业务实现,利用RestHighLevelClient实现搜索、分页
定义实体类
实体类有两个,一个是前端的请求参数实体,另一个是服务端应该返回的响应结果实体
- 请求参数
{key: "上海", page: 2, size: 5, sortBy: "default"}
- 在pojo包下定义一个实体类
import lombok.Data;
@Data
public class RequestParams {
private String key;
private Integer page;
private Integer size;
private String sortBy;
}
- 分页查询,需要返回分页结果PageResult
@Data
@AllArgsConstructor
public class PageResult {
private long total;
private List<HotelDoc> hotels;
}
定义controller
- 定义一个HotelController,声明查询接口,满足以下要求
- 请求方式:POST
- 请求路径:/hotel/list
- 请求参数:RequestParams对象
- 返回值:PageResult
- 在web.controller包下新建HotelController
@RestController
@RequestMapping("/hotel")
public class HotelController {
@Autowired
private HotelService hotelService;
@PostMapping("/list")
public PageResult search(@RequestBody RequestParams params){
return hotelService.search(params);
}
}
实现搜索业务
- 我们在controller中调用了IHotelService,那我们现在在IHotelService中定义方法,并实现业务逻辑
- 定义方法
PageResult search(RequestParams params);
实现搜索逻辑,我们需要实现将RestHighLevelClient注册到Spring中作为一个Bean
@MapperScan("cn.itcast.hotel.mapper")
@SpringBootApplication
public class HotelDemoApplication {
public static void main(String[] args) {
SpringApplication.run(HotelDemoApplication.class, args);
}
@Bean
public RestHighLevelClient client(){
return new RestHighLevelClient(RestClient.builder(
HttpHost.create("http://192.168.128.130:9200")));
}
}
实现搜索逻辑
@Service
public class HotelService extends ServiceImpl<HotelMapper, Hotel> implements IHotelService {
@Autowired
private RestHighLevelClient client;
@Override
public PageResult search(RequestParams params) {
try {
// 1. 准备request对象
SearchRequest request = new SearchRequest("hotel");
// 2. 准备DSL
// 2.1 获取搜索关键字
String key = params.getKey();
// 2.2 健壮性判断
if (StringUtils.isEmpty(key)) {
// 未输入搜索条件,则查询全部
request.source().query(QueryBuilders.matchAllQuery());
} else {
request.source().query(QueryBuilders.matchQuery("all", key));
}
// 2.3 分页
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) {
// 获取总条数
SearchHits searchHits = response.getHits();
long total = searchHits.getTotalHits().value;
// 获取文档数组
SearchHit[] hits = searchHits.getHits();
// 遍历
ArrayList<HotelDoc> hotels = new ArrayList<>();
for (SearchHit hit : hits) {
// 获取每条文档
String json = hit.getSourceAsString();
// 反序列化为对象
HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class);
// 放入集合
hotels.add(hotelDoc);
}
// 封装返回
return new PageResult(total, hotels);
}
}
酒店结果过滤
- 需求:添加品牌、城市、星级、价格等过滤功能
需求分析
- 在页面的搜索框下,会有一些过滤项
- 我们选中过滤项,查看传递的参数
{
brand: "7天酒店"
city: "上海"
key: "上海"
maxPrice: 999999
minPrice: 1500
page: 10
size: 5
sortBy: "default"
starName: "五钻"
}
- 包含的过滤条件有
- brand:品牌
- city:城市
- maxPrice~minPrice:价格范围
- starName:星级
- 那我们现在就需要修改我们的RequestParams,接收上述参数,并且还需要修改我们的业务逻辑,添加一些过滤条件
修改实体类
- 在RequestParams中添加额外的参数
@Data
public class RequestParams {
private String key;
private Integer page;
private Integer size;
private String sortBy;
// 额外参数
private String brand;
private String city;
private String starName;
private Integer maxPrice;
private Integer minPrice;
}
修改搜索逻辑
- 这里就涉及到了符合查询,所以就需要用到布尔查询
- 关键字放到must中,参与算分
- 其余过滤条件放到filter中,不参与算分
- 由于过滤条件比较复杂,所以这里先将其封装为一个名为
buildBasicQuery
函数
@Override
public PageResult search(RequestParams params) {
try {
SearchRequest request = new SearchRequest("hotel");
buildBasicQuery(params, request);
int page = params.getPage();
int size = params.getSize();
request.source()
.from((page - 1) * size)
.size(size);
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
return handleResponse(response);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
buildBasicQuery函数
private void buildBasicQuery(RequestParams params, SearchRequest request) {
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
String key = params.getKey();
if (StringUtils.isEmpty(key)) {
boolQuery.must(QueryBuilders.matchAllQuery());
} else {
boolQuery.must(QueryBuilders.matchQuery("all", key));
}
// 品牌条件
if (params.getBrand() != null && !params.getBrand().equals("")) {
boolQuery.filter(QueryBuilders.termsQuery("brand", params.getBrand()));
}
// 城市条件
if (params.getCity() != null && !params.getCity().equals("")) {
boolQuery.filter(QueryBuilders.termsQuery("city", params.getCity()));
}
// 星级条件
if (params.getStarName() != null && !params.getStarName().equals("")) {
boolQuery.filter(QueryBuilders.termsQuery("starName", params.getStarName()));
}
// 价格条件
if (params.getMaxPrice() != null && params.getMinPrice() != null) {
boolQuery.filter(QueryBuilders
.rangeQuery("price")
.gt(params.getMinPrice())
.lt(params.getMaxPrice()));
}
request.source().query(boolQuery);
}
我周边的酒店
- 需求:我附近的酒店
需求分析
在酒店列表页的右侧,有一个小地图,点击地图定位按钮,会找到你所在位置,并且前端会发起查询请求,将你的坐标发送到服务器
{ key: "", page: 1, size: 5, sortBy: "default", location: "39.882165, 116.531421" }
那我们还需要在RequestParams类中添加一个新字段,用户获取location坐标
然后修改搜索逻辑,如果location有值,则添加根据geo_distance排序的功能
@Data
public class RequestParams {
private String key;
private Integer page;
private Integer size;
private String sortBy;
// 额外参数
private String brand;
private String city;
private String starName;
private Integer maxPrice;
private Integer minPrice;
// 额外参数2
private String location;
}
排序API
基本语法
request.source().sort("price", SortOrder.ASC) .sort(SortBuilders.geoDistanceSort("location",new GeoPoint("39.9, 131.6")) .order(SortOrder.ASC) .unit(DistanceUnit.KILOMETERS));
添加距离排序
- 修改search方法,添加距离排序
String location = params.getLocation();
if (!StringUtils.isEmpty(location)) {
request.source()
.sort("price", SortOrder.ASC) // 价格升序
.sort(SortBuilders // 地理坐标排序
.geoDistanceSort("location", new GeoPoint(location))
.order(SortOrder.ASC)
.unit(DistanceUnit.KILOMETERS));
}
完整代码
@Override
public PageResult search(RequestParams params) {
try {
SearchRequest request = new SearchRequest("hotel");
buildBasicQuery(params, request);
int page = params.getPage();
int size = params.getSize();
request.source()
.from((page - 1) * size)
.size(size);
String location = params.getLocation();
if (!StringUtils.isEmpty(location)) {
request.source()
.sort("price", SortOrder.ASC)
.sort(SortBuilders
.geoDistanceSort("location", new GeoPoint(location))
.order(SortOrder.ASC)
.unit(DistanceUnit.KILOMETERS));
}
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
return handleResponse(response);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
距离排序显示
- 重启服务,测试酒店功能,但是现在没有显示酒店距离我有多远
- 排序完成后,页面还需要获取我到附近酒店的具体距离值
- 因此,在解析结果的时候,我们还需要获取sort部分,然后放到响应结果中
- 修改HotelDoc类,添加排序距离字段,用于页面显示
@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();
}
}
- 修改handleResponse方法,为HotelDoc对象赋sort值
private PageResult handleResponse(SearchResponse response) {
SearchHits searchHits = response.getHits();
long total = searchHits.getTotalHits().value;
SearchHit[] hits = searchHits.getHits();
ArrayList<HotelDoc> hotels = new ArrayList<>();
for (SearchHit hit : hits) {
String json = hit.getSourceAsString();
HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class);
// 获取排序值
Object[] sortValues = hit.getSortValues();
if (sortValues.length > 0){
hotelDoc.setDistance(sortValues[0]);
}
hotels.add(hotelDoc);
}
return new PageResult(total, hotels);
}
重启服务,这下就能成功显示距离了
酒店竞价排名
- 需求:让指定的酒店在搜索结果中排名置顶(给一个超级大的算分)
需求分析
- 如何才能让指定的酒店排名置顶呢?
- 上面学的function_score查询可以影响算分,算分高了,自然排名也就高了。而function_score包含3个要素
- 过滤条件:哪些文档要加分
- 算分函数:如何计算
function score
- 加权方式:
function score
和query score
如何运算
- 这里的需求是:让指定酒店排名靠前。因此我们需要给这些酒店加一个标记,这样在过滤条件中就可以根据这个标记来判断,是否要提高算分
- 例如我们给酒店添加一个boolean类型的isAD字段
- true:是广告
- false:不是广告
- 这样function_score的3个要素就很好确定了
- 过滤条件:判断idAD是否为true
- 算分函数:这里用最简单暴力的weight,固定权值
- 加权方式:可以使用默认的相乘,大大提高算分
- 因此,提高排名的实现步骤包括
- 修改HotelDoc类,添加isAD字段
- 修改文档,随便挑几个酒店添加isAD字段为true
- 修改search方法,添加function score功能,给isAD为true的酒店加权重
修改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();
}
}
添加广告标记
我们随便挑几个酒店,增加isAD字段
POST /hotel/_update/2056126831
{
"doc": {
"isAD": true
}
}
POST /hotel/_update/1989806195
{
"doc": {
"isAD": true
}
}
POST /hotel/_update/2056105938
{
"doc": {
"isAD": true
}
}
增加算分函数查询
- function_score查询结构如下PLAINTEXT
GET /hotel/_search
{
"query": {
"function_score": {
"query": {
//原始查询
},
"functions": [
{
"filter": {
// 过滤
},
"weight": // 权重
}
],
"boost_mode": "multiply" //加权方式
}
}
}
算分函数对应的JavaAPI如下
FunctionScoreQueryBuilder functionScoreQuery =
QueryBuilders.functionScoreQuery(
QueryBuilders.matchQuery("name", "外滩"),
new FunctionScoreQueryBuilder.FilterFunctionBuilder[]{
new FunctionScoreQueryBuilder.FilterFunctionBuilder(
QueryBuilders.termQuery("brand", "如家"),
ScoreFunctionBuilders.weightFactorFunction(10))});
我们可以将之前写布尔查询的boolQuery作为原始查询条件,放到function_score查询中,接下来就是添加过滤条件、算分函数、加权模式了。所以可以继续沿用我们的buildBasicQuery方法
private void buildBasicQuery(RequestParams params, SearchRequest request) {
// 1. 构建BoolQuery
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
String key = params.getKey();
if (StringUtils.isEmpty(key)) {
boolQuery.must(QueryBuilders.matchAllQuery());
} else {
boolQuery.must(QueryBuilders.matchQuery("all", key));
}
// 品牌条件
if (params.getBrand() != null && !params.getBrand().equals("")) {
boolQuery.filter(QueryBuilders.termsQuery("brand", params.getBrand()));
}
// 城市条件
if (params.getCity() != null && !params.getCity().equals("")) {
boolQuery.filter(QueryBuilders.termsQuery("city", params.getCity()));
}
// 星级条件
if (params.getStarName() != null && !params.getStarName().equals("")) {
boolQuery.filter(QueryBuilders.termsQuery("starName", params.getStarName()));
}
// 价格条件
if (params.getMaxPrice() != null && params.getMinPrice() != null) {
boolQuery.filter(QueryBuilders
.rangeQuery("price")
.gt(params.getMinPrice())
.lt(params.getMaxPrice()));
}
// 2.算分控制
FunctionScoreQueryBuilder functionScoreQuery =
QueryBuilders.functionScoreQuery(
boolQuery, new FunctionScoreQueryBuilder.FilterFunctionBuilder[]{
new FunctionScoreQueryBuilder.FilterFunctionBuilder(
QueryBuilders.termsQuery("isAD", true),
ScoreFunctionBuilders.weightFactorFunction(10))});
request.source().query(functionScoreQuery);
}
重启服务,可以看到竞价排名已经生效,排名第一的酒店左上角有广告图标