从环境搭建到代码落地:ES C++ 客户端封装的实践笔记

最近在做一个 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 遍类似代码 —— 不仅重复率高,容易因手误出错,后续想统一改日志格式,还得逐个文件修改。封装的核心目标就是解决这些问题:

  1. 少写重复代码:序列化、错误处理、HTTP 请求逻辑统一封装,一次写好后续复用;

  2. 降低使用门槛:不用每次都记 ES 的 JSON 语法,调用类方法就能完成操作;

  3. 统一代码规范:日志输出格式、错误判断标准都固定,后续维护更省心。

三、封装代码解析:从工具到业务类

封装分了三层: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;
}

五、实际使用中的坑点与解决办法

  1. elasticlient 编译报错 “undefined reference to googletest”

    原因:子模块 googletest 没编译安装,链接时找不到相关符号。解决:手动进入external/googletest目录编译安装(参考前面的步骤)。

  2. 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里永久生效。

  3. 中文乱码

    原因:JSON 序列化时没开 UTF-8,中文被转成了其他编码。解决:确保Serialize函数里swb.settings_["emitUTF8"] = true

  4. 查询不到数据(明明插入成功了)

    原因 1:字段类型错了,比如手机号用了 text 类型(分词后变成 “155”“6666”“7777”),用 term 精确匹配肯定查不到。解决:手机号、ID 等需要精确匹配的字段用 keyword 类型。

    原因 2:查询时没加.keyword,比如append_must_term("phone", "13344445555"),应该写成phone.keyword

总结

这套封装其实不算复杂,核心思路就是 “把重复工作交给代码,自己专注业务逻辑”。不用搞复杂的设计模式,也不用追求 “大而全”,能解决自己项目里的痛点就行。

封装完后,我自己写代码的效率提高了不少,就算隔段时间再看,也能快速明白每个类的作用。如果你的 C++ 项目也用 ES,不妨试试这个思路 —— 根据自己的需求调整字段类型和查询条件,适合自己的才是最好的。

 

评论 23
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值