【即时通讯系统】环境搭建4——Elasticsearch(ES)

目录

一.Elasticsearch

[1.1.什么是 Elasticsearch?](#1.1.什么是 Elasticsearch?)

1.2.传统数据库明明可以查询数据,为什么还需要ES?

1.2.1.正排索引和倒排索引

1.2.2.ES和传统数据库的关系与对比

1.3.Elasticsearch的核心概念讲解

二.安装ElasticStack及其配套工具

2.1.安装ElasticStack

2.2.安装IK分词器

2.3.安装Kibana

2.4.安装ES客户端

三.ES的使用

3.1.类型

3.2.映射

3.3.基于Kibana的使用示例

示例1------创建索引

示例2------创建索引,并同时定义映射

示例3------查询相关语句

示例4------综合示例

3.4.基于ES客户端的使用示例

[四.对ES 客户端API二次封装](#四.对ES 客户端API二次封装)

[4.1. 索引创建(ESIndex)](#4.1. 索引创建(ESIndex))

[4.2. 数据插入(ESInsert)](#4.2. 数据插入(ESInsert))

[4.3. 数据删除(ESRemove)](#4.3. 数据删除(ESRemove))

[4.4. 数据查询(ESSearch)](#4.4. 数据查询(ESSearch))

4.5.完整代码+测试


一.Elasticsearch

1.1.什么是 Elasticsearch?

Elasticsearch 是一个分布式搜索和分析引擎 ,简单来说,它是一个专门用来快速搜索、分析和探索大量数据的工具。

想象一下:

  • 传统数据库(比如MySQL)就像图书馆的藏书目录,可以帮你找到某本书,但如果想搜索书里的某个词,或者分析所有书的内容趋势,就比较吃力。

  • Elasticsearch 则像给图书馆的每一本书的每一页都做了索引,你可以瞬间搜索到包含某个词的所有页面,还能快速统计哪些词出现最多、哪些作者最常写某个主题等。

先从你日常遇到的情况说起

场景1:你在淘宝买东西

  • 你在搜索框输入"白色运动鞋"

  • 淘宝瞬间给你列出了成千上万双白色运动鞋

  • 而且还按"最相关"排序,甚至能按品牌、价格筛选

场景2:你在百度搜索

  • 输入"北京冬奥会开幕式"

  • 百度瞬间找到几百万个相关网页

  • 还自动给你总结了相关新闻、视频、图片

这些背后的技术就是 Elasticsearch 擅长的事情

那么 Elasticsearch 到底是什么?

一句话定义 :Elasticsearch 是一个超级搜索引擎 ,专门用来快速找到你想要的信息

用一个最简单的比喻

想象你有一个巨大的仓库,里面堆满了各种东西:

  • 有衣服、鞋子、书、玩具、食品...

  • 杂乱无章地堆着

如果没有 Elasticsearch:

你想找"红色衣服",只能满仓库翻找,翻半天可能还找不到。

有了 Elasticsearch:

就像给仓库配了一个智能管理员

  • 这个管理员提前把所有东西都分类整理好,做了详细记录

  • 你一说"红色衣服",管理员瞬间告诉你:"红色衣服在左边第三个架子上,一共有50件"

  • 你还能问:"哪个牌子的红色衣服最多?"管理员也能立刻回答

Elasticsearch 能做什么?

  1. 快速搜索

    • 你写一个字,它就立刻给出搜索结果

    • 比如在百度输入"北",下面马上弹出"北京天气""北京冬奥会"等建议

  2. 模糊匹配

    • 即使你记不清完整内容,也能找到

    • 比如只记得文章里有"冬奥"两个字,它能找出所有包含"冬奥"的文章

  3. 智能推荐

    • "买了这个商品的用户还买了..."

    • "猜你想搜..."

  4. 数据分析

    • 比如电商网站能知道"今天搜'羽绒服'的人比昨天多了3倍"

    • 公司能知道"服务器最近24小时出过多少次故障"

1.2.传统数据库明明可以查询数据,为什么还需要ES?

1.2.1.正排索引和倒排索引

  1. 正排索引

定义

正排索引是一种以文档为单位的索引结构。它记录了每个文档包含哪些内容(如单词、字段值等)。简单来说,就是从文档到内容的映射。

结构示例

假设有三个文档:

  • 文档1: "苹果是一种水果"

  • 文档2: "苹果手机是电子产品"

  • 文档3: "香蕉也是一种水果"

正排索引会像这样存储:

文档ID 内容(词项列表)
1 苹果,是,一种,水果
2 苹果,手机,是,电子产品
3 香蕉,也,是,一种,水果

工作原理

当你要查询某个文档的具体内容时(比如根据文档ID获取全文),正排索引非常高效:直接通过文档ID找到对应的记录即可。但如果想找到所有包含"苹果"的文档,正排索引就需要遍历每一个文档,检查每个文档的词项列表中是否有"苹果"。这种扫描在数据量庞大时效率极低。

优点

  • 结构简单,易于实现和维护。

  • 对于基于文档ID的精确查找,速度极快。

缺点

  • 对于关键词搜索,必须遍历所有文档,性能低下。

  • 不适合全文检索场景。

常见应用

  • 传统关系型数据库中的聚簇索引(数据行本身按主键顺序存储)可以看作一种正排索引。

  • 文件系统的目录结构:你知道文件路径,就能直接找到文件。


  1. 倒排索引

定义

倒排索引是一种以词项(关键词)为单位的索引结构。它记录了每个词项出现在哪些文档中。也就是说,它是从内容到文档的映射。

结构示例

还是上面的三个文档,倒排索引会这样构建:

词项 出现该词项的文档ID列表
苹果 1, 2
1, 2, 3
一种 1, 3
水果 1, 3
手机 2
电子产品 2
香蕉 3
3

(实际应用中,倒排索引通常还会记录词项在每个文档中的出现位置、词频等信息,用于相关性计算。)

工作原理

当你搜索"苹果"时,系统直接到倒排索引中查找"苹果"这个词项,立即得到文档ID列表 [1, 2],然后快速返回这两个文档。整个过程无需扫描所有文档,查找速度极快,与文档总数无关,只与词项数量有关。

优点

  • 全文搜索性能极高,尤其适合关键词查询。

  • 可以轻松支持复杂的查询,如布尔操作、短语匹配等。

  • 通过记录词频和位置,能实现相关性评分。

缺点

  • 构建和维护成本高:文档新增、删除或更新时,需要同步更新倒排列表。

  • 占用存储空间较大(需要存储词项、文档ID列表、位置信息等)。

常见应用

  • 搜索引擎(如 Google、百度)的核心技术。

  • Elasticsearch、Lucene 等全文检索引擎的基础数据结构。

  1. 为什么倒排索引适合搜索?

搜索的本质是"根据关键词找文档"。倒排索引恰好以关键词为入口,直接给出文档列表,这完全符合搜索的需求。而正排索引则需要反向遍历,不符合人类搜索的习惯。

  1. 正排索引与倒排索引在 Elasticsearch 中的角色

Elasticsearch 为了兼顾搜索、聚合、排序等多种需求,同时使用了这两种索引:

  • 倒排索引主要用于全文搜索。当你执行 match 查询时,Elasticsearch 会在倒排索引中快速定位匹配的文档。

  • 正排索引(Doc Values):用于排序、聚合和脚本计算。因为倒排索引对词项到文档的映射很快,但对文档到值的映射(例如统计某个字段的平均值)则效率不高。Doc Values 是一种列式存储的正排索引,它以文档ID为键,存储字段值,使得排序和聚合操作能够快速访问每个文档的字段值,避免加载整个文档。

1.2.2.ES和传统数据库的关系与对比

一、两种索引的核心差异

核心:传统数据库采用正排索引,ES使用的是倒排索引!!

  1. 传统数据库(如 MySQL)的索引方式(正排索引)

传统数据库最常用的索引结构是 B-Tree(平衡树)。你可以把它想象成一个高效的、有序的目录。假设有一个"用户表",你对"姓名"字段建立了 B-Tree 索引,那么索引中会存储排序后的姓名和对应的记录位置。

  • 它能做什么?

    • 精确查找:WHERE name = '张三' ------ 快速定位。

    • 范围查找:WHERE age BETWEEN 20 AND 30 ------ 快速定位起始点,然后顺序扫描。

    • 前缀匹配:WHERE name LIKE '张%' ------ 也能利用索引快速找到所有以"张"开头的姓名。

  • 它不擅长什么?

    • 全文搜索 :比如要在文章内容中查找包含"苹果"一词的记录。如果使用 WHERE content LIKE '%苹果%',由于 % 在开头,B-Tree 索引就失效了,数据库只能进行全表扫描 ------也就是把每一行的 content 字段都读出来,检查是否包含"苹果"。当表里有上亿条数据时,这个速度慢得让人无法接受。
  1. Elasticsearch 的倒排索引

倒排索引的设计目标就是为了解决"根据内容找文档"这个问题。它的结构在前面的讲解中已经提到:它是一个从词项到文档ID的映射表

  • 构建过程:当文档被索引时,Elasticsearch 会对文本内容进行分词,得到一个个单词(词项),然后记录每个单词出现在哪些文档中。

  • 查询过程:当搜索"苹果"时,ES 直接到倒排索引中查找"苹果"这个词项,瞬间得到所有包含它的文档ID列表,然后取出这些文档返回。

这个过程不需要扫描所有文档,查询速度与文档总数无关,只与词项的数量有关。 即使你有 10 亿条数据,搜索"苹果"也只需要毫秒级响应。


二、为什么 ES 能"碾压"传统数据库?

我们可以用一个类比来加深理解。

假设你有一个巨大的图书馆(数据库),里面有成千上万本书(文档)。现在你想找到所有**内容中提到"苹果"**的书。

  • 传统数据库的做法 :它没有针对"内容中的词"建立索引。所以它只能这样做:从第一本书开始,一本一本地翻开,逐页阅读,查找是否有"苹果"这个词。读完一本,记录结果,再读下一本......直到把整个图书馆的书都翻一遍。这就是全表扫描。即使图书馆有分类目录(B-Tree 索引),那也只是按书名、作者等分类,无法告诉你哪本书的内容里写了"苹果"。

  • Elasticsearch 的做法 :它在书入库的时候,就派了很多图书管理员(分词器)把每本书的内容拆成一个个单词,并制作了一个巨大的关键词目录(倒排索引)。这个目录上写着:"苹果"这个词,出现在第 1、5、100 本书里。当你要找包含"苹果"的书时,管理员直接翻开目录,找到"苹果"这个词,然后告诉你:去 1、5、100 号书架拿书吧。整个过程不需要翻阅任何一本书的内容。

这就是倒排索引带来的"降维打击":它将一个需要遍历所有文档的"文档内容扫描"问题,转化成了一个在词项字典中快速查找的"键值查询"问题。


三、传统数据库难道没有全文索引吗?

有的。像 MySQL 从 5.6 版本开始也支持全文索引(InnoDB 引擎),它底层也是基于倒排索引的思想。那么问题来了:既然 MySQL 也有倒排索引,为什么大家还说 ES 搜索更快?

主要有以下几个原因:

  1. 实现深度和专业性不同 :MySQL 的全文索引只是一个附加功能,它的分词器、相关性评分算法、查询优化等方面远不如 Elasticsearch(底层是 Lucene)专业和强大。ES 是专为搜索而生的,它在这个领域深耕多年,有海量的优化细节。

  2. 分布式架构:ES 天生就是分布式的。当数据量巨大时,ES 可以将索引拆分成多个分片,分散到成百上千台服务器上。搜索时可以并行在多个分片上执行,然后汇总结果,实现水平扩展。而传统数据库的全文索引通常局限于单机,即使有分库分表方案,也远不如 ES 的分布式查询灵活和高效。

  3. 丰富的查询和分析能力:ES 不仅支持简单的关键词匹配,还支持复杂的布尔查询、短语匹配、模糊查询、地理位置查询,以及基于相关度的排序、聚合分析等。这些功能在数据库的全文索引中要么不支持,要么实现得非常简陋。

  4. 实时性:ES 的索引是近实时的(数据写入后默认 1 秒可被搜索),适合频繁更新的数据。而数据库的全文索引更新往往代价较高,可能会有一定延迟。

所以,即使 MySQL 有全文索引,在数据量稍大、查询复杂度稍高的情况下,性能也无法与 Elasticsearch 相提并论。


四、ES 在所有方面都比数据库强吗?

绝对不是。 "碾压"只发生在"搜索"这个特定的领域。在其他方面,传统数据库依然有不可替代的优势:

  • 事务处理(ACID):数据库支持复杂的事务,保证数据的一致性和完整性。ES 在这方面非常弱,它更适合最终一致性的场景。

  • 精确查询 :对于根据主键或唯一索引查找单条记录(比如 SELECT * FROM users WHERE id = 123),数据库的 B-Tree 索引可能比 ES 更快,因为 ES 还需要经过分片路由等额外步骤。

  • 复杂关联查询 :数据库的 JOIN 操作虽然慢,但至少能实现。ES 不鼓励做关联查询,它的数据模型更倾向于反范式化,提前将关联数据扁平化存到一个文档里。

  • 数据更新和删除:数据库的更新是原地修改,效率很高。ES 的更新实际上是"标记删除旧文档 + 新建文档",在频繁更新场景下会有开销。

ES和传统数据库的互补

在技术选型时,通常会将两者结合使用:用数据库负责核心业务数据存储和事务处理,用 Elasticsearch 负责搜索和分析。这也是 Elastic Stack 如此流行的原因------它补全了传统数据库在搜索领域的短板。

1.3.Elasticsearch的核心概念讲解

Elasticsearch 中的索引(Index)、类型(Type)、字段(Field)和映射(Mapping),并且会一直和传统数据库(比如 MySQL)进行对比,这样你就能更直观地理解它们。

一、一个整体的类比

首先,你可以把 Elasticsearch 想象成一个数据库服务器 (比如 MySQL 实例),它里面可以创建多个索引 。这个结构就像 MySQL 里可以创建多个数据库一样。

接下来我们逐一拆解:

概念 Elasticsearch 中的角色 传统数据库(如 MySQL)中的类比
索引 (Index) 存储相关数据的逻辑容器 数据库 (Database) 或 表 (Table)
类型 (Type) 索引内部的逻辑分类(已废弃) 表 (Table)
文档 (Document) 一条具体的记录 行 (Row)
字段 (Field) 文档中的一个属性 列 (Column)
映射 (Mapping) 定义字段的数据类型和索引方式 表结构 (Schema)

二、索引(Index)------ 相当于数据库(Database)或表(Table)

  • 定义:索引是 Elasticsearch 中存储文档的地方,是一个逻辑命名空间。它把具有相似字段的文档聚集在一起。

  • 类比

    • 你可以把索引看作一个数据库,里面可以包含多种类型的数据(如果使用类型的话)。

    • 也可以更直接地把它看作一张,因为现在 Elasticsearch 推荐每个索引只存储一类数据(比如用户、订单、日志),这样索引就更贴近"表"的概念。

  • 特点

    • 每个索引有一个名字(小写),通过这个名字进行读写。

    • 索引可以被拆分成多个分片(shard)分布在集群的不同节点上,这是分布式的基础。

三、类型(Type)------ 曾经相当于表(Table),现在已废弃

  • 历史背景:在 Elasticsearch 早期版本(6.x 之前),一个索引可以包含多个类型。你可以想象成一个数据库里有多张表,每个类型代表一类文档,它们的字段可能不同。例如,一个"商城"索引下,可以有"用户"类型和"订单"类型。

  • 为什么废弃:因为这种设计会导致同一个索引下不同字段的映射混在一起,引发性能问题(比如不同字段的 Lucene 实现冲突)。从 7.x 开始,类型被移除,一个索引只能包含一种文档类型。

  • 现在的做法 :如果你想存储不同类型的数据,就创建不同的索引。比如 user_indexorder_index。这样更清晰,也避免了类型带来的混乱。

  • 初学者理解 :你可以完全忽略"类型"这个概念,直接认为索引 = 表

四、字段(Field)------ 相当于列(Column)

  • 定义:字段是文档中的一个属性,它有一个名字和一个值。例如一篇博客文档可以有标题(title)、内容(content)、发布时间(publish_date)等字段。

  • 类比:就像数据库表中的列,每一列存储一种特定类型的数据。

  • 区别:Elasticsearch 的字段可以是更复杂的数据结构,比如数组、对象、嵌套对象等。而传统数据库通常要求每个列是单一值。

五、映射(Mapping)------ 相当于表结构(Schema)

  • 定义:映射是用来定义索引中的文档有哪些字段、每个字段是什么数据类型(字符串、数字、日期等),以及这些字段如何被索引和存储的规则。

  • 类比 :就像数据库建表时的 CREATE TABLE 语句,它规定了列名、列类型、是否可为空、是否为主键等。

  • 关键区别

    1. 动态映射:传统数据库必须在插入数据前定义好表结构。而 Elasticsearch 默认支持动态映射:当你插入一条包含新字段的文档时,它会自动推断字段类型并添加到映射中。这带来了极大的灵活性,但也容易造成字段类型冲突,所以生产环境中常会手动定义严格的映射。

    2. 字段类型丰富 :Elasticsearch 的字段类型远多于数据库,除了基本的数字、字符串、日期,还有专门用于全文检索的 text 类型、用于精确值的 keyword 类型、地理位置 geo_point 类型、对象 object 类型等。

    3. 索引方式:在映射中,你可以控制字段是否被索引(即是否可以被搜索)、使用什么分词器等。传统数据库的列通常都支持搜索,但效率低下。

    4. 不可变性 :映射一旦正式使用,通常不建议修改,特别是修改已有字段的类型。如果需要修改,一般要重建索引。这有点像数据库修改列类型需要 ALTER TABLE 并且可能锁表一样,但在 Elasticsearch 中更推荐用重建索引+别名的方式实现零停机变更。

六、综合实例对比

假设我们要存储"商品"信息,包含商品名称、价格、上架时间。

传统数据库的做法

  1. 创建一个数据库(如 shop_db)。

  2. 在该数据库中创建一张表(如 products)。

  3. 定义表结构:name VARCHAR(100), price DECIMAL(10,2), create_time DATETIME

  4. 然后向表中插入数据行。

Elasticsearch 的做法

  1. 创建一个索引(如 products)------ 这相当于数据库+表的组合。

  2. 定义映射:规定 name 字段类型为 text(用于全文搜索),price 类型为 floatcreate_time 类型为 date

  3. 向索引中添加文档(JSON 格式),每条文档就是一条商品记录。

对比可见,两者逻辑结构非常相似,只是术语不同。

二.安装ElasticStack及其配套工具

2.1.安装ElasticStack

注意这里需要安装378MB左右,所以说如果使用ElasticStack官方源的话有点可能就是安装比较慢的。

我们这里是使用了清华源来进行安装我们的ElasticStack。

清华源ElasticStack官网:https://mirrors.tuna.tsinghua.edu.cn/help/elasticstack/

安装步骤

bash 复制代码
# 1. 下载并存储GPG密钥到规范位置
sudo wget -qO - https://artifacts.elastic.co/GPG-KEY-elasticsearch | sudo gpg --dearmor -o /usr/share/keyrings/elasticsearch-keyring.gpg

# 2. 添加清华镜像源,并绑定密钥
echo "deb [signed-by=/usr/share/keyrings/elasticsearch-keyring.gpg] https://mirrors.tuna.tsinghua.edu.cn/elasticstack/7.x/apt/ stable main" | sudo tee /etc/apt/sources.list.d/elastic-7.x.list

# 3. 更新软件包列表
sudo apt-get update

# 4. 安装Elasticsearch(安装7.17.21版本)
sudo apt-get install elasticsearch=7.17.21

2.2.安装IK分词器

Elasticsearch(ES)作为一个基于Lucene的搜索引擎,其核心功能之一就是分词------将文本切分成一个个词条(term),然后基于这些词条建立倒排索引。当用户搜索时,查询语句也会被同样的分词器处理,与索引中的词条进行匹配。因此,分词器的选择直接决定了搜索的准确性和召回率。

对于中文来说,ES默认的分词器(如standard、english等)存在明显的局限性,而IK分词器正是为了解决这些问题而生的。下面从几个维度详细说明为什么必须安装IK分词器。


  1. 中文与英文的语言结构差异

英文等西方语言有明显的单词边界------空格和标点符号天然地把句子分割成单词,比如"I love China"可以简单地通过空格分成[I, love, China]。因此,基于空格和符号的简单分词器(如standard)对英文就足够有效。

但中文不同:

  • 没有空格:中文句子中词与词之间没有显式的分隔符,比如"我爱中国"是一个连续的字符串。

  • 单字含义复杂:单个汉字在不同语境下可能独立成词,也可能与其他字组合成词。比如"中国"是一个词,但"中"和"国"单独看也有各自的意思。

  • 歧义切分:一句话可能有多种切分方式,比如"乒乓球拍卖完了"可以切分为"乒乓球/拍卖/完了"或"乒乓球拍/卖/完了",正确的分词需要结合上下文。

因此,中文搜索必须依赖专门的中文分词算法,而不是简单的按字切分。


  1. 默认分词器对中文的处理方式

ES默认的standard分词器遇到中文时,会把每个汉字当作一个独立的词元 。例如,对"中华人民共和国"进行分词,结果会是[中, 华, 人, 民, 共, 和, 国]

这种方式的缺点非常明显:

  • 语义丢失:搜索"中国"时,倒排索引中只有单字"中"和"国",而文档中的"中华人民共和国"包含"中"和"国"两个字,因此能匹配到,但匹配的得分很低,因为"中"和"国"是作为两个独立字出现的,并不代表"中国"这个词的含义。

  • 相关性差:如果用户搜索"中华",同样只能匹配到单字"中"和"华",无法意识到"中华"是一个整体概念。

  • 索引膨胀:每个汉字都作为一个词条,导致索引体积变大,而且许多无意义的单字组合会干扰搜索结果。

实际上,按字切分的中文索引,对于用户想要搜索一个完整词语的场景,效果往往很差。


  1. IK分词器的核心价值

IK分词器是一个基于词典和规则的中文分词插件,它内置了丰富的词典,能够识别出中文文本中的词语。它的核心机制包括:

  • 词典匹配:IK内置了一个主词典,包含了大量常用中文词汇(如"中华人民共和国""中国""人民"等)。分词时会尽可能地将文本与词典中的词条进行匹配。

  • 歧义处理:通过算法解决切分歧义,比如对"乒乓球拍卖完了"能根据上下文给出合理的切分。

  • 两种分词模式

    • ik_smart :粗粒度分词,倾向于将句子切分成最少的词。例如"中华人民共和国"可能被切分为[中华人民共和国](如果词典中有这个词)。这种模式适合在搜索时使用,减少词条数量,提高性能。

    • ik_max_word :细粒度分词,会尽可能多地切分出词语,包括组合词。例如"中华人民共和国"可能被切分为[中华人民共和国, 中华, 华人, 人民, 共和国, 共和, 国]。这种模式适合在索引时使用,可以覆盖更多的潜在搜索词,提高召回率。

通过这两种模式的配合,IK分词器既能保证索引的丰富性,又能保证搜索时的精确性。


  1. 实际效果对比

假设我们有一个文档,内容是"中华人民共和国成立了"。

  • 使用standard分词器 (按字切分):索引的词条为[中, 华, 人, 民, 共, 和, 国, 成, 立, 了]

    • 用户搜索"中国":查询被切分为[中, 国],能匹配到文档,但相关性得分很低。

    • 用户搜索"中华":查询被切分为[中, 华],同样能匹配,但得分低。

    • 用户搜索"共和国":查询切分为[共, 和, 国],也能匹配,但依然是按字匹配。

  • 使用IK分词器 (以ik_max_word为例):索引的词条为[中华人民共和国, 中华, 华人, 人民, 共和国, 共和, 国, 成立, 了]

    • 用户搜索"中国":如果使用ik_smart,查询切分为[中国](假设"中国"在词典中),倒排索引中有"中华"、"华人"等,但没有"中国",所以无法匹配?这里需要说明:IK分词器会根据词典识别"中国",如果索引时没有包含"中国"这个词(因为文档中没有"中国"),那么搜索"中国"确实无法匹配到文档。但文档中有"中华人民共和国",如果用户搜索"中国",我们希望它能匹配吗?这取决于业务需求。IK分词器的优势在于,它允许我们通过配置,将"中国"作为"中华人民共和国"的一部分被搜索到吗?实际上,如果索引时使用了ik_max_word,会把"中华人民共和国"拆成多个词,其中包括"中华"和"共和国",但不一定会拆出"中国"。不过,如果用户搜索"中国",查询词被切分为"中国",而索引中并没有"中国"这个词条,那么文档就不会被召回。这种情况下,IK分词器并不能解决同义词的问题,但可以通过同义词插件或自定义词典来扩展。

    • 用户搜索"中华":查询切分为[中华],索引中有"中华",精确匹配,得分高。

    • 用户搜索"共和国":查询切分为[共和国],索引中有"共和国",精确匹配。

可以看出,IK分词器能让搜索词和索引词更精确地对齐,从而提高搜索质量。


  1. 可扩展性:自定义词典

IK分词器允许用户自定义词典,即添加业务相关的词汇。比如电商网站需要识别"苹果手机""联想电脑"等专有名词,公司内部系统需要识别项目名称、产品型号等。这些词汇可能不在默认词典中,但通过自定义词典,IK可以正确切分它们,避免被错误拆分。

此外,IK还支持远程词典热更新,使得在不重启ES的情况下动态更新词库,非常实用。


  1. 没有IK分词器的后果

如果在中文场景下不安装IK分词器,ES的搜索能力将大打折扣:

  • 用户输入一个常见的中文词语,搜索结果可能包含大量不相关的内容,因为默认分词器把词语拆散了。

  • 相关性排序完全失效,得分无法反映文档的真实匹配程度。

  • 对于长文本(如文章、新闻),按字切分会导致倒排索引极其庞大,且搜索噪音极大。

总之,IK分词器是让Elasticsearch能够理解中文的关键组件。只有安装了它,ES才能像一个合格的中文搜索引擎那样工作,提供准确、高效的全文检索功能。

因此,为了在 Elasticsearch 中实现高效、准确的中文全文搜索,必须安装并配置 IK 分词器插件。

安装ik分词器插件

bash 复制代码
sudo /usr/share/elasticsearch/bin/elasticsearch-plugin install https://get.infini.cloud/elasticsearch/analysis-ik/7.17.21

安装完之后我们需要启动一下我们的ES

bash 复制代码
sudo systemctl start elasticsearch

现在我们就可以去看看有没有安装成功

bash 复制代码
curl -X GET "http://localhost:9200/"

2.3.安装Kibana

安装Kibana的主要原因是为了让Elasticsearch中的数据变得可视化、可交互和易于管理。简单来说,Elasticsearch是一个强大的搜索引擎,但它只提供RESTful API接口(即通过发送HTTP请求来查询数据),没有图形界面。如果你直接使用它,每次查询都需要手动构造JSON请求,查看结果也只是原始的JSON文本,这对于日常数据分析、监控和问题排查来说非常不方便。

Kibana正是为了解决这个问题而诞生的。它是Elastic Stack(也称ELK Stack,包括Elasticsearch、Logstash、Kibana)中的可视化工具,为Elasticsearch提供了一个图形化操作界面。下面详细说明它的核心功能和安装它的必要性。


将安装Kibana的步骤拆分为多个独立的代码块,每个步骤对应的命令如下:

安装Kibana

复制代码
sudo apt install -y kibana

配置Kibana

编辑配置文件:

复制代码
sudo vim /etc/kibana/kibana.yml

设置Elasticsearch URL(在配置文件中找到并修改):

修改成下面这样子

启动Kibana服务

复制代码
sudo systemctl start kibana

设置开机自启

复制代码
sudo systemctl enable kibana

验证安装

复制代码
sudo systemctl status kibana

访问Kibana

在浏览器中访问:

复制代码
http://你的服务器IP地址:5601

注意:如果是云服务器的话,需要先去官网去开放防火墙端口和安全组端口。

然后进入下面这个界面

后面我们主要使用的是

也就是下面这个界面来操作ES

2.4.安装ES客户端

ES C++的客户端选择并不多, 我们这里使用elasticlient库, 下面进行安装。

bash 复制代码
# 克隆代码 
git clone https://github.com/seznam/elasticlient
# 切换目录 
cd elasticlient
# 更新子模块 
git submodule update --init --recursive
# 需要安装MicroHTTPD 库
sudo apt-get install -y libmicrohttpd-dev
# 编译代码 
mkdir build && cd build && cmake -DCMAKE_INSTALL_PREFIX=/usr .. && make
# 安装 
sudo make install

注意:这个客户端的安装是不能去github里面下载.zip安装包到云服务器上安装的。因为我们这里还需要更新子模块,我们必须使用git clone来完成安装。!!!

不过也不用太担心,也不需要多久,10分钟之内可以完成。

如果我们在make 的时候编译出错:那么就可能是子模块googletest没有编译安装

bash 复制代码
collect2: error: ld returned 1 exit status 
make[2]: *** [external/httpmockserver/test/CMakeFiles/test
server.dir/build.make:105: bin/test-server] Error 1 
make[1]: *** [CMakeFiles/Makefile2:675: 
external/httpmockserver/test/CMakeFiles/test-server.dir/all] Error 2 
make: *** [Makefile:146: all] Error 2 

解决:手动安装子模块

bash 复制代码
cd ../external/googletest/ 
mkdir cmake && cd cmake/ 
cmake -DCMAKE_INSTALL_PREFIX=/usr .. 
make && sudo make install 

安装好重新cmake即可。

三.ES的使用

3.1.类型

我来为您详细讲解 Elasticsearch 中的字段数据类型。这些类型决定了数据如何被存储、索引和搜索,是构建索引映射的核心。

字符串类型:text 与 keyword

字符串类型分为两种,主要区别在于是否被分词。

text 类型

  • 特点:当您存入一段文字(如文章内容、产品描述)时,text 类型会通过分词器将其拆分成多个词项 (terms)**,并建立倒排索引。**这样搜索时,用户输入的关键词也会被分词,只要匹配到任意一个词项就能返回结果,实现全文搜索。
  • 适用场景:博客内容、商品标题、评论等需要模糊匹配的文本。
  • 注意:text 字段默认不支持聚合(aggregations)和排序,因为分词后的结果是无序的。如果需要聚合或排序,通常需要同时定义一个 keyword 子字段。

keyword 类型

  • 特点:**不会对内容进行分词,而是将整个字符串作为一个完整的词项存入索引。**搜索时必须完全匹配(或通过通配符、正则表达式),常用于精确值查找、过滤、聚合和排序。
  • 适用场景:邮箱地址、标签、状态码、身份证号等不需要拆分的精确值。
  • 注意:对于过长的文本(如超过 256 字符),keyword 类型会忽略超出部分(可通过 ignore_above 参数调整),因此不适合存储大段文本。

整型数值:integer、long、short、byte

这些类型用于存储整数,区别在于取值范围和存储空间。选择合适的类型可以优化索引大小和查询性能。

  • byte:有符号 8 位整数,范围 -128 ~ 127。
  • short:有符号 16 位整数,范围 -32,768 ~ 32,767。
  • integer:有符号 32 位整数,范围 -2^31 ~ 2^31-1(约 ±21 亿),最常用。
  • long:有符号 64 位整数,范围 -2^63 ~ 2^63-1,适用于大数值(如时间戳毫秒值、宇宙星星数量)。

注意:如果存入的值超出类型范围,Elasticsearch 会报错。因此要根据实际数据范围选择最紧凑的类型。

浮点类型:float 与 double

用于存储小数。

  • float:32 位单精度浮点数,大约 6-7 位有效数字。
  • double:64 位双精度浮点数,大约 15 位有效数字,精度更高。

适用场景:价格、评分、测量数据等。

注意:浮点数在计算机中存储存在精度误差,对于金额等要求精确计算的场景,建议使用 scaled_float 类型(例如将金额乘以 100 后存为整数)。

逻辑类型:boolean

用于存储真/假值。可接受的字面量包括 true、

false、"true"、"false"、"on"、"off"、"yes"、"no"、"0"、"1" 等,但存入后统一转换为 true 或 false。

适用场景:是否激活、是否删除、性别(男/女)等二元状态。

日期类型:date 与 date_nanos

用于存储日期时间。

date:支持毫秒级精度。可接受多种格式:

格式化字符串,如 "2018-01-13"、"2018-01-13T12:10:30Z"。

时间戳(毫秒数,如 1515888000000)或秒数(需通过 format 指定)。

Elasticsearch 内部会将日期统一转换为 UTC 时间的毫秒数存储。

date_nanos:纳秒级精度,适用于需要极高时间分辨率的场景(如日志记录、科学实验)。存储时会占用更多空间,且排序和聚合时需注意纳秒部分。

注意:默认日期格式为 strict_date_optional_time||epoch_millis,可通过 format 参数自定义。

二进制类型:binary

用于存储 base64 编码的二进制数据,例如图片、文件的小型缩略图或加密数据。

特点:

  • 字段默认 index: false,即不会被索引,因此不能用于搜索,只能通过 _source 返回原始值。
  • 适合存储不需要检索的元数据或小型附件。

注意:Elasticsearch 不是专门的文件存储系统,过大的二进制数据会影响性能,建议将文件存放在对象存储(如 S3)中,仅在 ES 中保存 URL。

范围类型:range

范围类型用于表示一个值区间,包含以下子类型:

  • integer_range:整数范围,如 {"gte": 10, "lte": 20}。
  • float_range:浮点数范围。
  • long_range:长整数范围。
  • double_range:双精度浮点数范围。
  • date_range:日期范围,如 {"gte": "2020-01-01", "lte": "2020-12-31"}。
  • ip_range:IP 地址范围,支持 IPv4 和 IPv6。

适用场景:年龄段、价格区间、会议时间、IP 段等。查询时可以使用专门的区间查询(如 range query)来匹配落在区间内的文档。

3.2.映射

映射(Mapping)是 Elasticsearch 中定义索引结构的关键环节,它决定了每个字段如何被存储、索引和搜索。合理设置映射不仅能保证数据正确性,还能大幅提升查询性能。下面逐一解释您列出的每个参数。

enabled

  • 含义:是否对字段进行索引和搜索。
  • 取值:true(默认)或 false。
  • 说明:当设置为 false 时,该字段不会被索引,也无法用于搜索、排序或聚合,但原始字段值仍会保存在 _source 中(如果 _source 启用)。适用于仅需存储、无需检索的元数据(如大型描述文本),可以节省索引空间和提升写入速度。

index

  • 含义:是否对字段构建倒排索引。
  • 取值:true(默认)或 false。
  • 说明:index: true 表示该字段可以被搜索;false 表示不可搜索,但字段值仍可返回(若 _source 存储)。常用于仅用于展示、不需要参与查询的字段(例如日志中的原始消息体,但需对部分字段单独搜索时,可以关闭其他字段的索引)。

index_options

含义:控制倒排索引中存储哪些信息,影响搜索能力和索引大小。

取值:

  • docs:只存储文档编号,可用于词项是否存在判断(如 term 查询)。
  • freqs:存储文档编号和词频,用于评分(如 BM25)。
  • positions:存储文档编号、词频和词位置,用于短语查询(match_phrase)。
  • offsets:存储文档编号、词频、位置和偏移量,用于高亮显示。

说明:默认值取决于字段类型,通常 text 类型默认为 positions,keyword 类型默认为 docs。根据查询需求选择合适的级别,可以平衡性能和存储开销。

dynamic

含义:控制映射能否自动添加新字段。

取值:

  • true(默认):遇到未映射的字段,自动将其加入映射。
  • false:忽略新字段,不索引也不存储(但 _source 中仍保留)。
  • strict:遇到新字段抛出异常,拒绝写入。

说明:生产环境建议设为 false 或 strict,避免字段爆炸或类型冲突,保持映射清晰可控。

doc_values

  • 含义:是否为字段开启列式存储(用于排序、聚合、脚本访问)。
  • 取值:true(默认,除 text 外)或 false。
  • 说明:doc_values 是正向索引结构,适合 keyword、数值、日期等不分析的字段。当需要对这些字段进行排序、聚合或脚本计算时,必须启用(默认已启用)。text 字段不支持 doc_values,只能通过 fielddata 实现类似功能(但代价较大)。

fielddata

  • 含义:是否为 text 字段启用内存中的 fielddata 结构,以支持排序、聚合或脚本。
  • 取值:true 或 false(默认)。
  • 说明:text 字段默认禁用 fielddata,因为加载到堆内存中极耗资源。仅在必须对分词结果进行聚合或排序时启用,且需注意内存限制。通常推荐使用多字段(fields)方案,对同一份数据同时保留 text 和 keyword 类型,用 keyword 子字段做聚合。

store

  • 含义:是否独立存储字段的值(脱离 _source 单独存储)。
  • 取值:true 或 false(默认)。
  • 说明:默认情况下,所有字段的值都保存在 _source 字段中。设置 store: true 会将字段值额外存储一份,以便在查询时只返回该字段(节省网络开销)。适用于频繁读取的大字段(如大段文本)或禁用 _source 的场景。注意这会增加存储空间。

coerce

  • 含义:是否自动清理并转换字段值以匹配映射类型。
  • 取值:true(默认)或 false。
  • 说明:例如,当映射为 integer 但传入字符串 "5" 时,coerce: true 会将其转换为整数 5;若为 false 则抛出异常。同样,浮点数 "1.0" 可转为整数 1。开启有助于数据清洗,但可能掩盖脏数据。

analyzer

  • 含义:指定用于索引和搜索的分词器(仅对 text 字段生效)。
  • 取值:内置或自定义分词器名称,如 standard、ik_max_word。
  • 说明:定义如何将文本拆分为词项。可以分别设置 search_analyzer 用于搜索,若未指定则默认使用 analyzer。良好的分词器选择直接影响搜索相关性。

boost

  • 含义:字段级别的权重,用于提升该字段在查询评分中的重要性。
  • 取值:浮点数,默认 1.0。
  • 说明:在查询时,匹配该字段的文档得分会乘以 boost 值。适用于业务上需要突出某些字段的场景(如标题比正文更重要),但需注意过度使用可能扰乱评分。

fields

含义:为同一字段定义多个不同的索引方式(多字段)。

取值:一个子字段映射对象。

说明:常见用法是对 text 字段同时添加 keyword 子字段,实现全文搜索和精确聚合两用。例如:

javascript 复制代码
"name": {
  "type": "text",
  "fields": {
    "raw": { "type": "keyword" }
  }
}

这样 name 用于分词搜索,name.raw 用于排序和聚合。

date_detection

  • 含义:是否自动识别字符串格式的日期并映射为 date 类型。
  • 取值:true(默认)或 false。
  • 说明:当 dynamic: true 且遇到类似 "2015-01-01" 的字符串时,ES 会尝试将其映射为 date。若设为 false,则视为 text。可配合 dynamic_templates 精细化控制。

3.3.基于Kibana的使用示例

我们在浏览器中访问:

复制代码
http://你的服务器IP地址:5601

注意:如果是云服务器的话,需要先去官网去开放防火墙端口和安全组端口。

然后进入下面这个界面

后面我们主要使用的是

也就是下面这个界面来操作ES

我们把里面的内容删除,换成我们自己写好的(注意不要注释)

示例1------创建索引

这里是一个最简单的创建索引的例子,不包含任何映射定义,完全使用 Elasticsearch 的默认设置。

  1. 创建索引(无映射)

在 Kibana 的 Dev Tools 中执行以下命令:

javascript 复制代码
PUT /my_index

说明:

  • 这会在 Elasticsearch 中创建一个名为 my_index 的索引。
  • 没有指定 mappings,因此索引使用动态映射------当你插入文档时,Elasticsearch 会自动检测字段类型并创建相应的映射。
  • 索引的设置(如分片数、副本数)也全部采用默认值(通常主分片 1,副本分片 1)。

输入进去之后点下面这个

执行是否成功需要看状态码

  1. 验证索引已创建

可以执行以下命令查看索引信息:

javascript 复制代码
GET /my_index

返回结果中会包含索引的基本设置和空的映射("mappings": { })。

  1. 添加数据(自动生成映射)

现在可以向索引中添加文档,字段类型会自动推断:

javascript 复制代码
POST /my_index/_doc
{
  "name": "张三",
  "age": 25,
  "email": "zhangsan@example.com"
}

有的人可能好奇我们上面创建的索引明明是/my_index,为啥这里却往/my_index/_doc里面进行POST?

您创建了一个叫 my_index 的索引。建好之后,我们肯定要往里面放数据、查数据,或者修改它的设置。那么怎么操作呢?

其实**Elasticsearch 在创建索引的同时,就已经为这个索引自动配好了一套固定的操作入口。**这些入口就是那些带下划线的词,比如 _doc、_mapping、_settings、_search 等等。您不需要自己去创建它们,它们本来就存在,而且对每个索引都一样。

操作的方法特别简单:您只需要把索引名和操作入口名拼在一起,组成一个类似"地址"的东西,然后告诉 Elasticsearch 您想做什么就行了。

举个例子:

  • 如果您想往 my_index 里添加一篇文档(也就是一条数据),就用 my_index/_doc 这个地址。
  • 如果您想看看这个索引当初规定了哪些字段(比如规定了必须有姓名、年龄),就用 my_index/_mapping 这个地址。
  • 如果您想修改这个索引的一些全局设置(比如把数据备份数量从1改成2),就用 my_index/_settings 这个地址。
  • 如果您想从这个索引里搜索符合某些条件的数据,就用 my_index/_search 这个地址。

这些带下划线的词,就像是索引的"功能按钮"。索引本身只是一个容器,而通过这些按钮,您就能对容器本身或容器里的内容做各种操作。按钮是系统自带的,您只管用就行。

那么我们这里就是这样子的情况:

我们添加数据就必须使用**/索引名/_doc**这个地址,具体来说完整的操作入口如下:

javascript 复制代码
/索引名/_doc/文档ID

其中:

  • _doc 是固定的路径段,不再代表具体的类型名,而是文档操作端点的标志

所以:

  • POST /my_index/_doc → 表示my_index 索引的文档集合中创建一篇新文档(不指定 ID,由 Elasticsearch 自动生成 ID)。

  • PUT /my_index/_doc/1 → 表示my_index 索引的文档集合中创建或替换 ID 为 1 的文档(指定 ID)。

为什么创建索引不用 /_doc,而创建文档要用 /_doc?

因为索引和文档是两个完全不同层级的资源:

  • 索引是"数据库"本身,你可以在它上面设置全局属性(如分片数、分词器等)。

  • 文档是"数据库里的一条记录",它必须归属于某个具体的索引。

类比关系型数据库:

  • PUT /my_index 相当于 CREATE DATABASE my_index;(但 Elasticsearch 的索引更接近于表,这里只是类比)

  • POST /my_index/_doc 相当于 INSERT INTO my_index ...(指定了表名,但具体插入数据)

在 REST 设计中,资源的层次通过 URL 的路径层级来体现:

javascript 复制代码
/my_index           → 索引层(集合)
/my_index/_doc      → 文档层(子集合)
/my_index/_doc/1    → 单个文档(元素)

这种层次结构让 API 既统一又直观。

现在我们可以再去查看一下索引信息

javascript 复制代码
GET /my_index

那么如果我只想观察映射呢?

javascript 复制代码
GET /my_index/_mapping

您创建了一个叫 my_index 的索引。建好之后,我们肯定要往里面放数据、查数据,或者修改它的设置。那么怎么操作呢?

其实**Elasticsearch 在创建索引的同时,就已经为这个索引自动配好了一套固定的操作入口。**这些入口就是那些带下划线的词,比如 _doc、_mapping、_settings、_search 等等。您不需要自己去创建它们,它们本来就存在,而且对每个索引都一样。

操作的方法特别简单:您只需要把索引名和操作入口名拼在一起,组成一个类似"地址"的东西,然后告诉 Elasticsearch 您想做什么就行了。

举个例子:

  • 如果您想往 my_index 里添加一篇文档(也就是一条数据),就用 my_index/_doc 这个地址。
  • 如果您想看看这个索引当初规定了哪些字段(比如规定了必须有姓名、年龄),就用 my_index/_mapping 这个地址。
  • 如果您想修改这个索引的一些全局设置(比如把数据备份数量从1改成2),就用 my_index/_settings 这个地址。
  • 如果您想从这个索引里搜索符合某些条件的数据,就用 my_index/_search 这个地址。

这些带下划线的词,就像是索引的"功能按钮"。索引本身只是一个容器,而通过这些按钮,您就能对容器本身或容器里的内容做各种操作。按钮是系统自带的,您只管用就行。

那么我们这里就是这样子的情况:

我们的索引是/my_index,那这里的_mapping 又是什么?

_mapping 也是一个固定的端点(endpoint),就像 _doc 一样。它们是 Elasticsearch 预定义的 API 路径,用于对索引或文档执行特定类型的操作。

  • _mapping 用于操作索引的映射(mapping),即字段的定义规则。
  • 通过 GET /my_index/_mapping,您可以查看 my_index 索引中所有字段的映射信息,包括字段类型、分词器、是否索引等设置。
  • 如果您想更新索引的映射(例如添加一个新字段),可以使用 PUT /my_index/_mapping 并在请求体中携带新的字段定义。

你会看到 Elasticsearch 自动为 name、age、email 创建了对应的字段类型(例如 text、long、text,但 email 可能被映射为 text,如果你想要精确匹配,最好在创建时指定映射,或者使用 keyword 类型)。

可以看到就显示了我们的映射

  1. 删除索引
javascript 复制代码
DELETE /my_index

示例2------创建索引,并同时定义映射

1. 创建索引(同时定义映射)

我们创建一个名为 my_users 的索引,包含三个字段:姓名、年龄、邮箱。使用默认分词器,不设置复杂参数。

bash 复制代码
PUT /my_users
{
  "mappings": {
    "properties": {
      "name": {
        "type": "text"
      },
      "age": {
        "type": "integer"
      },
      "email": {
        "type": "keyword"
      }
    }
  }
}

说明:

  • name 使用 text 类型,支持全文搜索。
  • age 使用 integer 类型,存储整数。
  • email 使用 keyword 类型,适合精确匹配(如登录、过滤)。

那么其实我又有一个问题:mappings代表映射的话,那么为什么下面就不能直接就是字段的定义,而中间还需要加一个properties?

  1. mappings 的顶层结构

mappings 对象本身可以包含多种配置项,而不仅仅是字段定义。它相当于一个"映射配置容器",可以设置:

  • properties:定义文档中的字段及其类型(您看到的)。
  • dynamic:控制是否允许自动添加新字段(可以在索引级别设置,也可以在字段级别覆盖)。
  • _source:配置原始文档的存储方式(例如是否禁用 _source)。
  • dynamic_templates:动态模板,用于根据字段名或类型自动应用映射规则。
  • date_detection / numeric_detection:是否自动识别日期/数字。
  • routing:路由相关设置。
  • meta:自定义元数据。

**如果字段定义直接放在 mappings 下(比如 mappings: { "name": { "type": "text" } }),那就会和这些顶级配置项混在一起,造成混乱。**例如,如果将来需要添加 dynamic 设置,就无法区分 dynamic 是顶级配置还是一个字段名。

因此,Elasticsearch 设计了一个专门的 properties 对象,用来集中存放所有字段的定义,这样顶级配置和字段定义就分开了,结构清晰且易于扩展。

  1. 为什么不能省略 properties?

假设省略 properties,直接将字段写在 mappings 下:

bash 复制代码
{
  "mappings": {
    "name": { "type": "text" },
    "age": { "type": "integer" }
  }
}

这时 Elasticsearch 就会困惑:name 和 age 到底是字段定义,还是像 dynamic 这样的顶级配置?如果未来增加一个新的顶级配置叫 age,岂不是冲突了?

为了避免这种歧义,Elasticsearch 强制要求所有字段定义必须放在 properties 对象内。这就像在编程中,一个类的成员变量必须放在类的内部,而不能和类的方法混在一起。

我们来看一个包含多种设置的完整映射例子:

bash 复制代码
{
  "mappings": {
    "dynamic": false,                    // 顶级设置:禁止自动添加字段
    "_source": { "enabled": false },      // 顶级设置:不存储原始文档
    "properties": {                       // 字段定义都在这里
      "name": { "type": "text" },
      "age": { "type": "integer" },
      "email": { "type": "keyword" }
    }
  }
}

这里的层次结构一目了然:dynamic 和 _source 是映射的整体行为控制,properties 是具体的字段清单。如果字段定义直接写在顶层,就无法同时容纳这些设置。

总结一下:

  • mappings 是映射的根对象,可以包含多种配置项。
  • properties 是专门存放字段定义的地方,它的存在让映射结构层次分明,避免与顶级配置项混淆。

这是 Elasticsearch 设计上的一种规范,保证了映射的清晰性和可扩展性。

事实上我们的索引除了mappings也是还有其他东西的


然后我们点击下面这个

出现下面这个状态码就表示成功

2. 添加文档

我们添加三条用户记录,每条是一个独立的 JSON 文档。

bash 复制代码
POST /my_users/_doc
{
  "name": "张三",
  "age": 25,
  "email": "zhangsan@example.com"
}
POST /my_users/_doc
{
  "name": "李四",
  "age": 30,
  "email": "lisi@example.com"
}
POST /my_users/_doc
{
  "name": "王五",
  "age": 28,
  "email": "wangwu@example.com"
}

说明:

  • 每执行一次 POST 就添加一条文档。
  • Elasticsearch 会自动为每个文档生成唯一 ID(也可以自己指定)。

接下来我们来了解一下

在 Elasticsearch 中,Bulk API 的标准端点格式是:

bash 复制代码
POST /<索引名>/<类型名>/_bulk

例如,你要对 user 索引执行批量操作,正确的请求路径应该是:

bash 复制代码
POST /user/_doc/_bulk

在 Elasticsearch 6.x 及之前版本,类型是必须的,因此这种带类型的路径很常见。在 Elasticsearch 7.x 中,类型概念被废弃,但为了向后兼容,仍然允许在路径中指定一个类型名(通常要求为 _doc)。此时,Elasticsearch 会忽略这个类型名,仅将其视为一种占位符,实际操作仍然针对前面的索引。

所以,POST /user/_doc/_bulk 的含义是:

  • 索引名:user
  • 类型名:_doc(被忽略)
  • 端点:_bulk

只要你的 Elasticsearch 版本(7.x 或更高)支持这种兼容写法,这个路径就能正常工作。数据会写入 user 索引,而 _doc 仅仅是一个形式上的类型名称,不影响实际存储。

  • 1. 如果让 Elasticsearch 自动生成文档 ID

你的 POST 请求没有指定 _id,Elasticsearch 会自动生成一个唯一 ID。

bash 复制代码
POST /my_users/_doc
{
  "name": "张三",
  "age": 25,
  "email": "zhangsan@example.com"
}

这在 Bulk API 中对应的写法是:

bash 复制代码
POST /my_users/_doc/_bulk
{"index":{}}          // 元数据行,不指定 _id
{"name":"张三","age":25,"email":"zhangsan@example.com"}
  • 2. 如果你想手动指定 ID(例如 "1")

单条 API 可以使用 PUT 并指定 ID:

bash 复制代码
POST /my_users/_doc/1
{
  "name": "张三",
  "age": 25,
  "email": "zhangsan@example.com"
}

对应的 Bulk 写法就是:

bash 复制代码
POST /my_users/_doc/_bulk
{"index":{"_id":"1"}}
{"name":"张三","age":25,"email":"zhangsan@example.com"}
  • 3.我们上面POST语句的等价 Bulk API 语句

事实上呢,上面这个添加文档的语句等价于下面这个

bash 复制代码
POST /my_users/_doc/_bulk
{"index":{}}
{"name":"张三","age":25,"email":"zhangsan@example.com"}
{"index":{}}
{"name":"李四","age":30,"email":"lisi@example.com"}
{"index":{}}
{"name":"王五","age":28,"email":"wangwu@example.com"}

每两行为一组,代表一个操作:

  • 第一行是操作元数据:{"index":{}} 表示要执行一个索引(插入/更新)操作,且由于不指定 _id,Elasticsearch 会自动生成文档 ID。
  • 第二行是实际的文档数据,即你要插入的 JSON 对象。

整个请求体是一个 文本块,每行之间必须用换行符(\n)分隔,并且最后一行也要以换行符结尾。

发送时需要设置 HTTP 头 Content-Type: application/json(或 application/x-ndjson,但 Elasticsearch 通常接受前者),并将请求发送到 Bulk API 端点,例如:

  • POST /_bulk(对所有索引通用)
  • POST /my_users/_bulk(指定默认索引名,这样元数据行中可以不写索引名)

3. 搜索数据

我们来搜索所有名字中包含"张"的用户。

bash 复制代码
GET /my_users/_search
{
  "query": {
    "match": {
      "name": "张"
    }
  }
}

说明:

match 查询会在 name 字段中搜索"张",返回匹配的文档。

这个/my_users/_search又是什么鬼?

您创建了一个叫 my_index 的索引。建好之后,我们肯定要往里面放数据、查数据,或者修改它的设置。那么怎么操作呢?

其实**Elasticsearch 在创建索引的同时,就已经为这个索引自动配好了一套固定的操作入口。**这些入口就是那些带下划线的词,比如 _doc、_mapping、_settings、_search 等等。您不需要自己去创建它们,它们本来就存在,而且对每个索引都一样。

操作的方法特别简单:您只需要把索引名和操作入口名拼在一起,组成一个类似"地址"的东西,然后告诉 Elasticsearch 您想做什么就行了。

举个例子:

  • 如果您想往 my_index 里添加一篇文档(也就是一条数据),就用 my_index/_doc 这个地址。
  • 如果您想看看这个索引当初规定了哪些字段(比如规定了必须有姓名、年龄),就用 my_index/_mapping 这个地址。
  • 如果您想修改这个索引的一些全局设置(比如把数据备份数量从1改成2),就用 my_index/_settings 这个地址。
  • 如果您想从这个索引里搜索符合某些条件的数据,就用 my_index/_search 这个地址。

这些带下划线的词,就像是索引的"功能按钮"。索引本身只是一个容器,而通过这些按钮,您就能对容器本身或容器里的内容做各种操作。按钮是系统自带的,您只管用就行。

后面我就不再提了

4. 删除索引

如果不再需要这个索引,可以将其删除。

bash 复制代码
DELETE /my_users

示例3------查询相关语句

Elasticsearch 的 bool 查询包含四种逻辑子句,它们可以灵活组合,实现复杂的搜索逻辑:

  • must:子句中的条件必须全部满足(逻辑 AND)。 匹配的文档会参与相关度评分(_score),**即满足的条件越多、越匹配,得分越高。**常用于需要同时满足多个条件且希望影响评分的情况。
  • filter: 子句中的条件也必须全部满足 (逻辑 AND),但与 must 不同之处在于不参与评分,仅作过滤。由于不计算评分,性能更好且结果可缓存。适合范围、状态、精确值等筛选。
  • should: 子句中的条件至少满足一个 (逻辑 OR),**满足的条件越多,文档的相关度评分越高。**如果 bool 查询中没有 must 或 filter,则至少需要满足一个 should 条件(除非通过 minimum_should_match 参数另行控制)。
  • must_not: 子句中的条件必须都不满足 (逻辑 NOT),用于排除文档。它也是过滤行为,不参与评分,同样可以利用缓存。

在构建 上面这4种 子句时,可以根据字段类型和查询意图选择不同的查询类型,其中最常用的是 term 和 match:

  • term 查询 :用于精确匹配某个字段的确切值。它不会对查询词进行分析(即不分词),直接在倒排索引中查找与给定值完全相等的词条。适用于 keyword 类型、数值、日期等需要精确匹配的字段。
  • 例如:{ "term": { "user_id": "USER4b862aaa..." } } 会精确匹配 user_id 字段为指定字符串的文档。
  • match 查询 :用于全文搜索,会对查询文本进行分析**(分词)**,然后去匹配字段中的词条。它适用于 text 类型字段,会按照字段的分析器(如 IK 分词器)对查询字符串进行分词,再在倒排索引中查找这些词条。
  • 例如:{ "match": { "nickname": "张三" } } 如果 nickname 是 text 类型,查询词 "张三" 会被分词为 "张" 和 "三",然后匹配包含这两个词的文档。

什么叫评分?

参与评分"是 Elasticsearch 中一个非常核心的概念。让我用最简单的生活例子来解释:

什么是评分(_score)?

评分就是 Elasticsearch 为每个匹配到的文档计算的一个"相关度分数",分数越高,代表这个文档越符合你的搜索意图,在结果中排得越靠前。

举个简单的例子

假设你有一个电影数据库,你要搜索"动作 科幻":

bash 复制代码
GET /movies/_search
{
  "query": {
    "bool": {
      "must": [
        { "match": { "title": "动作" } },
        { "match": { "title": "科幻" } }
      ]
    }
  }
}

Elasticsearch 会找到所有同时包含"动作"和"科幻"的电影,并且:

  • 给每部电影计算一个分数,计算方式可能包括:

    • "动作"和"科幻"这两个词在标题中出现的次数(词频)

    • 这两个词在整个数据库中的稀有程度(逆向文档频率)

    • 标题字段的长度等

结果排序

  1. 电影A:《动作科幻大片》(同时包含两个词,分数最高)

  2. 电影B:《科幻动作冒险》(同时包含两个词,但标题稍长,分数略低)

  3. 电影C:《科幻电影》(只包含"科幻",不包含"动作",不会被匹配)

参与评分 vs 不参与评分

javascript 复制代码
GET /movies/_search
{
  "query": {
    "bool": {
      "must": [
        { "match": { "title": "动作" } }  // 参与评分:影响排名
      ],
      "filter": [
        { "term": { "year": 2023 } }      // 不参与评分:只过滤,不计算分数
      ]
    }
  }
}
  • must 中的条件:不仅筛选文档,还会计算它们对相关度的贡献,影响最终排名。
  • filter 中的条件**:只负责筛选(比如只要2023年的电影),不计算分数**,所以这些条件不会影响文档的排序位置,但执行更快,结果可缓存。

为什么需要不参与评分的条件?

  • 性能更好:不计算分数,节省CPU。
  • 可缓存:过滤条件的结果可以被缓存,相同条件再次查询更快。
  • 逻辑清晰:有些条件只是筛选(如时间范围、状态),不应该影响相关性排序。

简单总结

  • 参与评分 = 影响文档的排名顺序,用于决定哪个结果更相关。
  • 不参与评分 = 只做筛选,不关心顺序,只要符合条件就行。

这就是为什么 must 和 should 参与评分(因为它们决定相关性),而 filter 和 must_not 不参与评分(它们只是过滤条件)。

示例

我们用一个电商商品搜索 的场景,来逐一讲解 bool 查询的四种子句。假设我们有一个 products 索引,里面存储了以下5件商品:

ID 名称 (name) 品牌 (brand) 价格 (price) 库存 (stock) 上架时间 (date)
1 苹果手机 Apple 6000 10 2023-01-01
2 华为手机 Huawei 5000 5 2023-02-01
3 小米手机 Xiaomi 3000 0 2023-03-01
4 苹果平板 Apple 4000 8 2023-01-15
5 华为平板 Huawei 3500 3 2023-02-15

字段说明:nametext 类型(支持分词),brandkeyword 类型(精确匹配),pricestock 是数值类型,date 是日期类型。


  1. must ------ 必须满足,且影响评分

场景 :用户想搜索"名称包含'手机',并且品牌是'Apple'的商品"。这两个条件都必须满足,而且我们希望根据匹配程度排序(例如名称中"手机"出现次数等)。

查询

javascript 复制代码
GET /products/_search
{
  "query": {
    "bool": {
      "must": [
        { "match": { "name": "手机" } },//分词搜索
        { "term": { "brand": "Apple" } }//不分词搜索
      ]
    }
  }
}

执行结果 :只有商品1(苹果手机)满足条件。Elasticsearch 会为它计算一个 _score 分数(比如 0.8),这个分数决定了它在结果列表中的位置(虽然只有一个,但如果有多个文档,分数高的排前面)。

关键点must 中的条件既筛选文档 ,又贡献分数,因此结果会按相关度排序。


  1. filter ------ 必须满足,但不影响评分

场景:用户想搜索"价格在 3000 到 5000 之间,并且库存大于 0 的商品"。这些条件只是过滤,用户并不关心价格范围对排序的影响。

查询

javascript 复制代码
GET /products/_search
{
  "query": {
    "bool": {
      "filter": [
        { "range": { "price": { "gte": 3000, "lte": 5000 } } },
        { "range": { "stock": { "gt": 0 } } }
      ]
    }
  }
}

执行结果 :符合条件的商品有:商品2(华为手机)、商品4(苹果平板)、商品5(华为平板)。注意商品1价格6000超出范围,商品3库存0被排除。由于 filter 不参与评分,所有结果的 _score 都相同(例如 0.0),默认按文档插入顺序返回(实际可能按其他方式,但分数一致)。

关键点filter 只做筛选,不计算分数,因此性能更好,结果可以缓存。适合范围、精确值、状态等不需要影响排名的条件。


3. should ------ 至少满足一个(可选),满足越多评分越高

场景 :用户搜索"名称包含'手机'的商品",同时如果品牌是"Apple"或"Huawei"则让它们排得更靠前(因为这些品牌更受欢迎)。should 子句中的条件不是必须的,但满足会加分。

查询

javascript 复制代码
GET /products/_search
{
  "query": {
    "bool": {
      "must": [
        { "match": { "name": "手机" } }//分词搜索
      ],
      "should": [
        { "term": { "brand": "Apple" } },//不分词
        { "term": { "brand": "Huawei" } }//不分词
      ]
    }
  }
}

执行结果

  • 所有名称包含"手机"的商品:商品1(Apple)、商品2(Huawei)、商品3(Xiaomi)都会返回。

  • 商品1 满足一个 should 条件(Apple),商品2 满足一个(Huawei),商品3 不满足任何 should 条件。

  • 因此商品1和商品2的 _score 会比商品3高,排在前面。

关键点should 用于提升相关性,满足的条件越多,分数越高。如果 bool 查询中没有 mustfilter,那么至少需要满足一个 should 条件(除非用 minimum_should_match 修改)。


  1. must_not ------ 必须不满足,不参与评分

场景 :用户想搜索所有商品,但排除库存为0 的商品。must_not 条件用于过滤掉不需要的文档。

查询

javascript 复制代码
GET /products/_search
{
  "query": {
    "bool": {
      "must_not": [
        { "term": { "stock": 0 } }//不分词
      ]
    }
  }
}

执行结果 :返回除了商品3(小米手机)之外的所有商品。must_not 是过滤行为,不参与评分,所以所有结果的 _score 相同(或者与其他评分条件共同决定)。

关键点must_not 用于排除文档,同样不计算分数,结果可缓存。


综合示例:四种子句一起使用

假设我们想搜索"名称包含'手机',品牌是'Apple'或'Huawei',价格在 3000~6000 之间,排除库存为0的商品"。查询可以写成:

javascript 复制代码
GET /products/_search
{
  "query": {
    "bool": {
      "must": [
        { "match": { "name": "手机" } }//分词
      ],
      "filter": [
        { "range": { "price": { "gte": 3000, "lte": 6000 } } }
      ],
      "should": [
        { "term": { "brand": "Apple" } },//不分词
        { "term": { "brand": "Huawei" } }//不分词
      ],
      "must_not": [
        { "term": { "stock": 0 } }//不分词
      ]
    }
  }
}

执行逻辑

  1. must:名称必须包含"手机" → 商品1、2、3 进入候选。

  2. filter:价格必须在 3000~6000 之间 → 商品1(6000)、商品2(5000)、商品3(3000)都满足(商品3价格3000在范围内)。

  3. must_not:库存不能为0 → 商品3(库存0)被排除,剩下商品1和2。

  4. should:如果品牌是Apple或Huawei则加分 → 商品1品牌Apple(加分),商品2品牌Huawei(加分)。两者都加分,分数可能相近,但根据具体算法可能有细微差异。

最终返回商品1和商品2,并且它们的 _score 会高于单纯 must+filter 的默认分数,因为 should 提升了它们。

返回的响应是

javascript 复制代码
{
  "took": 10,
  "timed_out": false,
  "_shards": {
    "total": 5,
    "successful": 5,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 100,
      "relation": "eq"
    },
    "max_score": 1.2,
    "hits": [
      {
        "_index": "products",
        "_type": "_doc",
        "_id": "1",
        "_score": 1.2,
        "_source": {
          "name": "苹果手机",
          "brand": "Apple",
          "price": 6000,
          "stock": 10
        }
      },
      {
        "_index": "products",
        "_type": "_doc",
        "_id": "2",
        "_score": 0.9,
        "_source": {
          "name": "华为手机",
          "brand": "Huawei",
          "price": 5000,
          "stock": 5
        }
      }
    ]
  }
}

示例4------综合示例

我们现在就创建索引

在 Elasticsearch 中,POST /user/_doc 这种写法并不是用来创建索引的,而是用来索引(写入)一个文档的。 之所以你看到它能"直接创建索引",是因为 Elasticsearch 默认开启了自动创建索引的功能。

  • Elasticsearch 有一个配置项 action.auto_create_index,默认值为 true(或某些特定模式),允许在写入文档时,如果目标索引不存在,就自动创建该索引。
  • 因此,当你执行 POST /user/_doc(或 PUT /user/_doc/1)向一个不存在的索引写入文档时,Elasticsearch 会自动创建名为 user 的索引,然后再写入文档数据。
bash 复制代码
POST /user/_doc   
{
  "settings": {
    "analysis": {
      "analyzer": {
        "ik": {
          "tokenizer": "ik_max_word"
        }
      }
    }
  },
  "mappings": {
    "dynamic": true,
    "properties": {
      "昵称": {
        "type": "text",
        "analyzer": "ik_max_word"
      },
      "用户ID": {
        "type": "keyword",
        "analyzer": "standard"
      },
      "手机号": {
        "type": "keyword",
        "analyzer": "standard"
      },
      "描述": {
        "type": "text",
        "enabled": false
      },
      "头像ID": {
        "type": "keyword",
        "enabled": false
      }
    }
  }
}

在 Elasticsearch 里创建一个名为 user 的索引,并且为这个索引设置好字段的"格式"(映射)和中文分词规则。但请求的地址用错了,所以实际效果可能和您想的不太一样。

让我用中文给您详细拆解一下:

📝 您本来想做什么(代码意图)

这个请求的"身体"部分(JSON)是正确的索引定义,它包含两大部分:

settings(设置)------ 配置中文分词器

  • 定义了一个名为 ik 的自定义分析器,并指定它使用 ik_max_word 分词器。
  • ik_max_word 是 IK 分词器的一种模式,它会将文本切成最细粒度的词,比如"中华人民共和国"会被切成"中华人民共和国"、"中华"、"华人"、"人民共和国"等,适合用于尽可能多地搜出结果(高召回率)。

mappings(映射)------ 定义字段的类型和行为

  • dynamic: true:允许未来插入文档时,自动添加您没在这里定义的新字段(生产环境建议设为 false 或 strict 以控制字段数量)。
  • 昵称:类型为 text(全文文本),并指定用刚才定义的 ik_max_word 分词器。这意味着搜索"昵称"时,会进行中文分词匹配。
  • 用户ID 和 手机号:类型为 keyword(关键词),这意味着它们不会被分词,存成一个完整的字符串,适用于精确匹配(比如 用户ID: "abc123")、排序或聚合统计。但您给它们指定了 analyzer: "standard",这其实对 keyword 类型没有实际作用(keyword 本身不分词),可以忽略或删除。
  • 描述 和 头像ID:这两个字段的 enabled: false 表示它们不会被索引(也就是不能用于搜索),但文档的原始数据里仍然会保留它们。这常用于存储一些不需要被搜索的辅助信息,以节省磁盘空间。

❌ 实际发生了什么(请求地址的问题)

使用的地址是 POST /user/_doc。

在 Elasticsearch 里,这个地址的标准用途是"创建一篇文档"(如果索引 user 不存在,它可能会根据动态映射自动创建,但不会读取您请求体里的 settings 和 mappings)。

因此,如果您用这个地址发送上面的 JSON,Elasticsearch 可能会:

因为找不到 user 索引,就自动创建了一个(但用的是默认设置,没有您定义的 IK 分词器)。

然后尝试把您整个 JSON(包括 settings 和 mappings 字段)当作一篇文档的内容存进去,这很可能导致错误,或者存进去一篇奇怪的文档。

然后我们点击下面这个

出现下面这个状态码就表示成功

这就算是完成了

那么我们新增数据

bash 复制代码
POST /user/_doc/_bulk
{"index":{"_id":"1"}}
{"user_id":"USER4b862aaa-2df8654a-7eb4bb65e3507f66","nickname":"昵称1","phone":"手机号1","description":"签名1","avatar_id":"头像1"}
{"index":{"_id":"2"}}
{"user_id":"USER14eeeaa5-442771b9-0262e455e4663d1d","nickname":"昵称2","phone":"手机号2","description":"签名2","avatar_id":"头像2"}
{"index":{"_id":"3"}}
{"user_id":"USER484a6734-03a124f0-996c169dd05c1869","nickname":"昵称3","phone":"手机号3","description":"签名3","avatar_id":"头像3"}
{"index":{"_id":"4"}}
{"user_id":"USER186ade83-4460d4a6-8c08068f83127b5d","nickname":"昵称4","phone":"手机号4","description":"签名4","avatar_id":"头像4"}
{"index":{"_id":"5"}}
{"user_id":"USER6f19d074-c33891cf-23bf5a8357189a19","nickname":"昵称5","phone":"手机号5","description":"签名5","avatar_id":"头像5"}
{"index":{"_id":"6"}}
{"user_id":"USER97605c64-9833ebb7-d045535335a59195","nickname":"昵称6","phone":"手机号6","description":"签名6","avatar_id":"头像6"}

注意这个数据插入语句等价于下面这个

bash 复制代码
PUT /user/_doc/1
{"user_id":"USER4b862aaa-2df8654a-7eb4bb65e3507f66","nickname":"昵称1","phone":"手机号1","description":"签名1","avatar_id":"头像1"}

PUT /user/_doc/2
{"user_id":"USER14eeeaa5-442771b9-0262e455e4663d1d","nickname":"昵称2","phone":"手机号2","description":"签名2","avatar_id":"头像2"}

PUT /user/_doc/3
{"user_id":"USER484a6734-03a124f0-996c169dd05c1869","nickname":"昵称3","phone":"手机号3","description":"签名3","avatar_id":"头像3"}

PUT /user/_doc/4
{"user_id":"USER186ade83-4460d4a6-8c08068f83127b5d","nickname":"昵称4","phone":"手机号4","description":"签名4","avatar_id":"头像4"}

PUT /user/_doc/5
{"user_id":"USER6f19d074-c33891cf-23bf5a8357189a19","nickname":"昵称5","phone":"手机号5","description":"签名5","avatar_id":"头像5"}

PUT /user/_doc/6
{"user_id":"USER97605c64-9833ebb7-d045535335a59195","nickname":"昵称6","phone":"手机号6","description":"签名6","avatar_id":"头像6"}

我们查询一下数据

bash 复制代码
GET /user/_doc/_search?pretty
{
  "query": {
    "bool": {
      "must_not": [
        {
          "terms": {
            "user_id.keyword": [
              "USER4b862aaa-2df8654a-7eb4bb65e3507f66",
              "USER14eeeaa5-442771b9-0262e455e4663d1d",
              "USER484a6734-03a124f0-996c169dd05c1869"
            ]
          }
        }
      ],
      "should": [
        {
          "match": {
            "user_id": "昵称"
          }
        },
        {
          "match": {
            "nickname": "昵称"
          }
        },
        {
          "match": {
            "phone": "昵称"
          }
        }
      ]
    }
  }
}

然后我们删除索引

bash 复制代码
DELETE /user

3.4.基于ES客户端的使用示例

我们上面是在可视化界面进行操作的,但是实际上,我们在操作的时候其实是通过代码来进行操作的。所以上面的步骤我们了解一下即可。

insert.cpp

cpp 复制代码
#include <elasticlient/client.h>
#include <json/json.h>
#include <iostream>
#include <cpr/cpr.h>

int main() {
    // 创建一个 elasticlient::Client 对象,指定 Elasticsearch 服务的地址列表
    // 这里使用本地默认端口 9200,实际部署时可替换为集群地址
    elasticlient::Client client({"http://127.0.0.1:9200/"});//地址最后一定要加一个斜杠

    // 构造要索引的文档(JSON 格式)
    // Json::Value 是 jsoncpp 中表示任意 JSON 数据的类
    Json::Value doc;
    // 设置文档的 "title" 字段为字符串 "Example Document"
    doc["title"] = "Example Document";
    // 设置文档的 "content" 字段为字符串 "This is a simple test."
    doc["content"] = "This is a simple test.";
    // 设置文档的 "timestamp" 字段为字符串 "2025-03-12"
    doc["timestamp"] = "2025-03-12";

    // 创建一个 jsoncpp 的流式写入器生成器,用于将 Json::Value 序列化为字符串
    Json::StreamWriterBuilder builder;
    // 将 doc 对象序列化为 JSON 字符串,作为 HTTP 请求的请求体
    std::string body = Json::writeString(builder, doc);

    // 调用 index 方法(索引名:test_index,类型:_doc,文档ID:1)
    // client.index() 方法用于在指定索引中创建或更新文档
    // 参数依次为:索引名称、文档类型(Elasticsearch 7.x 后通常用 _doc)、文档 ID、JSON 字符串
    // 如果索引不存在,默认会自动创建(但推荐预先创建索引以定义映射和分片设置)
    auto response = client.index("test_index", "_doc", "1", body);

    // 检查响应状态码(2xx 表示成功)
    // response.status_code 是 HTTP 状态码,2xx 代表成功,4xx/5xx 代表错误
    if (response.status_code >= 200 && response.status_code < 300) 
    {
        // 输出成功信息,并打印 Elasticsearch 返回的响应体(通常包含结果信息,如 "created" 或 "updated")
        std::cout << "文档索引成功!" << std::endl;
        std::cout << "响应体:" << response.text << std::endl;
    } 
    else 
    {
        // 如果状态码不是 2xx,输出错误状态码
        std::cerr << "索引失败,HTTP 状态码:" << response.status_code << std::endl;
    }
    return 0;
}

search.cpp

cpp 复制代码
#include <elasticlient/client.h>
#include <json/json.h>
#include <iostream>
#include <sstream>
#include <cpr/cpr.h>

int main() {
    // 创建一个 elasticlient::Client 对象,指定 Elasticsearch 服务的地址列表
    // 这里使用本地默认端口 9200,实际部署时可替换为集群地址
    elasticlient::Client client({"http://127.0.0.1:9200/"});//地址最后一定要加一个斜杠

    // 构造查询 DSL(Domain Specific Language),这里使用 match_all 查询所有文档
    // Json::Value 是 jsoncpp 中表示任意 JSON 数据的类
    Json::Value query;
    // 设置 query 对象的 "query" 字段为一个对象,其内包含 "match_all" 字段(值为空对象)
    // 等价于 JSON: { "query": { "match_all": {} } }
    query["query"]["match_all"] = Json::objectValue;

    // 创建一个 jsoncpp 的流式写入器生成器,用于将 Json::Value 序列化为字符串
    Json::StreamWriterBuilder builder;
    // 将 query 对象序列化为 JSON 字符串,作为 HTTP 请求的请求体
    std::string requestBody = Json::writeString(builder, query);

    // 执行搜索操作,指定索引名称为 "test_index",请求体为上面构造的 DSL
    // 返回的 response 对象包含 HTTP 状态码、响应头、响应体等信息
    auto response = client.search("test_index", "_doc", requestBody, "");

    // 检查 HTTP 响应状态码是否为 200(成功)
    if (response.status_code == 200) 
    {
        // 创建一个 jsoncpp 的字符读取器生成器,用于从流中解析 JSON
        Json::CharReaderBuilder readerBuilder;
        // 用于存储解析后的 JSON 数据
        Json::Value root;
        // 用于接收解析过程中的错误信息
        std::string errs;
        // 将响应体的字符串包装为输入字符串流,以便 jsoncpp 从流中解析
        std::istringstream iss(response.text);
        // 从流中解析 JSON 数据,结果存入 root,错误信息存入 errs
        if (Json::parseFromStream(readerBuilder, iss, &root, &errs)) 
        {
            // 从解析后的 JSON 中提取 "hits" 下的 "hits" 数组,该数组包含实际匹配的文档列表
            auto hits = root["hits"]["hits"];
            // 输出找到的文档总数(hits 数组的大小)
            std::cout << "共找到 " << hits.size() << " 条文档:" << std::endl;
            // 遍历每个命中的文档
            for (const auto& hit : hits) 
            {
                // 输出文档的 _id(文档唯一标识)、_score(相关性得分)以及 _source(原始文档内容)
                // 使用 toStyledString() 将 JSON 对象格式化为带缩进的字符串,便于阅读
                std::cout << "  ID: " << hit["_id"].asString()
                          << ",得分: " << hit["_score"].asFloat()
                          << ",来源: " << hit["_source"].toStyledString();
            }
        } 
        else 
        {
            // 如果 JSON 解析失败,输出错误信息
            std::cerr << "解析响应失败: " << errs << std::endl;
        }
    } 
    else 
    {
        // 如果 HTTP 状态码不是 200,输出错误状态码
        std::cerr << "搜索失败,HTTP 状态码:" << response.status_code << std::endl;
    }
    return 0;
}

remove.cpp

cpp 复制代码
#include <elasticlient/client.h>
#include <iostream>
#include <cpr/cpr.h>

int main() {
    elasticlient::Client client({"http://127.0.0.1:9200/"});//地址最后一定要加一个斜杠

    // 删除索引 test_index 中 ID 为 1 的文档
    auto response = client.remove("test_index", "_doc", "1");
    /*remove() 方法根据索引、类型和文档 ID 删除文档。
    成功时通常返回 200 或 204。*/

    if (response.status_code >= 200 && response.status_code < 300) {
        std::cout << "文档删除成功!" << std::endl;
    } else {
        std::cerr << "删除失败,HTTP 状态码:" << response.status_code << std::endl;
    }
    return 0;
}

makefile

cpp 复制代码
all: insert search remove
insert: insert.cpp
	g++ -g -std=c++11 $^ -o $@ -lelasticlient -lcpr -ljsoncpp
search: search.cpp
	g++ -std=c++11 $^ -o $@ -lelasticlient -lcpr -ljsoncpp
remove: remove.cpp
	g++ -std=c++11 $^ -o $@ -lelasticlient -lcpr -ljsoncpp

.PHONY: clean
clean:
	rm -f insert search remove

编译测试一下

非常完美!!!

四.对ES 客户端API二次封装

Elasticsearch 原生客户端仅提供了基础的 REST API 调用能力,开发者在使用时需要自行构建复杂的 JSON 请求体,包括索引映射(mapping)的定义、文档的增删改查以及查询 DSL 的组装。这种手动拼接 JSON 的方式不仅繁琐、容易出错,而且难以复用和维护,大大降低了开发效率。

为此,我们对 Elasticsearch 客户端 API 进行了二次封装,旨在将底层的 JSON 构造细节隐藏起来,对外提供一套简洁、直观的接口。使用者无需关心具体的 JSON 格式,只需通过链式调用或生成器模式动态添加字段、设置属性即可完成索引创建、数据插入、查询构造和文档删除等操作。

封装的核心接口

    1. 索引创建(ESIndex)
    1. 数据插入(ESInsert)
    1. 数据删除(ESRemove)
    1. 数据查询(ESSearch)

那么在实现这4个核心接口之前,我们需要先完成下面这2个核心接口

  • 将 Json::Value 对象序列化为字符串
  • 将字符串反序列化为 Json::Value 对象
cpp 复制代码
// 函数:将 Json::Value 对象序列化为字符串
// 参数 val:要序列化的 JSON 对象
// 参数 dst:输出参数,用于存储序列化后的 JSON 字符串
// 返回值:成功返回 true,失败返回 false
bool Serialize(const Json::Value &val, std::string &dst)
{
    // 创建 Json::StreamWriterBuilder 工厂类,用于配置和生成 StreamWriter
    Json::StreamWriterBuilder swb;
    // 设置选项:emitUTF8 为 true,确保生成的 JSON 字符串中 UTF-8 字符不被转义
    swb.settings_["emitUTF8"] = true;//这个是针对JSON的设置
    // 通过 StreamWriterBuilder 创建一个新的 StreamWriter 对象(使用智能指针自动管理)
    std::unique_ptr<Json::StreamWriter> sw(swb.newStreamWriter());
    // 创建一个字符串流,用于接收序列化输出
    std::stringstream ss;
    // 调用 StreamWriter 的 write 方法,将 val 写入到字符串流中
    int ret = sw->write(val, &ss);
    if (ret != 0) {
        // 如果返回值不为 0,表示序列化失败,输出错误信息
        std::cout << "Json反序列化失败!\n";
        return false;
    }
    // 将字符串流的内容赋给输出参数 dst
    dst = ss.str();
    return true;
}

// 函数:将字符串反序列化为 Json::Value 对象
// 参数 src:包含 JSON 数据的字符串
// 参数 val:输出参数,用于存储解析后的 JSON 对象
// 返回值:成功返回 true,失败返回 false
bool UnSerialize(const std::string &src, Json::Value &val)
{
    // 创建 Json::CharReaderBuilder 工厂类,用于配置和生成 CharReader
    Json::CharReaderBuilder crb;
    // 通过 CharReaderBuilder 创建一个新的 CharReader 对象(使用智能指针自动管理)
    std::unique_ptr<Json::CharReader> cr(crb.newCharReader());
    // 用于存储解析过程中的错误信息
    std::string err;
    // 调用 CharReader 的 parse 方法,从字符串 src 中解析 JSON 数据
    // 参数:起始指针、结束指针、输出 Json::Value、错误信息字符串
    bool ret = cr->parse(src.c_str(), src.c_str() + src.size(), &val, &err);
    if (ret == false) {
        // 如果解析失败,输出错误信息
        std::cout << "json反序列化失败: " << err << std::endl;
        return false;
    }
    return true;
}

那么为什么具体这么写?我就不在这里进行讲解了。

4.1. 索引创建(ESIndex)

这块的功能就是创建索引(定义好字段)

构造函数

我们可以借助上面是示例4来讲解我们的编写过程

bash 复制代码
POST /user/_doc   
{
  "settings": {
    "analysis": {
      "analyzer": {
        "ik": {
          "tokenizer": "ik_max_word"
        }
      }
    }
  },
  "mappings": {
    "dynamic": true,
    "properties": {
      "昵称": {
        "type": "text",
        "analyzer": "ik_max_word"
      },
      "用户ID": {
        "type": "keyword",
        "analyzer": "standard"
      },
      "手机号": {
        "type": "keyword",
        "analyzer": "standard"
      },
      "描述": {
        "type": "text",
        "enabled": false
      },
      "头像ID": {
        "type": "keyword",
        "enabled": false
      }
    }
  }
}

如果我们要想写构造函数,那么就是适用于任何索引的,那么在我们这里,好像也就是settings字段是不会进行变化的,那么我们就把这一部分的编写放到构造函数里面去。

我们先看看构造函数

cpp 复制代码
// 构造函数:初始化索引名称、文档类型,并设置默认的 IK 分词器配置
    // 参数 client:指向 elasticlient::Client 的共享指针,用于发送 HTTP 请求
    // 参数 name:索引名称
    // 参数 type:文档类型,默认为 "_doc"(Elasticsearch 7.x 后推荐)
    ESIndex(std::shared_ptr<elasticlient::Client> &client, 
        const std::string &name, 
        const std::string &type = "_doc"):
        _name(name), _type(type), _client(client) 
    {
        // 构建索引设置(settings)部分,配置 IK 分词器
        Json::Value analysis;   // analysis 对象
        Json::Value analyzer;   // analyzer 对象
        Json::Value ik;         // ik 分词器对象
        Json::Value tokenizer;  // tokenizer 对象
        // 设置分词器为 ik_max_word(IK 分词器的最大粒度分词模式)
        tokenizer["tokenizer"] = "ik_max_word";
        // 将 tokenizer 赋值给 ik 对象下的 "ik" 字段
        ik["ik"] = tokenizer;
        // 将 ik 对象赋值给 analyzer 对象下的 "analyzer" 字段
        analyzer["analyzer"] = ik;
        // 将 analyzer 对象赋值给 analysis 对象下的 "analysis" 字段
        analysis["analysis"] = analyzer;
        // 将 analysis 对象放入 _index 的 "settings" 字段中
        _index["settings"] = analysis;
    }

构造函数的目的是在 Elasticsearch 中配置索引的 IK 分词器,构造出的结构如下图所示

cpp 复制代码
{
  "settings": {
    "analysis": {
      "analyzer": {
        "ik": {
          "tokenizer": "ik_max_word"
        }
      }
    }
  }
}

这段 JSON 在 Kibana 中对应创建一个索引时写在 settings 里的内容。它的意思是:定义了一个名为 ik 的分析器(analyzer),这个分析器使用 ik_max_word 分词器(这是 IK 分词插件提供的最大粒度分词模式)。之后你在字段定义中指定 "analyzer": "ik" 时,就会使用这个分析器对文本进行分词。

这个结构就是最终存放到了下面这个成员变量里面

cpp 复制代码
Json::Value _index;                       // 完整的索引定义(包含 settings 和 mappings)

properties字段的添加

我们接着来看看上面的示例4

bash 复制代码
POST /user/_doc   
{
  "settings": {
    "analysis": {
      "analyzer": {
        "ik": {
          "tokenizer": "ik_max_word"
        }
      }
    }
  },
  "mappings": {
    "dynamic": true,
    "properties": {
      "昵称": {
        "type": "text",
        "analyzer": "ik_max_word"
      },
      "用户ID": {
        "type": "keyword",
        "analyzer": "standard"
      },
      "手机号": {
        "type": "keyword",
        "analyzer": "standard"
      },
      "描述": {
        "type": "text",
        "enabled": false
      },
      "头像ID": {
        "type": "keyword",
        "enabled": false
      }
    }
  }
}

在封装 Elasticsearch 索引创建功能时,我们深入理解了索引映射(mapping)的底层结构。通过分析 Elasticsearch 的索引定义可知,所有字段的真正定义都集中在 mappings 对象下的 properties 字段中------每个字段的名称、类型、分词器、索引选项等属性均以键值对形式存储于其中。

因此,为了提供最直观、最贴近 Elasticsearch 核心概念的接口,我们围绕 mappings.properties 设计了字段添加的接口。

这一设计的核心思想是:**让开发者直接针对"字段"这一核心单元进行配置,而无需关心外层的 JSON 嵌套。**用户通过链式调用添加字段时,实际上是在向内部的 _properties 对象中填充数据,最终这些数据会被准确放置到生成的索引定义 JSON 的 mappings.properties 路径下。这种设计不仅符合 Elasticsearch 的原生语义,还使得接口的使用方式与索引的实际结构一一对应,降低了学习成本。

这个结构最终都是存放到了下面这个成员变量里面

复制代码
Json::Value _properties;                  // 存储字段定义的 JSON 对象(mapping 的 properties 部分)

我们看看

cpp 复制代码
// 成员函数 append:向索引的 properties 中添加一个字段定义(链式调用)
    // 参数 key:字段名称
    // 参数 type:字段类型,默认为 "text"
    // 参数 analyzer:分词器名称,默认为 "ik_max_word"
    // 参数 enabled:是否启用该字段(若为 false,则该字段不会被索引和存储)
    // 返回值:返回当前对象的引用,支持链式调用
    ESIndex& append(const std::string &key, 
        const std::string &type = "text", 
        const std::string &analyzer = "ik_max_word", 
        bool enabled = true) 
    {
        Json::Value fields;
        fields["type"] = type;          // 设置字段类型
        fields["analyzer"] = analyzer;  // 设置分词器(仅对 text 类型有效)
        if (enabled == false) 
        {
            // 如果 enabled 为 false,则添加 "enabled": false 字段,表示该字段不参与索引和存储
            fields["enabled"] = enabled;
        }
        // 将该字段定义添加到 _properties 对象中,键为字段名
        _properties[key] = fields;
        // 返回当前对象引用,以便链式调用
        return *this;
    }

这里采用了链式调用风格

关键就在于 return *this。

  • this 是指向当前对象的指针。
  • *this 就是当前对象本身。
  • 函数返回类型是 ESIndex&,即当前对象的引用,所以调用者得到的就是原来的那个对象。

这样一来,当你调用一次 append 后,返回值仍然是原来的 ESIndex 对象,于是可以紧接着再次调用它的 append 方法,如此反复,就形成了链式调用。

举个例子

假设你想创建一个索引,其中包含三个字段:标题、内容和浏览量。用链式调用可以这样写:

cpp 复制代码
ESIndex index(client, "articles");          // 创建 ESIndex 对象
index.append("title", "text", "ik_max_word")
     .append("content", "text", "ik_max_word")
     .append("views", "integer")
     .create();                              // 最后创建索引

执行过程是这样的:

  • 第一个 append("title", ...) 执行完后,返回 index 本身。
  • 接着对返回的 index 调用 append("content", ...),再次返回 index。
  • 再对 index 调用 append("views", ...),返回 index。
  • 最后调用 create(),完成索引创建。

如果不使用链式调用,你就得写成多行:

cpp 复制代码
index.append("title", "text", "ik_max_word");
index.append("content", "text", "ik_max_word");
index.append("views", "integer");
index.create();

链式调用的好处是代码更紧凑、可读性更强,尤其适合这种需要连续添加多个配置项的场景。很多库(如建造者模式、查询构造器)都广泛采用这种设计。

发起索引创建请求

我们还是来看示例4

bash 复制代码
POST /user/_doc   
{
  "settings": {
    "analysis": {
      "analyzer": {
        "ik": {
          "tokenizer": "ik_max_word"
        }
      }
    }
  },
  "mappings": {
    "dynamic": true,
    "properties": {
      "昵称": {
        "type": "text",
        "analyzer": "ik_max_word"
      },
      "用户ID": {
        "type": "keyword",
        "analyzer": "standard"
      },
      "手机号": {
        "type": "keyword",
        "analyzer": "standard"
      },
      "描述": {
        "type": "text",
        "enabled": false
      },
      "头像ID": {
        "type": "keyword",
        "enabled": false
      }
    }
  }
}

按照上面的步骤,我们现在已经有了settings字段,mappings.properties字段。

那么我们接下来就来组织剩余的字段,并且向服务器发起创建文档的请求。

在创建文档的过程中,如果索引不存在,那么ES就会自动创建索引。

cpp 复制代码
// 成员函数 create:根据已添加的字段定义,创建索引(实际发送请求到 Elasticsearch)
    // 参数 index_id:可选的索引文档 ID,但这里可能用于某些特殊场景,默认为 "default_index_id"
    // 注意:在 elasticlient 的 index 方法中,如果指定了 ID,会创建或更新一个文档,而不是创建索引本身。
    // 但是如果索引不存在,那么就会自动触发索引的自动创建(同时设置 mapping),
    bool create(const std::string &index_id = "default_index_id") 
    {
        // 构建 mappings 部分
        Json::Value mappings;
        mappings["dynamic"] = true;                 // 允许动态添加字段(未在 mapping 中定义的字段也会被索引)
        mappings["properties"] = _properties;       // 将字段定义赋值给 properties
        _index["mappings"] = mappings;               // 将 mappings 对象放入 _index 的 "mappings" 字段

        // 将整个 _index 对象序列化为字符串
        std::string body;
        bool ret = Serialize(_index, body);
        if (ret == false) {
            LOG_ERROR("索引序列化失败!");
            return false;
        }
        LOG_DEBUG("{}", body);  // 输出序列化后的请求体,便于调试

        // 发起索引创建请求(实际上调用的是 index 方法,可能期望创建索引并插入一个文档)
        try 
        {
            // 调用 client->index 方法,传入索引名称、类型、文档 ID 和请求体
            // 注意:这实际上是在创建或更新一个文档,而不是纯粹的创建索引。
            // 如果索引不存在,Elasticsearch 会自动创建索引并使用请求体中的 mapping 作为索引的 mapping。
            auto rsp = _client->index(_name, _type, index_id, body);
            // 检查响应状态码是否为 2xx(成功)
            if (rsp.status_code < 200 || rsp.status_code >= 300) {
                LOG_ERROR("创建ES索引 {} 失败,响应状态码异常: {}", _name, rsp.status_code);
                return false;
            }
        } 
        catch(std::exception &e) 
        {
            // 捕获异常并记录错误
            LOG_ERROR("创建ES索引 {} 失败: {}", _name, e.what());
            return false;
        }
        return true;
    }

ESIndex::create 函数通过 _client->index 并指定了一个默认的 index_id,这相当于在创建索引的同时还插入了一条文档(ID 为 "default_index_id")。

这种做法虽然利用了自动创建机制,但不太规范:通常创建索引应该使用 Elasticsearch 的 PUT /{index} API(对应 client->createIndex(...) 之类的方法,如果 elasticlient 提供的话)。

不过在当前代码中,它也能达到目的------只要请求体中包含正确的 settings 和 mappings,Elasticsearch 就会按照这个结构创建索引。

完整代码

cpp 复制代码
// 类 ESIndex:用于定义和创建 Elasticsearch 索引的映射(mapping)和设置(settings)
class ESIndex 
{
public:
    // 构造函数:初始化索引名称、文档类型,并设置默认的 IK 分词器配置
    // 参数 client:指向 elasticlient::Client 的共享指针,用于发送 HTTP 请求
    // 参数 name:索引名称
    // 参数 type:文档类型,默认为 "_doc"(Elasticsearch 7.x 后推荐)
    ESIndex(std::shared_ptr<elasticlient::Client> &client, 
        const std::string &name, 
        const std::string &type = "_doc"):
        _name(name), _type(type), _client(client) 
    {
        // 构建索引设置(settings)部分,配置 IK 分词器
        Json::Value analysis;   // analysis 对象
        Json::Value analyzer;   // analyzer 对象
        Json::Value ik;         // ik 分词器对象
        Json::Value tokenizer;  // tokenizer 对象
        // 设置分词器为 ik_max_word(IK 分词器的最大粒度分词模式)
        tokenizer["tokenizer"] = "ik_max_word";
        // 将 tokenizer 赋值给 ik 对象下的 "ik" 字段
        ik["ik"] = tokenizer;
        // 将 ik 对象赋值给 analyzer 对象下的 "analyzer" 字段
        analyzer["analyzer"] = ik;
        // 将 analyzer 对象赋值给 analysis 对象下的 "analysis" 字段
        analysis["analysis"] = analyzer;
        // 将 analysis 对象放入 _index 的 "settings" 字段中
        _index["settings"] = analysis;
    }

    // 成员函数 append:向索引的 properties 中添加一个字段定义(链式调用)
    // 参数 key:字段名称
    // 参数 type:字段类型,默认为 "text"
    // 参数 analyzer:分词器名称,默认为 "ik_max_word"
    // 参数 enabled:是否启用该字段(若为 false,则该字段不会被索引和存储)
    // 返回值:返回当前对象的引用,支持链式调用
    ESIndex& append(const std::string &key, 
        const std::string &type = "text", 
        const std::string &analyzer = "ik_max_word", 
        bool enabled = true) 
    {
        Json::Value fields;
        fields["type"] = type;          // 设置字段类型
        fields["analyzer"] = analyzer;  // 设置分词器(仅对 text 类型有效)
        if (enabled == false) 
        {
            // 如果 enabled 为 false,则添加 "enabled": false 字段,表示该字段不参与索引和存储
            fields["enabled"] = enabled;
        }
        // 将该字段定义添加到 _properties 对象中,键为字段名
        _properties[key] = fields;
        // 返回当前对象引用,以便链式调用
        return *this;
    }

    // 成员函数 create:根据已添加的字段定义,创建索引(实际发送请求到 Elasticsearch)
    // 参数 index_id:可选的索引文档 ID,但这里可能用于某些特殊场景,默认为 "default_index_id"
    // 注意:在 elasticlient 的 index 方法中,如果指定了 ID,会创建或更新一个文档,而不是创建索引本身。
    // 但是如果索引不存在,那么就会自动触发索引的自动创建(同时设置 mapping),
    bool create(const std::string &index_id = "default_index_id") 
    {
        // 构建 mappings 部分
        Json::Value mappings;
        mappings["dynamic"] = true;                 // 允许动态添加字段(未在 mapping 中定义的字段也会被索引)
        mappings["properties"] = _properties;       // 将字段定义赋值给 properties
        _index["mappings"] = mappings;               // 将 mappings 对象放入 _index 的 "mappings" 字段

        // 将整个 _index 对象序列化为字符串
        std::string body;
        bool ret = Serialize(_index, body);
        if (ret == false) {
            LOG_ERROR("索引序列化失败!");
            return false;
        }
        LOG_DEBUG("{}", body);  // 输出序列化后的请求体,便于调试

        // 发起索引创建请求(实际上调用的是 index 方法,可能期望创建索引并插入一个文档)
        try 
        {
            // 调用 client->index 方法,传入索引名称、类型、文档 ID 和请求体
            // 注意:这实际上是在创建或更新一个文档,而不是纯粹的创建索引。
            // 如果索引不存在,Elasticsearch 会自动创建索引并使用请求体中的 mapping 作为索引的 mapping。
            auto rsp = _client->index(_name, _type, index_id, body);
            // 检查响应状态码是否为 2xx(成功)
            if (rsp.status_code < 200 || rsp.status_code >= 300) {
                LOG_ERROR("创建ES索引 {} 失败,响应状态码异常: {}", _name, rsp.status_code);
                return false;
            }
        } 
        catch(std::exception &e) 
        {
            // 捕获异常并记录错误
            LOG_ERROR("创建ES索引 {} 失败: {}", _name, e.what());
            return false;
        }
        return true;
    }

private:
    std::string _name;                      // 索引名称
    std::string _type;                       // 文档类型(通常为 "_doc")
    Json::Value _properties;                  // 存储字段定义的 JSON 对象(mapping 的 properties 部分)
    Json::Value _index;                       // 完整的索引定义(包含 settings 和 mappings)
    std::shared_ptr<elasticlient::Client> _client; // 共享的 elasticlient 客户端指针
};

那么我们就实现了这样的一个文档+索引创建。

使用方法对比

如果不调用 append 就直接调用 create,和调用 append 添加了字段后再调用 create,两者最主要的区别在于:最终创建的索引中是否包含预定义的字段映射(mapping properties)。

1. 不调用 append 直接 create

_properties 为空({})。

构建的请求体中,mappings 部分如下:

cpp 复制代码
{
  "mappings": {
    "dynamic": true,
    "properties": {}
  },
  "settings": { ... }  // IK 分词器设置
}

结果:创建的索引 没有任何预定义字段,但由于 dynamic 为 true,后续插入文档时,Elasticsearch 会根据文档内容动态推断字段类型并自动添加到 mapping 中。

缺点:动态推断的类型可能不符合预期(例如字符串可能被同时映射为 text 和 keyword,但不会自动应用 IK 分词器),且无法对特定字段设置分词器、是否索引等精细控制。

2. 调用 append 添加字段后再 create

通过多次调用 append,_properties 中会积累多个字段定义,例如:

cpp 复制代码
index.append("title", "text", "ik_max_word")
     .append("content", "text", "ik_max_word")
     .append("views", "integer");

此时 _properties 大致为:

cpp 复制代码
{
  "title": {"type": "text", "analyzer": "ik_max_word"},
  "content": {"type": "text", "analyzer": "ik_max_word"},
  "views": {"type": "integer"}
}

最终请求体的 mappings.properties 就会包含这些字段定义。

结果:创建的索引 按照你定义的字段结构建立映射,每个字段都明确了类型、分词器(对 text 类型)、是否启用等。之后插入文档时,Elasticsearch 会严格按照这些定义处理字段,确保中文分词生效,数值字段正确映射等。

4.2. 数据插入(ESInsert)

这一块的功能就是向已存在的索引中插入文档数据。

那么我们这里是使用下面这种方式来进行插入的

bash 复制代码
POST /my_users/_doc
{
  "name": "张三",
  "age": 25,
  "email": "zhangsan@example.com"
}

那么我们只需要组织好括号里面的数据结构即可。

那其实挺简单的,就使用一个Json::Value来保存即可

cpp 复制代码
// 模板成员函数 append:向待插入的文档中添加字段(链式调用)
    // 参数 key:字段名
    // 参数 val:字段值,可以是任意类型(Json::Value 支持的类型)
    // 返回值:返回当前对象的引用,支持链式调用
    template<typename T>
    ESInsert &append(const std::string &key, const T &val){
        _item[key] = val;   // 将键值对存入 _item 对象
        return *this;
    }

这里也是采用了链式调用

我们拿一个Json::Value对象来存储这个样式

cpp 复制代码
Json::Value _item;          // 存储待插入文档的 JSON 对象

有了这个,我们直接发起数据插入请求即可。

完整代码

cpp 复制代码
// 类 ESInsert:用于向 Elasticsearch 索引中插入或更新文档
class ESInsert 
{
public:
    // 构造函数:初始化索引名称、文档类型和客户端指针
    ESInsert(std::shared_ptr<elasticlient::Client> &client, 
        const std::string &name, //索引名称
        const std::string &type = "_doc"):
        _name(name), 
        _type(type),
         _client(client){}

    // 模板成员函数 append:向待插入的文档中添加字段(链式调用)
    // 参数 key:字段名
    // 参数 val:字段值,可以是任意类型(Json::Value 支持的类型)
    // 返回值:返回当前对象的引用,支持链式调用
    template<typename T>
    ESInsert &append(const std::string &key, const T &val){
        _item[key] = val;   // 将键值对存入 _item 对象
        return *this;
    }

    // 成员函数 insert:将构建的文档插入到 Elasticsearch 中
    // 参数 id:可选,文档的 ID。如果为空字符串,Elasticsearch 会自动生成 ID。
    // 返回值:成功返回 true,失败返回 false
    bool insert(const std::string id = "") 
    {
        // 将 _item 序列化为 JSON 字符串
        std::string body;
        bool ret = Serialize(_item, body);
        if (ret == false) 
        {
            LOG_ERROR("索引序列化失败!");
            return false;
        }
        LOG_DEBUG("{}", body);  // 输出请求体,便于调试

        // 发起索引文档请求
        try 
        {
            // 调用 client->index 方法,传入索引名、类型、文档 ID 和请求体
            auto rsp = _client->index(_name, _type, id, body);
            // 检查响应状态码
            if (rsp.status_code < 200 || rsp.status_code >= 300) {
                LOG_ERROR("新增数据 {} 失败,响应状态码异常: {}", body, rsp.status_code);
                return false;
            }
        } 
        catch(std::exception &e) 
        {
            LOG_ERROR("新增数据 {} 失败: {}", body, e.what());
            return false;
        }
        return true;
    }

private:
    std::string _name;          // 索引名称
    std::string _type;          // 文档类型
    Json::Value _item;          // 存储待插入文档的 JSON 对象
    std::shared_ptr<elasticlient::Client> _client; // 共享的客户端指针
};

4.3. 数据删除(ESRemove)

该封装类简化了文档删除操作,仅需提供文档 ID 即可。

  • 按 ID 删除:封装了底层的删除请求,自动处理索引名、文档类型和 ID 的拼接。

  • 结果校验:检查 HTTP 响应状态码,确保删除成功或捕获异常并反馈。

cpp 复制代码
// 类 ESRemove:用于从 Elasticsearch 索引中删除文档
class ESRemove 
{
public:
    // 构造函数:初始化索引名称、文档类型和客户端指针
    ESRemove(std::shared_ptr<elasticlient::Client> &client, 
        const std::string &name, 
        const std::string &type = "_doc"):
        _name(name), _type(type), _client(client){}

    // 成员函数 remove:根据文档 ID 删除文档
    // 参数 id:要删除的文档 ID
    // 返回值:成功返回 true,失败返回 false
    bool remove(const std::string &id) 
    {
        try 
        {
            // 调用 client->remove 方法,传入索引名、类型和文档 ID
            auto rsp = _client->remove(_name, _type, id);
            // 检查响应状态码
            if (rsp.status_code < 200 || rsp.status_code >= 300) {
                LOG_ERROR("删除数据 {} 失败,响应状态码异常: {}", id, rsp.status_code);
                return false;
            }
        } catch(std::exception &e) {
            LOG_ERROR("删除数据 {} 失败: {}", id, e.what());
            return false;
        }
        return true;
    }

private:
    std::string _name;          // 索引名称
    std::string _type;          // 文档类型
    std::shared_ptr<elasticlient::Client> _client; // 共享的客户端指针
};

这个说实话没什么好说的

4.4. 数据查询(ESSearch)

我们还是拿上面的示例3来作为基板

javascript 复制代码
GET /products/_search
{
  "query": {
    "bool": {
      "must": [
        { "match": { "name": "手机" } }//分词
      ],
      "filter": [
        { "range": { "price": { "gte": 3000, "lte": 6000 } } }
      ],
      "should": [
        { "term": { "brand": "Apple" } },//不分词
        { "term": { "brand": "Huawei" } }//不分词
      ],
      "must_not": [
        { "term": { "stock": 0 } }//不分词
      ]
    }
  }
}

首先看看这个

must_not字段

我们这里的must_not就固定死了,使用term(不分词模式)

javascript 复制代码
 // 成员函数 append_must_not_terms:向 bool 查询的 must_not 子句中添加一个 terms 条件
    // terms 用于精确匹配多个值(相当于 SQL 的 IN)
    // 参数 key:字段名
    // 参数 vals:要匹配的值列表
    // 返回值:返回当前对象的引用,支持链式调用
    ESSearch& append_must_not_terms(const std::string &key, const std::vector<std::string> &vals) 
    {
        Json::Value fields;
        for (const auto& val : vals)
        {
            fields[key].append(val);   // 构造 { "field": [val1, val2, ...] } 形式的 JSON
        }
        Json::Value terms;
        terms["terms"] = fields;       // 包装成 { "terms": { "field": [...] } }
        _must_not.append(terms);       // 将该条件添加到 must_not 数组中
        return *this;
    }

注意这里也是链式调用结构

里面的内容全部存放到了下面这个成员变量里面

javascript 复制代码
Json::Value _must_not;                   // 存储 must_not 子句的数组

should字段

我们这里的should字段也是固定死了使用match模式(分词模式)

javascript 复制代码
// 成员函数 append_should_match:向 bool 查询的 should 子句中添加一个 match 条件
    // match 用于全文搜索,会对查询文本进行分词
    // 参数 key:字段名
    // 参数 val:要匹配的文本
    // 返回值:返回当前对象的引用
    ESSearch& append_should_match(const std::string &key, const std::string &val) {
        Json::Value field;
        field[key] = val;               // 构造 { "field": "value" }
        Json::Value match;
        match["match"] = field;         // 包装成 { "match": { "field": "value" } }
        _should.append(match);          // 添加到 should 数组
        return *this;
    }

也是链式调用结构

javascript 复制代码
Json::Value _should;                     // 存储 should 子句的数组

must字段

那么针对must字段,我们提供了2种接口

  • term模式(不分词模式)
  • match模式(分词模式)
javascript 复制代码
// 成员函数 append_must_term:向 bool 查询的 must 子句中添加一个 term 条件
    // term 用于精确匹配单个值(不分析查询词)
    // 参数 key:字段名
    // 参数 val:要匹配的值
    // 返回值:返回当前对象的引用
    ESSearch& append_must_term(const std::string &key, const std::string &val) {
        Json::Value field;
        field[key] = val;               // 构造 { "field": "value" }
        Json::Value term;
        term["term"] = field;           // 包装成 { "term": { "field": "value" } }
        _must.append(term);             // 添加到 must 数组
        return *this;
    }

    // 成员函数 append_must_match:向 bool 查询的 must 子句中添加一个 match 条件
    // 参数 key:字段名
    // 参数 val:要匹配的文本
    // 返回值:返回当前对象的引用
    ESSearch& append_must_match(const std::string &key, const std::string &val){
        Json::Value field;
        field[key] = val;               // 构造 { "field": "value" }
        Json::Value match;
        match["match"] = field;         // 包装成 { "match": { "field": "value" } }
        _must.append(match);            // 添加到 must 数组
        return *this;
    }

这里都是链式调用结构。

它们都是存放到了下面这个成员变量里面

javascript 复制代码
Json::Value _must;                       // 存储 must 子句的数组

发起请求

我们还是拿示例3的例子来组织我们的代码

javascript 复制代码
GET /products/_search
{
  "query": {
    "bool": {
      "must": [
        { "match": { "name": "手机" } }//分词
      ],
      "filter": [
        { "range": { "price": { "gte": 3000, "lte": 6000 } } }
      ],
      "should": [
        { "term": { "brand": "Apple" } },//不分词
        { "term": { "brand": "Huawei" } }//不分词
      ],
      "must_not": [
        { "term": { "stock": 0 } }//不分词
      ]
    }
  }
}

那么写起来我们的代码也是很简单的

javascript 复制代码
// 成员函数 search:执行搜索,返回命中的文档列表
    // 返回值:Json::Value 类型,包含 "hits.hits" 数组,即所有匹配的文档
    Json::Value search()
    {
        // 构建 bool 查询的条件对象
        Json::Value cond;
        if (_must_not.empty() == false) cond["must_not"] = _must_not;  // 添加 must_not 条件
        if (_should.empty() == false) cond["should"] = _should;        // 添加 should 条件
        if (_must.empty() == false) cond["must"] = _must;              // 添加 must 条件

        // 构建查询 DSL
        Json::Value query;
        query["bool"] = cond;           // 将条件封装到 bool 查询中
        Json::Value root;
        root["query"] = query;          // 完整的查询 DSL

        // 将查询 DSL 序列化为字符串
        std::string body;
        bool ret = Serialize(root, body);
        if (ret == false) {
            LOG_ERROR("索引序列化失败!");
            return Json::Value();        // 返回空 Json::Value
        }
        LOG_DEBUG("{}", body);           // 输出请求体,便于调试

        // 发起搜索请求
        cpr::Response rsp;
        try {
            // 调用 client->search 方法,传入索引名、类型和请求体
            rsp = _client->search(_name, _type, body);
            // 检查响应状态码
            if (rsp.status_code < 200 || rsp.status_code >= 300) {
                LOG_ERROR("检索数据 {} 失败,响应状态码异常: {}", body, rsp.status_code);
                return Json::Value();
            }
        } catch(std::exception &e) {
            LOG_ERROR("检索数据 {} 失败: {}", body, e.what());
            return Json::Value();
        }

        // 对响应正文进行反序列化
        LOG_DEBUG("检索响应正文: [{}]", rsp.text);
        Json::Value json_res;
        ret = UnSerialize(rsp.text, json_res);
        if (ret == false) {
            LOG_ERROR("检索数据 {} 结果反序列化失败", rsp.text);
            return Json::Value();
        }

        // 返回 hits.hits 数组,即匹配的文档列表
        return json_res["hits"]["hits"];
    }

至于返回的响应结构,我们看看示例3的

javascript 复制代码
{
  "took": 10,
  "timed_out": false,
  "_shards": {
    "total": 5,
    "successful": 5,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 100,
      "relation": "eq"
    },
    "max_score": 1.2,
    "hits": [
      {
        "_index": "products",
        "_type": "_doc",
        "_id": "1",
        "_score": 1.2,
        "_source": {
          "name": "苹果手机",
          "brand": "Apple",
          "price": 6000,
          "stock": 10
        }
      },
      {
        "_index": "products",
        "_type": "_doc",
        "_id": "2",
        "_score": 0.9,
        "_source": {
          "name": "华为手机",
          "brand": "Huawei",
          "price": 5000,
          "stock": 5
        }
      }
    ]
  }
}

这个 JSON 结构是解析 Elasticsearch 搜索结果的基础,你需要从中提取 hits.hits 数组来获取文档数据。

完整代码

javascript 复制代码
// 类 ESSearch:用于构建复杂的布尔查询并执行搜索
class ESSearch 
{
public:
    // 构造函数:初始化索引名称、文档类型和客户端指针
    ESSearch(std::shared_ptr<elasticlient::Client> &client, 
        const std::string &name, 
        const std::string &type = "_doc"):
        _name(name), _type(type), _client(client){}

    // 成员函数 append_must_not_terms:向 bool 查询的 must_not 子句中添加一个 terms 条件
    // terms 用于精确匹配多个值(相当于 SQL 的 IN)
    // 参数 key:字段名
    // 参数 vals:要匹配的值列表
    // 返回值:返回当前对象的引用,支持链式调用
    ESSearch& append_must_not_terms(const std::string &key, const std::vector<std::string> &vals) 
    {
        Json::Value fields;
        for (const auto& val : vals)
        {
            fields[key].append(val);   // 构造 { "field": [val1, val2, ...] } 形式的 JSON
        }
        Json::Value terms;
        terms["terms"] = fields;       // 包装成 { "terms": { "field": [...] } }
        _must_not.append(terms);       // 将该条件添加到 must_not 数组中
        return *this;
    }

    // 成员函数 append_should_match:向 bool 查询的 should 子句中添加一个 match 条件
    // match 用于全文搜索,会对查询文本进行分词
    // 参数 key:字段名
    // 参数 val:要匹配的文本
    // 返回值:返回当前对象的引用
    ESSearch& append_should_match(const std::string &key, const std::string &val) {
        Json::Value field;
        field[key] = val;               // 构造 { "field": "value" }
        Json::Value match;
        match["match"] = field;         // 包装成 { "match": { "field": "value" } }
        _should.append(match);          // 添加到 should 数组
        return *this;
    }

    // 成员函数 append_must_term:向 bool 查询的 must 子句中添加一个 term 条件
    // term 用于精确匹配单个值(不分析查询词)
    // 参数 key:字段名
    // 参数 val:要匹配的值
    // 返回值:返回当前对象的引用
    ESSearch& append_must_term(const std::string &key, const std::string &val) {
        Json::Value field;
        field[key] = val;               // 构造 { "field": "value" }
        Json::Value term;
        term["term"] = field;           // 包装成 { "term": { "field": "value" } }
        _must.append(term);             // 添加到 must 数组
        return *this;
    }

    // 成员函数 append_must_match:向 bool 查询的 must 子句中添加一个 match 条件
    // 参数 key:字段名
    // 参数 val:要匹配的文本
    // 返回值:返回当前对象的引用
    ESSearch& append_must_match(const std::string &key, const std::string &val){
        Json::Value field;
        field[key] = val;               // 构造 { "field": "value" }
        Json::Value match;
        match["match"] = field;         // 包装成 { "match": { "field": "value" } }
        _must.append(match);            // 添加到 must 数组
        return *this;
    }

    // 成员函数 search:执行搜索,返回命中的文档列表
    // 返回值:Json::Value 类型,包含 "hits.hits" 数组,即所有匹配的文档
    Json::Value search()
    {
        // 构建 bool 查询的条件对象
        Json::Value cond;
        if (_must_not.empty() == false) cond["must_not"] = _must_not;  // 添加 must_not 条件
        if (_should.empty() == false) cond["should"] = _should;        // 添加 should 条件
        if (_must.empty() == false) cond["must"] = _must;              // 添加 must 条件

        // 构建查询 DSL
        Json::Value query;
        query["bool"] = cond;           // 将条件封装到 bool 查询中
        Json::Value root;
        root["query"] = query;          // 完整的查询 DSL

        // 将查询 DSL 序列化为字符串
        std::string body;
        bool ret = Serialize(root, body);
        if (ret == false) {
            LOG_ERROR("索引序列化失败!");
            return Json::Value();        // 返回空 Json::Value
        }
        LOG_DEBUG("{}", body);           // 输出请求体,便于调试

        // 发起搜索请求
        cpr::Response rsp;
        try {
            // 调用 client->search 方法,传入索引名、类型和请求体
            rsp = _client->search(_name, _type, body);
            // 检查响应状态码
            if (rsp.status_code < 200 || rsp.status_code >= 300) {
                LOG_ERROR("检索数据 {} 失败,响应状态码异常: {}", body, rsp.status_code);
                return Json::Value();
            }
        } catch(std::exception &e) {
            LOG_ERROR("检索数据 {} 失败: {}", body, e.what());
            return Json::Value();
        }

        // 对响应正文进行反序列化
        LOG_DEBUG("检索响应正文: [{}]", rsp.text);
        Json::Value json_res;
        ret = UnSerialize(rsp.text, json_res);
        if (ret == false) {
            LOG_ERROR("检索数据 {} 结果反序列化失败", rsp.text);
            return Json::Value();
        }

        // 返回 hits.hits 数组,即匹配的文档列表
        return json_res["hits"]["hits"];
    }

private:
    std::string _name;                      // 索引名称
    std::string _type;                      // 文档类型
    Json::Value _must_not;                   // 存储 must_not 子句的数组
    Json::Value _should;                     // 存储 should 子句的数组
    Json::Value _must;                       // 存储 must 子句的数组
    std::shared_ptr<elasticlient::Client> _client; // 共享的客户端指针
};

4.5.完整代码+测试

icsearch.hpp

cpp 复制代码
#pragma once

#include <elasticlient/client.h>
#include <cpr/cpr.h>
#include <json/json.h>
#include <iostream>
#include <memory>
#include "logger.hpp"

// 定义命名空间 IMS,封装所有 Elasticsearch 相关操作
namespace IMS
{

// 函数:将 Json::Value 对象序列化为字符串
// 参数 val:要序列化的 JSON 对象
// 参数 dst:输出参数,用于存储序列化后的 JSON 字符串
// 返回值:成功返回 true,失败返回 false
bool Serialize(const Json::Value &val, std::string &dst)
{
    // 创建 Json::StreamWriterBuilder 工厂类,用于配置和生成 StreamWriter
    Json::StreamWriterBuilder swb;
    // 设置选项:emitUTF8 为 true,确保生成的 JSON 字符串中 UTF-8 字符不被转义
    swb.settings_["emitUTF8"] = true;//这个是针对JSON的设置
    // 通过 StreamWriterBuilder 创建一个新的 StreamWriter 对象(使用智能指针自动管理)
    std::unique_ptr<Json::StreamWriter> sw(swb.newStreamWriter());
    // 创建一个字符串流,用于接收序列化输出
    std::stringstream ss;
    // 调用 StreamWriter 的 write 方法,将 val 写入到字符串流中
    int ret = sw->write(val, &ss);
    if (ret != 0) {
        // 如果返回值不为 0,表示序列化失败,输出错误信息
        std::cout << "Json反序列化失败!\n";
        return false;
    }
    // 将字符串流的内容赋给输出参数 dst
    dst = ss.str();
    return true;
}

// 函数:将字符串反序列化为 Json::Value 对象
// 参数 src:包含 JSON 数据的字符串
// 参数 val:输出参数,用于存储解析后的 JSON 对象
// 返回值:成功返回 true,失败返回 false
bool UnSerialize(const std::string &src, Json::Value &val)
{
    // 创建 Json::CharReaderBuilder 工厂类,用于配置和生成 CharReader
    Json::CharReaderBuilder crb;
    // 通过 CharReaderBuilder 创建一个新的 CharReader 对象(使用智能指针自动管理)
    std::unique_ptr<Json::CharReader> cr(crb.newCharReader());
    // 用于存储解析过程中的错误信息
    std::string err;
    // 调用 CharReader 的 parse 方法,从字符串 src 中解析 JSON 数据
    // 参数:起始指针、结束指针、输出 Json::Value、错误信息字符串
    bool ret = cr->parse(src.c_str(), src.c_str() + src.size(), &val, &err);
    if (ret == false) {
        // 如果解析失败,输出错误信息
        std::cout << "json反序列化失败: " << err << std::endl;
        return false;
    }
    return true;
}

// 类 ESIndex:用于定义和创建 Elasticsearch 索引的映射(mapping)和设置(settings)
class ESIndex 
{
public:
    // 构造函数:初始化索引名称、文档类型,并设置默认的 IK 分词器配置
    // 参数 client:指向 elasticlient::Client 的共享指针,用于发送 HTTP 请求
    // 参数 name:索引名称
    // 参数 type:文档类型,默认为 "_doc"(Elasticsearch 7.x 后推荐)
    ESIndex(std::shared_ptr<elasticlient::Client> &client, 
        const std::string &name, 
        const std::string &type = "_doc"):
        _name(name), _type(type), _client(client) 
    {
        // 构建索引设置(settings)部分,配置 IK 分词器
        Json::Value analysis;   // analysis 对象
        Json::Value analyzer;   // analyzer 对象
        Json::Value ik;         // ik 分词器对象
        Json::Value tokenizer;  // tokenizer 对象
        // 设置分词器为 ik_max_word(IK 分词器的最大粒度分词模式)
        tokenizer["tokenizer"] = "ik_max_word";
        // 将 tokenizer 赋值给 ik 对象下的 "ik" 字段
        ik["ik"] = tokenizer;
        // 将 ik 对象赋值给 analyzer 对象下的 "analyzer" 字段
        analyzer["analyzer"] = ik;
        // 将 analyzer 对象赋值给 analysis 对象下的 "analysis" 字段
        analysis["analysis"] = analyzer;
        // 将 analysis 对象放入 _index 的 "settings" 字段中
        _index["settings"] = analysis;
    }

    // 成员函数 append:向索引的 properties 中添加一个字段定义(链式调用)
    // 参数 key:字段名称
    // 参数 type:字段类型,默认为 "text"
    // 参数 analyzer:分词器名称,默认为 "ik_max_word"
    // 参数 enabled:是否启用该字段(若为 false,则该字段不会被索引和存储)
    // 返回值:返回当前对象的引用,支持链式调用
    ESIndex& append(const std::string &key, 
        const std::string &type = "text", 
        const std::string &analyzer = "ik_max_word", 
        bool enabled = true) 
    {
        Json::Value fields;
        fields["type"] = type;          // 设置字段类型
        fields["analyzer"] = analyzer;  // 设置分词器(仅对 text 类型有效)
        if (enabled == false) 
        {
            // 如果 enabled 为 false,则添加 "enabled": false 字段,表示该字段不参与索引和存储
            fields["enabled"] = enabled;
        }
        // 将该字段定义添加到 _properties 对象中,键为字段名
        _properties[key] = fields;
        // 返回当前对象引用,以便链式调用
        return *this;
    }

    // 成员函数 create:根据已添加的字段定义,创建索引(实际发送请求到 Elasticsearch)
    // 参数 index_id:可选的索引文档 ID,但这里可能用于某些特殊场景,默认为 "default_index_id"
    // 注意:在 elasticlient 的 index 方法中,如果指定了 ID,会创建或更新一个文档,而不是创建索引本身。
    // 但是如果索引不存在,那么就会自动触发索引的自动创建(同时设置 mapping),
    bool create(const std::string &index_id = "default_index_id") 
    {
        // 构建 mappings 部分
        Json::Value mappings;
        mappings["dynamic"] = true;                 // 允许动态添加字段(未在 mapping 中定义的字段也会被索引)
        mappings["properties"] = _properties;       // 将字段定义赋值给 properties
        _index["mappings"] = mappings;               // 将 mappings 对象放入 _index 的 "mappings" 字段

        // 将整个 _index 对象序列化为字符串
        std::string body;
        bool ret = Serialize(_index, body);
        if (ret == false) {
            LOG_ERROR("索引序列化失败!");
            return false;
        }
        LOG_DEBUG("{}", body);  // 输出序列化后的请求体,便于调试

        // 发起索引创建请求(实际上调用的是 index 方法,可能期望创建索引并插入一个文档)
        try 
        {
            // 调用 client->index 方法,传入索引名称、类型、文档 ID 和请求体
            // 注意:这实际上是在创建或更新一个文档,而不是纯粹的创建索引。
            // 如果索引不存在,Elasticsearch 会自动创建索引并使用请求体中的 mapping 作为索引的 mapping。
            auto rsp = _client->index(_name, _type, index_id, body);
            // 检查响应状态码是否为 2xx(成功)
            if (rsp.status_code < 200 || rsp.status_code >= 300) {
                LOG_ERROR("创建ES索引 {} 失败,响应状态码异常: {}", _name, rsp.status_code);
                return false;
            }
        } 
        catch(std::exception &e) 
        {
            // 捕获异常并记录错误
            LOG_ERROR("创建ES索引 {} 失败: {}", _name, e.what());
            return false;
        }
        return true;
    }

private:
    std::string _name;                      // 索引名称
    std::string _type;                       // 文档类型(通常为 "_doc")
    Json::Value _properties;                  // 存储字段定义的 JSON 对象(mapping 的 properties 部分)
    Json::Value _index;                       // 完整的索引定义(包含 settings 和 mappings)
    std::shared_ptr<elasticlient::Client> _client; // 共享的 elasticlient 客户端指针
};

// 类 ESInsert:用于向 Elasticsearch 索引中插入或更新文档
class ESInsert 
{
public:
    // 构造函数:初始化索引名称、文档类型和客户端指针
    ESInsert(std::shared_ptr<elasticlient::Client> &client, 
        const std::string &name, //索引名称
        const std::string &type = "_doc"):
        _name(name), 
        _type(type),
         _client(client){}

    // 模板成员函数 append:向待插入的文档中添加字段(链式调用)
    // 参数 key:字段名
    // 参数 val:字段值,可以是任意类型(Json::Value 支持的类型)
    // 返回值:返回当前对象的引用,支持链式调用
    template<typename T>
    ESInsert &append(const std::string &key, const T &val){
        _item[key] = val;   // 将键值对存入 _item 对象
        return *this;
    }

    // 成员函数 insert:将构建的文档插入到 Elasticsearch 中
    // 参数 id:可选,文档的 ID。如果为空字符串,Elasticsearch 会自动生成 ID。
    // 返回值:成功返回 true,失败返回 false
    bool insert(const std::string id = "") 
    {
        // 将 _item 序列化为 JSON 字符串
        std::string body;
        bool ret = Serialize(_item, body);
        if (ret == false) 
        {
            LOG_ERROR("索引序列化失败!");
            return false;
        }
        LOG_DEBUG("{}", body);  // 输出请求体,便于调试

        // 发起索引文档请求
        try 
        {
            // 调用 client->index 方法,传入索引名、类型、文档 ID 和请求体
            auto rsp = _client->index(_name, _type, id, body);
            // 检查响应状态码
            if (rsp.status_code < 200 || rsp.status_code >= 300) {
                LOG_ERROR("新增数据 {} 失败,响应状态码异常: {}", body, rsp.status_code);
                return false;
            }
        } 
        catch(std::exception &e) 
        {
            LOG_ERROR("新增数据 {} 失败: {}", body, e.what());
            return false;
        }
        return true;
    }

private:
    std::string _name;          // 索引名称
    std::string _type;          // 文档类型
    Json::Value _item;          // 存储待插入文档的 JSON 对象
    std::shared_ptr<elasticlient::Client> _client; // 共享的客户端指针
};

// 类 ESRemove:用于从 Elasticsearch 索引中删除文档
class ESRemove 
{
public:
    // 构造函数:初始化索引名称、文档类型和客户端指针
    ESRemove(std::shared_ptr<elasticlient::Client> &client, 
        const std::string &name, 
        const std::string &type = "_doc"):
        _name(name), _type(type), _client(client){}

    // 成员函数 remove:根据文档 ID 删除文档
    // 参数 id:要删除的文档 ID
    // 返回值:成功返回 true,失败返回 false
    bool remove(const std::string &id) 
    {
        try 
        {
            // 调用 client->remove 方法,传入索引名、类型和文档 ID
            auto rsp = _client->remove(_name, _type, id);
            // 检查响应状态码
            if (rsp.status_code < 200 || rsp.status_code >= 300) {
                LOG_ERROR("删除数据 {} 失败,响应状态码异常: {}", id, rsp.status_code);
                return false;
            }
        } catch(std::exception &e) {
            LOG_ERROR("删除数据 {} 失败: {}", id, e.what());
            return false;
        }
        return true;
    }

private:
    std::string _name;          // 索引名称
    std::string _type;          // 文档类型
    std::shared_ptr<elasticlient::Client> _client; // 共享的客户端指针
};

// 类 ESSearch:用于构建复杂的布尔查询并执行搜索
class ESSearch 
{
public:
    // 构造函数:初始化索引名称、文档类型和客户端指针
    ESSearch(std::shared_ptr<elasticlient::Client> &client, 
        const std::string &name, 
        const std::string &type = "_doc"):
        _name(name), _type(type), _client(client){}

    // 成员函数 append_must_not_terms:向 bool 查询的 must_not 子句中添加一个 terms 条件
    // terms 用于精确匹配多个值(相当于 SQL 的 IN)
    // 参数 key:字段名
    // 参数 vals:要匹配的值列表
    // 返回值:返回当前对象的引用,支持链式调用
    ESSearch& append_must_not_terms(const std::string &key, const std::vector<std::string> &vals) 
    {
        Json::Value fields;
        for (const auto& val : vals)
        {
            fields[key].append(val);   // 构造 { "field": [val1, val2, ...] } 形式的 JSON
        }
        Json::Value terms;
        terms["terms"] = fields;       // 包装成 { "terms": { "field": [...] } }
        _must_not.append(terms);       // 将该条件添加到 must_not 数组中
        return *this;
    }

    // 成员函数 append_should_match:向 bool 查询的 should 子句中添加一个 match 条件
    // match 用于全文搜索,会对查询文本进行分词
    // 参数 key:字段名
    // 参数 val:要匹配的文本
    // 返回值:返回当前对象的引用
    ESSearch& append_should_match(const std::string &key, const std::string &val) {
        Json::Value field;
        field[key] = val;               // 构造 { "field": "value" }
        Json::Value match;
        match["match"] = field;         // 包装成 { "match": { "field": "value" } }
        _should.append(match);          // 添加到 should 数组
        return *this;
    }

    // 成员函数 append_must_term:向 bool 查询的 must 子句中添加一个 term 条件
    // term 用于精确匹配单个值(不分析查询词)
    // 参数 key:字段名
    // 参数 val:要匹配的值
    // 返回值:返回当前对象的引用
    ESSearch& append_must_term(const std::string &key, const std::string &val) {
        Json::Value field;
        field[key] = val;               // 构造 { "field": "value" }
        Json::Value term;
        term["term"] = field;           // 包装成 { "term": { "field": "value" } }
        _must.append(term);             // 添加到 must 数组
        return *this;
    }

    // 成员函数 append_must_match:向 bool 查询的 must 子句中添加一个 match 条件
    // 参数 key:字段名
    // 参数 val:要匹配的文本
    // 返回值:返回当前对象的引用
    ESSearch& append_must_match(const std::string &key, const std::string &val){
        Json::Value field;
        field[key] = val;               // 构造 { "field": "value" }
        Json::Value match;
        match["match"] = field;         // 包装成 { "match": { "field": "value" } }
        _must.append(match);            // 添加到 must 数组
        return *this;
    }

    // 成员函数 search:执行搜索,返回命中的文档列表
    // 返回值:Json::Value 类型,包含 "hits.hits" 数组,即所有匹配的文档
    Json::Value search()
    {
        // 构建 bool 查询的条件对象
        Json::Value cond;
        if (_must_not.empty() == false) cond["must_not"] = _must_not;  // 添加 must_not 条件
        if (_should.empty() == false) cond["should"] = _should;        // 添加 should 条件
        if (_must.empty() == false) cond["must"] = _must;              // 添加 must 条件

        // 构建查询 DSL
        Json::Value query;
        query["bool"] = cond;           // 将条件封装到 bool 查询中
        Json::Value root;
        root["query"] = query;          // 完整的查询 DSL

        // 将查询 DSL 序列化为字符串
        std::string body;
        bool ret = Serialize(root, body);
        if (ret == false) {
            LOG_ERROR("索引序列化失败!");
            return Json::Value();        // 返回空 Json::Value
        }
        LOG_DEBUG("{}", body);           // 输出请求体,便于调试

        // 发起搜索请求
        cpr::Response rsp;
        try {
            // 调用 client->search 方法,传入索引名、类型和请求体
            rsp = _client->search(_name, _type, body);
            // 检查响应状态码
            if (rsp.status_code < 200 || rsp.status_code >= 300) {
                LOG_ERROR("检索数据 {} 失败,响应状态码异常: {}", body, rsp.status_code);
                return Json::Value();
            }
        } catch(std::exception &e) {
            LOG_ERROR("检索数据 {} 失败: {}", body, e.what());
            return Json::Value();
        }

        // 对响应正文进行反序列化
        LOG_DEBUG("检索响应正文: [{}]", rsp.text);
        Json::Value json_res;
        ret = UnSerialize(rsp.text, json_res);
        if (ret == false) {
            LOG_ERROR("检索数据 {} 结果反序列化失败", rsp.text);
            return Json::Value();
        }

        // 返回 hits.hits 数组,即匹配的文档列表
        return json_res["hits"]["hits"];
    }

private:
    std::string _name;                      // 索引名称
    std::string _type;                      // 文档类型
    Json::Value _must_not;                   // 存储 must_not 子句的数组
    Json::Value _should;                     // 存储 should 子句的数组
    Json::Value _must;                       // 存储 must 子句的数组
    std::shared_ptr<elasticlient::Client> _client; // 共享的客户端指针
};

} // 命名空间 IMS 结束

测试

cpp 复制代码
#include "../../common/icsearch.hpp"
#include <gflags/gflags.h>

DEFINE_bool(run_mode, false, "程序的运行模式,false-调试; true-发布;");
DEFINE_string(log_file, "", "发布模式下,用于指定日志的输出文件");
DEFINE_int32(log_level, 0, "发布模式下,用于指定日志输出等级");

int main(int argc, char *argv[])
{
    
    google::ParseCommandLineFlags(&argc, &argv, true);
    IMS::init_logger(FLAGS_run_mode, FLAGS_log_file, FLAGS_log_level);

    std::vector<std::string> host_list = {"http://127.0.0.1:9200/"};
    auto client = std::make_shared<elasticlient::Client>(host_list);
    std::cout << (uint64_t)client.get() << std::endl;

    bool ret = IMS::ESIndex(client, "test_user").append("nickname")
        .append("phone", "keyword", "standard", true)
        .create();
    if (ret == false) 
    {
        LOG_INFO("索引创建失败!");
        return -1;
    }else {
        LOG_INFO("索引创建成功!");
    }

    //数据的新增
    ret = IMS::ESInsert(client, "test_user")
        .append("nickname", "张三")
        .append("phone", "15566667777")
        .insert("00001");
    if (ret == false) 
    {
        LOG_ERROR("数据插入失败!");
        return -1;
    }
    else 
    {
        LOG_INFO("数据新增成功!");
    }

    //数据的修改
    ret = IMS::ESInsert(client, "test_user")
        .append("nickname", "张三")
        .append("phone", "13344445555")
        .insert("00001");
    if (ret == false) 
    {
        LOG_ERROR("数据更新失败!");
        return -1;
    }
    else 
    {
        LOG_INFO("数据更新成功!");
    }

    Json::Value user = IMS::ESSearch(client, "test_user")
        .append_should_match("phone", "13344445555")
        //.append_must_not_terms("nickname.keyword", {"张三"})
        .search();
    if (user.empty() || user.isArray() == false) {
        LOG_ERROR("结果为空,或者结果不是数组类型");
        return -1;
    } else {
        LOG_INFO("数据检索成功!");
    }
    int sz = user.size();
    LOG_DEBUG("检索结果条目数量:{}", sz);
    for (int i = 0; i < sz; i++) {
        LOG_INFO("nickname: {}", user[i]["_source"]["nickname"].asString());
    }

    ret = IMS::ESRemove(client, "test_user").remove("00001");
    if (ret == false) {
        LOG_ERROR("删除数据失败");
        return -1;
    }  else {
        LOG_INFO("数据删除成功!");
    }

    return 0;
}

那么这个测试做了啥呢?

以下是main函数中每个操作实际发送给Elasticsearch的Kibana Dev Tools命令(按执行顺序):

  1. 创建索引并插入默认文档(ID为 default_index_id)
cpp 复制代码
PUT /test_user/_doc/default_index_id
{
  "settings": {
    "analysis": {
      "analyzer": {
        "ik": {
          "tokenizer": "ik_max_word"
        }
      }
    }
  },
  "mappings": {
    "dynamic": true,
    "properties": {
      "nickname": {
        "type": "text",
        "analyzer": "ik_max_word"
      },
      "phone": {
        "type": "keyword",
        "analyzer": "standard"
      }
    }
  }
}

注:该请求会创建索引 test_user(如果不存在),并插入一个文档,其内容为索引的 settings 和 mappings。虽然文档内容本身不合理,但这是代码实际发送的请求。

  1. 插入文档 ID 为 00001
cpp 复制代码
PUT /test_user/_doc/00001
{
  "nickname": "张三",
  "phone": "15566667777"
}
  1. 更新文档 ID 为 00001(覆盖写入)
cpp 复制代码
PUT /test_user/_doc/00001
{
  "nickname": "张三",
  "phone": "13344445555"
}
  1. 搜索文档:phone 字段匹配 "13344445555"(使用 match 查询,放在 bool 的 should 子句中)
cpp 复制代码
POST /test_user/_search
{
  "query": {
    "bool": {
      "should": [
        { "match": { "phone": "13344445555" } }
      ]
    }
  }
}
  1. 删除文档 ID 为 00001
cpp 复制代码
DELETE /test_user/_doc/00001

以上命令按顺序在Kibana Dev Tools中执行,即可完整重现C++代码的所有ES操作。


整个测试用例的执行结果如下:

cpp 复制代码
ubuntu@10-13-52-255:~/cpp-chatsystem/server/example/es$ ./test
94436420871008
[default-logger][17:21:20][235123][debug   ][../../common/icsearch.hpp:137] {
	"mappings" : 
	{
		"dynamic" : true,
		"properties" : 
		{
			"nickname" : 
			{
				"analyzer" : "ik_max_word",
				"type" : "text"
			},
			"phone" : 
			{
				"analyzer" : "standard",
				"type" : "keyword"
			}
		}
	},
	"settings" : 
	{
		"analysis" : 
		{
			"analyzer" : 
			{
				"ik" : 
				{
					"tokenizer" : "ik_max_word"
				}
			}
		}
	}
}
[default-logger][17:21:20][235123][info    ][test_icsearch.cpp:26] 索引创建成功!
[default-logger][17:21:20][235123][debug   ][../../common/icsearch.hpp:204] {
	"nickname" : "\u5f20\u4e09",
	"phone" : "15566667777"
}
[default-logger][17:21:20][235123][info    ][test_icsearch.cpp:41] 数据新增成功!
[default-logger][17:21:20][235123][debug   ][../../common/icsearch.hpp:204] {
	"nickname" : "\u5f20\u4e09",
	"phone" : "13344445555"
}
[default-logger][17:21:20][235123][info    ][test_icsearch.cpp:56] 数据更新成功!
[default-logger][17:21:20][235123][debug   ][../../common/icsearch.hpp:361] {
	"query" : 
	{
		"bool" : 
		{
			"should" : 
			[
				{
					"match" : 
					{
						"phone" : "13344445555"
					}
				}
			]
		}
	}
}
[default-logger][17:21:20][235123][debug   ][../../common/icsearch.hpp:379] 检索响应正文: [{"took":0,"timed_out":false,"_shards":{"total":1,"successful":1,"skipped":0,"failed":0},"hits":{"total":{"value":1,"relation":"eq"},"max_score":0.6931471,"hits":[{"_index":"test_user","_type":"_doc","_id":"00001","_score":0.6931471,"_source":{
	"nickname" : "\u5f20\u4e09",
	"phone" : "13344445555"
}}]}}]
[default-logger][17:21:20][235123][info    ][test_icsearch.cpp:67] 数据检索成功!
[default-logger][17:21:20][235123][debug   ][test_icsearch.cpp:70] 检索结果条目数量:1
[default-logger][17:21:20][235123][info    ][test_icsearch.cpp:72] nickname: 张三
[default-logger][17:21:20][235123][info    ][test_icsearch.cpp:80] 数据删除成功!

非常的完美

相关推荐
Lw中2 小时前
Chroma查询集合:从基础检索到高级过滤的完整指南
搜索引擎·向量库·chroma向量库
forAllforMe2 小时前
IEC 60601 医疗电气设备安全标准解读
大数据·人工智能
2601_949221032 小时前
2026年金融AI投研工具对比测评:五大平台深度解析
大数据·人工智能·金融
光锥智能2 小时前
无界动力与生数科技达成战略合作,将在算法、数据与系统方面深度融合
大数据·人工智能·科技
龙亘川2 小时前
AI 时代数据治理的破局与重构:2025 白皮书核心洞察解析
大数据·人工智能·ai 时代数据治理白皮书
liu-yonggang2 小时前
ROS2 性能优化与功能增强方案
大数据·算法·性能优化
好运yoo2 小时前
git fetch和git pull的区别
大数据·git·elasticsearch
薛不痒2 小时前
github基础入门(3):版本控制(提交,分支删除,提交规范)
大数据·windows·git·elasticsearch·github
新诺韦尔API2 小时前
身份证验证接口详细开发对接指南
大数据·python·api