18、日志系统配置:配置文件解析(INI/JSON),运行时重载配置
日志系统这东西,说白了就是个「开关」和「水龙头」的组合。你写代码时恨不得把所有变量都打印出来,上线后却只想看到 ERROR 级别的信息。怎么切换?总不能每次改代码重新编译吧?
我个人习惯是把日志配置扔到外部文件里。INI 也好,JSON 也罢,甚至 YAML 也行。关键是——运行时能重载。你想想看,线上出了个 bug,你改一下日志级别,服务不用重启,立马看到调试信息,这感觉多爽。
18.1 为什么需要配置文件?
我在项目中遇到过好几次这样的场景:嵌入式设备跑得好好的,突然某个模块报错。但日志级别设的是 WARN,根本看不到 DEBUG 信息。等我把固件重新烧一遍,问题又复现不了了……
所以,配置文件至少解决三个问题:
- 解耦:日志策略和业务代码分开,改配置不用动代码
- 灵活:不同环境(开发、测试、生产)用不同配置
- 动态:运行时调整,不用重启系统
核心原则:日志配置应该是「运行时参数」,而不是「编译时常量」。
18.2 INI 格式解析
INI 格式简单粗暴,适合资源受限的嵌入式环境。我最早在 STM32 上做日志系统时,用的就是 INI。解析器自己写,几十行代码搞定。
一个典型的日志配置文件长这样:
; log_config.ini
[global]
level = DEBUG
output = serial,file
max_file_size = 1048576
[module:network]
level = INFO
[module:storage]
level = ERROR
解析的核心逻辑其实就三步:
- 按行读取,跳过注释(; 开头)和空行
- 遇到 [section] 就切换当前节
- key = value 解析,存入哈希表
我给你们看一个精简版的解析器:
#include <stdio.h>
#include <string.h>
typedef struct {
char section[32];
char key[32];
char value[64];
} INIEntry;
int parse_ini(const char *path, INIEntry *entries, int max_entries) {
FILE *fp = fopen(path, "r");
if (!fp) return -1;
char line[128];
char current_section[32] = "";
int count = 0;
while (fgets(line, sizeof(line), fp)) {
// 去掉换行符
line[strcspn(line, "\n")] = 0;
// 跳过注释和空行
if (line[0] == ';' || line[0] == '#') continue;
if (strlen(line) == 0) continue;
// 解析 section
if (line[0] == '[') {
char *end = strchr(line, ']');
if (end) {
*end = 0;
strncpy(current_section, line + 1, sizeof(current_section) - 1);
}
continue;
}
// 解析 key=value
char *eq = strchr(line, '=');
if (!eq) continue;
*eq = 0;
strncpy(entries[count].section, current_section, sizeof(entries[count].section) - 1);
strncpy(entries[count].key, line, sizeof(entries[count].key) - 1);
strncpy(entries[count].value, eq + 1, sizeof(entries[count].value) - 1);
// 去掉 key 和 value 两端的空格
// 这里省略了 trim 函数,实际项目中一定要加
count++;
if (count >= max_entries) break;
}
fclose(fp);
return count;
}
小技巧:解析 INI 时,别忘了处理 key 和 value 前后的空格。我曾经因为这个坑,查了整整一个下午——配置里写的是 "level = DEBUG",结果解析出来 key 是 "level ",多了一个空格,死活匹配不上。
18.3 JSON 格式解析
JSON 比 INI 表达能力更强,支持嵌套结构。如果你的嵌入式平台有足够的 RAM(比如跑 Linux 或 RT-Thread),我建议用 JSON。
同样的配置,用 JSON 写出来:
{
"global": {
"level": "DEBUG",
"output": ["serial", "file"],
"max_file_size": 1048576
},
"modules": {
"network": { "level": "INFO" },
"storage": { "level": "ERROR" }
}
}
解析 JSON 我一般用 cJSON 库,轻量、稳定。嵌入式圈子里用的人很多。
#include "cJSON.h"
int load_log_config(const char *json_str) {
cJSON *root = cJSON_Parse(json_str);
if (!root) {
printf("JSON parse error\n");
return -1;
}
cJSON *global = cJSON_GetObjectItem(root, "global");
if (global) {
cJSON *level = cJSON_GetObjectItem(global, "level");
if (level && level->valuestring) {
set_log_level(level->valuestring);
}
cJSON *output = cJSON_GetObjectItem(global, "output");
if (output && cJSON_IsArray(output)) {
int size = cJSON_GetArraySize(output);
for (int i = 0; i < size; i++) {
cJSON *item = cJSON_GetArrayItem(output, i);
if (item && item->valuestring) {
add_log_output(item->valuestring);
}
}
}
}
cJSON *modules = cJSON_GetObjectItem(root, "modules");
if (modules && cJSON_IsObject(modules)) {
cJSON *mod = NULL;
cJSON_ArrayForEach(mod, modules) {
cJSON *mod_level = cJSON_GetObjectItem(mod, "level");
if (mod_level && mod_level->valuestring) {
set_module_log_level(mod->string, mod_level->valuestring);
}
}
}
cJSON_Delete(root);
return 0;
}
注意:cJSON 解析出来的字符串是动态分配的,用完记得 cJSON_Delete。我见过有人忘了释放,跑几天后内存泄漏,系统直接挂掉。
18.4 运行时重载配置
配置文件解析出来只是第一步。真正有价值的是——运行时重载。
实现方式有很多种,我常用的有两种:
- 信号触发:收到 SIGHUP 信号时重新加载配置
- 文件监控:定期检查配置文件的时间戳,变了就重载
在嵌入式环境里,我更喜欢第二种。因为很多 RTOS 不支持信号机制。
核心代码大概这样:
#include <sys/stat.h>
static time_t last_mtime = 0;
static const char *config_path = "/etc/log_config.ini";
void check_config_reload(void) {
struct stat st;
if (stat(config_path, &st) != 0) return;
if (st.st_mtime != last_mtime) {
last_mtime = st.st_mtime;
printf("[LOG] Config file changed, reloading...\n");
// 重新解析配置文件
if (reload_log_config(config_path) == 0) {
printf("[LOG] Config reloaded successfully\n");
} else {
printf("[LOG] Config reload failed, keeping old config\n");
}
}
}
// 在主循环中定期调用
void main_loop(void) {
while (1) {
check_config_reload();
// ... 其他业务逻辑
sleep(1); // 每秒检查一次
}
}
关键点:重载失败时,一定要保留旧配置。不能因为配置文件写错了,导致整个日志系统崩溃。我一般会先解析到临时变量,验证通过后再替换。
18.5 配置加载的完整流程
嗯,这里我把整个流程画了一张图,方便你理解:
18.6 避坑指南
做配置文件解析,有几个坑我踩过,你们注意一下:
- 编码问题:配置文件保存为 UTF-8 with BOM 时,解析第一个 key 会多出三个字节。我当年在 Windows 上编辑配置文件,放到 Linux 上跑,解析出来的第一个 key 前面多了 \xEF\xBB\xBF,查了半天。
- 原子性写入:修改配置文件时,先写临时文件,再 rename 覆盖。防止写入一半时系统断电,留下一个半残的文件。
- 重载频率限制:不要每毫秒都去 stat 文件。1 秒检查一次足够了,省点 CPU。
- 配置回滚:新配置解析失败时,自动回滚到上一份有效配置。我一般会在内存里保留两份配置——当前生效的和上一次有效的。
我的习惯:在日志系统初始化时,先加载默认配置(硬编码在代码里),然后再尝试读取外部配置文件。如果外部文件不存在或解析失败,就用默认配置跑。这样即使配置文件丢了,系统也能正常工作。
18.7 小结
配置文件解析这件事,说难不难,说简单也不简单。关键是你要想清楚:什么时候加载?加载失败怎么办?运行时怎么更新?
我个人建议,小项目用 INI,解析简单,移植性好。大项目或者需要复杂配置结构的,上 JSON。至于运行时重载,文件监控 + 定时检查是最稳妥的方案,不依赖操作系统特性,通用性最强。
嗯,这一章就到这里。配置解析搞定了,你的日志系统才算真正「活」了起来。