本文最后更新于:6 个月前
前言
- 我很荣幸能够分享一篇关于 Elasticsearch 的博客笔记,部分正文内容转载自黑马程序员的优质笔记
- 该笔记在该博文的正文栏目,在正文之前,是我在实际使用ES的过程中,所做的相关经验分享
正文
经验分享
分词器
分词器是干啥用的?指定了分词的规则(2023/09/20午)
内置分词器
空格分词器
1 2 3 4 5
| POST _analyze { "analyzer": "whitespace", "text": "The quick brown fox." }
|
关键词分词器
标准分词规则
1 2 3 4 5 6
| POST _analyze { "tokenizer": "standard", "filter": [ "lowercase", "asciifolding" ], "text": "Is this déja vu?" }
|
分词器 analyze 和分词规则 tokenizer 有什么区别呢?
1 2 3 4 5 6 7
| 在搜索引擎和文本分析领域中,分词器(Analyzer)和分词规则器(Tokenizer)是两个不同的概念。
分词器(Analyzer)是一种将文本转换为单词(Term)序列的工具。它通常包含多个处理步骤,例如词法分析、去除停用词、小写转换、词干提取等。分词器的作用是将原始的文本输入转换为可供索引和搜索的标记流。例如,在Elasticsearch中,分词器被用于预处理文本数据并将其存储在倒排索引中,以支持全文搜索。
分词规则器(Tokenizer)是分词器的一个组成部分。它是文本分析的第一个处理步骤,将输入的文本按照指定的规则拆分为单词。常见的分词规则器有基于空格拆分的空格分词器、基于标点符号拆分的标点分词器等。分词规则器负责定义文本拆分的方式,决定了哪些字符会被视为词条的分隔。
总结来说,分词规则器(Tokenizer)是分词器(Analyzer)的组成部分,用于定义文本的拆分方式;而分词器(Analyzer)则包含多个处理步骤,用于将输入文本转换为标记流。
|
IK分词器(ES内置插件)
下载安装
- 下载完成,将压缩包解压在 Elasticsearch 的 plugins / ik目录下即可
修改版本一致
启动ES、Kibana
测试分词效果
测试成功,这里也能看出来 ik_smart 和 ik_max_word 这两种不同分词模式的区别了(2023/09/20午)
ik_smart
模式是 IK 分词器的简单模式,它会对文本进行较为粗粒度的切分,主要以将句子切分为一些较短的词语为目标,适用于快速搜索和一般文本处理场景。该模式下的分词结果倾向于保留短词
ik_max_word
模式是 IK 分词器的细粒度模式,它会尽可能多地将文本切分为更小的词语,包括一些更细致的切分,如拆分复合词和词组等。该模式下的分词结果倾向于将文本切分为更多的词
ES 调用方式
Java 操作 ES
ES实现搜索接口
建立索引
在ES中,也存在和 MySQL类似的表结构,这里可以将二者对比一下:
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 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| create table post ( id bigint auto_increment comment 'id' primary key, title varchar(512) null comment '标题', content text null comment '内容', tags varchar(1024) null comment '标签列表(json 数组)', thumbNum int default 0 not null comment '点赞数', favourNum int default 0 not null comment '收藏数', userId bigint not null comment '创建用户 id', createTime datetime default CURRENT_TIMESTAMP not null comment '创建时间', updateTime datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间', isDelete tinyint default 0 not null comment '是否删除' ) comment '帖子' collate = utf8mb4_unicode_ci;
|
建立索引语句:
ES Mapping:
id(可以不放到字段设置里)
ES 中,尽量存放需要用户筛选(搜索)的数据
aliases:别名(为了后续方便数据迁移)
字段类型是 text,这个字段是可被分词的、可模糊查询的;而如果是 keyword,只能完全匹配、精确查询。
analyzer(存储时生效的分词器):用 ik_max_word,拆的更碎、索引更多,更有可能被搜出来
search_analyzer(查询时生效的分词器):用 ik_smart,更偏向于用户想搜的分词
如果想要让 text 类型的分词字段也支持精确查询,可以创建 keyword 类型的子字段:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
| POST post_v1 { "aliases": { "post": {} }, "mappings": { "properties": { "title": { "type": "text", "analyzer": "ik_max_word", "search_analyzer": "ik_smart", "fields": { "keyword": { "type": "keyword", "ignore_above": 256 } } }, "content": { "type": "text", "analyzer": "ik_max_word", "search_analyzer": "ik_smart", "fields": { "keyword": { "type": "keyword", "ignore_above": 256 } } }, "tags": { "type": "keyword" }, "userId": { "type": "keyword" }, "createTime": { "type": "date" }, "updateTime": { "type": "date" }, "isDelete": { "type": "keyword" } } } }
|
1 2 3 4
| <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-elasticsearch</artifactId> </dependency>
|
1 2 3 4
| elasticsearch: uris: http://localhost:9200 username: root password: 123456
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101
| @Document(indexName = "post") @Data public class PostEsDTO implements Serializable { private static final String DATE_TIME_PATTERN = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'";
@Id private Long id;
private String title;
private String content;
private List<String> tags;
private Integer thumbNum;
private Integer favourNum;
private Long userId;
@Field(index = false, store = true, type = FieldType.Date, format = {}, pattern = DATE_TIME_PATTERN) private Date createTime;
@Field(index = false, store = true, type = FieldType.Date, format = {}, pattern = DATE_TIME_PATTERN) private Date updateTime;
private Integer isDelete;
private static final long serialVersionUID = 1L;
private static final Gson GSON = new Gson();
public static PostEsDTO objToDto(Post post) { if (post == null) { return null; } PostEsDTO postEsDTO = new PostEsDTO(); BeanUtils.copyProperties(post, postEsDTO); String tagsStr = post.getTags(); if (StringUtils.isNotBlank(tagsStr)) { postEsDTO.setTags(GSON.fromJson(tagsStr, new TypeToken<List<String>>() { }.getType())); } return postEsDTO; }
public static Post dtoToObj(PostEsDTO postEsDTO) { if (postEsDTO == null) { return null; } Post post = new Post(); BeanUtils.copyProperties(postEsDTO, post); List<String> tagList = postEsDTO.getTags(); if (CollectionUtils.isNotEmpty(tagList)) { post.setTags(GSON.toJson(tagList)); } return post; } }
|
1 2 3
| public interface PostEsDao extends ElasticsearchRepository<PostEsDTO, Long> { List<PostEsDTO> findByUserId(Long userId); }
|
增删改查
1 2
| @Resource private PostEsDao postEsDao;
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| @Test void testAdd() { PostEsDTO postEsDTO = new PostEsDTO(); postEsDTO.setId(5L); postEsDTO.setTitle("test"); postEsDTO.setContent("test"); postEsDTO.setTags(Arrays.asList("java", "python")); postEsDTO.setThumbNum(1); postEsDTO.setFavourNum(1); postEsDTO.setUserId(1L); postEsDTO.setCreateTime(new Date()); postEsDTO.setUpdateTime(new Date()); postEsDTO.setIsDelete(0); postEsDao.save(postEsDTO); System.out.println(postEsDTO.getId()); }
|
1 2 3 4 5 6
| @Test void testFindById() { Optional<PostEsDTO> postEsDTO = postEsDao.findById(1L); System.out.println(postEsDTO); }
|
1 2 3 4 5 6 7 8 9
| @Test void testSelect() { System.out.println(postEsDao.count()); Page<PostEsDTO> PostPage = postEsDao.findAll( PageRequest.of(0, 5, Sort.by("createTime"))); List<PostEsDTO> postList = PostPage.getContent(); System.out.println(postList); }
|
简单的增、删、改、查测试通过:(2023/09/20晚)
DSL查询
参考文档:
[Query and filter context | Elasticsearch Guide 7.17] | Elastic
[Boolean query | Elasticsearch Guide 7.17] | Elastic
详细的DSL查询学习可以看官网学习,待我学成归来,就在此留下我的学习笔记(2023/09/21晚)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| GET post/_search { "query": { "bool": { // 组合条件 "must": [ // 必须都满足 { "match": { "title": "鱼皮" }}, // match 模糊查询 { "match": { "content": "知识星球" }} ], "filter": [ { "term": { "status": "published" }}, // term 精确查询 { "range": { "publish_date": { "gte": "2015-01-01" }}} // range 范围 ] } } }
|
数据同步
一般情况下,如果做查询搜索功能,使用 ES 来模糊搜索 (2023/09/21晚)
但是数据是存放在数据库 MySQL 里 的,所以说我们需要把 MySQL 中的数据和 ES 进行同步,保证数据一致(以 MySQL 为主)
MySQL => ES (单向)
首次安装完 ES,把 MySQL 数据全量同步到 ES 里,写一个单次脚本 4 种方式,全量同步(首次)+ 增量同步(新数据):
定时任务:比如 1 分钟 1 次,找到 MySQL 中过去几分钟内(至少是定时周期的 2 倍)发 生改变的数据,然后更新到 ES。
- 优点:简单易懂、占用资源少、不用引入第三方中间件
- 缺点:有时间差 应用场景:数据短时间内不同步影响不大、或者数据几乎不发生修改
双写:写数据的时候,必须也去写 ES;更新删除数据库同理。
- 事务:建议先保证 MySQL 写成功
- 如果 ES 写失败了,可以通过定时任务 + 日志 + 告警进行检测和修复 (补偿)
Logstash 数据同步管道:(一般要配合 kafka 消息队列 + beats 采集器)
Canal 监听 MySQL Binlog:实时同步
Logstash
下载安装
demo测试
1
| logstash.bat -e "input { stdin { } } output { stdout {} }"
|
自定义配置
快速开始:[Running Logstash on Windows | Logstash Reference 7.17] | Elastic(2023/09/22晚)
在官方文档中,找到这一段简单的示例配置:
- 将这段配置粘贴进 config 下的 logstash-sample.conf 配置文件(可以保留该原文件,复制一份重命名)中:
- 这几行配置是干什么的呢?简单来讲就是定义了输入和输出:监听 UDP,并输出
- 按官方文档的操作来,尝试加载这个配置文件 并 启动 Logstash:
1
| .\bin\logstash.bat -f .\config\myTask.conf
|
运行这行命令,可以看到 Logstash 成功启动运行了
同步MySQL
1 2 3 4 5 6 7 8 9 10 11
| input { jdbc { jdbc_driver_library => "mysql-connector-java-5.1.36-bin.jar" jdbc_driver_class => "com.mysql.jdbc.Driver" jdbc_connection_string => "jdbc:mysql://localhost:3306/mydb" jdbc_user => "mysql" parameters => { "favorite_artist" => "Beethoven" } schedule => "* * * * *" statement => "SELECT * from songs where artist = :favorite_artist" } }
|
原因很简单,就是配置中的 mysql jar包找不到,我们需要自己配置一个 mysql jar包,并正确配置它的路径
这里有个技巧:在 IDEA 中找到项目所依赖的 jar 包
- 如图所示,选择对应的依赖后,可以直接在文件管理器中打开
- 然后直接在文件管理器中复制,粘贴到 Logstash 目录下即可
加载配置、启动 Logstash,启动成功了:
聊聊我在这段配置上踩过的坑吧:
- mysql jar 包路径外层多加了一层双引号
- 用户名、密码配置错误
- SQL 语句中 where 多写了一个
- timestamp 写成 timestampe
这段配置绝对不能出现任何问题,否则就会出现严重的报错。我的最终配置是这样的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
|
input { jdbc { jdbc_driver_library => "D:\softWare\logstash\logstash-7.17.9\config\mysql-connector-java-8.0.29.jar" jdbc_driver_class => "com.mysql.jdbc.Driver" jdbc_connection_string => "jdbc:mysql://localhost:3306/memory_search" jdbc_user => "root" jdbc_password => "Dw990831" statement => "SELECT * from post where 1 = 1" schedule => "*/5 * * * *" } }
output { stdout { codec => rubydebug } }
|
1 2 3 4 5 6
| statement => "SELECT * from post where updateTime > :sql_last_value" use_column_value => true tracking_column_type => "timestamp" tracking_column => "updateTime" schedule => "*/5 * * * * *" jdbc_default_timezone => "Asia/Shanghai"
|
这段配置就是根据 updateTime 字段的最新值,同步updateTime 大于最新值的数据:
- 所以说,sql_last_value 指定的是上次查到的数据的最后一行的指定字段,新的查询就是比较这个指定字段与sql_last_value的大小
- 但是经过多次查询发现,这里的 sql_last_value 始终不变
- 我们可以在 data\plugins\inputs\jdbc\logstash_jdbc_last_run 看到 sql_last_value 指定的数据,确实没有变化:
- 将 tracking_column => “updateTime” 的 updateTime 修改为 updatetime,日期同步成功
更新下数据库中的最新值,再看看效果,确实拿到了数据库中最新修改的值(参照上次修改后的最新值):
同步ES
- 跟着官网简单的 demo 学就行,配置过一次就会了,这是我完成同步 ES 后的配置:(部分私密信息已做处理)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
|
input { jdbc { jdbc_driver_library => "D:\softWare\logstash\logstash-7.17.9\config\mysql-connector-java-8.0.29.jar" jdbc_driver_class => "com.mysql.jdbc.Driver" jdbc_connection_string => "jdbc:mysql://localhost:3306/"******"" jdbc_user => "******" jdbc_password => ""******"" statement => "SELECT * from post where updateTime > :sql_last_value" use_column_value => true tracking_column_type => "timestamp" tracking_column => "updatetime" schedule => "*/5 * * * * *" jdbc_default_timezone => "Asia/Shanghai" } }
output { stdout { codec => rubydebug }
elasticsearch { hosts => "127.0.0.1:9200" index => "post_v1" document_id => "%{id}" } }
|
这里简单介绍下这几个配置的作用:
- host:标识要进行同步的 ES 地址,即指定了:数据从 MySQL 中取出后,发送到哪
- index:目标索引
- document_id:指定目标索引内,每一个文档的 id,就是从 SELECT * 中解构出 id 值
- data_stream:特殊的数据格式,我们从数据库中取到的都是普通类型,不需要这行配置
其他的目前暂且不需要了解,日后再进一步学习
加载配置,运行 Logstash,可以看到运行成功了,数据库中最新更新的数据也成功同步到了本地的 ES 上了:
从同步结果来看,我们还需要解决几个问题:
- 排除某些不需要同步的字段
- ES 中同步过来的文档数据字段都是全小写,不是驼峰式
- 查询结果按 updateTime 降序排列,避免重排序,导致多同步了不必要的数据,造成性能浪费
解决这三个问题当然很简单:
首先修改下 SQL 语句:(2023/09/23午)
1
| statement => "SELECT * from post where updateTime > :sql_last_value and updateTime < now() order by updateTime desc"
|
再写入如下过滤配置,将对应字段进行驼峰式转换,并排除不需要的字段:
1 2 3 4 5 6 7 8 9 10 11
| filter { mutate { rename => { "updatetime" => "updateTime" "userid" => "userId" "createtime" => "createTime" "isdelete" => "isDelete" } remove_field => ["thumbnum", "favournum"] } }
|
重新进行同步,结果完美,顺利完成:
Logstash 配置多个输入 / 输出源(小丑的第一次尝试)
🔥 最近在优化 Memory 聚合搜索平台,尝试实现博文 article 的快速搜索和关键词高亮显示等功能
主要工作如下:
- 新增 article 实体,表结构已给出 👇
- 新增博文的 ES 包装类(ArticleEsDao)、博文查询参数(ArticleQueryRequest)、博文高亮字段(ArticleEsHighlightData)
- 使用 Spring Data Elasticsearch 的 QueryBuilder 组合条件查询,实现使用 ES 快速搜索博文和关键词高亮显示
- 新增博文数据源接口(ArticleDataSource),供聚合搜索调用
- 配置 Logstash 实现 MySQL 和 ES 数据同步
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| create table article ( id bigint not null comment '文章id', title varchar(256) not null comment '文章标题', description varchar(256) not null comment '文章摘要', content varchar(2048) not null comment '文章内容', author_id bigint not null comment '创作者', view int default 0 not null comment '浏览量', likes int default 0 not null comment '点赞量', comments varchar(256) default '0' null comment '评论量', create_time datetime default CURRENT_TIMESTAMP not null comment '创建时间', update_time datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间', is_delete tinyint default 0 not null comment '逻辑删除', collects int not null comment '收藏量', article_url varchar(2048) null comment '封面图片', tags varchar(256) not null comment '文章标签' ) comment '博文';
|
同步配置
- 新增 article 相关实体的过程这里先不细讲,重点记录:如何实现 MySQL 和 ES 数据同步
- 在 Logstash 的 config 目录下,我们作如下配置:(2023/11/07晚)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66
|
input { jdbc { jdbc_driver_library => "D:\softWare\logstash\logstash-7.17.9\config\mysql-connector-java-8.0.29.jar" jdbc_driver_class => "com.mysql.jdbc.Driver" jdbc_connection_string => "jdbc:mysql://localhost:3306/******" jdbc_user => "******" jdbc_password => "******" statement => "SELECT * from article where update_time > :sql_last_value and update_time < now() order by update_time desc" use_column_value => true tracking_column_type => "timestamp" tracking_column => "update_time" schedule => "*/5 * * * * *" jdbc_default_timezone => "Asia/Shanghai" } }
input { jdbc { jdbc_driver_library => "D:\softWare\logstash\logstash-7.17.9\config\mysql-connector-java-8.0.29.jar" jdbc_driver_class => "com.mysql.jdbc.Driver" jdbc_connection_string => "jdbc:mysql://localhost:3306/******" jdbc_user => "******" jdbc_password => "******" statement => "SELECT * from post where updateTime > :sql_last_value and updateTime < now() order by updateTime desc" use_column_value => true tracking_column_type => "timestamp" tracking_column => "updatetime" schedule => "*/5 * * * * *" jdbc_default_timezone => "Asia/Shanghai" } }
filter { mutate { rename => { "updatetime" => "updateTime" "userid" => "userId" "createtime" => "createTime" "isdelete" => "isDelete" } remove_field => ["thumbnum", "favournum"] } }
output { stdout { codec => rubydebug }
elasticsearch { hosts => "127.0.0.1:9200" index => "post_v1" document_id => "%{id}" } }
output { stdout { codec => rubydebug }
elasticsearch { hosts => ["localhost:9200"] index => "article_v1" document_id => "%{id}" } }
|
🥣 我们废话少说,看清楚如上配置 👆
- 比较有趣的是,新增的 article 实体的字段是下划线命名法,而 post 实体的字段却是驼峰命名法:
- 这样的属性名肯定是不规范的(当然,是因为 article 是我从 Memory 缘忆交友社区下直接粘贴过来的)
- 不过,正好可以比对下不同命名规范的属性,在 Logstash 配置中的写法区别了:(2023/11/07晚)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| -- 下划线命名法 input { jdbc { jdbc_driver_library => "D:\softWare\logstash\logstash-7.17.9\config\mysql-connector-java-8.0.29.jar" jdbc_driver_class => "com.mysql.jdbc.Driver" jdbc_connection_string => "jdbc:mysql://localhost:3306/******" jdbc_user => "******" jdbc_password => "******" statement => "SELECT * from article where update_time > :sql_last_value and update_time < now() order by update_time desc" use_column_value => true tracking_column_type => "timestamp" tracking_column => "update_time" schedule => "*/5 * * * * *" jdbc_default_timezone => "Asia/Shanghai" } }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| -- 驼峰命名法 input { jdbc { jdbc_driver_library => "D:\softWare\logstash\logstash-7.17.9\config\mysql-connector-java-8.0.29.jar" jdbc_driver_class => "com.mysql.jdbc.Driver" jdbc_connection_string => "jdbc:mysql://localhost:3306/******" jdbc_user => "******" jdbc_password => "******" statement => "SELECT * from post where updateTime > :sql_last_value and updateTime < now() order by updateTime desc" use_column_value => true tracking_column_type => "timestamp" tracking_column => "updatetime" schedule => "*/5 * * * * *" jdbc_default_timezone => "Asia/Shanghai" }
|
ES 查询
- Logstash 同步配置写完之后,当然要进行测试了,看看数据是否成功从 MySQL 成功同步到了 ES 中
- 首先新增 ES 索引,在 Kibana 监控面板下,执行如下 DSL 语句:
1 2 3 4 5 6 7
| PUT /article_v1 { "settings": { "number_of_shards": 3, "number_of_replicas": 2 } }
|
🔥 注意:
索引名要跟 Logstash 配置中 output 块下的 index
属性对应:
跟 ArticleEsDao 的 Document
字段对应:
1
| @Document(indexName = "article_v1")
|
- 按官方文档的操作来,尝试加载这个配置文件 并 启动 Logstash:
1
| .\bin\logstash.bat -f .\config\myTask.conf
|
- 随便修改一条记录(下面的
实现 updateTime 字段自动更新
一栏中有提到,数据开始同步 👇:
- 在 Kibana 监控面板下,使用 DSL 语句执行查询,效果如下:
- 成功完成
article
实体的数据同步 (2023/11/07晚)
实现 updateTime 字段自动更新
- 什么意思呢?我们希望在修改完数据库表中的记录后,该条记录对应的 uodateTime 字段实现自动更新
- 实现方法很简单,在 IDEA 中,直接修改表的 updateTime 字段属性,如下:
1
| updateTime datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间',
|
- 这样,我们更新记录后,该记录 updateTime 字段会自动更新为最近修改时间:(2023/11/07晚)
Logstash 配置多个输入输出源Ⅱ
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
|
input { jdbc { jdbc_driver_library => "D:\softWare\logstash\logstash-7.17.9\config\mysql-connector-java-8.0.29.jar" jdbc_driver_class => "com.mysql.jdbc.Driver" jdbc_connection_string => "jdbc:mysql://localhost:3306/memory_search" jdbc_user => "root" jdbc_password => "Dw990831" statement => "SELECT * from post where updateTime > :sql_last_value and updateTime < now() order by updateTime desc" use_column_value => true tracking_column_type => "timestamp" tracking_column => "updatetime" schedule => "*/5 * * * * *" jdbc_default_timezone => "Asia/Shanghai" } }
filter { mutate { rename => { "updatetime" => "updateTime" "userid" => "userId" "createtime" => "createTime" "isdelete" => "isDelete" } remove_field => ["thumbnum", "favournum"] } }
output { stdout { codec => rubydebug } elasticsearch { hosts => "127.0.0.1:9200" index => "post_v1" document_id => "%{id}" } }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| input { jdbc { jdbc_driver_library => "D:\softWare\logstash\logstash-7.17.9\config\mysql-connector-java-8.0.29.jar" jdbc_driver_class => "com.mysql.jdbc.Driver" jdbc_connection_string => "jdbc:mysql://localhost:3306/memory_search" jdbc_user => "root" jdbc_password => "Dw990831" statement => "SELECT * from article where updateTime > :sql_last_value and updateTime < now() order by updateTime desc" use_column_value => true tracking_column_type => "timestamp" tracking_column => "updatetime" schedule => "*/5 * * * * *" jdbc_default_timezone => "Asia/Shanghai" } }
filter { mutate { rename => { "updatetime" => "updateTime" "userid" => "userId" "createtime" => "createTime" "isdelete" => "isDelete" } remove_field => ["thumbnum", "favournum"] } }
output { stdout { codec => rubydebug } elasticsearch { hosts => ["127.0.0.1:9200"] index => "article_v1" document_id => "%{id}" } }
|
1
| .\bin\logstash.bat -f .\config\myTask.conf -f .\config\myTask2.conf
|
- 有关 Logstash 的配置,还需要更多了解,目前知识掌握了 MSQL 向 ES 的映射配置(2023/11/16晚)
SQL,多输入,多输出
Kibana 监控面板
如下图所示:
看板的命名很有意思,看板的命名必须要匹配到已经创建的索引名,还不能重复,也就是说:
每个索引只可以创建一个看板,至少我目前的看法是这样的(2023/12/03午)
- 创建好新的看板之后,就可以再次进入
DashBorad
界面了,我们创建的可视化看板可以投入使用了
- 这里简单地介绍下各个板块的作用吧,其他没有什么好讲的,有时间玩玩就可以
- 使用
Kibana
可视化监控看板的教程到这里就结束了(2023/12/03午)
其他
搜索建议
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| POST my-index-000001/_search { "query" : { "match": { "message": "tring out Elasticsearch" } }, "suggest" : { "my-suggestion" : { "text" : "tring out Elasticsearch", "term" : { "field" : "message" } } } }
|
搜索高亮
官方文档:[Highlighting | Elasticsearch Guide 7.17] | Elastic
如何使搜索词高亮?ES文档里有现成的demo:
1 2 3 4 5 6 7 8 9 10 11
| GET /_search { "query": { "match": { "content": "kimchy" } }, "highlight": { "fields": { "content": {} } } }
|
后端
我们使用 Java客户端,这样编写:
使所有字段内匹配的关键字高亮: (2023/10/01晚)
1 2 3 4 5 6
| 搜索关键词高亮 HighlightBuilder highlightBuilder = new HighlightBuilder(); highlightBuilder.field("*") .preTags("<font color='#eea6b7'>") .postTags("</font>"); highlightBuilder.requireFieldMatch(false);
|
1 2 3 4 5 6 7 8
| HighlightBuilder highlightBuilder = new HighlightBuilder() .field("content") .requireFieldMatch(false) .preTags("<font color='#eea6b7'>") .postTags("</font>"); highlightBuilder.field("title") .requireFieldMa
|
1 2 3 4 5 6
| NativeSearchQuery searchQuery = new NativeSearchQueryBuilder() .withQuery(boolQueryBuilder) .withHighlightBuilder(highlightBuilder) .withPageable(pageRequest) .withSorts(sortBuilder).build();
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| List<SearchHit<PostEsDTO>> searchHitList = searchHits.getSearchHits(); Map<Long, PostEsHighlightData> highlightDataMap = new HashMap<>(); for (SearchHit hit : searchHits.getSearchHits()) { PostEsHighlightData data = new PostEsHighlightData(); data.setId(Long.valueOf(hit.getId())); if (hit.getHighlightFields().get("title") != null) { String highlightTitle = String.valueOf(hit.getHighlightFields().get("title")); data.setTitle(highlightTitle.substring(1, highlightTitle.length() - 1)); System.out.println(data.getTitle()); } if (hit.getHighlightFields().get("content") != null) { String highlightContent = String.valueOf(hit.getHighlightFields().get("content")); data.setContent(highlightContent.substring(1, highlightContent.length() - 1)); System.out.println(data.getContent()); } highlightDataMap.put(data.getId(), data); }
|
根据 id 拿到每一个 Post对象,使用高亮关键词替换原文本,返回结果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| List<Long> postIdList = searchHitList.stream().map(searchHit -> searchHit.getContent().getId()) .collect(Collectors.toList()); List<Post> postList = baseMapper.selectBatchIds(postIdList); if (postList != null) { Map<Long, List<Post>> idPostMap = postList.stream().collect(Collectors.groupingBy(Post::getId)); postIdList.forEach(postId -> { if (idPostMap.containsKey(postId)) { Post post = idPostMap.get(postId).get(0); String hl_title = highlightDataMap.get(postId).getTitle(); String hl_content = highlightDataMap.get(postId).getContent(); if (hl_title != null && hl_title.trim() != "") { post.setTitle(hl_title); } if (hl_content != null && hl_content.trim() != "") { post.setContent(hl_content); } resourceList.add(post); } else { String delete = elasticsearchRestTemplate.delete(String.valueOf(postId), PostEsDTO.class); log.info("delete post {}", delete); } }); } }
|
前端
- 后端已经将关键词高亮特殊处理了,前端应该做什么呢?
- 将后端响应的文本信息,放入 v-html 属性中,即可解析出文本的 CSS 样式:
1 2 3 4 5 6 7 8 9 10 11
| <!--标题--> <template #title> <a href="https://www.antdv.com/" v-html="item.title"></a> </template> <!--头像--> <template #avatar> <a-avatar src="https://zos.alipayobjects.com/rmsportal/ODTLcjxAfvqbxHnVXCYX.png"/> </template> <template #description> <div v-html="item.content" style="margin-bottom: 10px"></div> </template>
|
- 这让我想起了前两天刚实现过的前端解析 Markdown 格式文件的方法: (2023/10/01晚)
1 2 3 4 5 6 7
| import MarkdownIt from 'markdown-it';
const parsedContent = ref() const md = new MarkdownIt();
parsedContent.value = md.render(articleInfo.value.content);
|
1 2 3
| <div v-html="parsedContent" style="position: absolute; margin-left: 10px; margin-right: 10px; margin-top: 20px;"> </div>
|
搜索词建议
订阅数据库流水的同步方式 Canel
下载、安装
配置
项目导入Canel依赖
简单测试
Jmeter压力测试
流程记录(2023/09/25晚)
1.初识elasticsearch
1.1.了解ES
1.1.1.elasticsearch的作用
elasticsearch是一款非常强大的开源搜索引擎,具备非常多强大功能,可以帮助我们从海量数据中快速找到需要的内容
例如:
在GitHub搜索代码
在电商网站搜索商品
在百度搜索答案
在打车软件搜索附近的车
1.1.2.ELK技术栈
elasticsearch结合kibana、Logstash、Beats,也就是elastic stack(ELK)。被广泛应用在日志数据分析、实时监控等领域:
而elasticsearch是elastic stack的核心,负责存储、搜索、分析数据。
1.1.3.elasticsearch和lucene
elasticsearch底层是基于lucene来实现的。
Lucene是一个Java语言的搜索引擎类库,是Apache公司的顶级项目,由DougCutting于1999年研发。官网地址:https://lucene.apache.org/ 。
elasticsearch的发展历史:
- 2004年Shay Banon基于Lucene开发了Compass
- 2010年Shay Banon 重写了Compass,取名为Elasticsearch。
1.1.4.为什么不是其他搜索技术?
目前比较知名的搜索引擎技术排名:
虽然在早期,Apache Solr是最主要的搜索引擎技术,但随着发展elasticsearch已经渐渐超越了Solr,独占鳌头:
1.1.5.总结
什么是elasticsearch?
- 一个开源的分布式搜索引擎,可以用来实现搜索、日志统计、分析、系统监控等功能
什么是elastic stack(ELK)?
- 是以elasticsearch为核心的技术栈,包括beats、Logstash、kibana、elasticsearch
什么是Lucene?
- 是Apache的开源搜索引擎类库,提供了搜索引擎的核心API
1.2.倒排索引
倒排索引的概念是基于MySQL这样的正向索引而言的。
1.2.1.正向索引
那么什么是正向索引呢?例如给下表(tb_goods)中的id创建索引:
如果是根据id查询,那么直接走索引,查询速度非常快。
但如果是基于title做模糊查询,只能是逐行扫描数据,流程如下:
1)用户搜索数据,条件是title符合"%手机%"
2)逐行获取数据,比如id为1的数据
3)判断数据中的title是否符合用户搜索条件
4)如果符合则放入结果集,不符合则丢弃。回到步骤1
逐行扫描,也就是全表扫描,随着数据量增加,其查询效率也会越来越低。当数据量达到数百万时,就是一场灾难。
1.2.2.倒排索引
倒排索引中有两个非常重要的概念:
- 文档(
Document
):用来搜索的数据,其中的每一条数据就是一个文档。例如一个网页、一个商品信息
- 词条(
Term
):对文档数据或用户搜索数据,利用某种算法分词,得到的具备含义的词语就是词条。例如:我是中国人,就可以分为:我、是、中国人、中国、国人这样的几个词条
创建倒排索引是对正向索引的一种特殊处理,流程如下:
- 将每一个文档的数据利用算法分词,得到一个个词条
- 创建表,每行数据包括词条、词条所在文档id、位置等信息
- 因为词条唯一性,可以给词条创建索引,例如hash表结构索引
如图:
倒排索引的搜索流程如下(以搜索”华为手机”为例):
1)用户输入条件"华为手机"
进行搜索。
2)对用户输入内容分词,得到词条:华为
、手机
。
3)拿着词条在倒排索引中查找,可以得到包含词条的文档id:1、2、3。
4)拿着文档id到正向索引中查找具体文档。
如图:
虽然要先查询倒排索引,再查询倒排索引,但是无论是词条、还是文档id都建立了索引,查询速度非常快!无需全表扫描。
1.2.3.正向和倒排
那么为什么一个叫做正向索引,一个叫做倒排索引呢?
是不是恰好反过来了?
那么两者方式的优缺点是什么呢?
正向索引:
- 优点:
- 可以给多个字段创建索引
- 根据索引字段搜索、排序速度非常快
- 缺点:
- 根据非索引字段,或者索引字段中的部分词条查找时,只能全表扫描。
倒排索引:
- 优点:
- 缺点:
- 只能给词条创建索引,而不是字段
- 无法根据字段做排序
1.3.es的一些概念
elasticsearch中有很多独有的概念,与mysql中略有差别,但也有相似之处。
1.3.1.文档和字段
elasticsearch是面向文档(Document)存储的,可以是数据库中的一条商品数据,一个订单信息。文档数据会被序列化为json格式后存储在elasticsearch中:
而Json文档中往往包含很多的字段(Field),类似于数据库中的列。
1.3.2.索引和映射
索引(Index),就是相同类型的文档的集合。
例如:
- 所有用户文档,就可以组织在一起,称为用户的索引;
- 所有商品的文档,可以组织在一起,称为商品的索引;
- 所有订单的文档,可以组织在一起,称为订单的索引;
因此,我们可以把索引当做是数据库中的表。
数据库的表会有约束信息,用来定义表的结构、字段的名称、类型等信息。因此,索引库中就有映射(mapping),是索引中文档的字段约束信息,类似表的结构约束。
1.3.3.mysql与elasticsearch
我们统一的把mysql与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就不再需要mysql了呢?
并不是如此,两者各自有自己的擅长支出:
Mysql:擅长事务类型操作,可以确保数据的安全和一致性
Elasticsearch:擅长海量数据的搜索、分析、计算
因此在企业中,往往是两者结合使用:
- 对安全性要求较高的写操作,使用mysql实现
- 对查询性能要求较高的搜索需求,使用elasticsearch实现
- 两者再基于某种方式,实现数据的同步,保证一致性
1.4.安装es、kibana
1.4.1.安装
参考课前资料:
1.4.2.分词器
参考课前资料:
1.4.3.总结
分词器的作用是什么?
- 创建倒排索引时对文档分词
- 用户搜索时,对输入的内容分词
IK分词器有几种模式?
- ik_smart:智能切分,粗粒度
- ik_max_word:最细切分,细粒度
IK分词器如何拓展词条?如何停用词条?
- 利用config目录的IkAnalyzer.cfg.xml文件添加拓展词典和停用词典
- 在词典中添加拓展词条或者停用词条
2.索引库操作
索引库就类似数据库表,mapping映射就类似表的结构。
我们要向es中存储数据,必须先创建“库”和“表”。
2.1.mapping映射属性
mapping是对索引库中文档的约束,常见的mapping属性包括:
- type:字段数据类型,常见的简单类型有:
- 字符串:text(可分词的文本)、keyword(精确值,例如:品牌、国家、ip地址)
- 数值:long、integer、short、byte、double、float、
- 布尔:boolean
- 日期:date
- 对象:object
- index:是否创建索引,默认为true
- analyzer:使用哪种分词器
- properties:该字段的子字段
例如下面的json文档:
1 2 3 4 5 6 7 8 9 10 11 12
| { "age": 21, "weight": 52.1, "isMarried": false, "info": "黑马程序员Java讲师", "email": "zy@itcast.cn", "score": [99.1, 99.5, 98.9], "name": { "firstName": "云", "lastName": "赵" } }
|
对应的每个字段映射(mapping):
- age:类型为 integer;参与搜索,因此需要index为true;无需分词器
- weight:类型为float;参与搜索,因此需要index为true;无需分词器
- isMarried:类型为boolean;参与搜索,因此需要index为true;无需分词器
- info:类型为字符串,需要分词,因此是text;参与搜索,因此需要index为true;分词器可以用ik_smart
- email:类型为字符串,但是不需要分词,因此是keyword;不参与搜索,因此需要index为false;无需分词器
- score:虽然是数组,但是我们只看元素的类型,类型为float;参与搜索,因此需要index为true;无需分词器
- name:类型为object,需要定义多个子属性
- name.firstName;类型为字符串,但是不需要分词,因此是keyword;参与搜索,因此需要index为true;无需分词器
- name.lastName;类型为字符串,但是不需要分词,因此是keyword;参与搜索,因此需要index为true;无需分词器
2.2.索引库的CRUD
这里我们统一使用Kibana编写DSL的方式来演示。
2.2.1.创建索引库和映射
基本语法:
- 请求方式:PUT
- 请求路径:/索引库名,可以自定义
- 请求参数:mapping映射
格式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| PUT /索引库名称 { "mappings": { "properties": { "字段名":{ "type": "text", "analyzer": "ik_smart" }, "字段名2":{ "type": "keyword", "index": "false" }, "字段名3":{ "properties": { "子字段": { "type": "keyword" } } }, } } }
|
示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| PUT /heima { "mappings": { "properties": { "info":{ "type": "text", "analyzer": "ik_smart" }, "email":{ "type": "keyword", "index": "falsae" }, "name":{ "properties": { "firstName": { "type": "keyword" } } }, // ... 略 } } }
|
2.2.2.查询索引库
基本语法:
请求方式:GET
请求路径:/索引库名
请求参数:无
格式:
示例:
2.2.3.修改索引库
倒排索引结构虽然不复杂,但是一旦数据结构改变(比如改变了分词器),就需要重新创建倒排索引,这简直是灾难。因此索引库一旦创建,无法修改mapping。
虽然无法修改mapping中已有的字段,但是却允许添加新的字段到mapping中,因为不会对倒排索引产生影响。
语法说明:
1 2 3 4 5 6 7 8
| PUT /索引库名/_mapping { "properties": { "新字段名":{ "type": "integer" } } }
|
示例:
2.2.4.删除索引库
语法:
请求方式:DELETE
请求路径:/索引库名
请求参数:无
格式:
在kibana中测试:
2.2.5.总结
索引库操作有哪些?
- 创建索引库:PUT /索引库名
- 查询索引库:GET /索引库名
- 删除索引库:DELETE /索引库名
- 添加字段:PUT /索引库名/_mapping
3.文档操作
3.1.新增文档
语法:
1 2 3 4 5 6 7 8 9 10
| POST /索引库名/_doc/文档id { "字段1": "值1", "字段2": "值2", "字段3": { "子属性1": "值3", "子属性2": "值4" }, }
|
示例:
1 2 3 4 5 6 7 8 9
| POST /heima/_doc/1 { "info": "黑马程序员Java讲师", "email": "zy@itcast.cn", "name": { "firstName": "云", "lastName": "赵" } }
|
响应:
3.2.查询文档
根据rest风格,新增是post,查询应该是get,不过查询一般都需要条件,这里我们把文档id带上。
语法:
通过kibana查看数据:
查看结果:
3.3.删除文档
删除使用DELETE请求,同样,需要根据id进行删除:
语法:
示例:
1 2
| # 根据id删除数据 DELETE /heima/_doc/1
|
结果:
3.4.修改文档
修改有两种方式:
- 全量修改:直接覆盖原来的文档
- 增量修改:修改文档中的部分字段
3.4.1.全量修改
全量修改是覆盖原来的文档,其本质是:
注意:如果根据id删除时,id不存在,第二步的新增也会执行,也就从修改变成了新增操作了。
语法:
1 2 3 4 5 6 7
| PUT /{索引库名}/_doc/文档id { "字段1": "值1", "字段2": "值2", }
|
示例:
1 2 3 4 5 6 7 8 9
| PUT /heima/_doc/1 { "info": "黑马程序员高级Java讲师", "email": "zy@itcast.cn", "name": { "firstName": "云", "lastName": "赵" } }
|
3.4.2.增量修改
增量修改是只修改指定id匹配的文档中的部分字段。
语法:
1 2 3 4 5 6
| POST /{索引库名}/_update/文档id { "doc": { "字段名": "新的值", } }
|
示例:
1 2 3 4 5 6
| POST /heima/_update/1 { "doc": { "email": "ZhaoYun@itcast.cn" } }
|
3.5.总结
文档操作有哪些?
- 创建文档:POST /{索引库名}/_doc/文档id { json文档 }
- 查询文档:GET /{索引库名}/_doc/文档id
- 删除文档:DELETE /{索引库名}/_doc/文档id
- 修改文档:
- 全量修改:PUT /{索引库名}/_doc/文档id { json文档 }
- 增量修改:POST /{索引库名}/_update/文档id { “doc”: {字段}}
4.RestAPI
ES官方提供了各种不同语言的客户端,用来操作ES。这些客户端的本质就是组装DSL语句,通过http请求发送给ES。官方文档地址:https://www.elastic.co/guide/en/elasticsearch/client/index.html
其中的Java Rest Client又包括两种:
- Java Low Level Rest Client
- Java High Level Rest Client
我们学习的是Java HighLevel Rest Client客户端API
4.0.导入Demo工程
4.0.1.导入数据
首先导入课前资料提供的数据库数据:
数据结构如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| CREATE TABLE `tb_hotel` ( `id` bigint(20) NOT NULL COMMENT '酒店id', `name` varchar(255) NOT NULL COMMENT '酒店名称;例:7天酒店', `address` varchar(255) NOT NULL COMMENT '酒店地址;例:航头路', `price` int(10) NOT NULL COMMENT '酒店价格;例:329', `score` int(2) NOT NULL COMMENT '酒店评分;例:45,就是4.5分', `brand` varchar(32) NOT NULL COMMENT '酒店品牌;例:如家', `city` varchar(32) NOT NULL COMMENT '所在城市;例:上海', `star_name` varchar(16) DEFAULT NULL COMMENT '酒店星级,从低到高分别是:1星到5星,1钻到5钻', `business` varchar(255) DEFAULT NULL COMMENT '商圈;例:虹桥', `latitude` varchar(32) NOT NULL COMMENT '纬度;例:31.2497', `longitude` varchar(32) NOT NULL COMMENT '经度;例:120.3925', `pic` varchar(255) DEFAULT NULL COMMENT '酒店图片;例:/img/1.jpg', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
4.0.2.导入项目
然后导入课前资料提供的项目:
项目结构如图:
4.0.3.mapping映射分析
创建索引库,最关键的是mapping映射,而mapping映射要考虑的信息包括:
- 字段名
- 字段数据类型
- 是否参与搜索
- 是否需要分词
- 如果分词,分词器是什么?
其中:
- 字段名、字段数据类型,可以参考数据表结构的名称和类型
- 是否参与搜索要分析业务来判断,例如图片地址,就无需参与搜索
- 是否分词呢要看内容,内容如果是一个整体就无需分词,反之则要分词
- 分词器,我们可以统一使用ik_max_word
来看下酒店数据的索引库结构:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50
| 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", "copy_to": "all" }, "starName":{ "type": "keyword" }, "business":{ "type": "keyword" }, "location":{ "type": "geo_point" }, "pic":{ "type": "keyword", "index": false }, "all":{ "type": "text", "analyzer": "ik_max_word" } } } }
|
几个特殊字段说明:
- location:地理坐标,里面包含精度、纬度
- all:一个组合字段,其目的是将多字段的值 利用copy_to合并,提供给用户搜索
地理坐标说明:
copy_to说明:
4.0.4.初始化RestClient
在elasticsearch提供的API中,与elasticsearch一切交互都封装在一个名为RestHighLevelClient的类中,必须先完成这个对象的初始化,建立与elasticsearch的连接。
分为三步:
1)引入es的RestHighLevelClient依赖:
1 2 3 4
| <dependency> <groupId>org.elasticsearch.client</groupId> <artifactId>elasticsearch-rest-high-level-client</artifactId> </dependency>
|
2)因为SpringBoot默认的ES版本是7.6.2,所以我们需要覆盖默认的ES版本:
1 2 3 4
| <properties> <java.version>1.8</java.version> <elasticsearch.version>7.12.1</elasticsearch.version> </properties>
|
3)初始化RestHighLevelClient:
初始化的代码如下:
1 2 3
| RestHighLevelClient client = new RestHighLevelClient(RestClient.builder( HttpHost.create("http://192.168.150.101:9200") ));
|
这里为了单元测试方便,我们创建一个测试类HotelIndexTest,然后将初始化的代码编写在@BeforeEach方法中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| package cn.itcast.hotel;
import org.apache.http.HttpHost; 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;
@BeforeEach void setUp() { this.client = new RestHighLevelClient(RestClient.builder( HttpHost.create("http://192.168.150.101:9200") )); }
@AfterEach void tearDown() throws IOException { this.client.close(); } }
|
4.1.创建索引库
4.1.1.代码解读
创建索引库的API如下:
代码分为三步:
- 1)创建Request对象。因为是创建索引库的操作,因此Request是CreateIndexRequest。
- 2)添加请求参数,其实就是DSL的JSON参数部分。因为json字符串很长,这里是定义了静态字符串常量MAPPING_TEMPLATE,让代码看起来更加优雅。
- 3)发送请求,client.indices()方法的返回值是IndicesClient类型,封装了所有与索引库操作有关的方法。
4.1.2.完整示例
在hotel-demo的cn.itcast.hotel.constants包下,创建一个类,定义mapping映射的JSON字符串常量:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53
| package cn.itcast.hotel.constants;
public class HotelConstants { 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" + " \"copy_to\": \"all\"\n" + " },\n" + " \"starName\":{\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" + " \"all\":{\n" + " \"type\": \"text\",\n" + " \"analyzer\": \"ik_max_word\"\n" + " }\n" + " }\n" + " }\n" + "}"; }
|
在hotel-demo中的HotelIndexTest测试类中,编写单元测试,实现创建索引:
1 2 3 4 5 6 7 8 9
| @Test void createHotelIndex() throws IOException { CreateIndexRequest request = new CreateIndexRequest("hotel"); request.source(MAPPING_TEMPLATE, XContentType.JSON); client.indices().create(request, RequestOptions.DEFAULT); }
|
4.2.删除索引库
删除索引库的DSL语句非常简单:
与创建索引库相比:
- 请求方式从PUT变为DELTE
- 请求路径不变
- 无请求参数
所以代码的差异,注意体现在Request对象上。依然是三步走:
- 1)创建Request对象。这次是DeleteIndexRequest对象
- 2)准备参数。这里是无参
- 3)发送请求。改用delete方法
在hotel-demo中的HotelIndexTest测试类中,编写单元测试,实现删除索引:
1 2 3 4 5 6 7
| @Test void testDeleteHotelIndex() throws IOException { DeleteIndexRequest request = new DeleteIndexRequest("hotel"); client.indices().delete(request, RequestOptions.DEFAULT); }
|
4.3.判断索引库是否存在
判断索引库是否存在,本质就是查询,对应的DSL是:
因此与删除的Java代码流程是类似的。依然是三步走:
- 1)创建Request对象。这次是GetIndexRequest对象
- 2)准备参数。这里是无参
- 3)发送请求。改用exists方法
1 2 3 4 5 6 7 8 9
| @Test void testExistsHotelIndex() throws IOException { GetIndexRequest request = new GetIndexRequest("hotel"); boolean exists = client.indices().exists(request, RequestOptions.DEFAULT); System.err.println(exists ? "索引库已经存在!" : "索引库不存在!"); }
|
4.4.总结
JavaRestClient操作elasticsearch的流程基本类似。核心是client.indices()方法来获取索引库的操作对象。
索引库操作的基本步骤:
- 初始化RestHighLevelClient
- 创建XxxIndexRequest。XXX是Create、Get、Delete
- 准备DSL( Create时需要,其它是无参)
- 发送请求。调用RestHighLevelClient#indices().xxx()方法,xxx是create、exists、delete
5.RestClient操作文档
为了与索引库操作分离,我们再次参加一个测试类,做两件事情:
- 初始化RestHighLevelClient
- 我们的酒店数据在数据库,需要利用IHotelService去查询,所以注入这个接口
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| package cn.itcast.hotel;
import cn.itcast.hotel.pojo.Hotel; import cn.itcast.hotel.service.IHotelService; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest;
import java.io.IOException; import java.util.List;
@SpringBootTest public class HotelDocumentTest { @Autowired private IHotelService hotelService;
private RestHighLevelClient client;
@BeforeEach void setUp() { this.client = new RestHighLevelClient(RestClient.builder( HttpHost.create("http://192.168.150.101:9200") )); }
@AfterEach void tearDown() throws IOException { this.client.close(); } }
|
5.1.新增文档
我们要将数据库的酒店数据查询出来,写入elasticsearch中。
5.1.1.索引库实体类
数据库查询后的结果是一个Hotel类型的对象。结构如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| @Data @TableName("tb_hotel") public class Hotel { @TableId(type = IdType.INPUT) 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 longitude; private String latitude; private String pic; }
|
与我们的索引库结构存在差异:
- longitude和latitude需要合并为location
因此,我们需要定义一个新的类型,与索引库结构吻合:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| package cn.itcast.hotel.pojo;
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(); this.pic = hotel.getPic(); } }
|
5.1.2.语法说明
新增文档的DSL语句如下:
1 2 3 4 5
| POST /{索引库名}/_doc/1 { "name": "Jack", "age": 21 }
|
对应的java代码如图:
可以看到与创建索引库类似,同样是三步走:
- 1)创建Request对象
- 2)准备请求参数,也就是DSL中的JSON文档
- 3)发送请求
变化的地方在于,这里直接使用client.xxx()的API,不再需要client.indices()了。
5.1.3.完整代码
我们导入酒店数据,基本流程一致,但是需要考虑几点变化:
- 酒店数据来自于数据库,我们需要先查询出来,得到hotel对象
- hotel对象需要转为HotelDoc对象
- HotelDoc需要序列化为json格式
因此,代码整体步骤如下:
- 1)根据id查询酒店数据Hotel
- 2)将Hotel封装为HotelDoc
- 3)将HotelDoc序列化为JSON
- 4)创建IndexRequest,指定索引库名和id
- 5)准备请求参数,也就是JSON文档
- 6)发送请求
在hotel-demo的HotelDocumentTest测试类中,编写单元测试:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| @Test void testAddDocument() throws IOException { Hotel hotel = hotelService.getById(61083L); HotelDoc hotelDoc = new HotelDoc(hotel); String json = JSON.toJSONString(hotelDoc);
IndexRequest request = new IndexRequest("hotel").id(hotelDoc.getId().toString()); request.source(json, XContentType.JSON); client.index(request, RequestOptions.DEFAULT); }
|
5.2.查询文档
5.2.1.语法说明
查询的DSL语句如下:
非常简单,因此代码大概分两步:
不过查询的目的是得到结果,解析为HotelDoc,因此难点是结果的解析。完整代码如下:
可以看到,结果是一个JSON,其中文档放在一个_source
属性中,因此解析就是拿到_source
,反序列化为Java对象即可。
与之前类似,也是三步走:
- 1)准备Request对象。这次是查询,所以是GetRequest
- 2)发送请求,得到结果。因为是查询,这里调用client.get()方法
- 3)解析结果,就是对JSON做反序列化
5.2.2.完整代码
在hotel-demo的HotelDocumentTest测试类中,编写单元测试:
1 2 3 4 5 6 7 8 9 10 11 12
| @Test void testGetDocumentById() throws IOException { GetRequest request = new GetRequest("hotel", "61082"); GetResponse response = client.get(request, RequestOptions.DEFAULT); String json = response.getSourceAsString();
HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class); System.out.println(hotelDoc); }
|
5.3.删除文档
删除的DSL是这样的:
与查询相比,仅仅是请求方式从DELETE变成GET,可以想象Java代码应该依然是三步走:
- 1)准备Request对象,因为是删除,这次是DeleteRequest对象。要指定索引库名和id
- 2)准备参数,无参
- 3)发送请求。因为是删除,所以是client.delete()方法
在hotel-demo的HotelDocumentTest测试类中,编写单元测试:
1 2 3 4 5 6 7
| @Test void testDeleteDocument() throws IOException { DeleteRequest request = new DeleteRequest("hotel", "61083"); client.delete(request, RequestOptions.DEFAULT); }
|
5.4.修改文档
5.4.1.语法说明
修改我们讲过两种方式:
- 全量修改:本质是先根据id删除,再新增
- 增量修改:修改文档中的指定字段值
在RestClient的API中,全量修改与新增的API完全一致,判断依据是ID:
- 如果新增时,ID已经存在,则修改
- 如果新增时,ID不存在,则新增
这里不再赘述,我们主要关注增量修改。
代码示例如图:
与之前类似,也是三步走:
- 1)准备Request对象。这次是修改,所以是UpdateRequest
- 2)准备参数。也就是JSON文档,里面包含要修改的字段
- 3)更新文档。这里调用client.update()方法
5.4.2.完整代码
在hotel-demo的HotelDocumentTest测试类中,编写单元测试:
1 2 3 4 5 6 7 8 9 10 11 12
| @Test void testUpdateDocument() throws IOException { UpdateRequest request = new UpdateRequest("hotel", "61083"); request.doc( "price", "952", "starName", "四钻" ); client.update(request, RequestOptions.DEFAULT); }
|
5.5.批量导入文档
案例需求:利用BulkRequest批量将数据库数据导入到索引库中。
步骤如下:
5.5.1.语法说明
批量处理BulkRequest,其本质就是将多个普通的CRUD请求组合在一起发送。
其中提供了一个add方法,用来添加其他请求:
可以看到,能添加的请求包括:
- IndexRequest,也就是新增
- UpdateRequest,也就是修改
- DeleteRequest,也就是删除
因此Bulk中添加了多个IndexRequest,就是批量新增功能了。示例:
其实还是三步走:
- 1)创建Request对象。这里是BulkRequest
- 2)准备参数。批处理的参数,就是其它Request对象,这里就是多个IndexRequest
- 3)发起请求。这里是批处理,调用的方法为client.bulk()方法
我们在导入酒店数据时,将上述代码改造成for循环处理即可。
5.5.2.完整代码
在hotel-demo的HotelDocumentTest测试类中,编写单元测试:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| @Test void testBulkRequest() throws IOException { List<Hotel> hotels = hotelService.list();
BulkRequest request = new BulkRequest(); 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); }
|
5.6.小结
文档操作的基本步骤:
- 初始化RestHighLevelClient
- 创建XxxRequest。XXX是Index、Get、Update、Delete、Bulk
- 准备参数(Index、Update、Bulk时需要)
- 发送请求。调用RestHighLevelClient#.xxx()方法,xxx是index、get、update、delete、bulk
- 解析结果(Get时需要)