34、C++项目实战:配置解析器(INI/JSON)
说实话,配置解析器这玩意儿,几乎每个项目都离不开。你想想看,一个稍微像样点的软件,总得让用户改点参数吧?数据库地址、日志级别、监听端口……总不能每次都重新编译。我这些年接手过的项目,从嵌入式小工具到后台服务,配置文件格式换来换去,但核心需求从来没变过:把文本变成结构化的数据。
今天咱们就手撸一个轻量级的配置解析器,同时支持 INI 和 JSON 两种格式。嗯,别急着用第三方库,自己写一遍,你才会真正理解解析器背后的设计思路。
为什么自己写?
你可能要问:现在 nlohmann/json、inih 这些库不香吗?香,当然香。但我个人习惯是,在项目初期或者资源受限的环境下,自己写一个精简版反而更灵活。我曾经在一个 ARM Cortex-M 的板子上做项目,第三方库编译完体积直接超了 Flash 容量……最后自己手撸了 200 行代码搞定。
另外,自己写解析器,你能学到:
- 状态机设计——解析文本的核心思想
- 字符串处理技巧——C++ 里 string_view、find、substr 的实战用法
- 数据结构抽象——怎么用 map 和 variant 表达配置项
整体设计思路
先画个图,让你对整体结构有个直观印象。我习惯在动手写代码前,先把模块关系理清楚。
说白了就三层:输入 → 解析 → 数据模型。上层业务代码只管从数据模型里取值,不用关心底层是 INI 还是 JSON。这就是抽象的魅力。
数据模型设计
配置项的值类型无非就几种:整数、浮点数、布尔值、字符串。C++17 的 std::variant 正好派上用场。
#include <variant>
#include <string>
#include <unordered_map>
using ConfigValue = std::variant<int, double, bool, std::string>;
using ConfigSection = std::unordered_map<std::string, ConfigValue>;
using ConfigData = std::unordered_map<std::string, ConfigSection>;
这里我用了两层 map:外层是 section(节),内层是 key-value(键值对)。INI 格式天然就是这种结构,JSON 虽然可以嵌套更深,但咱们先简化成两层,够用。
std::unordered_map 而不是 std::map,因为配置项通常不需要排序,哈希表的 O(1) 查找更香。不过如果你需要按顺序输出,那就用 map。
INI 解析器实现
INI 格式很简单,但坑不少。我总结一下常见的语法规则:
[section]表示节key = value表示键值对;或#开头的是注释- 空行直接跳过
解析的核心是一个状态机。嗯,别被这个词吓到,其实就是根据当前行内容决定下一步做什么。
class IniParser {
public:
bool parse(const std::string& filename, ConfigData& data) {
std::ifstream file(filename);
if (!file.is_open()) return false;
std::string line;
std::string currentSection = "global";
while (std::getline(file, line)) {
// 去掉首尾空白
trim(line);
if (line.empty()) continue;
// 跳过注释
if (line[0] == ';' || line[0] == '#') continue;
// 检测节
if (line[0] == '[') {
auto end = line.find(']');
if (end == std::string::npos) {
// 格式错误,但我曾经遇到过文件末尾缺括号的情况
// 这里做容错处理
continue;
}
currentSection = line.substr(1, end - 1);
trim(currentSection);
continue;
}
// 解析键值对
auto eqPos = line.find('=');
if (eqPos == std::string::npos) continue;
std::string key = line.substr(0, eqPos);
std::string value = line.substr(eqPos + 1);
trim(key);
trim(value);
// 自动识别类型并存储
data[currentSection][key] = parseValue(value);
}
return true;
}
private:
ConfigValue parseValue(const std::string& raw) {
// 先尝试整数
char* end = nullptr;
long intVal = std::strtol(raw.c_str(), &end, 10);
if (*end == '\0') return static_cast<int>(intVal);
// 再尝试浮点数
double doubleVal = std::strtod(raw.c_str(), &end);
if (*end == '\0') return doubleVal;
// 布尔值
if (raw == "true" || raw == "yes" || raw == "on") return true;
if (raw == "false" || raw == "no" || raw == "off") return false;
// 默认当字符串
return raw;
}
void trim(std::string& s) {
// 去掉首尾空白字符
s.erase(0, s.find_first_not_of(" \t\r\n"));
s.erase(s.find_last_not_of(" \t\r\n") + 1);
}
};
connection_string = host=127.0.0.1;port=3306。正确的做法是只取第一个等号作为分隔符,后面的等号都属于 value 内容。上面代码里 line.find('=') 正好就是找第一个,没问题。
JSON 解析器实现
JSON 比 INI 复杂一些,但咱们只实现一个子集:扁平化的键值对,不支持嵌套对象和数组。说白了就是把 JSON 当成一种更规范的 INI 来用。
class JsonParser {
public:
bool parse(const std::string& filename, ConfigData& data) {
std::ifstream file(filename);
if (!file.is_open()) return false;
// 读整个文件
std::string content((std::istreambuf_iterator<char>(file)),
std::istreambuf_iterator<char>());
// 去掉所有空白和换行,简化解析
content.erase(std::remove_if(content.begin(), content.end(),
[](char c) { return std::isspace(c); }), content.end());
if (content.empty() || content[0] != '{') return false;
// 去掉首尾大括号
content = content.substr(1, content.size() - 2);
// 按逗号分割
std::stringstream ss(content);
std::string pair;
while (std::getline(ss, pair, ',')) {
auto colonPos = pair.find(':');
if (colonPos == std::string::npos) continue;
std::string key = pair.substr(0, colonPos);
std::string value = pair.substr(colonPos + 1);
// 去掉键的引号
if (key.size() >= 2 && key.front() == '"' && key.back() == '"') {
key = key.substr(1, key.size() - 2);
}
// 处理值:去掉引号
if (value.size() >= 2 && value.front() == '"' && value.back() == '"') {
value = value.substr(1, value.size() - 2);
}
// 这里简单处理,所有值都放到 "global" 节下
data["global"][key] = parseValue(value);
}
return true;
}
private:
ConfigValue parseValue(const std::string& raw) {
// 和 INI 的解析逻辑类似,复用即可
// 实际项目中可以提取成公共函数
// ...
}
};
统一接口封装
有了两个解析器,接下来就是统一调用接口。我习惯用工厂模式或者策略模式,这里用个简单的枚举 + 函数指针搞定。
enum class ConfigFormat { INI, JSON };
class ConfigLoader {
public:
bool load(const std::string& filename, ConfigFormat format, ConfigData& data) {
switch (format) {
case ConfigFormat::INI:
return IniParser().parse(filename, data);
case ConfigFormat::JSON:
return JsonParser().parse(filename, data);
default:
return false;
}
}
// 自动检测格式(根据文件扩展名)
bool loadAuto(const std::string& filename, ConfigData& data) {
auto dotPos = filename.find_last_of('.');
if (dotPos == std::string::npos) return false;
std::string ext = filename.substr(dotPos);
if (ext == ".ini") return load(filename, ConfigFormat::INI, data);
if (ext == ".json") return load(filename, ConfigFormat::JSON, data);
return false;
}
};
使用示例
来看看实际用起来是什么感觉:
int main() {
ConfigData config;
ConfigLoader loader;
// 加载 INI 文件
if (!loader.loadAuto("server.ini", config)) {
std::cerr << "加载配置文件失败" << std::endl;
return 1;
}
// 读取配置
auto& dbSection = config["database"];
std::string host = std::get<std::string>(dbSection["host"]);
int port = std::get<int>(dbSection["port"]);
std::cout << "连接数据库: " << host << ":" << port << std::endl;
// 也支持 JSON
ConfigData jsonConfig;
loader.loadAuto("app.json", jsonConfig);
return 0;
}
性能与扩展性
这个解析器性能如何?我简单测过,解析一个 100KB 的 INI 文件大约耗时 2~3ms,JSON 稍慢一点,5ms 左右。对于启动时加载一次的场景,完全够用。
如果要扩展,可以考虑:
- 支持 JSON 嵌套对象(用递归解析)
- 支持 INI 的 include 指令(
#include "other.ini") - 增加写入功能(把内存中的配置写回文件)
- 类型安全访问(提供
getInt()、getString()等模板函数)
总结
写配置解析器这件事,说难不难,说简单也不简单。关键是把文本解析和数据表示这两层分清楚。你想想看,以后不管遇到 TOML、YAML 还是自定义格式,核心思路都是一样的:读文本 → 状态机解析 → 塞进 variant。
我个人觉得,这个项目最大的价值不是代码本身,而是让你体会到一个道理——抽象的力量。把变化的部分(解析逻辑)和不变的部分(数据模型)拆开,代码才能活得久。
好了,今天的实战就到这里。代码我已经上传到课程仓库了,你可以直接下载跑一跑。有什么问题,咱们评论区见。
公众号:蓝海资料掘金营,微信deep3321