实战案例2:配置文件解析:解析INI配置文件、提取键值对、处理注释和空行、支持节(Section)解析

说实话,INI配置文件解析这个需求,在我十几年的C语言开发生涯里,碰到过不下二十次。从嵌入式设备到桌面工具,从游戏引擎到网络服务,几乎每个项目都有那么一个.ini文件在默默工作。你想想看,为什么大家都爱用INI?因为它简单、直观,人类可读性极好。

今天我就带你手写一个完整的INI解析器。嗯,这里要注意,我们不光要解析键值对,还要处理注释、空行,以及最重要的——节(Section)支持。

INI文件长什么样?

先看一个典型的INI文件结构:

; 这是注释
# 这也是注释
[server]
host = 192.168.1.1
port = 8080
; 上面是服务器配置

[database]
host = localhost
port = 3306
user = admin
password = 123456

看到了吗?分号(;)和井号(#)开头的行是注释,空行直接跳过,[xxx]是节名,剩下的就是key=value格式。我在项目中遇到过最坑的情况是,有人把等号两边加了空格,还有人用冒号代替等号。所以我们的解析器必须足够健壮。

核心数据结构设计

我个人习惯用链表来存储解析结果。为什么?因为INI文件的行数不确定,链表可以动态扩展,而且插入删除都很方便。

// 键值对节点
typedef struct kv_node {
    char key[64];
    char value[128];
    struct kv_node *next;
} kv_node_t;

// 节(Section)节点
typedef struct section_node {
    char name[64];
    kv_node_t *kv_head;  // 该节下的键值对链表
    struct section_node *next;
} section_node_t;

// 整个INI文件的解析结果
typedef struct {
    section_node_t *section_head;
    int section_count;
} ini_file_t;

这个设计其实很经典。每个Section下面挂一个键值对链表,Section之间也是链表连接。你想想看,这样我们遍历某个节下的所有配置就非常方便了。

解析核心逻辑

解析过程说白了就是逐行扫描,根据行首字符决定如何处理。我画了一张流程图,帮你理清思路:

打开INI文件 读取一行文本 行首字符 判断 ; 或 # 跳过注释行 空行 跳过空行 [ 解析节名 其他 解析 key=value 继续下一行

这个流程其实不复杂。核心就是:遇到注释和空行就跳过,遇到[xxx]就切换当前节,遇到key=value就存到当前节下面。我曾经在解析一个大型游戏配置文件时,因为没处理好节切换逻辑,导致所有键值对都跑到了同一个节下面,排查了整整一个下午。所以节切换这个点,一定要小心。

关键代码实现

下面我给出解析函数的核心实现。注意看我是怎么处理字符串修剪和注释行判断的:

// 去除字符串首尾空白
char* trim(char *str) {
    char *end;
    // 跳过前导空白
    while(isspace((unsigned char)*str)) str++;
    if(*str == 0) return str;
    // 跳过尾部空白
    end = str + strlen(str) - 1;
    while(end > str && isspace((unsigned char)*end)) end--;
    *(end+1) = '\0';
    return str;
}

// 判断是否是注释行
int is_comment(const char *line) {
    char *trimmed = trim((char*)line);
    return *trimmed == ';' || *trimmed == '#';
}

// 判断是否是节定义
int is_section(const char *line, char *section_name) {
    char *trimmed = trim((char*)line);
    if(*trimmed != '[') return 0;
    char *end = strchr(trimmed, ']');
    if(end == NULL) return 0;
    int len = end - trimmed - 1;
    strncpy(section_name, trimmed + 1, len);
    section_name[len] = '\0';
    return 1;
}

// 解析键值对
int parse_kv(const char *line, char *key, char *value) {
    char *trimmed = trim((char*)line);
    char *eq = strchr(trimmed, '=');
    if(eq == NULL) return 0;
    // 提取key
    *eq = '\0';
    strcpy(key, trim(trimmed));
    // 提取value
    strcpy(value, trim(eq + 1));
    return 1;
}
我的小技巧: 在trim函数里,我用了isspace()来判断空白字符。这个函数能处理空格、制表符、换行符等所有空白字符。很多新手只处理空格,结果遇到制表符就翻车了。

完整的解析流程

有了上面的基础函数,主解析逻辑就水到渠成了:

ini_file_t* parse_ini(const char *filename) {
    FILE *fp = fopen(filename, "r");
    if(fp == NULL) return NULL;
    
    ini_file_t *ini = (ini_file_t*)malloc(sizeof(ini_file_t));
    ini->section_head = NULL;
    ini->section_count = 0;
    
    // 默认创建一个全局节
    section_node_t *current_section = create_section("global");
    add_section(ini, current_section);
    
    char line[256];
    char section_name[64];
    char key[64], value[128];
    
    while(fgets(line, sizeof(line), fp)) {
        // 去掉换行符
        line[strcspn(line, "\n")] = '\0';
        
        // 跳过注释和空行
        if(is_comment(line) || strlen(trim(line)) == 0) {
            continue;
        }
        
        // 检查是否是节定义
        if(is_section(line, section_name)) {
            current_section = create_section(section_name);
            add_section(ini, current_section);
            continue;
        }
        
        // 解析键值对
        if(parse_kv(line, key, value)) {
            add_kv(current_section, key, value);
        }
    }
    
    fclose(fp);
    return ini;
}
避坑指南: 我曾经在解析一个中文编码的INI文件时,fgets读取的行长度超过了缓冲区大小,导致解析错乱。建议根据实际文件情况调整缓冲区大小,或者使用动态分配。另外,Windows下的INI文件换行符是\r\n,记得用strcspn处理时要注意。

查询接口设计

解析完了,怎么用?我一般提供两个查询接口:

// 获取指定节下的键值
const char* get_value(ini_file_t *ini, const char *section, const char *key) {
    section_node_t *sec = find_section(ini, section);
    if(sec == NULL) return NULL;
    
    kv_node_t *kv = sec->kv_head;
    while(kv) {
        if(strcmp(kv->key, key) == 0) {
            return kv->value;
        }
        kv = kv->next;
    }
    return NULL;
}

// 获取整型值(带默认值)
int get_int_value(ini_file_t *ini, const char *section, 
                  const char *key, int default_val) {
    const char *val = get_value(ini, section, key);
    if(val == NULL) return default_val;
    return atoi(val);
}

你想想看,有了默认值机制,调用方就不用每次都判断返回值是否为NULL了。这个设计思路在很多开源项目里都能看到,比如nginx的配置解析就是这么干的。

测试一下效果

假设我们有这样一个配置文件:

; 应用配置
[app]
name = MyApp
version = 1.0
debug = true

[logging]
level = INFO
file = /var/log/app.log

调用我们的解析器:

ini_file_t *ini = parse_ini("config.ini");
printf("App name: %s\n", get_value(ini, "app", "name"));
printf("Log level: %s\n", get_value(ini, "logging", "level"));
printf("Debug: %d\n", get_int_value(ini, "app", "debug", 0));
// 输出:
// App name: MyApp
// Log level: INFO
// Debug: 1

核心要点回顾:

  • INI解析的核心是逐行扫描,根据行首字符分流处理
  • 用链表存储节和键值对,方便动态扩展
  • 字符串修剪和注释判断是基础,但容易出错
  • 提供带默认值的查询接口,提升易用性
  • 注意处理不同平台的换行符差异

说实话,这个解析器虽然简单,但我在实际项目中用了很多年。后来虽然也用过一些第三方库,但自己写的这个版本最顺手,因为完全可控。你如果要在生产环境中使用,建议再加上错误处理、重复键检测、值类型转换等功能。嗯,这些就留给你自己去扩展了。

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