19、配置文件解析:INI/JSON/XML解析库选择、配置管理接口设计

配置文件这东西,说大不大,说小不小。我见过不少项目,代码写得漂漂亮亮,结果配置文件管理一塌糊涂。要么是解析库选得不对,要么是接口设计得让人想骂娘。今天咱们就聊聊这个看似简单、实则暗藏玄机的话题。

为什么配置文件这么重要?

你想想看,一个嵌入式设备出厂后,用户不可能为了改个参数就去重新烧录固件。配置文件的本质,就是把「可变的部分」从代码里剥离出来。我早期做的一个项目,把波特率、IP地址、传感器阈值全写死在代码里,结果每次客户需求变更都要重新编译发布,那叫一个痛苦。

配置文件的价值在于:

  • 运行时可变:不用重新编译,改个文件就行
  • 环境隔离:开发、测试、生产环境用不同配置
  • 模块解耦:每个模块读自己的配置段,互不干扰

三大格式:INI、JSON、XML怎么选?

说实话,没有银弹。每种格式都有它的脾气。我根据实际项目经验,给你列个对比表:

特性 INI JSON XML
可读性 ★★★★★ ★★★★ ★★★
解析复杂度 低(手写都行) 高(需要DOM/SAX)
内存占用
嵌套支持 差(只有节+键值) 非常好
嵌入式适用性 ★★★★★ ★★★★ ★★

我个人习惯是:资源受限的MCU用INI,有文件系统的Linux设备用JSON,需要复杂数据交换的场景才考虑XML。XML在嵌入式里真的不太常见,除非你对接的是某些工业协议。

解析库选择:别重复造轮子

嗯,这里要重点说说。我见过有人自己手写JSON解析器,结果遇到转义字符就崩了。其实开源社区已经有很多成熟的库,直接拿来用就好。

INI解析库

  • inih:轻量级,C语言,几百行代码。我一直在用,稳定可靠。
  • libini:功能更全,支持注释、多行值。

JSON解析库

  • cJSON:嵌入式领域的事实标准。MIT协议,随便用。
  • jsmn:极简,只有头文件,适合RAM极小的场景。
  • parson:API设计得比较优雅,支持序列化。

XML解析库

  • minixml:专为嵌入式设计,内存占用小。
  • expat:基于SAX的事件驱动解析,适合大文件。
我的建议:如果项目刚起步,先用inih+cJSON组合。这两个库我用了五六年,从来没出过问题。别一上来就上XML,除非你确定需要。

配置管理接口设计:核心思路

光选对库还不够,接口设计才是灵魂。我总结了一套「三层接口」模式,你可以参考:

/* 第一层:底层解析器封装 */
typedef struct {
    void *parser_handle;  /* 具体解析库的句柄 */
    int   type;           /* CFG_TYPE_INI / CFG_TYPE_JSON */
} cfg_parser_t;

/* 第二层:配置项读写接口 */
int cfg_get_int(cfg_parser_t *parser, const char *section, 
                const char *key, int default_val);
const char* cfg_get_str(cfg_parser_t *parser, const char *section,
                        const char *key, const char *default_val);
int cfg_set_int(cfg_parser_t *parser, const char *section,
                const char *key, int val);

/* 第三层:模块专属配置结构体 */
typedef struct {
    int  baudrate;
    char ip[16];
    int  timeout_ms;
} comm_config_t;

int comm_config_load(cfg_parser_t *parser, comm_config_t *cfg);

为什么要分三层?

  • 第一层:屏蔽不同解析库的差异。今天用inih,明天想换libini,改这一层就行。
  • 第二层:提供统一的读写接口。所有模块都通过这组函数访问配置。
  • 第三层:每个模块定义自己的配置结构体,把字符串转成结构化数据。

我在项目中遇到过一个问题:有个同事直接在每个模块里调用cJSON的API,结果后来要换成INI格式,改了几十个文件。从那以后,我强制要求所有配置访问必须经过中间层。

避坑指南:我曾经踩过的坑

坑1:配置文件编码问题
我曾经遇到一个诡异的问题:配置文件在Windows上编辑正常,放到Linux设备上就解析失败。查了半天,原来是UTF-8 BOM头搞的鬼。解决方案:解析前检查前三个字节是不是EF BB BF,是的话跳过。
坑2:默认值策略
配置项缺失时怎么办?我见过直接返回错误的,也见过用随机值的。正确的做法是:每个配置项都定义默认值,读取失败时用默认值,同时打印警告日志。这样设备至少能跑起来。
坑3:写回时的原子性
如果你支持运行时修改配置并写回文件,一定要先写临时文件,再rename覆盖原文件。直接写原文件,万一写一半断电,配置文件就毁了。这个教训是我用一块砖头换来的。

配置管理流程图

下面这张图展示了配置管理的完整流程,从文件加载到模块使用:

配置管理流程 配置文件 config.ini / config.json 解析器层 inih / cJSON / minixml 统一配置接口层 cfg_get_int / cfg_get_str / cfg_set_int 模块配置结构体 comm_config_t / sensor_config_t 存储层 解析层 接口层 应用层

一个完整的配置管理示例

说了这么多,不如直接看代码。下面是一个基于inih的配置管理实现:

/* config_manager.h */
#ifndef CONFIG_MANAGER_H
#define CONFIG_MANAGER_H

#include <stdint.h>

/* 配置管理器句柄 */
typedef struct config_manager config_manager_t;

/* 创建配置管理器 */
config_manager_t* cfg_mgr_create(const char *filepath);

/* 销毁配置管理器 */
void cfg_mgr_destroy(config_manager_t *mgr);

/* 读取配置项 */
int cfg_mgr_get_int(config_manager_t *mgr, 
                    const char *section, 
                    const char *key,
                    int default_val);

const char* cfg_mgr_get_string(config_manager_t *mgr,
                               const char *section,
                               const char *key,
                               const char *default_val);

/* 写入配置项(可选,需要写回文件时使用) */
int cfg_mgr_set_int(config_manager_t *mgr,
                    const char *section,
                    const char *key,
                    int val);

int cfg_mgr_save(config_manager_t *mgr);

#endif /* CONFIG_MANAGER_H */
/* config_manager.c - 基于inih实现 */
#include "config_manager.h"
#include "ini.h"
#include <stdlib.h>
#include <string.h>

struct config_manager {
    char filepath[256];
    ini_t *ini;  /* inih的解析句柄 */
};

config_manager_t* cfg_mgr_create(const char *filepath) {
    config_manager_t *mgr = calloc(1, sizeof(config_manager_t));
    if (!mgr) return NULL;
    
    strncpy(mgr->filepath, filepath, sizeof(mgr->filepath) - 1);
    mgr->ini = ini_load(filepath);
    
    if (!mgr->ini) {
        /* 文件不存在时,创建一个空的配置 */
        mgr->ini = ini_create();
        if (!mgr->ini) {
            free(mgr);
            return NULL;
        }
    }
    
    return mgr;
}

void cfg_mgr_destroy(config_manager_t *mgr) {
    if (mgr) {
        if (mgr->ini) ini_destroy(mgr->ini);
        free(mgr);
    }
}

int cfg_mgr_get_int(config_manager_t *mgr,
                    const char *section,
                    const char *key,
                    int default_val) {
    if (!mgr || !mgr->ini) return default_val;
    
    const char *val = ini_get(mgr->ini, section, key);
    if (!val) return default_val;
    
    return atoi(val);
}

const char* cfg_mgr_get_string(config_manager_t *mgr,
                               const char *section,
                               const char *key,
                               const char *default_val) {
    if (!mgr || !mgr->ini) return default_val;
    
    const char *val = ini_get(mgr->ini, section, key);
    return val ? val : default_val;
}

int cfg_mgr_set_int(config_manager_t *mgr,
                    const char *section,
                    const char *key,
                    int val) {
    if (!mgr || !mgr->ini) return -1;
    
    char buf[32];
    snprintf(buf, sizeof(buf), "%d", val);
    return ini_set(mgr->ini, section, key, buf);
}

int cfg_mgr_save(config_manager_t *mgr) {
    if (!mgr || !mgr->ini) return -1;
    
    /* 先写临时文件,保证原子性 */
    char tmp_path[256];
    snprintf(tmp_path, sizeof(tmp_path), "%s.tmp", mgr->filepath);
    
    if (ini_save(mgr->ini, tmp_path) != 0) {
        return -1;
    }
    
    /* rename是原子操作 */
    if (rename(tmp_path, mgr->filepath) != 0) {
        remove(tmp_path);
        return -1;
    }
    
    return 0;
}

核心要点总结:

  • 选库要匹配场景:MCU用INI,Linux用JSON,特殊场景用XML
  • 接口设计要分层:底层解析器 → 统一接口 → 模块结构体
  • 默认值策略:读取失败时用默认值,保证设备能启动
  • 写回文件:先写临时文件再rename,防止断电损坏
  • 编码问题:注意UTF-8 BOM头,解析前做预处理

配置文件管理看着简单,但做得好不好,直接影响到项目的可维护性。我见过太多项目因为配置管理混乱,后期改个参数都要翻半天代码。希望今天分享的这些经验,能帮你少走一些弯路。


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