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

解析的核心逻辑其实就三步:

  1. 按行读取,跳过注释(; 开头)和空行
  2. 遇到 [section] 就切换当前节
  3. 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 配置加载的完整流程

嗯,这里我把整个流程画了一张图,方便你理解:

日志配置加载与重载流程 系统启动 读取配置文件(INI/JSON) 解析配置项 验证配置合法性 使用默认配置 应用新配置 运行时重载 文件变更检测 重新解析配置

18.6 避坑指南

做配置文件解析,有几个坑我踩过,你们注意一下:

  • 编码问题:配置文件保存为 UTF-8 with BOM 时,解析第一个 key 会多出三个字节。我当年在 Windows 上编辑配置文件,放到 Linux 上跑,解析出来的第一个 key 前面多了 \xEF\xBB\xBF,查了半天。
  • 原子性写入:修改配置文件时,先写临时文件,再 rename 覆盖。防止写入一半时系统断电,留下一个半残的文件。
  • 重载频率限制:不要每毫秒都去 stat 文件。1 秒检查一次足够了,省点 CPU。
  • 配置回滚:新配置解析失败时,自动回滚到上一份有效配置。我一般会在内存里保留两份配置——当前生效的和上一次有效的。

我的习惯:在日志系统初始化时,先加载默认配置(硬编码在代码里),然后再尝试读取外部配置文件。如果外部文件不存在或解析失败,就用默认配置跑。这样即使配置文件丢了,系统也能正常工作。

18.7 小结

配置文件解析这件事,说难不难,说简单也不简单。关键是你要想清楚:什么时候加载?加载失败怎么办?运行时怎么更新?

我个人建议,小项目用 INI,解析简单,移植性好。大项目或者需要复杂配置结构的,上 JSON。至于运行时重载,文件监控 + 定时检查是最稳妥的方案,不依赖操作系统特性,通用性最强。

嗯,这一章就到这里。配置解析搞定了,你的日志系统才算真正「活」了起来。


公众号:蓝海资料掘金营,微信 deep3321