最近在做一个 C++ 项目,需要用 Elasticsearch 存储用户行为数据。直接用 elasticlient 原生 SDK 写的时候,发现每次操作都要手动拼 JSON、处理 HTTP 响应,还得重复写错误处理代码 —后来索性基于项目需求封装了一套轻量接口,把索引创建、数据增删改查都包成了易用的类。这篇笔记就把整个过程拆解开,从环境搭建到代码细节,再到实际运行的坑点,都跟大家捋一捋。
一、前置准备:先把环境搭起来
在写一行封装代码前,得先把 ES 和客户端依赖搞定。这部分我踩了不少坑,尤其是 elasticlient 的编译,所以尽量写细点,避免大家走弯路。
1. Elasticsearch 安装(Ubuntu 20.04 为例)
项目用的是 ES 7.17.21(稳定版,兼容性好),步骤参考了官方文档和实际测试:
# 1. 添加ES仓库密钥(两种方式,选一个就行,避免apt-key警告)
# 方式一:传统添加(可能报警告,不影响)
wget -qO - https://artifacts.elastic.co/GPG-KEY-elasticsearch | sudo apt-key add -
# 方式二:推荐,把密钥放到trusted.gpg.d下
curl -s https://artifacts.elastic.co/GPG-KEY-elasticsearch | sudo gpg --no-default-keyring --keyring gnupg-ring:/etc/apt/trusted.gpg.d/icsearch.gpg --import
# 2. 添加ES源到apt
echo "deb https://artifacts.elastic.co/packages/7.x/apt stable main" | sudo tee /etc/apt/sources.list.d/elasticsearch.list
# 3. 安装指定版本(避免装最新版出兼容问题)
sudo apt update
sudo apt-get install elasticsearch=7.17.21
# 4. 解决启动报错:虚拟内存不足
# ES默认要求虚拟内存映射数≥262144,系统默认才65530,必须改
sysctl -w vm.max_map_count=262144
# 永久生效(重启后也有效):echo "vm.max_map_count=262144" >> /etc/sysctl.conf
# 5. 调整JVM内存(默认可能太大/太小,根据服务器配置来)
sudo vim /etc/elasticsearch/jvm.options
# 新增或修改:小服务器用512m,大服务器可设4g
-Xms512m
-Xmx512m
# 6. 启动并验证
sudo systemctl start elasticsearch
sudo systemctl status elasticsearch.service
# 用curl测是否活了:返回JSON带version就对了
curl -X GET "http://localhost:9200/"
2. 安装 IK 分词器(中文必备)
ES 默认分词器对中文不友好(会把 “张三” 拆成 “张”“三”),必须装 IK。注意 IK 版本要和 ES 完全一致:
# 进入ES插件目录,执行安装命令
cd /usr/share/elasticsearch/
sudo bin/elasticsearch-plugin install https://get.infini.cloud/elasticsearch/analysis-ik/7.17.21
# 安装后重启ES
sudo systemctl restart elasticsearch
3. elasticlient 客户端编译安装
C++ 的 ES 客户端选择不多,我最终用了 seznam 的 elasticlient(开源、轻量,支持 ES 7.x)。编译时会缺依赖,记好解决办法:
# 1. 克隆代码(带子模块,必须加--recursive)
git clone https://github.com/seznam/elasticlient.git
cd elasticlient
git submodule update --init --recursive
# 2. 解决依赖:缺libmicrohttpd-dev(编译httpmockserver用)
sudo apt-get install libmicrohttpd-dev
# 3. 解决子模块问题:googletest没编译会报错
cd external/googletest/
mkdir build && cd build
cmake .. -DCMAKE_INSTALL_PREFIX=/usr
make && sudo make install
cd ../../..
# 4. 编译安装elasticlient
mkdir build && cd build
cmake ..
make
sudo make install
# 5. 验证:查看动态库是否存在
ls /usr/local/lib/libelasticlient.so*
4. 其他依赖
代码里用到了 jsoncpp(JSON 处理)、spdlog(日志)、gflags(命令行参数),直接用 apt 装就行:
sudo apt-get install libjsoncpp-dev libspdlog-dev libgflags-dev
二、为什么要封装?原生 SDK 的痛点
先给大家看段没封装时的 “痛苦代码”—— 比如插入一条用户数据:
// 原生elasticlient插入数据示例(繁琐版)
#include <elasticlient/client.h>
#include <json/json.h>
#include <cpr/cpr.h>
int main() {
// 1. 初始化客户端
elasticlient::Client client({"http://127.0.0.1:9200/"});
// 2. 手动拼JSON
Json::Value user;
user["nickname"] = "张三";
user["phone"] = "15566667777";
Json::StreamWriterBuilder swb;
std::stringstream ss;
swb.write(ss, user); // 手动序列化
std::string body = ss.str();
// 3. 手动发请求+处理错误
try {
auto rsp = client.index("test_user", "_doc", "00001", body);
if (rsp.status_code < 200 || rsp.status_code >= 300) {
std::cout << "插入失败,状态码:" << rsp.status_code << std::endl;
}
} catch (std::exception& e) {
std::cout << "异常:" << e.what() << std::endl;
}
return 0;
}
我当时要处理 10 个不同类型的数据存储,就得写 10 遍类似代码 —— 不仅重复率高,容易因手误出错,后续想统一改日志格式,还得逐个文件修改。封装的核心目标就是解决这些问题:
-
少写重复代码:序列化、错误处理、HTTP 请求逻辑统一封装,一次写好后续复用;
-
降低使用门槛:不用每次都记 ES 的 JSON 语法,调用类方法就能完成操作;
-
统一代码规范:日志输出格式、错误判断标准都固定,后续维护更省心。
三、封装代码解析:从工具到业务类
封装分了三层:JSON 工具函数(基础)、日志类(辅助)、业务操作类(核心)。每一层都尽量简单,不搞过度设计,够用就行。
1. JSON 工具函数:Serialize/UnSerialize
所有 ES 操作都要 JSON 序列化 / 反序列化,单独抽成两个函数,避免在每个类里重复写:
#include <json/json.h>
#include <sstream>
#include <iostream>
// 序列化:Json::Value转string
bool Serialize(const Json::Value &val, std::string &dst) {
Json::StreamWriterBuilder swb;
// 关键配置:强制UTF-8,避免中文乱码
swb.settings_["emitUTF8"] = true;
// 用unique_ptr管理内存,自动释放
std::unique_ptr<Json::StreamWriter> sw(swb.newStreamWriter());
std::stringstream ss;
// 序列化返回0表示成功,非0失败
int ret = sw->write(val, &ss);
if (ret != 0) {
std::cout << "JSON序列化失败!\n";
return false;
}
dst = ss.str();
return true;
}
// 反序列化:string转Json::Value
bool UnSerialize(const std::string &src, Json::Value &val) {
Json::CharReaderBuilder crb;
std::unique_ptr<Json::CharReader> cr(crb.newCharReader());
std::string err;
// 解析范围是整个src字符串
bool ret = cr->parse(src.c_str(), src.c_str() + src.size(), &val, &err);
if (!ret) {
// 打印错误信息,方便排查(比如JSON格式错了)
std::cout << "JSON反序列化失败: " << err << std::endl;
return false;
}
return true;
}
实际用下来,这两个函数没出过问题 —— 唯一要注意的是,序列化时必须开emitUTF8,不然中文存到 ES 里会是乱码,后续查数据特别麻烦。
2. 日志类:基于 spdlog 的封装
项目里需要区分 “调试模式”(输出到控制台,方便开发时看日志)和 “发布模式”(输出到文件,便于后续排查问题),所以封装了init_logger函数,配合宏定义用:
#pragma once
#include <spdlog/spdlog.h>
#include <spdlog/sinks/stdout_color_sinks.h>
#include <spdlog/sinks/basic_file_sink.h>
// 全局日志对象
std::shared_ptr<spdlog::logger> g_default_logger;
// 初始化日志:mode=true(发布),mode=false(调试)
void init_logger(bool mode, const std::string &file, int32_t level) {
if (!mode) {
// 调试模式:彩色控制台输出,最低日志级别(trace)
g_default_logger = spdlog::stdout_color_mt("debug-log");
g_default_logger->set_level(spdlog::level::trace);
g_default_logger->flush_on(spdlog::level::trace);
} else {
// 发布模式:输出到文件,日志级别由参数指定
g_default_logger = spdlog::basic_file_sink_mt("release-log", file);
g_default_logger->set_level(static_cast<spdlog::level::level_enum>(level));
g_default_logger->flush_on(static_cast<spdlog::level::level_enum>(level));
}
// 日志格式:[日志名][时间][线程号][级别] 内容(带文件名和行号)
g_default_logger->set_pattern("[%n][%H:%M:%S][%t][%-8l] [%s:%#] %v");
}
// 日志宏定义:方便调用,自动加文件名和行号
#define LOG_TRACE(...) g_default_logger->trace(__VA_ARGS__)
#define LOG_DEBUG(...) g_default_logger->debug(__VA_ARGS__)
#define LOG_INFO(...) g_default_logger->info(__VA_ARGS__)
#define LOG_ERROR(...) g_default_logger->error(__VA_ARGS__)
用宏定义的好处是,后续要改日志格式或级别,只改这里就行 —— 不用在几百行代码里找g_default_logger->info,省了不少事。
3. 核心业务类:按操作拆分
3.1 ESIndex:索引创建类
ES 里 “索引” 类似数据库的 “表”,创建时要定义字段类型、分词器这些配置,我把这些逻辑都封装到ESIndex里:
#include <elasticlient/client.h>
#include <memory>
#include "logger.hpp" // 上面的日志类
class ESIndex {
public:
// 构造函数:传入客户端、索引名、文档类型(ES 7.x默认_doc)
ESIndex(std::shared_ptr<elasticlient::Client> &client,
const std::string &name,
const std::string &type = "_doc"):
_name(name), _type(type), _client(client) {
// 默认配置IK分词器(项目里中文多,直接内置省得每次配)
Json::Value analysis;
Json::Value analyzer;
Json::Value ik;
Json::Value tokenizer;
tokenizer["tokenizer"] = "ik_max_word"; // 细粒度分词
ik["ik"] = tokenizer;
analyzer["analyzer"] = ik;
analysis["analysis"] = analyzer;
_index["settings"] = analysis; // 放到索引设置里
}
// 追加字段:key=字段名,type=字段类型,analyzer=分词器,enabled=是否存储
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;
// 只有text类型需要分词器,keyword类型不用
if (type == "text") {
fields["analyzer"] = analyzer;
}
// enabled=false:该字段只存储不索引(比如大文本字段,不用搜)
if (!enabled) {
fields["enabled"] = false;
}
_properties[key] = fields;
return *this; // 链式调用:方便连续加字段
}
// 创建索引:index_id默认即可(ES 7.x用不到)
bool create(const std::string &index_id = "default_index_id") {
Json::Value mappings;
// dynamic=true:允许动态添加字段(开发期方便,生产期建议关)
mappings["dynamic"] = true;
mappings["properties"] = _properties;
_index["mappings"] = mappings;
// 序列化索引配置
std::string body;
if (!Serialize(_index, body)) {
LOG_ERROR("索引[{}]序列化失败", _name);
return false;
}
LOG_DEBUG("创建索引请求体:{}", body);
try {
// 调用elasticlient的index接口创建索引
auto rsp = _client->index(_name, _type, index_id, body);
// 200=索引已存在更新成功,201=新建成功;3xx/4xx/5xx都是错
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_INFO("索引[{}]创建成功", _name);
return true;
}
private:
std::string _name; // 索引名
std::string _type; // 文档类型(ES 7.x默认_doc)
Json::Value _properties; // 字段配置
Json::Value _index; // 完整索引配置(settings+mappings)
std::shared_ptr<elasticlient::Client> _client; // ES客户端(共享,避免重复创建)
};
用的时候特别简单,比如创建 “用户表” 索引:
// 假设client已经初始化
ESIndex(client, "test_user")
.append("nickname") // 默认text+IK分词
.append("phone", "keyword") // 手机号精确匹配,不用分词
.append("avatar", "keyword", "standard", false) // 头像地址只存储不索引
.create();
3.2 ESInsert:数据插入 / 更新类
ES 里 “更新” 其实是 “覆盖写入”(用同一个 ID 就能覆盖原有数据),所以我把插入和更新放到同一个类里,不用单独写两个:
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){}
// 模板方法:支持任意类型(string、int、bool等)
template<typename T>
ESInsert &append(const std::string &key, const T &val){
_item[key] = val;
return *this; // 链式调用
}
// 插入/更新:id为空则ES自动生成ID
bool insert(const std::string id = "") {
std::string body;
if (!Serialize(_item, body)) {
LOG_ERROR("数据序列化失败");
return false;
}
LOG_DEBUG("插入数据请求体:{}", body);
try {
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;
}
LOG_INFO("数据{}成功(ID:{})", id.empty() ? "插入" : "更新", id);
return true;
}
private:
std::string _name;
std::string _type;
Json::Value _item; // 单条数据
std::shared_ptr<elasticlient::Client> _client;
};
示例:插入一条用户数据,再更新手机号:
// 插入
ESInsert(client, "test_user")
.append("nickname", "王五")
.append("phone", "15456321587")
.append("age", 28)
.insert("00001"); // 指定ID
// 更新(同一个ID)
ESInsert(client, "test_user")
.append("nickname", "王五")
.append("phone", "453423543543") // 改手机号
.append("age", 28)
.insert("00001");
3.3 ESRemove:数据删除类
删除比较简单,只需要文档 ID 就能操作,所以类里就一个remove方法:
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){}
bool remove(const std::string &id) {
// 防呆:ID为空直接返回失败,避免误操作
if (id.empty()) {
LOG_ERROR("删除失败:文档ID为空");
return false;
}
try {
auto rsp = _client->remove(_name, _type, id);
if (rsp.status_code == 404) {
// 404是“文档不存在”,算警告不算错误
LOG_WARN("删除文档[{}]:文档不存在", id);
return false;
}
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;
}
LOG_INFO("文档[{}]删除成功", id);
return true;
}
private:
std::string _name;
std::string _type;
std::shared_ptr<elasticlient::Client> _client;
};
3.4 ESSearch:数据查询类
查询是最复杂的,我项目里用得最多的是 Bool 查询(must/must_not/should),所以重点封装了这几个条件,满足日常检索需求:
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){}
// must_not + terms:排除指定值(比如排除ID为1、2的用户)
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);
}
Json::Value terms;
terms["terms"] = fields;
_must_not.append(terms);
return *this;
}
// should + match:满足任一条件(比如昵称含“张”或“三”)
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;
}
// must + term:必须满足精确匹配(比如手机号是13344445555)
ESSearch& append_must_term(const std::string &key, const std::string &val) {
Json::Value field;
field[key] = val;
Json::Value term;
term["term"] = field;
_must.append(term);
return *this;
}
// 执行查询:返回命中的文档(hits.hits)
Json::Value search() {
Json::Value cond;
// 只加非空条件,避免生成无效JSON
if (!_must_not.empty()) cond["must_not"] = _must_not;
if (!_should.empty()) cond["should"] = _should;
if (!_must.empty()) cond["must"] = _must;
Json::Value query;
query["bool"] = cond;
Json::Value root;
root["query"] = query;
std::string body;
if (!Serialize(root, body)) {
LOG_ERROR("查询条件序列化失败");
return Json::Value();
}
LOG_DEBUG("查询请求体:{}", body);
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();
}
// 反序列化响应
Json::Value json_res;
if (!UnSerialize(rsp.text, json_res)) {
LOG_ERROR("查询结果反序列化失败,响应:{}", rsp.text);
return Json::Value();
}
// 返回hits.hits(实际文档数据在这)
return json_res["hits"]["hits"];
} catch (std::exception &e) {
LOG_ERROR("查询异常:{},请求体:{}", e.what(), body);
return Json::Value();
}
}
private:
std::string _name;
std::string _type;
Json::Value _must_not; // Bool查询的“排除”条件
Json::Value _should; // Bool查询的“或”条件
Json::Value _must; // Bool查询的“且”条件
std::shared_ptr<elasticlient::Client> _client;
};
查询示例:找手机号是 13344445555,且昵称不含 “李四” 的用户:
Json::Value result = ESSearch(client, "test_user")
.append_must_term("phone.keyword", "13344445555") // 手机号精确匹配
.append_must_not_terms("nickname.keyword", {"李四"}) // 排除昵称是李四的
.search();
// 解析结果
if (result.empty() || !result.isArray()) {
LOG_ERROR("查询结果为空或格式错误");
} else {
LOG_INFO("查询到{}条数据", result.size());
for (int i = 0; i < result.size(); i++) {
std::string nickname = result[i]["_source"]["nickname"].asString();
std::string phone = result[i]["_source"]["phone"].asString();
LOG_INFO("昵称:{},手机号:{}", nickname, phone);
}
}
四、完整运行示例:从初始化到增删改查
把上面的类拼起来,写一个完整的 main 函数,再用 gflags 加几个命令行参数(方便切换运行模式和日志配置):
#include "../common/icsearch.hpp" // 上面的所有类
#include <gflags/gflags.h>
// 定义命令行参数:--run_mode(调试/发布)、--log_file(日志文件)、--log_level(日志级别)
DEFINE_bool(run_mode, false, "运行模式:false=调试,true=发布");
DEFINE_string(log_file, "es_demo.log", "发布模式日志文件路径");
DEFINE_int32(log_level, 3, "发布模式日志级别:0=trace,1=debug,2=info,3=warn,4=error");
int main(int argc, char *argv[]) {
// 1. 解析命令行参数
google::ParseCommandLineFlags(&argc, &argv, true);
// 2. 初始化日志
init_logger(FLAGS_run_mode, FLAGS_log_file, FLAGS_log_level);
// 3. 初始化ES客户端(用shared_ptr,共享给所有类)
std::vector<std::string> host_list = {"http://127.0.0.1:9200/"};
auto client = std::make_shared<elasticlient::Client>(host_list);
LOG_INFO("ES客户端初始化成功,地址:{}", host_list[0]);
// 4. 创建索引
bool ret = ESIndex(client, "test_user")
.append("nickname") // text+IK
.append("phone", "keyword") // 精确匹配
.append("age", "integer") // 整数类型
.create();
if (!ret) {
LOG_ERROR("索引创建失败,程序退出");
return -1;
}
// 5. 插入数据
ret = ESInsert(client, "test_user")
.append("nickname", "张三")
.append("phone", "15566667777")
.append("age", 28)
.insert("00001");
if (!ret) {
LOG_ERROR("数据插入失败");
return -1;
}
// 6. 更新数据
ret = ESInsert(client, "test_user")
.append("nickname", "张三")
.append("phone", "13344445555") // 改手机号
.append("age", 28)
.insert("00001");
if (!ret) {
LOG_ERROR("数据更新失败");
return -1;
}
// 7. 查询数据
Json::Value result = ESSearch(client, "test_user")
.append_must_term("phone.keyword", "13344445555")
.search();
if (result.empty() || !result.isArray()) {
LOG_ERROR("查询失败");
return -1;
}
for (int i = 0; i < result.size(); i++) {
LOG_INFO("查询结果:昵称={},年龄={}",
result[i]["_source"]["nickname"].asString(),
result[i]["_source"]["age"].asInt());
}
// 8. 删除数据
ret = ESRemove(client, "test_user").remove("00001");
if (!ret) {
LOG_ERROR("数据删除失败");
return -1;
}
LOG_INFO("所有操作执行完成");
return 0;
}
五、实际使用中的坑点与解决办法
-
elasticlient 编译报错 “undefined reference to googletest”
原因:子模块 googletest 没编译安装,链接时找不到相关符号。解决:手动进入
external/googletest目录编译安装(参考前面的步骤)。 -
ES 启动报错 “max virtual memory areas vm.max_map_count [65530] is too low”
原因:系统默认的虚拟内存映射数不够,ES 启动要求至少 262144。解决:执行
sysctl -w vm.max_map_count=262144,并加到/etc/sysctl.conf里永久生效。 -
中文乱码
原因:JSON 序列化时没开 UTF-8,中文被转成了其他编码。解决:确保
Serialize函数里swb.settings_["emitUTF8"] = true。 -
查询不到数据(明明插入成功了)
原因 1:字段类型错了,比如手机号用了 text 类型(分词后变成 “155”“6666”“7777”),用 term 精确匹配肯定查不到。解决:手机号、ID 等需要精确匹配的字段用 keyword 类型。
原因 2:查询时没加
.keyword,比如append_must_term("phone", "13344445555"),应该写成phone.keyword。
总结
这套封装其实不算复杂,核心思路就是 “把重复工作交给代码,自己专注业务逻辑”。不用搞复杂的设计模式,也不用追求 “大而全”,能解决自己项目里的痛点就行。
封装完后,我自己写代码的效率提高了不少,就算隔段时间再看,也能快速明白每个类的作用。如果你的 C++ 项目也用 ES,不妨试试这个思路 —— 根据自己的需求调整字段类型和查询条件,适合自己的才是最好的。
660

被折叠的 条评论
为什么被折叠?



