目录
[二、common.h --- 数据结构的地基](#二、common.h — 数据结构的地基)
[2.1 Message --- 一条消息](#2.1 Message — 一条消息)
[2.2 Config / APIConfig / OllamaConfig](#2.2 Config / APIConfig / OllamaConfig)
[2.3 为什么要加虚析构?](#2.3 为什么要加虚析构?)
[2.4 Session --- 对话的容器](#2.4 Session — 对话的容器)
[2.5 ModelInfo --- 描述模型的元信息](#2.5 ModelInfo — 描述模型的元信息)
[三、myLog.h/cpp --- 日志系统](#三、myLog.h/cpp — 日志系统)
[3.1 为什么不用 std::cout?](#3.1 为什么不用 std::cout?)
[3.2 myLog.h --- 头文件](#3.2 myLog.h — 头文件)
[3.3 myLog.cpp --- 实现(双重检查锁定)](#3.3 myLog.cpp — 实现(双重检查锁定))
[4.1 Ubuntu 依赖安装](#4.1 Ubuntu 依赖安装)
[4.2 API Key 申请](#4.2 API Key 申请)
一、开篇
上一篇我们聊了这个项目的定位和技术栈。这一篇我们真正开始看代码,从最底层的数据结构开始,一步一个脚印往上走。
一个 C++ 后端项目,做得怎么样,看看数据结构设计和日志系统就能判断个七七八八。这篇我们就从这两块入手:
- common.h --- 项目里所有类型都从这里来,是地基中的地基
- myLog.h/cpp --- 没有好的日志,调试和生产排障都是噩梦
- 环境搭建 --- 让大家能在自己的机器上跑起来
二、common.h --- 数据结构的地基
2.1 Message --- 一条消息
cpp
struct Message {
std::string _messageId; // 唯一标识,自动生成
std::string _role; // "user" 或 "assistant"
std::string _content; // 实际对话内容
std::time_t _timestamp; // 时间戳
};
设计意图:
_role只有两种取值,对应 AI 对话中的基本角色。实际上 AI 的 API 支持四种角色(system/user/assistant/tool),本项目简化了,只用了 user 和 assistant_messageId是有格式的:msg_时间戳_8位序号,比如msg_1758016244_00000001_timestamp用time_t(秒级精度),因为聊天记录只要知道大概时间就行,不需要毫秒级
为什么不把 Message 设计成 class 用 getter/setter?
对于这种纯数据结构 (POD-like),用 struct 直接暴露成员比 class 封装更简洁。如果未来需要加校验逻辑,再改成 class 也不晚。不要过度设计。
2.2 Config / APIConfig / OllamaConfig
这是项目中继承关系的体现:
Config(基类)
├── APIConfig(在线模型:需要 API Key)
└── OllamaConfig(本地模型:需要 endpoint)
cpp
struct Config {
std::string _modelName;
double _temperature = 0.7; // 默认值 0.7
int _maxTokens = 2048;
virtual ~Config() = default; // ← 重点!
};
struct APIConfig : public Config {
std::string _apiKey; // DeepSeek/ChatGPT/Gemini
};
struct OllamaConfig : public Config {
std::string _modelDesc;
std::string _endpoint; // 本地 Ollama 服务地址
};
为什么分成两个子类?
因为两种模型的配置信息不一样:
- 调用 DeepSeek 的 API 需要 API Key,但不需要 endpoint(用默认的 https://api.deepseek.com)
- 调用本地的 Ollama 不需要 API Key,但需要指定 endpoint(比如 http://127.0.0.1:11434)
如果合并在一个结构体里,会有很多字段在某些场景下是无效的。继承让每个子类只保留自己需要的字段,语义清晰。
2.3 为什么要加虚析构?
virtual ~Config() = default; 这一行是整个数据结构设计的画龙点睛之笔。
来看后面的代码中是怎么用 Config 的(ChatSDK.cpp):
cpp
// 统一用基类指针存储
std::vector<std::shared_ptr<Config>> configs;
configs.push_back(std::make_shared<APIConfig>(...));
configs.push_back(std::make_shared<OllamaConfig>(...));
// 运行时判断具体类型(dynamic_pointer_cast)
for(const auto& config : configs) {
if(auto apiCfg = std::dynamic_pointer_cast<APIConfig>(config)) {
// 处理 APIConfig...
} else if(auto ollaCfg = std::dynamic_pointer_cast<OllamaConfig>(config)) {
// 处理 OllamaConfig...
}
}
两个作用:
-
RTTI(运行时类型识别) :
std::dynamic_pointer_cast<T>(ptr):专门用于智能指针的动态类型转换 。它会尝试把基类指针config转换成子类APIConfig的指针。dynamic_pointer_cast要求基类有虚函数表(vtable)。只要有一个虚函数(哪怕只是虚析构),编译器就会为这个类生成虚函数表,RTTI 才能工作。如果没有虚析构,dynamic_pointer_cast编译不过。 -
正确析构 :如果通过
Config*指针删除一个APIConfig对象,而析构函数不是虚函数,只会调用Config的析构,不会调用APIConfig的析构,造成资源泄漏。虽然现在两个子类没有额外资源,但这是好习惯。
2.4 Session --- 对话的容器
cpp
struct Session {
std::string _sessionId;
std::string _modelName;
std::vector<Message> _messages; // 核心:历史消息列表
std::time_t _createdAt;
std::time_t _updatedAt;
};
关键设计 :_messages 是一个 vector<Message>,存了完整的对话历史。
为什么这么做?因为 AI 模型的多轮对话需要把全部历史消息都发给它。比如:
用户:你好 → 发给 AI:[{"role":"user","content":"你好"}]
AI:你好! → 存到 messages
用户:今天天气怎么样 → 发给 AI:[{"role":"user","content":"你好"},
{"role":"assistant","content":"你好!"},
{"role":"user","content":"今天天气怎么样"
每次发消息都带上历史,AI 才能"记得"之前说了什么。所以 Session 里必须维护完整的消息列表。
2.5 ModelInfo --- 描述模型的元信息
cpp
struct ModelInfo {
std::string _modelName; // 模型名称
std::string _modelDesc; // 模型描述
std::string _provider; // 提供商
std::string _endpoint; // API 端点
bool _isAvailable = false; // 是否可用
};
这个结构体主要在 GET /api/models 接口中使用,让前端知道有哪些模型可以用。_isAvailable 标记某个模型是否初始化成功(比如 API Key 无效时,该模型就不可用)。
三、myLog.h/cpp --- 日志系统
3.1 为什么不用 std::cout?
新手最容易犯的错:到处 std::cout << "xxx" << std::endl;
这么做的问题:
| 问题 | 说明 |
|---|---|
| 没有分级 | 调试信息和错误信息混在一起,生产环境没法看 |
| 没有时间戳 | 出了问题不知道什么时候发生的 |
| 没有文件/行号 | 不知道这行日志是哪个文件的哪行代码打的 |
| 多线程混乱 | 多个线程同时 cout,输出会交叉混在一起 |
| 没法关 | 线上不能关 cout,注释掉又麻烦 |
所以正经的 C++ 项目都会用专业的日志库。这个项目选了 spdlog,C++ 社区最流行的日志库。
3.2 myLog.h --- 头文件
cpp
#pragma once
#include <mutex>
#include <spdlog/logger.h>
#include <spdlog/spdlog.h>
namespace bite {
class Logger {
public:
// 初始化日志器
static void initLogger(
const std::string& loggerName, // 日志器名称
const std::string& loggerFile, // "stdout" 或文件路径
spdlog::level::level_enum logLevel = spdlog::level::info // 日志级别
);
// 获取日志器实例
static std::shared_ptr<spdlog::logger> getLogger();
private:
Logger(); // 禁止外部构造
Logger(const Logger&) = delete; // 禁止拷贝
Logger& operator=(const Logger&) = delete; // 禁止赋值
static std::shared_ptr<spdlog::logger> _logger; // 全局唯一实例
static std::mutex _mutex; // 线程安全锁
};
//五个日志宏(自动带文件名+行号)
#define TRACE(format, ...) bite::Logger::getLogger()->trace( \
std::string("[{:>10s}:{:<4d}]")+format, __FILE__, __LINE__, ##__VA_ARGS__)
#define DBG(format, ...) bite::Logger::getLogger()->debug(...)
#define INFO(format, ...) bite::Logger::getLogger()->info(...)
#define WARN(format, ...) bite::Logger::getLogger()->warn(...)
#define ERR(format, ...) bite::Logger::getLogger()->error(...)
#define CRIT(format, ...) bite::Logger::getLogger()->critical(...)
} // end namespace bite
3.3 myLog.cpp --- 实现(双重检查锁定)
cpp
#include "../../include/util/myLog.h"
#include <spdlog/sinks/stdout_color_sinks.h>
#include <spdlog/sinks/basic_file_sink.h>
#include <spdlog/async.h>
namespace bite {
std::shared_ptr<spdlog::logger> Logger::_logger = nullptr;
std::mutex Logger::_mutex;
void Logger::initLogger(const std::string& loggerName,
const std::string& loggerFile,
spdlog::level::level_enum logLevel) {
// 第一层检查:不加锁,快速判断
if (nullptr == _logger) {
std::lock_guard<std::mutex> lock(_mutex);
// 第二层检查:加锁后再判断一次,防止重复创建
if (nullptr == _logger) {
// 设置自动刷新级别
spdlog::flush_on(logLevel);
// 启用异步日志(队列大小 32768,1 个后台线程)
spdlog::init_thread_pool(32768, 1);
if ("stdout" == loggerFile) {
// 控制台输出(带颜色)
_logger = spdlog::stdout_color_mt(loggerName);
} else {
// 文件输出(异步模式)
_logger = spdlog::basic_logger_mt<spdlog::async_factory>(
loggerName, loggerFile);
}
// 设置日志格式:[时分秒][日志器名称][日志级别]消息
_logger->set_pattern("[%H:%M:%S][%n][%-7l]%v");
_logger->set_level(logLevel);
}
}
}
std::shared_ptr<spdlog::logger> Logger::getLogger() {
return _logger;
}
} // end namespace bite
3.4双重检查锁定
这是单例模式的一种经典实现方式,面试高频。
cpp
if (nullptr == _logger) { // 第一层检查(无锁)
std::lock_guard<std::mutex> lock(_mutex);
if (nullptr == _logger) { // 第二层检查(有锁)
_logger = ...; // 真正创建
}
}
为什么需要两次检查?
-
第一次检查(无锁) :
initLogger可能在程序启动时被多次调用,但创建只需要一次。如果_logger已经不为空,直接跳过,避免每次调用都加锁(加锁是有性能开销的)。 -
第二次检查(有锁) :假设线程 A 和线程 B 同时通过了第一次检查(此时
_logger都是 nullptr),线程 A 拿到锁开始创建,线程 B 在锁外面等。等 A 创建完释放锁,B 拿到锁后如果不再检查一次,就会再次创建,把 A 创建好的实例覆盖掉。
四、环境搭建
千里之行始于足下,先把环境搭好。
4.1 Ubuntu 依赖安装
cpp
# 必备库
sudo apt-get install libgflags-dev # 命令行参数解析
sudo apt-get install libspdlog-dev # 日志库
sudo apt-get install libjsoncpp-dev # JSON 解析/生成
sudo apt-get install libgtest-dev # 单元测试
sudo apt-get install libssl-dev # HTTPS 传输
sudo apt-get install libsqlite3-dev # 数据库
sudo apt-get install cmake # 构建工具
sudo apt-get install curl # HTTP 工具
sudo apt-get install pkg-config # 依赖管理
# cpp-httplib(header-only 库,只需头文件)
git clone https://github.com/yhirose/cpp-httplib.git
cd cpp-httplib
sudo cp httplib.h /usr/include/
4.2 API Key 申请
调用厂商的 API 需要 API Key,相当于你的"账号密码":
| 厂商 | 申请地址 | 环境变量名 |
|---|---|---|
| DeepSeek | https://platform.deepseek.com | deepseek_apikey |
| OpenAI (ChatGPT) | https://platform.openai.com | chatgpt_apikey |
| Google Gemini | https://aistudio.google.com | gemini_apikey |
五、本期总结
你这个项目是怎么组织数据结构的?
设计了六个核心结构体:Message 表示单条消息,Session 表示一次会话(包含消息列表),Config 是模型配置的基类,APIConfig 和 OllamaConfig 分别继承它,ModelInfo 描述模型的元信息。
这里有一个设计细节:Config 的析构函数加了 virtual 。因为后面在 ChatSDK 中,我会用
shared_ptr<Config>统一管理多种配置(APIConfig 和 OllamaConfig),运行时用dynamic_pointer_cast判断具体类型。虚析构保证了 RTTI 可用,也保证了通过基类指针删除派生类对象时能正确调用派生类析构函数。
你项目里的日志是怎么做的?
基于 spdlog 封装了一个 Logger 单例。用双重检查锁定 保证线程安全,用宏 自动带上
__FILE__和__LINE__,支持 TRACE/DBG/INFO/WARN/ERR/CRIT 六个级别。支持控制台彩色输出和文件输出两种模式,底层用 spdlog 的异步模式,不阻塞主线程。