一文入门elasticsearch 2024-07-10 15:23 ## 简介 ES是基于Lucene开发的搜索引擎,Lucene是一套Java api,由某个哥们在1999年开发的。这套api的好处是扩展性搞;使用倒排索引做查询性能非常高。所以后面有很多搜索服务都是基于它做的。ES和solr都是。 ES是某个哥们在2004年开发的,最开始叫Compress,后来又重写了一版支持分布式,支持resuful调用(支持多语言使用),改名es,后来才超过了solr。 Kibana是es官方提供的图形化看板。 ## 一、安装 ### es安装 8以上版本api变化很大,所以企业中较多还是使用8以下版本。 ```sh docker pull elasticsearch:7.12.1 # single-node 单点运行,挂载数据存储目录和插件目录,--privileged 容器获得对主机的所有访问权限,9200 http访问端口,9300 集群间通信端口 # es-data 和 es-plugins是命名卷,在/var/lib/docker/volumes/ # es默认安装时使用内存大小是1G,我们机器内存有限,所以调低到了512m,但是不能再低了,再低使用es可能会出问题。 # ELASTIC_PASSWORD指定密码,内置超级用户名elastic docker run -d \ --name es \ -e "ES_JAVA_OPTS=-Xms512m -Xmx512m" \ -e "discovery.type=single-node" \ -e "ELASTIC_PASSWORD=xxx" \ -v es-data:/usr/share/elasticsearch/data \ -v es-plugins:/usr/share/elasticsearch/plugins \ --privileged \ -p 9200:9200 \ -p 9300:9300 \ elasticsearch:7.12.1 ``` 访问:http://ip:9200/ (本机访问:curl -X GET "http://localhost:9200/") ![](https://minio.riun.xyz/riun1/2024-07-03_67oz09auroqvI8o3yW.jpg) ### Kibana安装 Kibana就是为了图形化es的,所有安装时要指定es的地址,这样在Kibana中就能随意操作es了。 ```sh docker pull kibana:7.12.1 docker run -d \ --name kibana \ -e ELASTICSEARCH_HOSTS=http://ip:9200 \ -p 5601:5601 \ kibana:7.12.1 ``` 访问:http://ip:5601 Kibana有很多功能,不过我们现在主要是用它操作es执行http请求。所以我们先点击Explore on my own: ![](https://minio.riun.xyz/riun1/2024-07-03_67pw08ElWBR8shJlYM.jpg) 再点击Dev tools: ![](https://minio.riun.xyz/riun1/2024-07-03_67pwGGhsRxIAYc5eKt.jpg) 在这里我们就可以输入各种请求去执行,然后点击右边的运行按钮: > 输入内容后,ctrl + enter 快捷键执行 ![](https://minio.riun.xyz/riun1/2024-07-03_67pym64CuaMFmKnKGQ.jpg) 由于我们启动kibana时已经指定了es的地址,所以请求时不再需要输入ip什么的,直接Get / 就是访问:http://ip:9200/ ## 二、基础 ### 倒排索引 ES在做搜索的时候使用的是倒排索引,倒排索引包含两个重要概念: - 文档(Document):每条数据就是一个文档(mysql的一行数据就是一个文档)。 - 词条(term):文档按照**语义**分成的词语(英文语句按照空格分,中文语句按照语言意思分成多个词语)。 ES也会先按照数据的id建立正向索引,然后再将文档分词,根据词条建立词条到id集合的倒排索引: ![](https://minio.riun.xyz/riun1/2024-07-03_67pZkCwSSHlQYzjkF1.jpg) 倒排索引中,词条是唯一的,所以可以将词条建立唯一索引。做倒排索引时,同时也会记录词条在文档数据中的位置,方便后面的高亮显示功能。 然后在查询时,先将输入的语句分词,然后通过每个词条查询文档id集合,最终得到多个结果,根据业务做排序展示。比如搜索“华为手机”,分词为“华为”,“手机”;查询文档id是[2, 3]和[1, 2],然后就可以取出文档id为2、1、3的数据进行展示。 这样虽然进行了两次查询,但是每次查询都是有索引的。 MySQL这种传统数据库和ES对比,优点在于支持事务,比较安全。而ES优点在于查询效率巨快。所以两者各有其应用场景。 ### 分词器 在倒排索引中,存储时需要对文档分词,查询时需要对查询语句分词,所以分词器是很关键的。 最好的中文分词器是IK分词器,是国人林良益在2006年开源的,采用了**正向迭代最细粒度切分算法**一直沿用至今。2012年他不再维护后,就有很多社区、公司去维护这个分词器。现在最常用的是这个:https://github.com/infinilabs/analysis-ik 它的版本号刚好也是和ES一一对应的。 > **正向迭代最细粒度切分算法**的大概思路:内部维护一个常见中文词典;对于一句要被分词的语句,从每2个字开始逐个检查是否在词典中:是,则分出来;不是则继续;然后再三个字检查,四个字检查......,直到最后,如果某个字没有被分词,则单独把他作为一个词出来。 > > 当然它内部肯定不是这么粗暴,会有各种优化。 #### analysis-ik分词器安装 去github下载zip:https://github.com/infinilabs/analysis-ik/releases/tag/v7.12.1 然后解压成一个文件夹,把这个文件夹上传到我们es挂载的plugins目录下即可: ```sh # 查看插件目录挂载到哪里了 docker volume inspect es-plugins # /var/lib/docker/volumes/es-plugins/_data,将文件夹上传到这个目录下 # 重启容器 docker restart es ``` #### 测试ik分词器 在Kibana的dev_tool里测试分词 ``` POST /_analyze { "analyzer": "standard", "text": ["黑马程序员学习Java太棒了"] } POST /_analyze { "analyzer": "ik_smart", "text": ["黑马程序员学习Java太棒了"] } POST /_analyze { "analyzer": "ik_max_word", "text": ["黑马程序员学习Java太棒了"] } ``` /_analyze就是分词功能的路径,analyzer是分词器模式,相当于用哪个分词器: ![](https://minio.riun.xyz/riun1/2024-07-04_67KDKCj8N56NQbw4sK.jpg) standard是Es默认的分词器,ik_smart和ik_max_word是ik分词器的两种模式。 #### 补充词典和违禁词典 如果有需要分词的词语,而没有被ik分出来,则可以人工添加到补充词典ext.dic;也可以添加一些不想要被分出的词语,如违禁词,语气助词等,添加到stopword.dic中,这样这些词语就不会出现在结果集中。这个配置在elasticsearch-analysis-ik-7.12.1\config\IKAnalyzer.cfg.xml中: > ext.dic、stopword.dic是文件名,可以随便取。key="ext_dict"和key="ext_stopwords"是固定的,不能变。 ```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> <!--用户可以在这里配置远程扩展字典 --> <!-- <entry key="remote_ext_dict">words_location</entry> --> <!--用户可以在这里配置远程扩展停止词字典--> <!-- <entry key="remote_ext_stopwords">words_location</entry> --> </properties> ``` 这个配置会在当前目录(elasticsearch-analysis-ik-7.12.1\config)中寻找ext.dic和stopword.dic,ext.dic默认没有,我们需要新建一个。然后把这三个文件上传到服务器插件目录下替换掉。 ### 基础概念 ES中的文档数据会被序列化为json格式后储存在ES中,一些概念和MySQL非常相似,可以拿来对比学习: ![](https://minio.riun.xyz/riun1/2024-07-04_67KZWjXi1nRQcubjg7.jpg) 表:索引库 行:文档 列:字段 约束:映射(Mapping) SQL:DSL #### Mapping属性映射及操作★★★ mapping是对索引库中的文档的约束,常见的mapping属性包括4种: - type:字段数据类型(必须指定)。常见的简单类型有: - text:字符串(可分词的文本)、keyword:精确值(不再需要分词的文本,比如:品牌名,国家名,Ip地址等) - long、integer、short、byte、double、float等:数值 - boolean:布尔 - date:日期 - object:对象。(如果type是object,那他一定有另一个属性properties) - index:是否创建倒排索引。默认为true。为true时,可以根据该字段做搜索和排序;如果为false,则该字段不可用于搜索、排序。 - analyzer:使用哪种分词器。只有当type:text时,才需要指定分词器。其他大多数类型都不需要分词,不用指定分词器。 - properties:该字段的子字段。 一般的字段,我们只需要指定type即可。如果是可分词的文本text类型,则需要指定分词器analyzer。少数不参与搜索和排序的(看具体业务情况)需要把index置为false。字段有子字段的话,用properties来按照当前规则继续指定子字段的映射。 常见的举例: ```sh # 比如文章标题title "title": { "type": "text", "analyzer": "ik_max_word" }, # 年龄 "age": { "type": "integer" }, # 分数 "score": { "type": "float" }, # 姓名 "name": { "type": "object", "properties": { "firstName": { "type": "keyword" }, "lastName": { "type": "keyword" } } }, ``` ##### 创建索引库:PUT ```sh PUT /heima { "mappings": { "properties": { "info":{ "type": "text", "analyzer": "ik_smart" }, "email":{ "type": "keyword", "index": "false" }, "name":{ "properties": { "firstName": { "type": "keyword" } } } } } } # 创建成功返回: "acknowledged" : true, { "acknowledged" : true, "shards_acknowledged" : true, "index" : "work" } ``` ##### 查询索引库:GET ```sh # GET /索引库名 GET /heima ``` ##### 删除索引库:DELETE ```sh # DELETE /索引库名 DELETE /heima # 删除成功返回: { "acknowledged" : true } ``` ##### 修改索引库:PUT /xxx/_mapping ES中的索引库一旦创建,不允许对已有字段修改。但是可以新增字段: ```sh PUT /索引库名/_mapping { "properties": { "新字段名":{ "type": "integer" } } } # 比如 PUT /heima/_mapping { "properties": { "age":{ "type": "integer" } } } ``` #### 文档操作 有了索引库后,我们就能向里面添加文档数据了 > 注意,以下的{文档id}是这个文档单独的id;若你自己定义了索引库中有个字段也叫id,但这两个id是不同的。 ##### 新增/全量修改文档:POST\PUT ```sh # PUT /{索引库名称}/_doc/{文档id} POST /{索引库名称}/_doc/{文档id} { "字段1": "值1", "字段2": "值2", "字段3": { "子属性1": "值3", "子属性2": "值4" }, } # 如果id为1的数据没有,则新增;如果有,则先删除id为1的,再新增(全量修改) # PUT /heima/_doc/1 POST /heima/_doc/1 { "info": "黑马程序员高级Java讲师", "email": "zy@itcast.cn", "name": { "firstName": "云", "lastName": "赵" } } # 返回201,新增成功;返回200,修改成功 ``` ##### 查询文档:GET ```sh # GET /{索引库名称}/_doc/{文档id} GET /heima/_doc/2 # 查询结果 200 - OK { "_index" : "work", "_type" : "_doc", "_id" : "2", "_version" : 1, "_seq_no" : 1, "_primary_term" : 1, "found" : true, "_source" : { "age" : 18, "title" : "2024年终总结", "name" : { "firstName" : "韩", "lastName" : "旭" } } } ``` ##### 查询文档数量 GET ```sh # GET /{索引库名称}/_count GET /heima/_count # 查询结果 200 - OK # count 即查询结果总数:2条 # total 分片数量 总共有1个分片参与了查询 # successful 成功执行查询的分片数量 表示有1个分片成功执行了查询 { "count" : 2, "_shards" : { "total" : 1, "successful" : 1, "skipped" : 0, "failed" : 0 } } ``` ##### 删除文档:DELETE ```sh # DELETE /{索引库名称}/_doc/{文档id} DELETE /heima/_doc/1 # 执行结果 200 - OK { "_index" : "work", "_type" : "_doc", "_id" : "1", "_version" : 2, "result" : "deleted", "_shards" : { "total" : 2, "successful" : 1, "failed" : 0 }, "_seq_no" : 4, "_primary_term" : 1 } ``` ##### 修改文档:PUT\POST 全量修改:先将原本的文档数据删除,再新增,即上述【新增/全量修改文档】。 部分修改:只修改指定字段数据: ```sh # POST /{索引库名称}/_update/{文档id} { "doc": { "字段名": "新的值", } } # 如果email原本就是这个值,则返回200:"result" : "noop"(空操作); # 如果email原本不是这个值,则返回200:"result" : "updated" POST /heima/_update/1 { "doc": { "email": "ZhaoYun@itcast.cn" } } # 执行结果 200 - OK { "_index" : "work", "_type" : "_doc", "_id" : "1", "_version" : 3, "result" : "updated", "_shards" : { "total" : 2, "successful" : 1, "failed" : 0 }, "_seq_no" : 6, "_primary_term" : 1 } ``` #### 文档批处理 批处理即一次请求,操作多条数据。可以批量新增、修改、删除。 语法: ```sh # _bulk是固定的,代表批处理 # { "index" : { "_index" : "test", "_id" : "1" } } index代表我要新增,"_index" : "test"代表操作的索引库名字是test,"_id" : "1"代表新增的数据id为1 # { "field1" : "value1" } 即新增的具体数据 # 新增和修改都是两行两行是一对,一行指定命令、索引库和id,一行是具体数据。删除操作只需要一行即可。 # 注意批处理不能随意换行哦 POST _bulk { "index" : { "_index" : "test", "_id" : "1" } } { "field1" : "value1" } { "delete" : { "_index" : "test", "_id" : "2" } } { "create" : { "_index" : "test", "_id" : "3" } } { "field1" : "value3" } { "update" : {"_id" : "1", "_index" : "test"} } { "doc" : {"field2" : "value2"} } ``` 批量新增: ```sh POST /_bulk {"index": {"_index":"heima", "_id": "3"}} {"info": "黑马程序员C++讲师", "email": "ww@itcast.cn", "name":{"firstName": "五", "lastName":"王"}} {"index": {"_index":"heima", "_id": "4"}} {"info": "黑马程序员前端讲师", "email": "zhangsan@itcast.cn", "name":{"firstName": "三", "lastName":"张"}} ``` 批量删除: ```sh POST /_bulk {"delete":{"_index":"heima", "_id": "3"}} {"delete":{"_index":"heima", "_id": "4"}} ``` ## 三、Java中使用 Java客户端操作时,8以上版本的es,都是lambda表达式,响应式编程的客户端。而企业中大多数使用的es7的客户端都不是这些,所以我们使用的还是8以下的客户端版本,官方API地址:https://www.elastic.co/guide/en/elasticsearch/client/java-rest/7.12/java-rest-high.html ![](https://minio.riun.xyz/riun1/2024-07-04_67M24BFcPVS0zXfRl9.jpg) 从这个入口点进去查找各个版本的API。 ### SpringBoot依赖 pom中添加依赖: > 注意是high-level版本的,不要导错了 ```xml <dependency> <groupId>org.elasticsearch.client</groupId> <artifactId>elasticsearch-rest-high-level-client</artifactId> </dependency> ``` 我这里使用的spring-boot-starter-parent版本是2.5.3,点进去后,再点spring-boot-dependencies进入,可以找到elasticsearch版本就是7.12.1,和我们安装的es版本相同,所以可以直接用。 ![](https://minio.riun.xyz/riun1/2024-07-04_67Mb26LP64WInFOPzz.jpg) ![](https://minio.riun.xyz/riun1/2024-07-04_67Mbo6X11WZNXRcely.jpg) 如果你的不同,则需要在父工程中指定版本: ```xml <properties> <elasticsearch.version>7.12.1</elasticsearch.version> </properties> ``` #### 测试连接 ```java package com.example.esdemo; 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; /** * @author: HanXu * on 2024/7/4 * Class description: 测试es连接 * 注意HttpHost.create("http://ip:9200") 9200后面不能再加/ */ public class ESTest { private RestHighLevelClient client; @Test void testClient() { System.out.println("client:" + client); } @BeforeEach void setUp() { client = new RestHighLevelClient(RestClient.builder( HttpHost.create("http://ip:9200") )); } @AfterEach void tearDown() throws IOException { if (client != null) { client.close(); } } } ``` ### Java客户端操作索引库 假设我们有一个数据库表items,对应到ES中的文档结构如下: ```sh PUT /items { "mappings": { "properties": { "id": { "type": "keyword" }, "name":{ "type": "text", "analyzer": "ik_max_word" }, "price":{ "type": "integer" }, "stock":{ "type": "integer" }, "image":{ "type": "keyword", "index": false }, "category":{ "type": "keyword" }, "brand":{ "type": "keyword" }, "sold":{ "type": "integer" }, "commentCount":{ "type": "integer", "index": false }, "isAD":{ "type": "boolean" }, "updateTime":{ "type": "date" } } } } ``` #### Java创建索引库 ```java @Test void testCreateIndex() throws IOException { // 1.创建Request对象 CreateIndexRequest request = new CreateIndexRequest("items"); // 2.准备请求参数 request.source(MAPPING_TEMPLATE, XContentType.JSON); // 3.发送请求 client.indices().create(request, RequestOptions.DEFAULT); } 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" + " },\n" + " \"price\":{\n" + " \"type\": \"integer\"\n" + " },\n" + " \"stock\":{\n" + " \"type\": \"integer\"\n" + " },\n" + " \"image\":{\n" + " \"type\": \"keyword\",\n" + " \"index\": false\n" + " },\n" + " \"category\":{\n" + " \"type\": \"keyword\"\n" + " },\n" + " \"brand\":{\n" + " \"type\": \"keyword\"\n" + " },\n" + " \"sold\":{\n" + " \"type\": \"integer\"\n" + " },\n" + " \"commentCount\":{\n" + " \"type\": \"integer\"\n" + " },\n" + " \"isAD\":{\n" + " \"type\": \"boolean\"\n" + " },\n" + " \"updateTime\":{\n" + " \"type\": \"date\"\n" + " }\n" + " }\n" + " }\n" + "}"; ``` #### Java删除索引库 ```java @Test void testDeleteIndex() throws IOException { // 1.创建Request对象 DeleteIndexRequest request = new DeleteIndexRequest("items"); // 2.发送请求 client.indices().delete(request, RequestOptions.DEFAULT); } ``` #### Java查询索引库 ```java @Test void testGetIndex() throws IOException { GetIndexRequest request = new GetIndexRequest("items"); GetIndexResponse response = client.indices().get(request, RequestOptions.DEFAULT); System.out.println("response = " + response); //response = org.elasticsearch.client.indices.GetIndexResponse@68b32e3e } ``` 我们一般不直接查询索引库,因为内容较多,一般只需要判断是否存在即可: #### Java判断索引库是否存在 ```java @Test void testExistsIndex() throws IOException { // 1.创建Request对象 GetIndexRequest request = new GetIndexRequest("items"); // 2.发送请求 boolean exists = client.indices().exists(request, RequestOptions.DEFAULT); // 3.输出 System.err.println(exists ? "索引库已经存在!" : "索引库不存在!"); } ``` ### Java客户端操作文档 索引库有了后,我们就可以操作文档了。这里我们假设本地有一个MySQL数据库items,已经有了一些数据。然后把他的数据导入到es中。 MySQL items: ```sql DROP TABLE IF EXISTS `items`; CREATE TABLE `items` ( `id` bigint(0) NOT NULL AUTO_INCREMENT, `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT 'sku名称', `price` int(0) NULL DEFAULT NULL COMMENT '价格(分)', `stock` int(0) NULL DEFAULT NULL COMMENT '库存数量', `image` varchar(1024) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '图片', `category` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '类目', `brand` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '品牌', `sold` int(0) NULL DEFAULT NULL COMMENT '销量', `comment_count` int(0) NULL DEFAULT NULL COMMENT '评论数', `is_ad` tinyint(0) NULL DEFAULT NULL COMMENT '是否推广广告', `update_time` datetime(0) NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP(0) COMMENT '更新时间', PRIMARY KEY (`id`) USING BTREE ) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic; -- ---------------------------- -- Records of items -- ---------------------------- INSERT INTO `items` VALUES (1, '莎米特太空杯', 1900, 2000, 'https://minio.riun.xyz/riun1/2024-07-04_67OfVX2nc5G9PGnp3r.jpg', '水杯', '莎米特', 95, 18, 0, '2024-07-04 18:24:29'); ``` 对应Java中的DTO: ```java package com.example.esdemo.dto; import io.swagger.annotations.ApiModel; import io.swagger.annotations.ApiModelProperty; import lombok.Data; import java.time.LocalDateTime; @Data @ApiModel(description = "索引库实体") public class Items { @ApiModelProperty("商品id") private Long id; @ApiModelProperty("商品名称") private String name; @ApiModelProperty("价格(分)") private Integer price; @ApiModelProperty("商品图片") private String image; @ApiModelProperty("类目名称") private String category; @ApiModelProperty("品牌名称") private String brand; @ApiModelProperty("销量") private Integer sold; @ApiModelProperty("评论数") private Integer commentCount; @ApiModelProperty("是否是推广广告,true/false") private Boolean isAd; @ApiModelProperty("更新时间") private LocalDateTime updateTime; } ``` 新增一个mapper: ```java public interface ItemsMapper extends BaseMapper<Items> { } ``` 在启动类上需要新增注解:@MapperScan("com.example.esdemo.mapper") 以上通常都是项目中已经有了的东西。为了给es使用,我们需要新增一个DTO: ```java package com.hmall.item.domain.po; import io.swagger.annotations.ApiModel; import io.swagger.annotations.ApiModelProperty; import lombok.Data; import java.time.LocalDateTime; @Data @ApiModel(description = "索引库实体") public class ItemDoc{ @ApiModelProperty("商品id") private String id; @ApiModelProperty("商品名称") private String name; @ApiModelProperty("价格(分)") private Integer price; @ApiModelProperty("商品图片") private String image; @ApiModelProperty("类目名称") private String category; @ApiModelProperty("品牌名称") private String brand; @ApiModelProperty("销量") private Integer sold; @ApiModelProperty("评论数") private Integer commentCount; @ApiModelProperty("是否是推广广告,true/false") private Boolean isAD; @ApiModelProperty("更新时间") private LocalDateTime updateTime; } ``` #### 新增/全量更新文档 ```java package com.example.esdemo; import cn.hutool.core.bean.BeanUtil; import cn.hutool.json.JSONUtil; import com.example.esdemo.dto.Items; import com.example.esdemo.dto.ItemDoc; import com.example.esdemo.mapper.ItemsMapper; import org.apache.http.HttpHost; import org.elasticsearch.action.index.IndexRequest; import org.elasticsearch.client.RequestOptions; import org.elasticsearch.client.RestClient; import org.elasticsearch.client.RestHighLevelClient; import org.elasticsearch.common.xcontent.XContentType; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; import javax.annotation.Resource; import java.io.IOException; /** * @author: HanXu * on 2024/7/4 * Class description: 测试操作文档数据 */ @SpringBootTest public class ESDocTest { private RestHighLevelClient client; @Resource private ItemsMapper itemsMapper; @BeforeEach void setUp() { client = new RestHighLevelClient(RestClient.builder( HttpHost.create("http://ip:9200") )); } @AfterEach void tearDown() throws IOException { if (client != null) { client.close(); } } @Test void testAddDocument() throws IOException { // 1.根据id查询商品数据 Items items = itemsMapper.selectById(1); // 2.转换为文档类型 ItemDoc itemDoc = BeanUtil.copyProperties(items, ItemDoc.class); // 3.将ItemDTO转json String doc = JSONUtil.toJsonStr(itemDoc); // 1.准备Request对象 该id存在则先删除,再全量更新;不存在则直接新增 IndexRequest request = new IndexRequest("items").id(itemDoc.getId()); // 2.准备Json文档 request.source(doc, XContentType.JSON); // 3.发送请求 client.index(request, RequestOptions.DEFAULT); } } ``` #### 查询文档 ```java @Test void testGetDocumentById() throws IOException { // 1.准备Request对象 GetRequest request = new GetRequest("items").id("1"); // 2.发送请求 GetResponse response = client.get(request, RequestOptions.DEFAULT); // 3.获取响应结果中的source String json = response.getSourceAsString(); ItemDoc itemDoc = JSONUtil.toBean(json, ItemDoc.class); System.out.println("itemDoc= " + itemDoc); } ``` #### 删除文档 ```java @Test void testDeleteDocument() throws IOException { // 1.准备Request,两个参数,第一个是索引库名,第二个是文档id DeleteRequest request = new DeleteRequest("items", "1"); // 2.发送请求 client.delete(request, RequestOptions.DEFAULT); } ``` #### 部分修改文档 ```java @Test void testUpdateDocument() throws IOException { // 1.准备Request UpdateRequest request = new UpdateRequest("items", "1"); // 2.准备请求参数 注意:每两个是一对,也可以用map参数的方法 request.doc( "price", 58800, "commentCount", 1 ); // 3.发送请求 client.update(request, RequestOptions.DEFAULT); } ``` #### 批量导入文档 ```java @Test void testBulk() throws IOException { // 1.创建Request BulkRequest request = new BulkRequest(); // 2.准备请求参数 request.add(new IndexRequest("items").id("1").source("json doc1", XContentType.JSON)); request.add(new IndexRequest("items").id("2").source("json doc2", XContentType.JSON)); // 3.发送请求 client.bulk(request, RequestOptions.DEFAULT); } @Test void testLoadItemDocs() throws IOException { // 分页查询商品数据 int pageNo = 1; int size = 1000; while (true) { Page<Item> page = itemService.lambdaQuery().eq(Item::getStatus, 1).page(new Page<Item>(pageNo, size)); // 非空校验 List<Item> items = page.getRecords(); if (CollUtils.isEmpty(items)) { return; } log.info("加载第{}页数据,共{}条", pageNo, items.size()); // 1.创建Request BulkRequest request = new BulkRequest("items"); // 2.准备参数,添加多个新增的Request for (Item item : items) { // 2.1.转换为文档类型ItemDTO ItemDoc itemDoc = BeanUtil.copyProperties(item, ItemDoc.class); // 2.2.创建新增文档的Request对象 request.add(new IndexRequest() .id(itemDoc.getId()) .source(JSONUtil.toJsonStr(itemDoc), XContentType.JSON)); } // 3.发送请求 client.bulk(request, RequestOptions.DEFAULT); // 翻页 pageNo++; } } ``` ## 四、es的复杂查询DSL★★★ 上述我们都是通过文档id对文档进行查询,下面我们介绍一些更复杂更常用的查询,DSL查询: - 叶子查询(Leaf query clauses):一般是查询特定字段的特定值,属于简单查询,很少单独使用。常用的包括: > https://www.elastic.co/guide/en/elasticsearch/reference/7.12/query-dsl.html - 全文检索查询(full text):对查询关键字分词,然后去词条列表匹配。常见关键字:match、multi_match - 精确查询:不对查询关键字分词,精准匹配。常见关键字:ids(类似mysql的in)、range(范围)、term(equals) - 地理坐标查询:geo_distance、geo_bounding_box - 复合查询(Compound query clauses):组合多个叶子查询,或更改叶子查询的行为方式。 > https://www.elastic.co/guide/en/elasticsearch/reference/7.12/compound-queries.html - 基于逻辑运算,组合多个查询:bool - 基于某种算法修改文档的相关性算分,从而改变排名:function_score、dis_max 查询后还能对结果处理,包括: - 排序:按照某个或某些字段排序 - 分页:根据from和size分页,与MySQL类似 - 高亮:对搜索结果的关键词添加特殊样式 - 聚合:对结果做数据统计形成报表 ### 叶子查询 #### 查询全部 ```sh GET /{索引库名}/_search { "query": { "查询类型": { "查询条件": "条件值" } } } ``` 比如,查询所有: ```sh # 查询所有就不需要加条件了 GET /mbtest/_search { "query": { "match_all": {} } } ``` ![](https://minio.riun.xyz/riun1/2024-07-05_67UAyee1ES0fSjm0OB.jpg) 执行结果中,took:耗时,timed_out:是否超时,_shards:集群相关信息,hits:查询结果 total:总记录数,value是值,relation是关系。上图所示eq就是等于209 max_score:搜索相关度得分。现在没有条件这个值就会是1.0 hits是结果集,里面是真正的结果数据。 > 在es内部默认有查询限制:单次运行查询的最大条数是10000,所以有再多数据也只会查出10000。此时relation会是gte,表示查询结果大于等于10000 > > 而hits中展示的真是数据部分,只要结果超出10条,则默认只会展示10条。但是是可以控制的。 hits中:\_index是索引库名称;\_type是数据类型,\_doc代表文档;_id就是文档id;\_source就是源数据 #### 全文检索查询 对用户输入内容分词,然后去倒排索引库检索 ##### match(推荐使用) > 查询一个字段 ```sh # FIELD是要查询的内容所在字段,比如查询姓名,则FIELD就是name # TEXT就是查询的关键字,比如查询姓名是riun的,那TEXT就是riun GET /indexName/_search { "query": { "match": { "FIELD": "TEXT" } } } # 举例:在文章标题中查询包含java的 GET /mbtest/_search { "query": { "match": { "wtitle": "java" } } } ``` 查询结果按照score得分从高到底排序 ![](https://minio.riun.xyz/riun1/2024-07-05_68b2ShicQnGKtchyaj.jpg) ##### multi_match(推荐使用) > 与match类似,不过支持多个字段查询。比如从文章标题、文章简介、文章内容中搜索关键字Java > > 查询的字段表越多,效率越底 ```sh # "query": "TEXT" query固定语法,TEXT就是查询的关键字,比如java # "fields": ["FIELD1", "FIELD2"] fields固定语法,["FIELD1", "FIELD2"]即为要查询的字段,比如title,intro,info等 GET /indexName/_search { "query": { "multi_match": { "query": "TEXT", "fields": ["FIELD1", "FIELD2"] } } } # 举例:在文章标题、文章介绍、文章内容中查询包含java的 GET /mbtest/_search { "query": { "multi_match": { "query": "mysql", "fields": ["wtitle", "wintro", "winfocontent"] } } } ``` ![](https://minio.riun.xyz/riun1/2024-07-05_68b6i3cvOt7dYOPHwy.jpg) #### 精确查询 > 所有精确查询都是没有得分的。(得分是固定值1.0,因为是精确搜索)(term查询字段可分词除外) ##### ids ```sh # 需要注意这个ids查询的是文档id:_id。如果你文档数据中有个字段叫id,那跟这个id无关 GET /mbtest/_search { "query": { "ids": { "values": [] } } } GET /mbtest/_search { "query": { "ids": { "values": [238, 239, 240] } } } ``` ![](https://minio.riun.xyz/riun1/2024-07-06_68uoiaAChdh3N6P3Rg.jpg) ##### range ```sh # 查询某个字段大于等于 小于等于 GET /mbtest/_search { "query": { "range": { "FIELD": { "gte": 10, # ge 就是大于 不包含等于 "lte": 20 # lt 就是小于 不包含等于 } } } } # 查询创建时间在某个范围内的 GET /mbtest/_search { "query": { "range": { "createTime": { "gte": 1660745488000, "lte": 1670745488000 } } } } ``` ##### term term查询相当于equals,是直接拿输入的内容(不分词)去倒排索引中查询,查询的字段最好是不分词的。比如查商品的分类字段,查id,查时间等。 > 查询的字段如果是分词的也可以,但是容易查不到: > > 查询关键字脱脂牛奶,去商品名称中查询,就会查不到。因为商品名称肯定是会分词的,分词会把他分为“脱脂”和“牛奶”两个词,也就是说倒排索引中没有一个唯一词叫“脱脂牛奶”,所以查不到。 > > 而如果查询关键字Mysql,如文章标题中查询,有可能查到。因为如果文章标题如果有包含Mysql的,Mysql是一个英文单词,会被当做一个词,在倒排索引中存在,所以可能查到。 ```sh # 有两种格式的写法,要不要value关键字都可以 GET /mbtest/_search { "query": { "term": { "FIELD": { "value": "VALUE" } } } } GET /mbtest/_search { "query": { "term": { "wtitle": { "value": "mysql" } } } } # 或 GET /mbtest/_search { "query": { "term": { "wtitle": "mysql" } } } ``` ### 复合查询 #### 逻辑运算 bool查询即布尔查询,就是我们说的与或非: - must:类似与,必须匹配每个子查询 - should:类似或,选择性匹配子查询 - must_not:类似非,必须不匹配子查询。**不参与算分** - filter:类似与,必须匹配每个子查询。**不参与算分** 比如我们要查询“大屏手机”,但是我们只想看小米和vivo的,这时就可以添加filter,把小米和vivo的手机过滤出来;因为只要过滤出来的一定是我们要的,所以这个条件筛选商品时只有【满足】或【不满足】两种结果,因此也就没有得分这一说(参与算分就没意义,但是参与算分会影响效率),所以不参与算分。must_not也类似。 ```sh # 查询文章标题包含java(算分);并且创建时间在1560745488000 - 1670745488000之间的 isActived是true的文档数据 (这两个条件也要满足但不算分) GET /mbtest/_search { "query": { "bool": { "must": [ { "match": { "wtitle": "java" } } ], "filter": [ { "range": { "createTime": { "gte": 1560745488000, "lte": 1670745488000 } } }, { "term": { "isActived": { "value": "true" } } } ] } } } ``` 查询文章标题包含java,文章wid在[102, 49]里;并且创建时间在1560745488000 - 1670745488000之间的 isActived是true的文档数据 (这两个条件也要满足但不算分) ```sh GET /mbtest/_search { "query": { "bool": { "must": [ { "match": { "wtitle": "java" } } ], "should": [ { "term": { "wid": { "value": "102" } } }, { "term": { "wid": { "value": "49" } } } ], "filter": [ { "range": { "createTime": { "gte": 1560745488000, "lte": 1670745488000 } } }, { "term": { "isActived": { "value": true } } } ] } } } ``` #### 算分函数 从es5.1开始,采用的相关性打分算法都是BM25算法,公式如下: ![](https://minio.riun.xyz/riun1/2024-07-05_68dI1qYpwoPBh4sp4W.jpg) 基于这套公式,可以判断出搜索内容与目标文档数据的关联度,还是挺准确的。但实际业务中,往往还有竞价排名的功能。想要认为控制相关度算分,就要利用es中的function score查询了。 function score包含四部分: - **原始查询条件**:query部分,就是原生的查询,得出**原始算分**(query score)。 - **过滤条件**:filter部分,符合该条件的文档才会重新算分 - **算分函数**:符合filter条件的文档数据要跟这个函数做运算,得到**函数算分**(function score)。有四种函数: - weight:函数结果是常量 - field_value_factor:以文档中的某个字段值作为函数结果 - random_score:以随机数作为函数结果 - script_score:自定义算分函数算法 - **运算模式**:算分函数结果与原始算分,两者之间的运算方式,包括: - multiply:相乘 - replace:用function score替代query score - 其他,例如:sum、ave、max、min function score的运行流程如下: 1、根据**原始条件**查询搜索文档,并且计算相关性算分,称为**原始算分**(query score) 2、根据**过滤条件**,过滤文档 3、符合**过滤条件**的文档,基于**算分函数**运算,得到**函数算分**(function score) 4、将**原始算分**(query score)和**函数算分**(function score)基于**运算模式**做运算,得到最终结果,作为相关性算分。 因此,其中的关键点是: - 过滤条件:决定哪些文档的算分被修改 - 算分函数:决定函数算分的算法 - 运算模式:决定最终算分结果 示例:给IPhone这个品牌的手机算分提高十倍,分析如下: - 过滤条件:品牌必须为IPhone - 算分函数:常量weight,值为10 - 算分模式:相乘multiply 对应代码如下: ```sh GET /hotel/_search { "query": { "function_score": { "query": { .... }, // 原始查询,可以是任意条件 "functions": [ // 算分函数 { "filter": { // 满足的条件,品牌必须是Iphone "term": { "brand": "Iphone" } }, "weight": 10 // 算分权重为2 } ], "boost_mode": "multipy" // 加权模式,求乘积 } } } ``` ### 排序 es默认是按照相关度算分来排序的,但是也支持自定义排序,要注意的是分词字段无法排序(也就是说type:text的字段无法排序)。一般参与排序的字段有:keyword类型、数值类型、日期类型、地理坐标类型。(如果把该字段的index手动置为了false,也无法排序【默认都是true一般不改】) > https://www.elastic.co/guide/en/elasticsearch/reference/7.12/sort-search-results.html ```sh # 语法 order关键字可省略 GET /indexName/_search { # 这里就是查询条件 "query": { "match_all": {} }, "sort": [ { "FIELD": { "order": "desc" } } ] } # 举例:按销量降序, 销量相同时,按价格升序 GET /items/_search { "query": { "match_all": {} }, "sort": [ { "sold": "desc" }, { "price": "asc" } ] } ``` ### 分页 > https://www.elastic.co/guide/en/elasticsearch/reference/7.12/paginate-search-results.html es默认返回top10数据,要查询更多需要添加分页参数。 - from:从第几个文档开始,这个不是_id,也不是页码。是跟Mysql的limit一样的,从第n个文档数据开始 - size:查询几个文档 ```sh # 查询第一页(前10条) GET /items/_search { "query": { "match_all": {} }, "sort": [ { "sold": "desc" }, { "price": "asc" } ], "from": 0, "size": 10 } # 查询第2页(后10条) GET /items/_search { "query": { "match_all": {} }, "sort": [ { "sold": "desc" }, { "price": "asc" } ], "from": 10, "size": 10 } ``` 如果业务上页码从1开始,那from就是(页码 - 1) * size #### 深度分页的问题 es的数据是采用分片存储的,当数据量很大时,会把一个索引库的数据分成N份。储存到不同节点上。查询数据时需要汇总各个分片的数据。 ![](https://minio.riun.xyz/riun1/2024-07-05_68cPEygChm8yZHBeW8.jpg) 假设我们每页查10条,要查询第100页的数据: - from:990 - size:10 看起来我们只需要第(990-1000]的10条数据,可是es可无法直接取出这10条。es需要先对每个分片上的数据排序,找到各个分片的前1000条数据;然后将这些数据聚合排序,重新找到它们之中的前1000条数据,然后再找到第(990-1000]的10条数据返回。 这样做已经很好了,不用把全部的数据load到内存中排序,获取。但是当深分页时仍然有问题。比如每页10条,我查第10000页的数据呢?那是不是要把每个分片的前10w条数据都聚合在内存中,对这40w条数据排序最终获取(99990 - 100000]之间的数据呢? 由此我们可以得出结论:深度分页一定会导致内存不足。数据库分页同样的道理。 #### 解决思路 ES官方对深度分页提出两种解决方案: - search after:分页时需要排序,从上一次排序值开始,查询下一页的数据。[官方推荐](https://www.elastic.co/guide/en/elasticsearch/reference/7.12/paginate-search-results.html)。 - scroll:原理是将排序数据形成快照,保存在内存中。已不推荐使用。 第一种search after就是业界对所有深度分页的解决方案,就是[流利说](https://riun.xyz/work/2563231)那种分页,只能一页一页的查询。查某页的时候,在底层的sql里是带了上一页的最大(小)值做判断。这样一下就能筛掉许多数据。 优点:没有查询上限,支持深度分页。 缺点:只能向前、后逐行翻页,不能随机翻页。 适用场景:数据迁移、手机翻页等。 另外向百度、京东这种,它们数据量很大,但仍然采用传统分页,那它们就不会有深度分页的问题了吗?有,只是它们会对分页做限制。比如百度只会让你查询到第77页,每页有20条数据。也就是只会查询出前一百多的数据。京东也类似。 所以如果我们需要传统随机翻页的方式,同时数据量又特别大的话,那就要对分页设置上限,不允许查询太靠后的数据。 事实上es也对分页做了限制,只能查询前1w条数据(from + size不能大于10000)。如果分页查询: ```sh "from": 10000, "size": 3 ``` 则报错(不管有没有这么多数据都会报错): ![](https://minio.riun.xyz/riun1/2024-07-05_68d9XkWkFeJojVYeNc.jpg) #### 深分页总结 要解决深度分页的问题,有两种思路: 1、使用search after,将业务设置为只能前一页,后一页的查询 2、使用传统的可随机翻页的查询方法,但是设置查询上限,例如每页10条只能查询前100页的数据。【大多数场景适用】 ### 高亮 高亮显示这个功能需要后端对指定词条添加标签,前段对标签写css样式就好。 ```sh GET /mbtest/_search { # 正常的查询语句 "query": { "match": { "wtitle": "java" } }, # 高亮显示 highlight、fields都是固定语法 wtitle是需要高亮显示的词条在哪个字段中,指定wtitle,就是说需要对wtitle中的java高亮显示 "highlight": { "fields": { "wtitle": { "pre_tags": "<em>", "post_tags": "</em>" } } } } # <em>就是一个普通的标签element,es默认加的标签也是它,所以可以不写 GET /mbtest/_search { "query": { "match": { "wtitle": "java" } }, "highlight": { "fields": { "wtitle": {} } } } ``` 要注意执行结果中_source是永远不会变的,代表源数据。会额外给一个同级的highlight存放加了标签后的内容: ![](https://minio.riun.xyz/riun1/2024-07-05_68dysQ0S7Ns8VXnidl.jpg) #### 特别注意 > 默认情况下参与高亮的字段要与搜索字段一致,除非添加:required_field_match=false 要高亮还必须同时满足以下两个条件 ##### 要高亮的词条必须有查询条件 ```sh # term类似于equals,所以也算查询条件,执行结果也会成立 GET /mbtest/_search { "query": { "term": { "wtitle": "mysql" } }, "highlight": { "fields": { "wtitle": {} } } } ``` ![](https://minio.riun.xyz/riun1/2024-07-05_68dCiXKoIQKQsAQfm3.jpg) ##### 字段必须是text字段 ```sh # 虽然createTime大于等于,小于等于 也是查询条件,但是它不是text类型,所以执行结果不会有高亮显示标签 GET /mbtest/_search { "query": { "range": { "createTime": { "gte": 1660745488000, "lte": 1670745488000 } } }, "highlight": { "fields": { "createTime": {} } } } ``` ![](https://minio.riun.xyz/riun1/2024-07-05_68dEeyULtXBEizwX91.jpg) ### 总结DSL 四部分组成:查询条件,分页,排序,高亮 ```sh GET /mbtest/_search { "query": { "match": { "wtitle": "java" } }, "from": 0, "size": 3, "sort": [ { "createTime": "desc" } ], "highlight": { "fields": { "wtitle": {} } } } ``` ### 聚合 > https://www.elastic.co/guide/en/elasticsearch/reference/7.12/search-aggregations.html es的聚合就相当于sql的分组和聚合函数,例如对手机品牌分组,然后求价格最大值,最小值,平均值等。实现非常方便而且速度接近实时查询的效果。 聚合常见有三类: - 桶聚合(Bucket):用来对文档分组 - `TermAggregation`:按照文档字段值分组,例如按照品牌值分组、按照国家分组 - `Date Histogram`:按照日期阶梯分组,例如一周为一组,或者一月为一组 - 度量聚合(Metric):计算最大值、最小值、平均值等。 - `Avg`:求平均值 - `Max`:求最大值 - `Min`:求最小值 - `Stats`:同时求`max`、`min`、`avg`、`sum`等 - 管道聚合(pipeline):其它聚合的结果为基础做进一步运算 **注意:**参加聚合的字段必须是**keyword、日期、数值、布尔类型** #### Bucket聚合 例如我们要统计共有哪些商品分类,其实就是以商品分类字段做分组,category值一样的放在一组。这属于Bucket聚合中的Term聚合: ```sh GET /items/_search { #这里不写query,就是查全部,match_all,查全部可省略 "size": 0, # 分页查询的参数,代表每页0条,就是结果中不包含文档数据,因为我们只要聚合结果。减少网络传输压力 "aggs": { # 定义聚合,关键字 "category_agg": { # 聚合名称,自定义,但不能重复 "terms": { # 聚合类型,按分类聚合,terms关键字 "field": "category", # 参加聚合的字段名称 "size": 20 # 查询出聚合结果的条数 } } } } ``` ![](https://minio.riun.xyz/riun1/2024-07-08_69iPw3eYKp6jZK80Fo.jpg) #### 带条件的聚合 默认情况下,Bucket聚合是对所有文档做聚合,但是有时候我们只想看某些条件下的结果聚合,那就可以给聚合添加限定条件。 例如,我想知道**价格高于3000元的手机品牌**有哪些,该怎么统计呢?我们要先分析出搜索查询条件和聚合目标: 搜索条件:价格高于3000,必须是手机 聚合目标:统计的是品牌,肯定是对brand字段做term聚合 ```sh GET /items/_search { "query": { "bool": { "filter": [ { "term": { "category": "手机" } }, { "range": { "price": { "gte": 300000 } } } ] } }, "size": 0, "aggs": { "brand_agg": { "terms": { "field": "brand", "size": 20 } } } } ``` #### Metric聚合 我们统计了价格高于3000的手机品牌,形成了一个个桶。现在我们需要对桶内的商品做运算,获取每个品牌价格的最小值、最大值、平均值。这就要用到`Metric`聚合了,例如`stat`聚合,就可以同时获取`min`、`max`、`avg`等结果。 ```sh GET /items/_search { "query": { "bool": { "filter": [ { "term": { "category": "手机" } }, { "range": { "price": { "gte": 300000 } } } ] } }, "size": 0, "aggs": { "brand_agg": { "terms": { "field": "brand", "size": 20 }, "aggs": { # aggs是关键字,和上面的aggs意思一样,和brand_agg同级,是子聚合。对brand_agg形成的每个桶中的文档分别统计 "stats_meric": { # 聚合名称 "stats": { # 聚合类型 stats是metric聚合的一种 "field": "price" # 聚合字段 } } } } } } ``` ![](https://minio.riun.xyz/riun1/2024-07-08_69iWo54OpLnXHDbj6K.jpg) 另外,我们还可以让聚合按照每个品牌的价格平均值排序: ![](https://minio.riun.xyz/riun1/2024-07-08_69iWHc1pXiMv2vZfM6.jpg) #### 聚合总结 aggs代表聚合,与query同级,此时query的作用是: - 限定聚合的的文档范围 聚合必须的三要素: - 聚合名称 - 聚合类型 - 聚合字段 聚合可配置属性有: - size:指定聚合结果数量 - order:指定聚合结果排序方式 - field:指定聚合字段 ## 五、Java客户端查询 ### 快速入门 #### 步骤: 1、创建SearchRequest 2、构建request的内容:**QueryBuilders搜索条件**。所有条件都是request.source同级的。 3、发送请求,client.search 4、解析结果 ![](https://minio.riun.xyz/riun1/2024-07-06_68umkT2FIkq3WBHBRf.jpg) #### 所有API如下: ![](https://minio.riun.xyz/riun1/2024-07-06_68umWKBM0QJCVnomAb.jpg) #### Java代码示例: ```java package com.example.esdemo; import cn.hutool.json.JSONUtil; import com.example.esdemo.dto.WorkDoc; import org.apache.http.HttpHost; import org.elasticsearch.action.search.SearchRequest; import org.elasticsearch.action.search.SearchResponse; import org.elasticsearch.client.RequestOptions; import org.elasticsearch.client.RestClient; import org.elasticsearch.client.RestHighLevelClient; import org.elasticsearch.index.query.QueryBuilder; import org.elasticsearch.index.query.QueryBuilders; import org.elasticsearch.search.SearchHit; import org.elasticsearch.search.SearchHits; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import java.io.IOException; /** * @author: HanXu * on 2024/7/6 * Class description: 测试核心搜索功能 */ public class EsSearchTest { private RestHighLevelClient client; @BeforeEach void setUp() { client = new RestHighLevelClient(RestClient.builder( HttpHost.create("http://ip:9200") )); } @AfterEach void tearDown() throws IOException { if (client != null) { client.close(); } } @Test void testMatchAll() throws IOException { SearchRequest request = new SearchRequest("mbtest"); request.source() .query(QueryBuilders.matchAllQuery()); SearchResponse response = client.search(request, RequestOptions.DEFAULT); System.out.println("response = " + response); //解析response SearchHits searchHits = response.getHits(); long total = searchHits.getTotalHits().value; System.out.println("总条数total = " + total); SearchHit[] hitArray = searchHits.getHits(); for (SearchHit hit : hitArray) { String hitSourceStr = hit.getSourceAsString(); WorkDoc workDoc = JSONUtil.toBean(hitSourceStr, WorkDoc.class); System.out.println("workDoc = " + workDoc); } } } ``` #### 执行结果: ![](https://minio.riun.xyz/riun1/2024-07-06_68uDGG07dfIzTn2Eal.jpg) response结果格式化: ![](https://minio.riun.xyz/riun1/2024-07-06_68uyZUT32hzC68jufm.jpg) ### 复杂查询 在Java API中,所有查询都是通过QueryBuilders构建的。 #### multiMatch ![](https://minio.riun.xyz/riun1/2024-07-06_68uGOAYj3IW74jxwem.jpg) #### term range ![](https://minio.riun.xyz/riun1/2024-07-06_68uHVyvRlGExxkpecn.jpg) #### bool 符合查询就是使用QueryBuilders构造一个BoolQueryBuilder,然后通过must、should等连接一个个QueryBuilders。 ![](https://minio.riun.xyz/riun1/2024-07-06_68uJgTZYX1GHgakA0T.jpg) #### 分页和排序 分页排序和高亮是和query同级的,通过request.source()构造。 ![](https://minio.riun.xyz/riun1/2024-07-06_68uKPkSnftJvFTgFKU.jpg) #### 高亮 分页排序和高亮是和query同级的,通过request.source()构造。 ![](https://minio.riun.xyz/riun1/2024-07-06_68uoT6yLmVoj4Rye6R.jpg) ![](https://minio.riun.xyz/riun1/2024-07-06_68ufix5T5J1P4inBkb.jpg) 高亮结果明明是一个字符串,但为什么es的结果输出为数组了呢? 因为es内部在这里有一个字符串处理阈值,当字符串太长的时候,它在处理后,会把字符串分割为几个字符数组做为结果,因此这里是数组。 所以我们在企业级开发中处理高亮的时候,应该把结果数组拼接,而不是像上图的代码中简单的取[0]。 #### Java代码示例 ##### 需求 用Java代码实现以下查询: 查询文章标题带java的,创建时间大于等于1560745488000,小于等于1670745488000,isActived字段为true的。将结果按照createTime降序排列,createTime相同的按照updateTime升序排列。并做分页处理,查出前3条。并对结果中wtitle字段的java关键字做高亮显示(添加标签)。 ```sh GET /mbtest/_search { "query": { "bool": { "must": [ { "match": { "wtitle": "java" } } ], "filter": [ { "range": { "createTime": { "gte": 1560745488000, "lte": 1670745488000 } } }, { "term": { "isActived": { "value": true } } } ] } }, "sort": [ { "createTime": "desc" }, { "updateTime": "asc" } ], "from": 0, "size": 3, "highlight": { "fields": { "wtitle": {} } } } ``` ##### 代码 ```java static final String indexName = "mbtest"; static final String searchColumn = "wtitle"; static final String searchText = "java"; static final String searchScopeColumn1 = "createTime"; static final String searchScopeColumn2 = "isActived"; static final long searchTimeBegin = 1560745488000L; static final long searchTimeEnd = 1670745488000L; static final String searchSortColumn1 = "createTime"; static final String searchSortColumn2 = "updateTime"; @Test void testMultiMatch() throws IOException { //前端参数 int pageNum = 1, pageSize = 3; //组装请求 SearchRequest request = new SearchRequest(indexName); request.source().query( QueryBuilders.boolQuery() .must(QueryBuilders.matchQuery(searchColumn, searchText)) .filter(QueryBuilders.rangeQuery(searchScopeColumn1).gte(searchTimeBegin).lte(searchTimeEnd)) .filter(QueryBuilders.termQuery(searchScopeColumn2, true)) ); request.source().sort(searchSortColumn1, SortOrder.DESC).sort(searchSortColumn2, SortOrder.ASC); request.source().from((pageNum - 1) * pageSize).size(pageSize); request.source().highlighter(SearchSourceBuilder.highlight().field(searchColumn)); SearchResponse response = client.search(request, RequestOptions.DEFAULT); //解析response SearchHits searchHits = response.getHits(); long value = searchHits.getTotalHits().value; System.out.println("总条数value = " + value); SearchHit[] hitArray = searchHits.getHits(); for (SearchHit hit : hitArray) { String hitSourceStr = hit.getSourceAsString(); WorkDoc workDoc = JSONUtil.toBean(hitSourceStr, WorkDoc.class); //将highlight内容替换掉原本的内容 Map<String, HighlightField> highlightFieldMap = hit.getHighlightFields(); if (highlightFieldMap != null && !highlightFieldMap.isEmpty()) { HighlightField hf = highlightFieldMap.get(searchColumn); Text[] texts = hf.getFragments(); StringBuffer sb = new StringBuffer(); for (Text text : texts) { sb.append(text.toString()); } workDoc.setWtitle(sb.toString()); } System.out.println("workDoc = " + workDoc); } } ``` ##### 执行结果 ![](https://minio.riun.xyz/riun1/2024-07-06_68vcwzkiTts1fIwA7K.jpg) 这个总条数value = 7 是查询总条数,我们做了分页,把结果复制出来可以看到只有3个workDoc。 ### 聚合查询 在DSL中,`aggs`聚合条件与`query`条件是同一级别,都属于查询JSON参数。因此依然是利用`request.source()`方法来设置。 不过聚合条件的要利用`AggregationBuilders`这个工具类来构造。DSL与JavaAPI的语法对比如下: ![](https://minio.riun.xyz/riun1/2024-07-08_69j6tBhTMnESYzC8It.jpg) 聚合结果与搜索文档同一级别,因此需要单独获取和解析。具体解析语法如下: ![](https://minio.riun.xyz/riun1/2024-07-08_69j6HY8ytq1iv4LyAD.jpg) ```java @Test void testAgg() throws IOException { // 1.创建Request SearchRequest request = new SearchRequest("items"); // 2.准备请求参数 BoolQueryBuilder bool = QueryBuilders.boolQuery() .filter(QueryBuilders.termQuery("category", "手机")) .filter(QueryBuilders.rangeQuery("price").gte(300000)); request.source().query(bool).size(0); // 3.聚合参数 request.source().aggregation( AggregationBuilders.terms("brand_agg").field("brand").size(5) ); // 4.发送请求 SearchResponse response = client.search(request, RequestOptions.DEFAULT); // 5.解析聚合结果 Aggregations aggregations = response.getAggregations(); // 5.1.获取品牌聚合 返回的是Aggregation,是一个父接口,我们要使用具体的子类接收 Terms brandTerms = aggregations.get("brand_agg"); // 5.2.获取聚合中的桶 List<? extends Terms.Bucket> buckets = brandTerms.getBuckets(); // 5.3.遍历桶内数据 for (Terms.Bucket bucket : buckets) { // 5.4.获取桶内key String brand = bucket.getKeyAsString(); System.out.print("brand = " + brand); long count = bucket.getDocCount(); System.out.println("; count = " + count); } } ``` > 部分内容参考: > > 视频:https://www.bilibili.com/video/BV1S142197x7/?p=116 > > 文档:https://b11et3un53m.feishu.cn/wiki/LDLew5xnDiDv7Qk2uPwcoeNpngf --END--
发表评论