尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

【C++ 基于微服务的即时通讯系统】Elasticsearch 从安装到 C++ 客户端二次封装全落地

【C++ 基于微服务的即时通讯系统】Elasticsearch 从安装到 C++ 客户端二次封装全落地 草莓熊Lotso个人主页❄️个人专栏:《C知识分享》 《Linux 入门到实践零基础也能懂》✨生活是默默的坚持毅力是永久的享受 博主简介文章目录前言一、ES 的安装与介绍1.1 ES 是什么1.2 ES 服务端安装步骤1添加软件源与密钥步骤2安装指定版本步骤3启动服务步骤4网络配置可选步骤5安装 IK 中文分词器步骤6安装 Kibana可选1.3 C 客户端 elasticlient 安装下载与编译常见踩坑二、ES 核心概念扫盲2.1 概念对应关系2.2 核心概念详解索引Index类型Type字段与数据类型映射Mapping文档Document2.3 实战 DSL 示例创建索引与映射批量新增数据布尔查询用户搜索场景三、ES C 客户端接口介绍3.1 Client 客户端类3.2 Response 响应类3.3 两个必踩的基础坑四、Jsoncpp 序列化与反序列化4.1 核心类4.2 序列化样例4.3 反序列化样例五、ES 客户端基础调用样例六、ES 客户端 API 二次封装6.1 索引创建类 ESIndex6.2 数据新增类 ESInsert6.3 数据删除类 ESRemove6.4 数据查询类 ESSearch七、封装组件编译与测试7.1 完整测试代码7.2 编译与踩坑记录测试过程中的典型坑八、核心要点总结结尾前言做即时通讯系统有两个功能绕不开全文检索用户昵称搜索、聊天历史消息搜索。如果直接用 MySQL 的like模糊查询数据量上来之后全表扫描的效率根本没法看。所以我们这套微服务聊天系统里引入了 Elasticsearch 来专门承担全文检索的工作。很多 C 开发者接触 ES 比较少不知道怎么和 C 项目结合。本文就顺着项目落地的完整路径从 ES 服务端安装、核心概念扫盲到 C 客户端选型、编译再到 JSON 工具、原生接口调用最后封装成业务级别的 CRUD 接口。全程附带实战踩坑和源码解读跟着走一遍就能把 ES 集成到自己的 C 项目里。一、ES 的安装与介绍1.1 ES 是什么Elasticsearch简称 ES是一款开源的分布式搜索引擎基于 Java 开发底层以 Lucene 为核心实现索引和搜索能力对外提供简洁的 RESTful 接口。它的特点很鲜明分布式、零配置自动发现、索引自动分片、副本机制、近实时搜索、扩展性极强可以横向扩展到上百台服务器处理 PB 级数据。它和传统数据库最核心的区别在于搜索原理关系型数据库做模糊查询基本是全表遍历匹配数据量大了之后性能急剧下降ES 会对文本做分词建立倒排索引搜索的时候直接命中索引效率高出几个数量级举个最简单的例子文本“你好比特”数据库用%你好%要逐行扫描而 ES 会分词成“你、好、比特、你好”并建立索引搜索时直接定位到对应文档这也是全文检索的核心价值。1.2 ES 服务端安装我们用 7.17.21 版本Ubuntu 下通过 apt 安装步骤如下步骤1添加软件源与密钥# 添加GPG密钥 curl -s [https://artifacts.elastic.co/GPG-KEY-elasticsearch](https://artifacts.elastic.co/GPG-KEY-elasticsearch) | sudo gpg --no-default-keyring --keyring gnupg-ring:/etc/apt/trusted.gpg.d/elasticsearch.gpg --import # 添加镜像源 echo deb [https://artifacts.elastic.co/packages/7.x/apt](https://artifacts.elastic.co/packages/7.x/apt) stable main | sudo tee /etc/apt/sources.list.d/elasticsearch.list # 更新软件包列表 sudo apt update步骤2安装指定版本sudo apt-get install elasticsearch7.17.21步骤3启动服务# 启动服务 sudo systemctl start elasticsearch # 设置开机自启 sudo systemctl enable elasticsearch步骤4网络配置可选默认 ES 只允许本地访问如果需要外网访问修改配置文件/etc/elasticsearch/elasticsearch.ymlnetwork.host: 0.0.0.0 http.port: 9200 cluster.initial_master_nodes: [node-1]注意生产环境不要直接暴露公网一定要加安全认证开发环境本地用默认配置即可。步骤5安装 IK 中文分词器默认分词器对中文支持很差必须安装 IK 分词器sudo /usr/share/elasticsearch/bin/elasticsearch-plugin install [https://get.infini.cloud/elasticsearch/analysis-ik/7.17.21](https://get.infini.cloud/elasticsearch/analysis-ik/7.17.21)安装完重启 ES 生效。步骤6安装 Kibana可选Kibana 是 ES 的可视化工具自带 Dev Tools 可以很方便地写 DSL 调试查询sudo apt install kibana sudo systemctl start kibana sudo systemctl enable kibana默认端口 5601浏览器访问即可使用。1.3 C 客户端 elasticlient 安装C 的 ES 客户端选择不多我们选用elasticlient轻量够用。下载与编译# 克隆代码 git clone [https://github.com/seznam/elasticlient](https://github.com/seznam/elasticlient) cd elasticlient # 更新子模块 git submodule update --init --recursive # 编译安装 mkdir build cd build cmake .. make -j$(nproc) sudo make install常见踩坑cmake 阶段大概率会报错MHD_INCLUDE_DIR NOTFOUND这是缺少依赖库安装即可sudo apt-get install libmicrohttpd-dev安装完重新 cmake 就能通过了。二、ES 核心概念扫盲上来直接写代码很容易写得一团糟先把核心概念理清楚对应关系型数据库做个类比大家理解起来会快很多。2.1 概念对应关系关系型数据库Elasticsearch说明Database库Index索引一类相似文档的集合比如用户索引、消息索引Table表Type类型索引下的逻辑分类7.x 后基本弃用统一用_docRow行Document文档一条完整的数据JSON 格式Column列Field字段文档里的单个属性2.2 核心概念详解索引Index一个索引就是一组相似特征文档的集合比如user索引存所有用户数据message索引存所有聊天消息。索引名必须全部小写操作的时候通过索引名指定。类型Type索引下的逻辑分类早期版本用来细分数据现在已经处于弃用状态统一使用默认的_doc即可。字段与数据类型字段就是文档的属性对应数据库的列。常用类型分类类型说明字符串text会分词建立倒排索引用于模糊搜索、全文检索字符串keyword不分词只能精确匹配用于过滤、排序、精确查找数值integerlongshortbyte整数类型浮点doublefloat浮点类型布尔booleantrue/false日期date支持日期字符串、时间戳格式二进制binary仅存储不索引重点区分 text 和 keyword这是新手最容易搞混的点。比如用户 ID、手机号这种需要精确匹配的一定要用keyword昵称、个人签名、消息内容这种需要模糊搜索的才用text。用错了要么搜不到要么性能差。映射Mapping映射相当于数据库的表结构定义用来指定每个字段的类型、分词器、是否索引、是否存储等规则。设计好映射对性能影响非常大。常用映射配置项配置项默认值说明enabledtrue设为 false 则仅存储、不建索引节省空间比如头像、签名这种不用来搜索的字段indextrue是否构建倒排索引设为 false 无法被搜索dynamictrue是否自动映射未定义的字段开启的话新增字段会自动猜类型analyzerstandard指定分词器中文要设为ik_max_wordboost1.0字段级别的权重影响搜索得分排序文档Document文档是 ES 中可被索引的基础单元JSON 格式。一个索引里可以存储任意多的文档每条文档都有一个唯一 ID。2.3 实战 DSL 示例结合我们聊天系统的用户搜索场景看三段最常用的 DSL。创建索引与映射PUT /user { settings: { analysis: { analyzer: { ik: { tokenizer: ik_max_word } } } }, mappings: { dynamic: true, properties: { nickname: { type: text, analyzer: ik_max_word }, user_id: { type: keyword }, phone: { type: keyword }, description: { type: text, enabled: false }, avatar_id: { type: keyword, enabled: false } } } }这段代码创建了 user 索引配置了 IK 中文分词器昵称字段支持分词搜索用户 ID 和手机号精确匹配描述和头像只存不搜。批量新增数据POST /user/_doc/_bulk {index:{_id:1}} {user_id:USER001,nickname:昵称1,phone:13300001111,description:签名1,avatar_id:头像1} {index:{_id:2}} {user_id:USER002,nickname:昵称2,phone:13300002222,description:签名2,avatar_id:头像2}用_bulk批量接口两行一条数据第一行指定操作和 ID第二行是实际数据。布尔查询用户搜索场景GET /user/_doc/_search { query: { bool: { must_not: [ { terms: { user_id.keyword: [ USER001, USER002 ] } } ], should: [ {match: {user_id: 昵称}}, {match: {nickname: 昵称}}, {match: {phone: 昵称}} ] } } }这就是用户搜索的底层逻辑must_not排除已经是好友的用户对应 SQL 里的NOT INshould三个字段任意匹配一个就行对应 SQL 里的OR用户输入关键字同时匹配 ID、昵称、手机号就是全局搜索框的效果三、ES C 客户端接口介绍elasticlient 库的接口很简洁核心就一个客户端类和一个响应类。3.1 Client 客户端类核心操作接口class Client { public: // 构造函数传入ES节点地址列表超时时间 explicit Client(const std::vectorstd::string hostUrlList, std::int32_t timeout 6000); // 创建索引、新增/更新文档 cpr::Response index(const std::string indexName, const std::string docType, const std::string id, const std::string body); // 搜索查询 cpr::Response search(const std::string indexName, const std::string docType, const std::string body); // 删除文档 cpr::Response remove(const std::string indexName, const std::string docType, const std::string id); // 获取单条文档 cpr::Response get(const std::string indexName, const std::string docType, const std::string id); };3.2 Response 响应类响应用的是 cpr 库的Response类我们最常用两个成员status_codeHTTP 状态码200-299 代表成功400 是请求格式错404 是不存在5xx 是服务端错text响应体字符串也就是 ES 返回的 JSON 结果3.3 两个必踩的基础坑地址末尾必须加斜杠比如要写成[http://127.0.0.1:9200/](http://127.0.0.1:9200/)不能漏了最后的/。不然内部拼接路径会变成9200user而不是9200/user直接请求失败。必须捕获异常网络不通、ES 报错、参数错误都会抛出异常不做捕获程序会直接崩溃。所有调用都要包在try-catch里。四、Jsoncpp 序列化与反序列化ES 全部用 JSON 交互我们需要一个 JSON 库来做数据的序列化和反序列化。elasticlient 已经依赖了 jsoncpp不用额外安装。4.1 核心类Json::Value通用 JSON 容器可以表示对象、数组、字符串、数字等所有类型。支持[]运算符访问字段append()方法添加数组元素。Json::StreamWriter序列化类把Json::Value序列化为 JSON 字符串通过StreamWriterBuilder创建。Json::CharReader反序列化类把 JSON 字符串解析为Json::Value通过CharReaderBuilder创建。4.2 序列化样例#include json/json.h #include iostream #include sstream #include memory bool Serialize(const Json::Value val, std::string dst) { Json::StreamWriterBuilder swb; std::unique_ptrJson::StreamWriter sw(swb.newStreamWriter()); std::stringstream ss; int ret sw-write(val, ss); if (ret ! 0) { std::cout JSON序列化失败! std::endl; return false; } dst ss.str(); return true; } int main() { Json::Value stu; stu[姓名] 张三; stu[年龄] 18; stu[成绩].append(88); stu[成绩].append(89.5); stu[成绩].append(99); std::string stu_str; Serialize(stu, stu_str); std::cout stu_str std::endl; return 0; }说明默认中文会输出 Unicode 编码可以通过swb.settings_[emitUTF8] true;设置输出 UTF-8 中文不同版本略有差异。4.3 反序列化样例bool UnSerialize(const std::string src, Json::Value val) { Json::CharReaderBuilder crb; std::unique_ptrJson::CharReader cr(crb.newCharReader()); std::string err; bool ret cr-parse(src.c_str(), src.c_str() src.size(), val, err); if (!ret) { std::cout JSON反序列化失败: err std::endl; return false; } return true; } int main() { // ... 接上一段的stu_str ... Json::Value val; UnSerialize(stu_str, val); std::cout val[姓名].asString() std::endl; std::cout val[年龄].asInt() std::endl; int sz val[成绩].size(); for (int i 0; i sz; i) { std::cout val[成绩][i].asFloat() std::endl; } return 0; }五、ES 客户端基础调用样例先写一个最简单的查询程序走一遍原生接口的完整流程。#include elasticlient/client.h #include cpr/cpr.h #include iostream int main() { // 1. 构造ES客户端注意地址末尾的斜杠 elasticlient::Client client({[http://127.0.0.1:9200/](http://127.0.0.1:9200/)}); try { // 2. 发起搜索请求查询所有文档 auto rsp client.search(user, _doc, {\query\:{\match_all\:{}}}); // 3. 打印状态码和响应正文 std::cout 状态码: rsp.status_code std::endl; std::cout 响应: rsp.text std::endl; } catch (std::exception e) { std::cout 请求失败: e.what() std::endl; return -1; } return 0; }编译命令g -stdc17 main.cc -o main -lcpr -lelasticlient能正常输出状态码 200 和 JSON 结果说明基础调用就通了。六、ES 客户端 API 二次封装原生接口每次都要手拼 JSON 字符串既容易出错也不好复用。我们把它封装成四个业务类分别对应索引创建、数据新增、数据删除、数据查询屏蔽底层 DSL 细节上层业务只需要传参数。为什么要分成四个类因为不同的微服务节点需要的功能不一样用户服务只需要新增和查询管理服务才需要建索引。按需依赖不用把所有操作都揉在一起。6.1 索引创建类 ESIndex负责创建索引自动配置 IK 分词器链式添加字段。#pragma once #include elasticlient/client.h #include json/json.h #include memory #include string #include logger.hpp class ESIndex { public: ESIndex(std::shared_ptrelasticlient::Client client, const std::string name, const std::string type _doc) : _client(client), _name(name), _type(type) { // 构造函数里直接配置好IK分词器 Json::Value ik; ik[tokenizer] ik_max_word; Json::Value analyzer; analyzer[ik] ik; Json::Value analysis; analysis[analyzer] analyzer; _index[settings][analysis] analysis; } // 链式添加字段 ESIndex append(const std::string key, const std::string type text, const std::string analyzer ik_max_word, bool enabled true) { Json::Value field; field[type] type; if (type text) { field[analyzer] analyzer; } if (!enabled) { field[enabled] enabled; } _properties[key] field; return *this; } // 执行创建 bool create() { Json::Value mappings; mappings[dynamic] true; mappings[properties] _properties; _index[mappings] mappings; std::string body; if (!Serialize(_index, body)) { LOG_ERROR(索引序列化失败); return false; } try { auto rsp _client-index(_name, _type, , body); if (rsp.status_code 200 || rsp.status_code 300) { LOG_ERROR(创建索引{}失败状态码:{}, _name, rsp.status_code); return false; } } catch (std::exception e) { LOG_ERROR(创建索引{}异常: {}, _name, e.what()); return false; } LOG_DEBUG(索引{}创建成功, _name); return true; } private: std::shared_ptrelasticlient::Client _client; std::string _name; std::string _type; Json::Value _index; Json::Value _properties; };代码解读构造函数直接把 IK 分词器配置好业务层不用关心分词器细节采用链式调用写起来流畅index.append(nickname).append(phone, keyword)统一做异常捕获和日志出错了能快速定位6.2 数据新增类 ESInsert负责单条数据的新增或更新同样链式调用。class ESInsert { public: ESInsert(std::shared_ptrelasticlient::Client client, const std::string name, const std::string type _doc) : _client(client), _name(name), _type(type) {} ESInsert append(const std::string key, const std::string val) { _item[key] val; return *this; } bool insert(const std::string id ) { std::string body; if (!Serialize(_item, body)) { LOG_ERROR(数据序列化失败); return false; } try { auto rsp _client-index(_name, _type, id, body); if (rsp.status_code 200 || rsp.status_code 300) { LOG_ERROR(新增数据失败状态码:{}, rsp.status_code); return false; } } catch (std::exception e) { LOG_ERROR(新增数据异常: {}, e.what()); return false; } return true; } private: std::shared_ptrelasticlient::Client _client; std::string _name; std::string _type; Json::Value _item; };说明ES 的 index 接口本身就是 upsert 语义ID 存在就是更新不存在就是新增。6.3 数据删除类 ESRemove最简单的一个类按 ID 删除文档。class ESRemove { public: ESRemove(std::shared_ptrelasticlient::Client client, const std::string name, const std::string type _doc) : _client(client), _name(name), _type(type) {} bool remove(const std::string id) { try { 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::shared_ptrelasticlient::Client _client; std::string _name; std::string _type; };6.4 数据查询类 ESSearch最复杂也最核心的类封装布尔查询支持排除条件和多字段匹配。class ESSearch { public: ESSearch(std::shared_ptrelasticlient::Client client, const std::string name, const std::string type _doc) : _client(client), _name(name), _type(type) {} // 添加必须排除的精确值列表 ESSearch append_must_not_terms(const std::string key, const std::vectorstd::string vals) { Json::Value fields; for (const auto val : vals) { fields[key].append(val); } Json::Value terms; terms[terms] fields; _must_not.append(terms); return *this; } // 添加应该匹配的字段 ESSearch append_should_match(const std::string key, const std::string val) { Json::Value field; field[key] val; Json::Value match; match[match] field; _should.append(match); return *this; } // 执行搜索返回命中的文档数组 Json::Value search() { // 构造bool查询条件 Json::Value cond; if (!_must_not.empty()) { cond[must_not] _must_not; } if (!_should.empty()) { cond[should] _should; } Json::Value query; query[bool] cond; Json::Value root; root[query] query; std::string body; if (!Serialize(root, body)) { LOG_ERROR(查询序列化失败); return Json::Value(); } try { auto rsp _client-search(_name, _type, body); if (rsp.status_code 200 || rsp.status_code 300) { LOG_ERROR(查询失败状态码:{}, 请求体:{}, rsp.status_code, body); return Json::Value(); } // 解析响应取出hits.hits层 Json::Value json_res; if (!UnSerialize(rsp.text, json_res)) { LOG_ERROR(查询结果反序列化失败); return Json::Value(); } return json_res[hits][hits]; } catch (std::exception e) { LOG_ERROR(查询异常: {}, e.what()); return Json::Value(); } } private: std::shared_ptrelasticlient::Client _client; std::string _name; std::string _type; Json::Value _must_not; Json::Value _should; };代码解读对应业务场景must_not排除已有好友should多字段模糊匹配重点返回的是hits.hits数组每个元素的_source字段才是原始文档数据这是新手最高频的坑ES 的响应结构是两层 hits少一层都拿不到数据七、封装组件编译与测试现在写一个完整的测试程序把四个类串起来验证全链路是否正常。7.1 完整测试代码#include ../common/es.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); init_logger(FLAGS_run_mode, FLAGS_log_file, FLAGS_log_level); // 创建ES客户端 std::vectorstd::string hosts {[http://127.0.0.1:9200/](http://127.0.0.1:9200/)}; auto client std::make_sharedelasticlient::Client(hosts); // 1. 创建索引 bool ret ESIndex(client, test_user, _doc) .append(nickname) .append(phone, keyword, standard, true) .create(); if (!ret) { LOG_ERROR(索引创建失败); return -1; } LOG_INFO(索引创建成功); // 2. 新增数据 ret ESInsert(client, test_user, _doc) .append(nickname, 张三) .append(phone, 15566667777) .insert(0001); if (!ret) { LOG_ERROR(数据新增失败); return -1; } LOG_INFO(数据新增成功); // 3. 更新数据同ID就是更新 ret ESInsert(client, test_user, _doc) .append(nickname, 张三) .append(phone, 13344445555) .insert(0001); if (!ret) { LOG_ERROR(数据更新失败); return -1; } LOG_INFO(数据更新成功); // 4. 查询数据 Json::Value users ESSearch(client, test_user, _doc) .append_should_match(phone.keyword, 13344445555) .search(); if (users.empty() || !users.isArray()) { LOG_ERROR(查询结果为空或格式错误); return -1; } LOG_INFO(数据检索成功命中{}条, users.size()); for (size_t i 0; i users.size(); i) { LOG_INFO(nickname: {}, users[i][_source][nickname].asString()); } // 5. 删除数据 ret ESRemove(client, test_user, _doc).remove(0001); if (!ret) { LOG_ERROR(数据删除失败); return -1; } LOG_INFO(数据删除成功); return 0; }7.2 编译与踩坑记录编译命令g -stdc17 main.cc -o main -lcpr -lelasticlient -lspdlog -lfmt -lgflags -ljsoncpp测试过程中的典型坑查询报 400 错误一开始写查询的时候很容易少写外层的query直接把bool放根节点ES 不认识就会 400。一定要记住查询条件都要包在query里面。精确匹配搜不到用 keyword 类型的字段做匹配字段名要加.keyword后缀比如phone.keyword。不然会默认去匹配分词结果对不上精确值。拿到的结果是空的90% 的情况是少了一层_source。ES 的结果结构是hits.hits[]._source两层 hits 再加 _source少任何一层都拿不到真实数据。中文搜索结果乱七八糟没装 IK 分词器的时候默认按单字切搜“张伟”会把“张三丰”“李伟”都搜出来。这不是优化问题是功能能不能用的问题中文场景一定要装 IK 分词器并且在 mapping 里指定好。八、核心要点总结字段类型设计是根基text还是keyword要不要建索引用什么分词器设计 mapping 的时候就要想清楚。用错了要么性能差要么结果不准后期改代价很大。二次封装提升工程化效率屏蔽 DSL 拼接细节统一异常和日志业务层只关心数据不用关心 ES 语法既能少写重复代码也能降低出错概率。响应结构记牢两层 hits真实数据在hits.hits[]._source里这是新手最高频的踩坑点没有之一。中文必须配 IK 分词器默认分词器不适合中文搜索质量会非常差这是必做项不是可选项。基础细节不能漏地址末尾的斜杠、异常捕获都是很小的点但漏了要么跑不通要么直接崩。结尾 我是草莓熊 Lotso若这篇技术干货帮你打通了学习中的卡点 【关注】跟我一起深耕技术领域从基础到进阶见证每一次成长 ❤️ 【点赞】让优质内容被更多人看见让知识传递更有力量 ⭐ 【收藏】把核心知识点、实战技巧存好需要时直接查、随时用 【评论】分享你的经验或疑问比如曾踩过的技术坑一起交流避坑 ️ 【投票】用你的选择助力社区内容方向告诉大家哪个技术点最该重点拆解 技术之路难免有困惑但同行的人会让前进更有方向愿我们都能在自己专注的领域里一步步靠近心中的技术目标结语到这里Elasticsearch 从服务端安装到 C 客户端二次封装的完整链路就跑通了。在我们的即时通讯系统里用户搜索、全局消息搜索这两个核心场景都是基于这套封装实现的性能和准确性都能满足业务需求。ES 本身的玩法还有很多比如分页、高亮、聚合分析后续做消息的多维度统计的时候还会深入。接下来我们会继续把 ES 集成到微服务的各个业务子服务里完整落地用户搜索和消息搜索功能。后续也会分享更多 C 后端、微服务架构的实战内容欢迎关注交流。✨把这些内容吃透超牛的放松下吧✨ʕ˘ᴥ˘ʔづきらど
返回列表