实战案例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之间也是链表连接。你想想看,这样我们遍历某个节下的所有配置就非常方便了。
解析核心逻辑
解析过程说白了就是逐行扫描,根据行首字符决定如何处理。我画了一张流程图,帮你理清思路:
这个流程其实不复杂。核心就是:遇到注释和空行就跳过,遇到[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;
}
完整的解析流程
有了上面的基础函数,主解析逻辑就水到渠成了:
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;
}
查询接口设计
解析完了,怎么用?我一般提供两个查询接口:
// 获取指定节下的键值
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解析的核心是逐行扫描,根据行首字符分流处理
- 用链表存储节和键值对,方便动态扩展
- 字符串修剪和注释判断是基础,但容易出错
- 提供带默认值的查询接口,提升易用性
- 注意处理不同平台的换行符差异
说实话,这个解析器虽然简单,但我在实际项目中用了很多年。后来虽然也用过一些第三方库,但自己写的这个版本最顺手,因为完全可控。你如果要在生产环境中使用,建议再加上错误处理、重复键检测、值类型转换等功能。嗯,这些就留给你自己去扩展了。