一、ElasticSearch介绍
1.1 前言
系统中的数据, 随着业务的发展,时间的推移, 将会非常多, 而业务中往往采用模糊查询进行数据的搜索, 而模糊查询会导致查询引擎放弃索引,导致系统查询数据时都是全表扫描,在百万级别的数据库中,查询效率是非常低下的,而我们使用 ElasticSearch 做一个全文索引,将经常查询的系统功能的某些字段,比如说电商系统的商品表中商品名,描述、价格还有 id 这些字段我们放入 ElasticSearch 索引里,可以提高查询速度。
1.2 了解ElasticSearch
Elasticsearch是一款非常强大的开源搜索引擎,可以帮助我们从海量数据中快速找到需要的内容。
日常场景:
- 旅游平台对游记进行搜索,对酒店搜索,对机票搜索
- 知识库搜索平台
- 电商系统对商品进行检索
基于地理位置的打车应用,检索当前定位的车辆信息
Elasticsearch还结合kibana、Logstash、Beats等组件,也就是elastic stack(ELK)。被广泛应用在日志数据分析、实时监控等领域。
1.3 ElasticSearch发展历史
2004 年,有一个以色列小伙子,名字叫谢伊·班农( Shay Banon),他成亲不久来到伦敦,因为当时他的夫人正好在伦敦学厨师。
初来乍到,也没有找到工作,于是班农就打算写一个叫作 iCook 的小程序来管理和搜索菜谱,一来练练手,方便找工作;二来这个小工具还可以给其夫人用。
班农在编写 iCook 的过程中,使用了 Lucene,感受到了直接使用 Lucene 开发程序的各种暴击和痛苦,于是他在 Lucene 之上,封装了一个叫作 Compass 的程序框架,与 Hibernate 和 JPA 等 ORM 框架进行集成,通过操作对象的方式来自动地调用 Lucene 以构建索引。
这样做的好处是,可以很方便地实现对‘领域对象’进行索引的创建,并实现‘字段级别’的检索,以及实现‘全文搜索’功能。可以说,Compass 大大简化了给 Java 程序添加搜索功能的开发。Compass 开源出来,变得很流行。
在 Compass 编写到 2.x 版本的时候,社区里面出现了更多需求,比如需要有处理更多数据的能力以及分布式的设计。班农发现只有重写 Compass ,才能更好地实现这些分布式搜索的需求,于是 Compass 3.0 就没有了,取而代之的是一个全新的项目,也就是 Elasticsearch。
2018 年上市后最高市值达到 102.59 亿美元。
1.4 为什么选择ElasticSearch
ElasticSearch是目前市面上市场份额最大的搜索引擎.
1.5 倒排索引概念
- 正排索引(Forward Index)
正排索引是将文档的标识符(ID)与其内容一一对应的索引结构。它以文档为单位,存储了每个文档中的所有信息。例如,在一个网页搜索引擎中,正排索引可能包含每个网页的标题、内容、发布日期等信息。这样的索引结构使得系统可以快速地检索和展示文档的详细信息。
举个例子,如果有一个包含网页信息的数据库,正排索引可能如下:
文档ID | 标题 | 内容 |
---|---|---|
1 | 如何制作披萨 | 从面团到烤箱,教你制作美味的披萨。 |
2 | 健身计划 | 一个简单但有效的健身计划,帮助你保持健康。 |
3 | 学习JavaScript | 了解JavaScript编程语言的基础知识和技巧。 |
- 倒排索引(Inverted Index)
倒排索引是一种反转的索引结构,它以词汇为单位,存储了每个词汇出现在哪些文档中的信息。它更侧重于关键词与文档之间的关系。在搜索引擎中,倒排索引用于快速查找包含特定关键词的文档。
以下是一个简化的倒排索引的例子:
词汇 | 文档ID列表 |
---|---|
制作 | 1 |
披萨 | 1 |
健身 | 2 |
计划 | 2 |
学习 | 3 |
JavaScript | 3 |
在这个例子中,你可以看到每个词汇(关键词)都映射到了包含这个词汇的文档ID列表。倒排索引的优势在于可以快速定位包含特定关键词的文档,从而提高搜索效率。
综合起来,正排索引以文档为单位存储信息,而倒排索引以词汇为单位存储信息,使得系统能够高效地进行文档检索。
1.6 ElasticSearch概念类比
MySQL | Elasticsearch | 说明 |
---|---|---|
Table | Index | 索引(index),就是文档的集合,类似数据库的表(table) |
Row | Document | 文档(Document),就是一条条的数据,类似数据库中的行(Row),文档都是JSON格式 |
Column | Field | 字段(Field),就是JSON文档中的字段,类似数据库中的列(Column) |
Schema | Mapping | Mapping(映射)是索引中文档的约束,例如字段类型约束。类似数据库的表结构(Schema) |
SQL | DSL | DSL是elasticsearch提供的JSON风格的请求语句,用来操作elasticsearch,实现CRUD |
- 数据库负责事务类型操作
- Elasticsearch负责海量数据的搜索、分析、计算
二、ElasticSearch安装部署
2.1 部署ES
-
创建网络
我们还需要部署kibana容器,因此需要让es和kibana容器互联。这里先创建一个网络:
docker network create es-net
-
创建ElasticSearch容器
docker run -d \ --name es \ -e "ES_JAVA_OPTS=-Xms512m -Xmx512m" \ -e "discovery.type=single-node" \ -v es-data:/usr/share/elasticsearch/data \ -v es-plugins:/usr/share/elasticsearch/plugins \ --privileged \ --network es-net \ -p 9200:9200 \ -p 9300:9300 \ elasticsearch:7.12.1
-e "cluster.name=es-docker-cluster"
:设置集群名称-e "http.host=0.0.0.0"
:监听的地址,可以外网访问-e "ES_JAVA_OPTS=-Xms512m -Xmx512m"
:内存大小-e "discovery.type=single-node"
:非集群模式-v es-data:/usr/share/elasticsearch/data
:挂载逻辑卷,绑定es的数据目录-v es-logs:/usr/share/elasticsearch/logs
:挂载逻辑卷,绑定es的日志目录-v es-plugins:/usr/share/elasticsearch/plugins
:挂载逻辑卷,绑定es的插件目录--privileged
:授予逻辑卷访问权--network es-net
:加入一个名为es-net的网络中-p 9200:9200
:端口映射配置
-
验证: 我们在浏览器中输入http://192.168.202.133:9200 看到如下界面,说明部署是没问题的
2.2 部署Kibana
-
创建Kibana容器
docker run -d \ --name kibana \ -e ELASTICSEARCH_HOSTS=http://es:9200 \ --network=es-net \ -p 5601:5601 \ kibana:7.12.1
--network es-net
:加入一个名为es-net的网络中,与Elasticsearch在同一个网络中-e ELASTICSEARCH_HOSTS=http://es:9200"
:设置Elasticsearch的地址,因为kibana已经与Elasticsearch在一个网络,因此可以用容器名直接访问Elasticsearch-p 5601:5601
:端口映射配置
设置中文
#1、查看Kibana容器id
docker ps
#2、进入容器
docker exec -it Kibana容器id bash
#3、进入config目录
cd config/
#4、编辑Kibana.yml
vi kibana.yml
#5、添加中文配置
i18n.locale: "zh-CN"
#6、退出容器
exit
#7、重启Kibana
docker restart Kibana容器id
- 验证: 我们在浏览器中输入http://192.168.202.133:5601 看到如下界面,说明部署是没问题的
ElasticSearch提供了REST接口,可以给客户端调用,我们学习阶段的话,可以使用Postman工具来进行测试,但是没有提示,不太方便。Kibana提供了DevTools可视化界面,具有语法提示功能,比较适合入门学习.
三、ElasticSearch基础操作
3.1 语法规则
ElasticSearch提供REST的方式,客户端可以非常方便的操作,参数的格式都是以JSON的形式定义.
以下是一个关于分词的操作
GET /_analyze
{
"text": "god is a girl"
}
语法说明:
- POST:请求方式
- /_analyze:请求路径,这里省略了http://192.168.202.133:9200,有kibana帮我们补充
- 请求参数,json风格:
- text:要分词的内容
3.2 分词器
3.2.1 默认分词器
我们在前面有提到倒排索引的概念,存储的文档需要按照分词器进行分词,才能编排成倒排索引,所以分词器对于搜索来说是非常重要。我们前面的案例使用的是默认分词器standard
,对于英文分词还好,但是如果文本内容是中文的话,分词效果就不太好了,我们使用如下案例测试一下:
GET /_analyze
{
"text": "上帝是女孩"
}
可以看到默认的分词器,对于中文的分词并不友好,所以我们需要引入额外的分词器IK分词器
https://github.com/medcl/elasticsearch-analysis-ik
3.2.2 安装IK分词器
我们在创建ES容器的时候,设置了一个插件的数据卷目录,我们可以通过命令查看到
docker volume inspect es-plugins
结果如下:
[
{
"CreatedAt": "2023-09-04T15:46:27+08:00",
"Driver": "local",
"Labels": null,
"Mountpoint": "/var/lib/docker/volumes/es-plugins/_data",
"Name": "es-plugins",
"Options": null,
"Scope": "local"
}
]
将elasticsearch-analysis-ik-7.12.1.zip
文件解压到ik
目录中,然后将ik
整个目录上传到服务器/var/lib/docker/volumes/es-plugins/_data
这个路径下
重启容器
docker restart es
- IK分词器测试
GET /_analyze
{
"analyzer": "ik_max_word",
"text": "上帝是女孩"
}
运行之后,可以看到分词效果明显好很多.
3.2.3 分词模式
-
ik_smart:最少切分,粗粒度。我是程序员 ----> 我 是 程序员
GET /_analyze { "analyzer": "ik_smart", "text": "我是程序员" }
-
ik_max_word:最细切分,细粒度。我是程序员 ----> 我 是 程序员 程序 员
GET /_analyze { "analyzer": "ik_max_word", "text": "我是程序员" }
3.2.4 拓展词库
随着互联网的发展,“造词运动”也越发的频繁。出现了很多新的词语,在原有的词汇列表中并不存在。
比如下面这个案例,广州塔和小蛮腰应该是一个完整的单词,但是被拆开了
GET /_analyze
{
"analyzer": "ik_smart",
"text": "广州塔,也被亲切地称为小蛮腰"
}
我们希望这些词在分词器中不要被拆分,这时候我们就可以给IK分词器去拓展词典.
要拓展ik分词器的词库,只需要修改一个ik分词器目录中的config目录中的IkAnalyzer.cfg.xml文件:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE properties SYSTEM "http://java.sun.com/dtd/properties.dtd">
<properties>
<comment>IK Analyzer 扩展配置</comment>
<!--用户可以在这里配置自己的扩展字典 *** 添加扩展词典-->
<entry key="ext_dict">ext.dic</entry>
</properties>
然后在名为ext.dic的文件中,添加想要拓展的词语即可:
广州塔
小蛮腰
重启容器即可
3.2.5 停用词库
比如有些语气助词希望在分词的时候不出现,或者禁用某些敏感词条,那么可以配置停用词库,
只需要修改一个ik分词器目录中的config目录中的IkAnalyzer.cfg.xml文件:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE properties SYSTEM "http://java.sun.com/dtd/properties.dtd">
<properties>
<comment>IK Analyzer 扩展配置</comment>
<!--用户可以在这里配置自己的扩展字典-->
<entry key="ext_dict">ext.dic</entry>
<!--用户可以在这里配置自己的扩展停止词字典 *** 添加停用词词典-->
<entry key="ext_stopwords">stopword.dic</entry>
</properties>
然后在名为stopword.dic的文件中,添加想要拓展的词语即可:
也
被
地
重启容器即可
3.3 索引库操作
我们在操作MySQL的时候,首先是需要创建一张表,比如通过create table
命令创建,然后我们需要定义有哪些字段,字段的类型是哪些? 现在我们在操作ES的索引库也是需要先定义文档都有哪些字段?都有哪些类型?
在ES中mapping
是对索引库中文档的约束,常见的mapping
属性包括:
-
type:字段数据类型,常见的简单类型有:
- 字符串:text(可分词的文本)、keyword(精确值,例如:品牌、国家、ip地址)
- 数值:long、integer、short、byte、double、float、
- 布尔:boolean
- 日期:date
- 对象:object
-
index:是否创建索引,默认为true
-
analyzer:使用哪种分词器
-
properties:该字段的子字段
需求: 比如我需要做一个酒店搜索的功能,里面包含这些字段:
CREATE TABLE `tb_hotel` (
`id` bigint(20) NOT NULL COMMENT '酒店id',
`name` varchar(255) NOT NULL COMMENT '酒店名称',
`address` varchar(255) NOT NULL COMMENT '酒店地址',
`price` int(10) NOT NULL COMMENT '酒店价格',
`score` int(2) NOT NULL COMMENT '酒店评分',
`brand` varchar(32) NOT NULL COMMENT '酒店品牌',
`city` varchar(32) NOT NULL COMMENT '所在城市',
`star_name` varchar(16) NOT NULL COMMENT '酒店星级,1星到5星,1钻到5钻',
`business` varchar(255) DEFAULT NULL COMMENT '商圈',
`latitude` varchar(32) NOT NULL COMMENT '纬度',
`longitude` varchar(32) NOT NULL COMMENT '经度',
`pic` varchar(255) DEFAULT NULL COMMENT '酒店图片',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=COMPACT;
我们在创建ES的索引库映射mapping
的时候,就需要考虑哪些字段需要分词?分词的话使用什么分词器?哪些不需要分词?哪些字段需要被搜索到?
- 酒店id,需要搜索,不需要分词
- 酒店名称,需要搜索,需要分词
- 酒店地址,不需要搜索,不需要分词
- 酒店价格,需要范围查询,不需要分词
- 酒店评分,需要范围查询,不需要分词
- 酒店品牌,需要搜索,不需要分词
- 所在城市,需要搜索,不需要分词
- 酒店星级,需要搜索,不需要分词
- 商圈,需要搜索,不需要分词
- 经度/纬度,需要基于地理位置搜索,不需要分词
- 酒店图片,不需要搜索,不需要分词
3.3.1 REST方式
3.3.1.1 新增
ES中通过RESTful请求操作索引库、文档。请求内容用DSL语句来表示。创建索引库和mapping的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"
},
"starName":{
"type":"keyword"
},
"city":{
"type":"keyword"
},
"business":{
"type":"keyword",
"copy_to": "all"
},
"location":{
"type":"geo_point"
},
"pic":{
"type": "keyword",
"index": false
},
"all": {
"type": "text",
"analyzer": "ik_max_word"
}
}
}
}
3.3.1.2 查看
查看索引库
GET /hotel
3.3.1.3 修改
索引库和mapping一旦创建无法修改,但是可以添加新的字段,操作如下:
PUT /hotel/_mapping
{
"properties":{
"info":{
"type":"keyword"
}
}
}
3.3.1.4 删除
DELETE /hotel
3.3.2 项目环境准备
-
创建一个名字为
tb_hotel
的数据库 -
导入资料中的初始项目
-
添加
RestHighLevelClient
的bean@Bean public RestHighLevelClient restHighLevelClient(){ return new RestHighLevelClient( RestClient.builder(HttpHost.create("http://192.168.202.133:9200"))); }
-
注册高德账号
因为我们需要用到高德地图(JSAPI),需要先申请高德地图的appKey,同学们自行去这个网站进行注册
地图JS API接口免费配额调整 | 高德地图API (amap.com)
- 创建应用
登录之后,在
管控台-->应用管理-->我的应用
- 添加Key
把这个key覆盖项目中的key
3.3.3 RestClient方式
ES官方提供了各种不同语言的客户端,用来操作ES。这些客户端的本质就是组装DSL语句,通过http请求发送给ES。官方文档地址:
https://www.elastic.co/guide/en/elasticsearch/client/java-rest/current/java-rest-high-getting-started.html
3.3.3.1 新增
@Test
void testCreateIndex() throws IOException {
//1.定义请求对象
CreateIndexRequest request = new CreateIndexRequest("hotel");
//2.设置内容
request.source(HotelConstants.HOTEL_MAPPING, XContentType.JSON);
//3.发起请求
client.indices().create(request, RequestOptions.DEFAULT);
}
将mapping内容封装到静态变量中
public class HotelConstants {
public final static String HOTEL_MAPPING = "{\n" +
" \"mappings\": {\n" +
" \"properties\": {\n" +
" \"id\": {\n" +
" \"type\": \"keyword\"\n" +
" },\n" +
" \"name\": {\n" +
" \"type\": \"text\",\n" +
" \"analyzer\": \"ik_max_word\"\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" +
" },\n" +
" \"starName\":{\n" +
" \"type\":\"keyword\"\n" +
" },\n" +
" \"city\":{\n" +
" \"type\":\"keyword\"\n" +
" },\n" +
" \"business\":{\n" +
" \"type\":\"keyword\"\n" +
" },\n" +
" \"location\":{\n" +
" \"type\":\"geo_point\"\n" +
" },\n" +
" \"pic\":{\n" +
" \"type\": \"keyword\",\n" +
" \"index\": false\n" +
" }\n" +
" }\n" +
" }\n" +
"}";
}
3.3.3.2 查看
@Test
void testGetIndex() throws IOException {
//1.定义请求对象
GetIndexRequest request = new GetIndexRequest("hotel");
//2.发起请求
GetIndexResponse response = client.indices().get(request, RequestOptions.DEFAULT);
System.out.println(JSON.toJSONString(response.getMappings()));
}
3.3.3.3 修改
@Test
void testUpdateIndex() throws IOException {
//1.定义请求对象
PutMappingRequest request = new PutMappingRequest("hotel");
//2.设置内容
request.source(HotelConstants.HOTEL_MAPPING_UPDATE, XContentType.JSON);
//3.发起请求
client.indices().putMapping(request, RequestOptions.DEFAULT);
}
将mapping内容封装到静态变量中
public final static String HOTEL_MAPPING_UPDATE = "{\n" +
" \"properties\":{\n" +
" \"info\":{\n" +
" \"type\":\"keyword\"\n" +
" }\n" +
" }\n" +
"}";
}
3.3.3.4 删除
@Test
void testDeleteIndex() throws IOException {
//1.定义请求对象
DeleteIndexRequest request = new DeleteIndexRequest("hotel");
//2.发起请求
client.indices().delete(request, RequestOptions.DEFAULT);
}
3.4 文档操作
3.4.1 REST方式
3.4.1.1 新增
POST /hotel/_doc/38812
{
"name": "7天连锁酒店(上海漕溪路地铁站店)",
"address": "徐汇龙华西路315弄58号",
"price": 298,
"score": 37,
"pic": "t0.jpg",
"brand": "7天酒店",
"star_name": "二钻",
"business": "八万人体育场地区",
"city": "上海",
"location:": "31.174377,121.442875"
}
3.4.1.2 查看
GET /hotel/_doc/38812
3.4.1.3 修改
- 方式一: 全量修改,会删除旧文档,添加新文档
PUT /hotel/_doc/38812
{
"name": "7天连锁酒店(上海漕溪路地铁站店)",
"address": "徐汇龙华西路315弄58号",
"price": 398,
"score": 37,
"pic": "t0.jpg",
"brand": "7天酒店",
"star_name": "二钻",
"business": "八万人体育场地区",
"city": "上海",
"location:": "31.174377,121.442875"
}
- 方式二: 增量修改,修改指定字段值
POST /hotel/_update/38812
{
"doc": {
"price": 298
}
}
3.4.1.4 删除
DELETE /hotel/_doc/38812
3.4.2 RestClient方式
3.4.2.1 新增
@Test
void testAddDocument() throws IOException {
//1.定义请求对象
IndexRequest request = new IndexRequest("hotel").id("38812");
String data = "{\n" +
"\t\"name\": \"7天连锁酒店(上海漕溪路地铁站店)\",\n" +
"\t\"address\": \"徐汇龙华西路315弄58号\",\n" +
"\t\"price\": 298,\n" +
"\t\"score\": 37,\n" +
"\t\"pic\": \"t0.jpg\",\n" +
"\t\"brand\": \"7天酒店\",\n" +
"\t\"star_name\": \"二钻\",\n" +
"\t\"business\": \"八万人体育场地区\",\n" +
"\t\"city\": \"上海\",\n" +
"\t\"location:\": \"31.174377,121.442875\"\n" +
"}";
//2.设置内容
request.source(data,XContentType.JSON);
//3,发起请求
client.index(request,RequestOptions.DEFAULT);
}
3.4.2.2 查看
@Test
void testGetDocument() throws IOException {
//1.定义请求对象
GetRequest request = new GetRequest("hotel","38812");
//2.发起请求
GetResponse response = client.get(request, RequestOptions.DEFAULT);
//3.输入内容
System.out.println(response.getSourceAsString());
}
3.4.2.3 修改
@Test
void testUpdateDocument() throws IOException {
//1.定义请求对象
UpdateRequest request = new UpdateRequest("hotel","38812");
String data = "{\"price\": 398}";
//2.设置内容
request.doc(data,XContentType.JSON);
//3.发起请求
client.update(request,RequestOptions.DEFAULT);
}
3.4.2.4 删除
@Test
void testDeleteDocument() throws IOException {
//1.定义请求对象
DeleteRequest request = new DeleteRequest("hotel","38812");
//2.发起请求
client.delete(request,RequestOptions.DEFAULT);
}
3.4.2.5 批量操作
@Test
void testBulkRequest() throws IOException {
//1.查询数据
List<Hotel> hotelList = hotelService.list();
//2.设置批量请求
BulkRequest request = new BulkRequest();
//3.批量设置数据
hotelList.forEach(hotel -> {
request.add(new IndexRequest().index("hotel")
.id(hotel.getId().toString())
.source(JSON.toJSONString(new HotelDoc(hotel)),XContentType.JSON)
);
});
//发起请求
client.bulk(request,RequestOptions.DEFAULT);
}
3.5 文档查询操作
Elasticsearch提供了基于JSON的DSL(Domain Specific Language)来定义查询。
3.5.1 REST方式
3.5.1.1查询所有
查询出所有数据,一般测试用。
match_all查询
GET /hotel/_search
{
"query": {
"match_all": {}
}
}
3.5.1.2全文检索查询
利用分词器对用户输入内容分词,然后去倒排索引库中匹配。
match查询
全文检索查询的一种,会对用户输入内容分词,然后去倒排索引库检索,语法:
GET /hotel/_search
{
"query": {
"match": {
"all": "如家"
}
}
}
multi_match查询
与match查询类似,只不过允许同时查询多个字段,语法:
GET /hotel/_search
{
"query": {
"multi_match": {
"query": "如家",
"fields": ["brand","city","name"]
}
}
}
参与查询字段越多,查询性能越差
3.5.1.3精确查询
term查询
term属于精确查询,根据词条精确值查询,一般是查找keyword、数值、日期、boolean等类型字段,所以不会对搜索条件分词。
GET /hotel/_search
{
"query": {
"term": {
"city": {
"value": "北京"
}
}
}
}
range查询
range属于精确查询,根据值的范围查询
GET /hotel/_search
{
"query": {
"range": {
"price": {
"gte": 100,
"lte": 400
}
}
}
}
ids查询
range属于精确查询,根据Id进行查询
GET /hotel/_search
{
"query": {
"ids": {
"values": ["36934","38609"]
}
}
}
3.5.1.4地理(geo)查询
根据经纬度查询
geo_distance查询
询到指定中心点小于某个距离值的所有文档
GET /hotel/_search
{
"query": {
"geo_distance":{
"distance":"10km",
"location":"30.921659, 121.575572"
}
}
}
geo_bounding_box查询
查询geo_point值落在某个矩形范围的所有文档
GET /hotel/_search
{
"query": {
"geo_bounding_box":{
"location":{
"top_left":{
"lat":31.1,
"lon":121.5
},
"bottom_right":{
"lat":30.9,
"lon":121.7
}
}
}
}
}
3.5.1.5 复合(compound)查询
复合查询可以将上述各种查询条件组合起来,合并查询条件。
Function Score Query查询
使用 function score query,可以修改文档的相关性算分(query score),根据新得到的算分排序。
GET /hotel/_search
{
"query": {
"function_score": {
"query": {
"match": {
"all": "外滩"
}
},
"functions": [
{
"filter": {"term": {"brand": "如家"}},
"weight": 10
}
],
"boost_mode": "multiply"
}
}
}
算分函数:算分函数的结果称为function score ,将来会与query score运算,得到新算分,常见的算分函数有:
-
weight:给一个常量值,作为函数结果(function score)
-
field_value_factor:用文档中的某个字段值作为函数结果
-
random_score:随机生成一个值,作为函数结果
-
script_score:自定义计算公式,公式结果作为函数结果
加权模式:定义function score与query score的运算方式,包括:
- multiply:两者相乘。默认就是这个
- replace:用function score 替换 query score
其它:sum、avg、max、min
Boolean Query查询
布尔查询是一个或多个查询子句的组合。子查询的组合方式有:
- must:必须匹配每个子查询,类似“与”
- should:选择性匹配子查询,类似“或”
- must_not:必须不匹配,不参与算分,类似“非”
- filter:必须匹配,不参与算分
需求:搜索名字包含“如家”,价格不高于400,在坐标31.21,121.5周围10km范围内的酒店。
GET /hotel/_search
{
"query": {
"bool": {
"must": [
{
"match": {
"name": "如家"
}
}
],
"must_not": [
{
"range": {
"price": {
"gt": 400
}
}
}
],
"filter": [
{
"geo_distance": {
"distance": "10km",
"location": {
"lat": 31.21,
"lon": 121.5
}
}
}
]
}
}
}
3.5.1.6 文档结果处理
排序处理
Elasticsearch支持对搜索结果排序,默认是根据相关度算分(_score)来排序。可以排序字段类型有:keyword类型、数值类型、地理坐标类型、日期类型等
- 按照分数排序
GET /hotel/_search
{
"query": {
"match_all": {}
},
"sort": [
{
"score": {
"order": "desc"
}
}
]
}
- 按照距离排序
GET /hotel/_search
{
"query": {
"match_all": {}
},
"sort": [
{
"_geo_distance": {
"location": {
"lat": 22.507276,
"lon": 113.931251
},
"order": "asc",
"unit": "km"
}
}
]
}
分页处理
Elasticsearch 默认情况下只返回top10的数据。而如果要查询更多数据就需要修改分页参数了。
Elasticsearch中通过修改from、size参数来控制要返回的分页结果:
GET /hotel/_search
{
"query": {
"match_all": {}
},
"from": 0,
"size": 10,
"sort": [
{
"score": {
"order": "desc"
}
}
]
}
高亮处理
必须要有搜索的内容才能进行高亮处理
GET /hotel/_search
{
"query": {
"match": {
"all": "如家"
}
},
"highlight": {
"fields": {
"name": {
"pre_tags": "<em>",
"post_tags": "</em>"
}
},
"require_field_match": "false"
}
}
3.5.2 RestClient方式
3.5.2.1 查询所有
match_all查询
@Test
void testMathAllQuery() throws IOException {
//1.定义查询请求
SearchRequest request = new SearchRequest("hotel");
//2.设置请求
request.source().query(QueryBuilders.matchAllQuery());
//3.发送请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
//4.处理结果
SearchHits hits = response.getHits();
System.out.println("结果总数:"+hits.getTotalHits().value);
hits.forEach(hit->{
System.out.println(hit.getSourceAsString());
});
}
3.5.2.2 全文检索查询
math查询
@Test
void testMatchQuery() throws IOException {
//1.定义查询请求
SearchRequest request = new SearchRequest("hotel");
//2.设置请求
request.source().query(QueryBuilders.matchQuery("name","如家"));
//3.发送请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
//4.处理结果
SearchHits hits = response.getHits();
System.out.println("结果总数:"+hits.getTotalHits().value);
hits.forEach(hit->{
System.out.println(hit.getSourceAsString());
});
}
multi_match查询
@Test
void testMultiMatchQuery() throws IOException {
//1.定义查询请求
SearchRequest request = new SearchRequest("hotel");
//2.设置请求
request.source().query(QueryBuilders.multiMatchQuery("如家","brand","name"));
//3.发送请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
//4.处理结果
SearchHits hits = response.getHits();
System.out.println("结果总数:"+hits.getTotalHits().value);
hits.forEach(hit->{
System.out.println(hit.getSourceAsString());
});
}
3.5.2.3 精确查询
term查询
@Test
void testTermQuery() throws IOException {
//1.定义查询请求
SearchRequest request = new SearchRequest("hotel");
//2.设置请求
request.source().query(QueryBuilders.termQuery("city","北京"));
//3.发送请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
//4.处理结果
SearchHits hits = response.getHits();
System.out.println("结果总数:"+hits.getTotalHits().value);
hits.forEach(hit->{
System.out.println(hit.getSourceAsString());
});
}
range查询
@Test
void testRangeQuery() throws IOException {
//1.定义查询请求
SearchRequest request = new SearchRequest("hotel");
//2.设置请求
request.source().query(QueryBuilders.rangeQuery("price").gte(100).lte(400));
//3.发送请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
//4.处理结果
SearchHits hits = response.getHits();
System.out.println("结果总数:"+hits.getTotalHits().value);
hits.forEach(hit->{
System.out.println(hit.getSourceAsString());
});
}
ids查询
@Test
void testIdsQuery() throws IOException {
//1.定义查询请求
SearchRequest request = new SearchRequest("hotel");
//2.设置请求
request.source().query(QueryBuilders.idsQuery().addIds("36934","38609"));
//3.发送请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
//4.处理结果
SearchHits hits = response.getHits();
System.out.println("结果总数:"+hits.getTotalHits().value);
hits.forEach(hit->{
System.out.println(hit.getSourceAsString());
});
}
3.5.2.4地理(geo)查询
geo_distance查询
@Test
void testGeoDistanceQuery() throws IOException {
//1.定义查询请求
SearchRequest request = new SearchRequest("hotel");
//2.设置请求
request.source().query(QueryBuilders.geoDistanceQuery("location").distance("10km").point(30.921659,121.575572));
//3.发送请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
//4.处理结果
SearchHits hits = response.getHits();
System.out.println("结果总数:"+hits.getTotalHits().value);
hits.forEach(hit->{
System.out.println(hit.getSourceAsString());
});
}
geo_bounding_box查询
@Test
void testGeoBoundingBoxQuery() throws IOException {
//1.定义查询请求
SearchRequest request = new SearchRequest("hotel");
//2.设置请求
request.source().query(QueryBuilders.geoBoundingBoxQuery("location").setCorners(31.1,121.5,30.9,121.7));
//3.发送请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
//4.处理结果
SearchHits hits = response.getHits();
System.out.println("结果总数:"+hits.getTotalHits().value);
hits.forEach(hit->{
System.out.println(hit.getSourceAsString());
});
}
3.5.2.5 复合(compound)查询
Function Score Query
@Test
void testFunctionScoreQuery() throws IOException {
//1.定义查询请求
SearchRequest request = new SearchRequest("hotel");
//2.设置请求
request.source().query(QueryBuilders.functionScoreQuery(QueryBuilders.matchQuery("all","外滩"),new FunctionScoreQueryBuilder.FilterFunctionBuilder[]{
new FunctionScoreQueryBuilder.FilterFunctionBuilder(QueryBuilders.termQuery("brand","如家"), ScoreFunctionBuilders.weightFactorFunction(10))
}).boostMode(CombineFunction.MULTIPLY));
//3.发送请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
//4.处理结果
SearchHits hits = response.getHits();
System.out.println("结果总数:"+hits.getTotalHits().value);
hits.forEach(hit->{
System.out.println(hit.getSourceAsString());
});
}
Boolean Query
@Test
void testBoolQuery() throws IOException {
//1.定义查询请求
SearchRequest request = new SearchRequest("hotel");
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
boolQuery.must(QueryBuilders.matchQuery("name","如家"));
boolQuery.mustNot(QueryBuilders.rangeQuery("price").gt(400));
boolQuery.filter(QueryBuilders.geoDistanceQuery("location")
.distance("10km").point(31.21,121.5));
//2.设置请求
request.source().query(boolQuery);
//3.发送请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
//4.处理结果
SearchHits hits = response.getHits();
System.out.println("结果总数:"+hits.getTotalHits().value);
hits.forEach(hit->{
System.out.println(hit.getSourceAsString());
});
}
3.5.2.6 文档结果处理
排序分页
@Test
void testScoreAndSortQuery() throws IOException {
//1.定义查询请求
SearchRequest request = new SearchRequest("hotel");
//2.设置请求
request.source().query(QueryBuilders.matchAllQuery());
request.source().from(0).size(10).sort("score", SortOrder.DESC);
//3.发送请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
//4.处理结果
SearchHits hits = response.getHits();
System.out.println("结果总数:"+hits.getTotalHits().value);
hits.forEach(hit->{
System.out.println(hit.getSourceAsString());
});
}
高亮处理
@Test
void testHighLightQuery() throws IOException {
//1.定义查询请求
SearchRequest request = new SearchRequest("hotel");
//2.设置请求
request.source().query(QueryBuilders.matchQuery("all","如家"));
request.source().highlighter(new HighlightBuilder().field("name").preTags("<em>").postTags("</em>").requireFieldMatch(false));
//3.发送请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
//4.处理结果
SearchHits hits = response.getHits();
System.out.println("结果总数:"+hits.getTotalHits().value);
hits.forEach(hit->{
System.out.println(hit.getSourceAsString());
HighlightField nameField = hit.getHighlightFields().get("name");
System.out.println("高亮内容:"+nameField.getFragments()[0]);
});
}
3.6 案例操作
3.6.1 搜索/分页功能
-
定义实体类,接收前端请求
@Data public class RequestParams { private String key; private Integer page; private Integer size; private String sortBy; }
-
处理前端
/hotel/list
请求,需要返回RequestParam
@Data public class PageResult { private Long total; private List<HotelDoc> hotels = new ArrayList<>(); }
-
在Service中,使用all查询,然后进行排序分页
3.6.2 多条件查询
需求: 添加品牌、城市、星级、价格等过滤功能
-
修改RequestParams类,添加brand、city、starName、minPrice、maxPrice等参数
@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; }
-
修改search方法的实现,在关键字搜索时,如果brand等参数存在,对其做过滤
过滤条件包括:
- city精确匹配
- brand精确匹配
- starName精确匹配
- price范围过滤
注意事项:
- 多个条件之间是AND关系,组合多条件用BooleanQuery
- 参数存在才需要过滤,做好非空判断
3.6.3 基于地理位置排序
需求: 按照当前位置,然后基于距离进行排序
前端页面点击定位后,会将你所在的位置发送到后台,我们要根据这个坐标,将酒店结果按照到这个点的距离升序排序,按照距离排序后,还需要显示具体的距离值:
-
修改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; }
-
修改search方法业务逻辑,如果location有值,添加根据geo_distance排序的功能
3.6.4 文档权重设置
需求:让指定的酒店在搜索结果中排名置顶
我们给需要置顶的酒店文档添加一个标记。然后利用function score给带有标记的文档增加权重。
- 给HotelDoc类添加isAD字段,Boolean类型
- 挑选几个酒店,给它的文档数据添加isAD字段,值为true
- 修改search方法,添加function score功能,给isAD值为true的酒店增加权重
3.6.5 高亮功能
当搜索的时候,我们需要让酒店名称高亮
3.6.6 排序功能
可以按照评分降序和价格升序排序
3.7 聚合操作
聚合(aggregations)可以实现对文档数据的统计、分析、运算。聚合常见的有三类:
- 桶(Bucket)聚合:用来对文档做分组
- TermAggregation:按照文档字段值分组
- Date Histogram:按照日期阶梯分组,例如一周为一组,或者一月为一组
- 度量(Metric)聚合:用以计算一些值,比如:最大值、最小值、平均值等
- Avg:求平均值
- Max:求最大值
- Min:求最小值
- Stats:同时求max、min、avg、sum等
- 管道(pipeline)聚合:其它聚合的结果为基础做聚合
3.7.1 REST方式
3.7.1.1 Bucket聚合
现在,我们要统计所有数据中的酒店品牌有几种,此时可以根据酒店品牌的名称做聚合。
GET /hotel/_search
{
"size": 0,
"aggs": {
"brandAgg": {
"terms": {
"field": "brand",
"size": 10
}
}
}
}
默认情况下,Bucket聚合会统计Bucket内的文档数量,记为_count,并且按照_count降序排序。
我们可以修改结果排序方式:
GET /hotel/_search
{
"size": 0,
"aggs": {
"brandAgg": {
"terms": {
"field": "brand",
"size": 10,
"order": {
"_count": "asc"
}
}
}
}
}
默认情况下,Bucket聚合是对索引库的所有文档做聚合,我们可以限定要聚合的文档范围,只要添加query条件即可:
GET /hotel/_search
{
"query": {
"range": {
"price": {
"lte": 200
}
}
},
"size": 0,
"aggs": {
"brandAgg": {
"terms": {
"field": "brand",
"size": 10,
"order": {
"_count": "asc"
}
}
}
}
}
3.7.1.2 Metrics 聚合
例如,我们要求获取每个品牌的用户评分的min、max、avg等值.
GET /hotel/_search
{
"size": 0,
"aggs": {
"brandAgg": {
"terms": {
"field": "brand",
"size": 10
},
"aggs": {
"score_stat": {
"stats": {
"field": "score"
}
}
}
}
}
}
3.7.2 RestClient方式
3.7.2.1 Bucket聚合
@Test
void testBucket() throws IOException {
SearchRequest request = new SearchRequest("hotel");
//2.设置请求
request.source().query(QueryBuilders.rangeQuery("price").lte(200));
request.source().size(0);
request.source().aggregation(AggregationBuilders.terms("brandAgg")
.field("brand")
.size(20)
.order(BucketOrder.count(true)));
//3.发送请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
//4.处理结果
Aggregations aggregations = response.getAggregations();
Terms terms = aggregations.get("brandAgg");
List<? extends Terms.Bucket> buckets = terms.getBuckets();
buckets.stream().forEach(bucket->{
System.out.println(bucket.getKeyAsString());
System.out.println(bucket.getDocCount());
});
}
3.7.2.2 Metrics聚合
@Test
void testMetrics() throws IOException {
SearchRequest request = new SearchRequest("hotel");
//2.设置请求
request.source().size(0);
request.source().aggregation(AggregationBuilders.terms("brandAgg")
.field("brand")
.size(20)
.subAggregation(AggregationBuilders.stats("score_stat").field("score"))
);
//3.发送请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
//4.处理结果
Aggregations aggregations = response.getAggregations();
Terms terms = aggregations.get("brandAgg");
List<? extends Terms.Bucket> buckets = terms.getBuckets();
buckets.stream().forEach(bucket->{
System.out.println(bucket.getKeyAsString());
ParsedStats parsedStats = bucket.getAggregations().get("score_stat");
System.out.println("平均值:"+parsedStats.getAvg());
System.out.println("最大值:"+parsedStats.getMax());
System.out.println("最小值:"+parsedStats.getMin());
System.out.println("总数:"+parsedStats.getCount());
System.out.println("求和:"+parsedStats.getSum());
System.out.println("===================");
});
}
3.7.3 案例集成聚合操作
需求:搜索页面的品牌、城市等信息不应该是在页面写死,而是通过聚合索引库中的酒店数据得来的
完成/hotel/filters
接口,
返回数据如下:
{"城市": ["上海", "北京"], "品牌": ["如家", "希尔顿"]}
3.8 自动补全
当用户在搜索框输入字符时,我们应该提示出与该字符有关的搜索项
3.8.1 安装拼音分词器
要实现根据字母做补全,就必须对文档按照拼音分词。在GitHub上恰好有Elasticsearch的拼音分词插件。地址:
https://github.com/medcl/elasticsearch-analysis-pinyin
安装步骤:
- 解压
elasticsearch-analysis-pinyin-7.12.1.zip
- 上传到虚拟机中,Elasticsearch的plugin目录
- 重启Elasticsearch
- 测试
POST _analyze
{
"text": "今天天气真不错",
"analyzer": "pinyin"
}
3.8.2 自定义分词器
Elasticsearch中分词器(analyzer)的组成包含三部分:
- character filters:在tokenizer之前对文本进行处理。例如删除字符、替换字符
- tokenizer:将文本按照一定的规则切割成词条(term)。例如keyword,就是不分词;还有ik_smart
- tokenizer filter:将tokenizer输出的词条做进一步处理。例如大小写转换、同义词处理、拼音处理等
我们针对分词器进行设置
PUT /test
{
"mappings": {
"properties": {
"name":{
"type": "text",
"analyzer": "my_analyzer"
}
}
},
"settings": {
"analysis": {
"analyzer": {
"my_analyzer": {
"tokenizer": "ik_max_word",
"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
}
}
}
}
}
测试:
POST /test/_analyze
{
"text": "今天天气真不错",
"analyzer": "my_analyzer"
}
- 拼音分词器适合在创建倒排索引的时候使用,但不能在搜索的时候使用。
比如我们先看看下面的例子:
POST /test/_doc/1
{
"id": 1,
"name": "狮子"
}
POST /test/_doc/2
{
"id": 2,
"name": "虱子"
}
GET /test/_search
{
"query": {
"match": {
"name": "掉入狮子笼咋办"
}
}
}
运行案例之后,我们会发现id=2的文档也会被搜索出来,原因如下:
id=1中name=“狮子”====>分词结果为[“狮子”,“shizi”,“sz”]
id=2中name=“虱子”====>分词结果为:[“虱子”,“shizi”,“sz”]
词条 | 文档编号 |
---|---|
狮子 | 1 |
虱子 | 2 |
shizi | 1,2 |
sz | 1,2 |
当我们搜索的时候,也会使用拼音分词器进行分词处理.
name:=“掉入狮子笼咋办”====>分词结果为[“狮子”,“shizi”,“sz”,“掉入”,“diaoru”,“dr”,…],所以就会被搜索到我们需要修改一下name的分词配置
DELETE /test
PUT /test
{
"mappings": {
"properties": {
"name":{
"type": "text",
"analyzer": "my_analyzer",
"search_analyzer": "ik_smart"
}
}
},
"settings": {
"analysis": {
"analyzer": {
"my_analyzer": {
"tokenizer": "ik_max_word",
"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
}
}
}
}
}
我们重新再执行一次案例就不会有这些问题了.
3.8.3 completion suggester查询
Elasticsearch提供了Completion Suggester查询来实现自动补全功能。这个查询会匹配以用户输入内容开头的词条并返回。为了提高补全查询的效率,对于文档中字段的类型有一些约束:
- 参与补全查询的字段必须是completion类型。
- 字段的内容一般是用来补全的多个词条形成的数组。
先做一下数据的初始化:
- 删除之前的索引库
DELETE /test
- 新增索引库
PUT /test
{
"mappings": {
"properties": {
"title":{
"type": "completion"
}
}
}
}
- 添加文档数据
POST test/_doc
{
"title": ["Sony", "WH-1000XM3"]
}
POST test/_doc
{
"title": ["SK-II", "PITERA"]
}
POST test/_doc
{
"title": ["Nintendo", "switch"]
}
3.8.3.1 REST方式
GET /test/_search
{
"suggest": {
"title_suggest": {
"text": "w",
"completion":{
"field":"title",
"skip_duplicates":true,
"size":10
}
}
}
}
3.8.3.2 RestClient方式
@Test
void testCompletion() throws IOException {
//1.定义请求对象
SearchRequest request = new SearchRequest("test");
//2.设置补全参数
request.source().suggest(new SuggestBuilder().addSuggestion("title_suggest",
SuggestBuilders.completionSuggestion("title").prefix("w").skipDuplicates(true).size(10)));
//3.发起请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
//处理请求
Suggest suggest = response.getSuggest();
CompletionSuggestion suggestion = suggest.getSuggestion("title_suggest");
suggestion.getOptions().stream().forEach(option -> {
System.out.println(option.getText());
});
}
3.8.4 案例集成自动补全
思路步骤:
-
修改hotel索引库结构,设置自定义拼音分词器
-
修改索引库的name、all字段,使用自定义分词器
-
索引库添加一个新字段suggestion,类型为completion类型,使用自定义的分词器
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_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": "text_anlyzer", "search_analyzer": "ik_max_word" }, "suggestion":{ "type": "completion", "analyzer": "completion_analyzer" } } } }
-
给HotelDoc类添加suggestion字段,内容包含brand、business
@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[] arr = this.business.split("、"); this.suggestion = new ArrayList<>(); this.suggestion.add(this.brand); Collections.addAll(this.suggestion,arr); }else if(this.business.contains("/")){ 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); this.suggestion = Arrays.asList(this.brand,this.business); } } }
-
重新导入数据到hotel库
-
完成
/hotel/suggestion
请求处理
文章评论