varch/doc/ini.md
2024-07-21 19:02:13 +08:00

23 KiB
Raw Blame History

介绍

ini是常用的工程配置文件语法简单。varch提供的ini解析器给定了简单可靠的api可以很轻松的加载和生成ini文件。

简单介绍下ini规范ini的组成部分包含了以下的部分。

注释

ini支持注释注释独占一行
注释以'#'或';'为标识
注释标识在行首,前面允许有空格,但不允许为其他字符

例子:

###########################
# 这是注释
###########################

aaa ; 这后面的不算注释

section

每一个section独占一行
section名称用"[]"方括号括起来以最外围方括号包含的内容作为section的名称其中方括号内也可以包含方括号在内的任意字符
section名对大小写敏感
section不能重复

例子:

[section]

key

键与值组成键值对
键值对以':'号或者'='号分隔,分割符两端的允许有空格不参与解析(但建议不留空格)
键对大小写不敏感
键在同一个section内不允许重复但是可以在不同的section内重复

value

在键值分隔符后面的内容即为值
值允许有任意字符(包含'='和':'分割符等字符纳入值内)
值可以跨行,前提是换行的缩进得大于值的缩进,才纳入换行值得解析

例子:

# 下面这个例子是允许的
value1 = =aaaa;#[]

# 值允许分行,此[]括起来的将不被解析为section
value2 = 1
    2
    3
    4
    [sss]

windows系统下的system.ini文件

; for 16-bit app support
[386Enh]
woafont=dosapp.fon
EGA80WOA.FON=EGA80WOA.FON
EGA40WOA.FON=EGA40WOA.FON
CGA80WOA.FON=CGA80WOA.FON
CGA40WOA.FON=CGA40WOA.FON

[drivers]
wave=mmdrv.dll
timer=timer.drv

[mci]

接口

创建和删除ini对象

ini_t ini_create(void);
void ini_delete(ini_t ini);

其中ini_t为ini的结构体创建方法则会返回一个空的ini对象删除方法则是删除传入的ini对象。

添加和移除section

int ini_add_section(ini_t ini, const char* section);
int ini_remove_section(ini_t ini, const char* section);

首先是添加section方法会在ini对象后面添加指定名称的section添加成功返回1失败返回0比如名字重复就会导致添加失败。
然后是移除section方法同样也是传入指定的section名移除成功返回1失败返回0移除section会将该section下的所有键值对也会被移除。

获取section

int ini_section_index(ini_t ini, const char* section);
const char* ini_section_name(ini_t ini, int index);

这两个获取section的方法刚好是对称的方法第一个是通过section名获取section所在索引索引就是从ini文件从头到尾的section排序索引从0开始当找不到指定section则返回负数-1而第二个则相反是通过索引来获取section名当不存在这个索引的section时候则返回NULL。
varch的ini解析器底层对于section的存储采取了内置迭代器的单向链表在每次调用的时候都会记录下当前的位置然后在下一次调用时候就会先判断是否和前一次的位置一样所以对于与前一次相同的调用时间复杂度为O(1),以及采用ini_section_name方法遍历时候,时间复杂度为O(n)

设置和获取value

int ini_set_value(ini_t ini, const char* section, const char* key, const char* value);
const char* ini_get_value(ini_t ini, const char* section, const char* key);

设置value方法就是将ini指定的section对应的key的value设为指定的value这个方法除了修改已经存在的键值对之外还用于添加键值对。首先是找有没有这个section没有这个section就添加这个section然后找这个key找不到这个key就添加这个key把value设置为指定value。成功返回1失败返回0。
获取value则是查找有没有这个section下的key-value对存在就返回value不存在则返回NULL。

移除key-value

int ini_remove_key(ini_t ini, const char* section, const char* key);

此方法将移除指定section下指定key的key-value对移除成功返回1失败返回0。

获取key

int ini_key_index(ini_t ini, const char* section, const char* key);
const char* ini_key_name(ini_t ini, const char* section, int index);

获取key的方法和获取section方法很类似分别通过key名找索引和通过索引找key名。找索引时候失败就返回负数找key名失败就返回NULL。
同样存储键值对也是采用内置迭代器的单向链表当key名或者索引和上次调用的一致的时候时间复杂度为O(1)通过index正向遍历时间复杂度为O(n)。

获取计数

int ini_section_count(ini_t ini);
int ini_pair_count(ini_t ini, const char* section);

这个两个方法分别获取ini下section的计数和指定section下键值对的计数。

ini对象转储

char* ini_dumps(ini_t ini, int preset, int *len);
int ini_file_dump(ini_t ini, char* filename);

首先ini_dumps方法将ini对象按格式转储为字符串。其中传入的preset参数是预测转出来的字符串的长度因为在将ini对象转为字符串的过程中是一边转换一边重新分配空间的如果预测的这个长度准的话预先分配的空间足够就可以提高重新分配空间的次数提高转换效率。*len则是转换出来的字符串长度传入NULL时候就是不获取长度。返回值则是转换出来的字符串这个字符串是函数分配的在结束使用需要free掉
ini_file_dump方法则是在ini_dumps的基础上将ini转储到文件当中filename传入文件名返回值为转储的长度负值表示转储失败。

ini对象加载

ini_t ini_loads(const char* text);
ini_t ini_file_load(const char* filename);

类似转储的方法ini对象可以从字符串文本中加载也可以从文件中加载。加载成功则会返回一个ini对象失败则返回NULL。

ini加载错误

int ini_error_info(int* line, int* type);

varch的ini解析器提供了精准的错误识别在ini加载的失败的时候可以调用此方法去定位错误位置和错误的类型。返回值为1表示当前解析出错了0则为未出错。line输出错误所在行type输出错误的类型。错误类型定义如下

#define INI_E_OK				(0) // ok
#define INI_E_BRACKETS			(1) // missing brackets ']'
#define INI_E_DELIM				(2) // missing delimiter
#define INI_E_KEY				(3) // missing key
#define INI_E_SECTION			(4) // missing section
#define INI_E_REKEY				(5) // key repeat
#define INI_E_RESECTION			(6) // section repeat
#define INI_E_MEMORY			(7) // memory allocation failed
#define INI_E_OPEN				(8) // fail to open file

参考例子

生成ini文件

static void test_dump(void)
{
    ini_t ini = NULL; // 定义ini对象习惯初始化为NULL

    ini = ini_create(); // 创建空ini对象
    if (ini == NULL) 
    {
        printf("ini create fail!\r\n");
        return;
    } 

    /* 添加section */
    ini_add_section(ini, "Zhang San");
    ini_add_section(ini, "Li Si");
    ini_add_section(ini, "Wang Wu");

    /* 添加键值 */
    ini_set_value(ini, "Zhang San", "age", "18");
    ini_set_value(ini, "Zhang San", "height", "178");
    ini_set_value(ini, "Zhang San", "email", "123456@qq.com");

    ini_set_value(ini, "Li Si", "age", "20");
    ini_set_value(ini, "Li Si", "gender", "man");
    ini_set_value(ini, "Li Si", "weight", "65");

    ini_set_value(ini, "Wang Wu", "age", "22");

    /* 转储ini到文件 */
    ini_file_dump(ini, WRITE_FILE);

    ini_delete(ini); // 用完之后需要删除
}

转储的文件

[Zhang San]
age = 18
height = 178
email = 123456@qq.com

[Li Si]
age = 20
gender = man
weight = 65

[Wang Wu]
age = 22

例子里面使用函数很多没有对返回值进行判断,实际应用需对返回值进行判断。

加载ini文件

测试的文件如前面提到的windows系统下找到的一个ini文件 system.ini

; for 16-bit app support
[386Enh]
woafont=dosapp.fon
EGA80WOA.FON=EGA80WOA.FON
EGA40WOA.FON=EGA40WOA.FON
CGA80WOA.FON=CGA80WOA.FON
CGA40WOA.FON=CGA40WOA.FON

[drivers]
wave=mmdrv.dll
timer=timer.drv

[mci]

加在测试代码

void test(void)
{
    ini_t ini = NULL; // 定义ini对象习惯初始化为NULL

    /* 加载ini文件 */
    ini = ini_file_load("system.ini");
    if (ini == NULL)
    {
        int line, type;
        ini_error_info(&line, &type);
        printf("ini parse error! line %d, error %d.\r\n", line, type);
        return;
    }

    /* 遍历ini对象 */
    int section_count = ini_section_count(ini); // 获取section计数
    for (int i = 0; i < section_count; i++)
    {
        char *section_name = ini_section_name(ini, i); // 获取section名称
        printf("section: [%s]\r\n", section_name);
        int pair_count = ini_pair_count(ini, section_name); // 获取pair计数
        for (int j = 0; j < pair_count; j++)
        {
            char *key = ini_key_name(ini, section_name, j); // 获取key名称
            printf("key[%s], value[%s]\r\n", key, ini_get_value(ini, section_name, key)); // 打印键值对
        }
    }

    ini_delete(ini); // 用完之后需要删除
}

运行结果:

section: [386Enh]
key[woafont], value[dosapp.fon]
key[EGA80WOA.FON], value[EGA80WOA.FON]
key[EGA40WOA.FON], value[EGA40WOA.FON]
key[CGA80WOA.FON], value[CGA80WOA.FON]
key[CGA40WOA.FON], value[CGA40WOA.FON]
section: [drivers]
key[wave], value[mmdrv.dll]
key[timer], value[timer.drv]
section: [mci]

加载错误

在上面例子的基础上把system.ini文件修改一下把section [mci] 右边的括号"]"删掉,再加载。

; for 16-bit app support
[386Enh]
woafont=dosapp.fon
EGA80WOA.FON=EGA80WOA.FON
EGA40WOA.FON=EGA40WOA.FON
CGA80WOA.FON=CGA80WOA.FON
CGA40WOA.FON=CGA40WOA.FON

[drivers]
wave=mmdrv.dll
timer=timer.drv

[mci

运行结果:

ini parse error! line 13, error 1.

如此能定位到13行出现1号错误也就是

#define INI_E_BRACKETS			(1) // missing brackets ']'

源码解析

ini解析器结构体

ini解析器的所有结构体都是隐式的也就是不能直接访问到结构体成员的这样子的方式保证了模块的独立与安全防止外部调用修改结构体的成员导致ini存储结构的破坏。所以ini解析器只留了唯一一个ini的声明在头文件然后结构体的定义都在源文件。只能使用ini解析器提供的方法对ini对象进行操作。
ini类型声明

typedef struct INI* ini_t;

使用时候,只是用ini_t即可。

typedef struct INI 
{
	SECTION* sections;			/* sections base */
	ITERATOR iterator;			/* section iterator */
	int count;					/* section count */
} INI;

INI结构体中包含了5个成员sectionssection的链表countsection的计数iteratorsection的迭代器。特别说明这个iterator就是这个迭代器记录section访问时候的位置当再一次访问检查到是同位置时候就可以快速返回而不用从头到尾再遍历。迭代器后续再说明。

typedef struct SECTION
{
	struct SECTION *next;		/* link */
	char* name;					/* section name */
	PAIR* pairs;				/* pairs base */
	ITERATOR iterator;			/* pair iterator */
	int count;					/* pair count */
} SECTION;

看SECTION结构体包含了5个成员next指向下一个SECTION形成单向链表namesection名pairs键值对链表iteratorpair的迭代器countpair的计数

typedef struct PAIR
{
	struct PAIR *next;		 	/* link */
	char* key;				 	/* key */
	char* value;				/* value */
} PAIR;

再看PAIR结构体包含3个成员next指向下一个PAIR形成单向链表keyvalue

typedef struct
{
	void *p;					/* iteration pointer */
	int i;						/* iteration index */
} ITERATOR;

最后看看这个迭代器其实这个迭代器很简单就是记录当前所指向的单向链表的结点成员p以及记录当前所在单向链表的索引。具体是怎么记录的下文再聊。

结构体介绍到这里INI类的存储结构已经很明了

INI
                                                <it>
        section[0] --> pair[0] --> pair[1] --> pair[2]
            |           k  v        k  v        k  v  
            |                                         
            v                       <it>              
        section[1] --> pair[0] --> pair[1] --> pair[2]
            |           k  v        k  v        k  v  
            |                                         
            v           <it>                          
  <it>  section[2] --> pair[0] --> pair[1]            
            |           k  v        k  v              
            |                                         
            v           <it>                          
        section[3] --> pair[0]                        
            .           k  v                            
            .
            .

单向链表的迭代

单向链表的操作不是这里的重点这里内置的迭代器为了提高单向链表的访问效率迭代过程着重说明下。以section迭代为说明说明下这个迭代器获取的链表结点的过程

static SECTION* ini_section(ini_t ini, int index) // 传入索引
{
	if (index >= ini->count) return NULL; // 判断索引有没有越界

    /*
    这个一步是重置迭代,也就是将迭代器定位回到链表首位
    满足其中3个条件之一都重置迭代器
    1、因为单向链表不能反向指向所以目标索引小于迭代器的索引时候就要重置然后从链表首位迭代到指定索引
    2、迭代器指针p成员为空时为空则没有指向具体的结点当然得重置迭代所以外部想重置迭代器只需将p成员置NULL即可
    3、目标索引index为0时主动获取第0位也就是首位
    */
	if (index < ini->iterator.i || !ini->iterator.p || index == 0)
	{
		ini->iterator.i = 0;
		ini->iterator.p = ini->sections;
	}

    /*
    循环将迭代器迭代到指定的索引位置
    单向链表索引正向递增所以正向遍历时候时间复杂度O(n)反向遍历还是O(n^2)
    */
	while (ini->iterator.p && ini->iterator.i < index)
	{
		ini->iterator.p = ((SECTION *)(ini->iterator.p))->next;
		ini->iterator.i++;
	}
    
    /* 返回迭代器指向的结点 */
	return ini->iterator.p;
}

迭代器在对链表进行调整的时候需要相应调整最简单的方式就是把成员p设为NULL重置迭代器。

这个迭代器在通过索引访问时候提高了效率通过section名的访问呢通过section名随机访问则是首先判断当前迭代器指向section名称时候匹配匹配上则直接返回不匹配还是得从头到尾遍历来匹配。

ini转储说明

转储就是按格式将ini“打印”出来不过的是不是打印在控制台而是打印在指定内存空间。那这个空间哪来的呢是动态内存分配来的分配多少呢这就回到前文说到preset形参了如果preset设置的够好就可以一次性分配到合适的空间不然得在转储的时候动态的调整空间去存放这些“打印”的字符了。
现在先来看维护这个打印空间的结构体:

typedef struct
{
    char* address;              /**< buffer base address */
    unsigned int size;          /**< size of buffer */
    unsigned int end;           /**< end of buffer used */
} BUFFER;

一共也就是3个成员address空间的基地址size空间的大小end已使用的结尾索引
动态调整空间的过程:

static int expansion(BUFFER* buf, unsigned int needed) // neede为所需的追加的容量
{
	char* address;
	int size;

	if (!buf || !buf->address) return 0;

    /* 计算当前已使用的加上所需的一共所需多大的空间 */
	needed += buf->end;

    /* 计算当前的空间还能满足需要 */
	if (needed <= buf->size) return 1; /* there is still enough space in the current buf */

    /* 
    当前空间大小满足不了所需,重新计算能满足所需的空间
    新的空间不是满足当前所需就行了的,还得留有余量,让下次追加时候用
    不然每次追加一点容量都要重新分配空间,很浪费效率
    而新分配的空间要多大才好2的平方也就是比所需大的最小的2的次方
    为什么选择2的次方
    1、算法好实现计算比指定值大的最小2的次方数好实现
    2、空间利用率好1 + 2/ 2 = 75%
    3、空间重分配的次数小比如最终需要128时候每次定量10的空间新增需要13次而2的次方只需7次。
    */
	size = pow2gt(needed);
	address = (char*)realloc(buf->address, size);
	if (!address) return 0;

	buf->size = size;
	buf->address = address;

	return 1;
}

以其中一段转储的代码为例,看看这个是怎么使用的

olen = strlen(sect->name); // 先计算section名多长
if (expansion(&p, olen + 3) == 0) goto FAIL; // 追加相应的空间,+3是放其他字符
/* 将字符复制到打印空间 */
p.address[p.end++] = '[';
for (k = 0; k < olen; k++)
{
    p.address[p.end++] = sect->name[k];
}
p.address[p.end++] = ']';
p.address[p.end++] = '\n';

在转储value的时候有个点需要注意的就是value是允许换行的只是换行的缩进要大于其key的缩进由于打印key时候是没有添加缩进的所以value有换行的时候就需要在换行后面插入一个缩进表明后面那行是归属于前面的键值对的。

/* get length of value */
olen = 0;
k = 0;
while (1)
{
    if (!pair->value[olen]) break;
    if (pair->value[olen] == '\n') k++; // 记录value换行数以便插入缩进
    olen++; // 记录value实际长度
}
if (expansion(&p, olen + k + 1) == 0) goto FAIL;

/* dump value */
for (k = 0; k < olen; k++)
{
    p.address[p.end++] = pair->value[k];
    if (pair->value[k] == '\n') p.address[p.end++] = '\t'; // 插入缩进
}
p.address[p.end++] = '\n';

最终打印时候就是将ini遍历动态调整空间把每个section和键值对按顺序打印到指定空间。

ini加载说明

ini解析器最重要以及最复杂的部分就这个加载解析的部分。

  • 1、首先创建一个ini空对象然后在随着解析把解析到的section、key、value这些逐个添加到ini对象等到解析完就可以得到一个完整的ini对象

  • 2、在解析之前的首先得将解析的行号以及错误重置然后随着解析遇到换行符就让行号相应的递增碰到解析出错了就记录下错误的类型

  • 3、然后就到了真正的解析过程解析的过程就是逐个字符的遍历判断判断相应的字符段是什么范围的是section是key是value是注释得到相应的范围就执行相应的解析动作。

整个解析就是在一个大循环当中

while (*text)
{
    ...
    ...
    ...
}

因为ini语法规定的内容基本都是以行来划分value特殊情况可以换行所以基本在这个循环里面就是在处理一行行的信息。

while循环的第一步

s = skip(text);
depth = s - text;
text = s;

跳过无用的字符这里无用的字符就比如空格或者tab这些没有实际意义的字符。在跳过字符的同时记录下来缩进也就是后面判断下一行是归属于新的一行还是上一行的value的范围的依据。

紧接着,就判断是不是注释了,因为注释是独占一行的,碰到注释标识了,后面的内容就不管了,跳过注释。

/* skip comments */
if (iscomment(*text))
{
    text = lend(text); // 直接定位到行末
    if (*text == '\n') 
    {
        text++;
        eline++;
        continue;
    }
}

剩下的就是规定section、key、value范围以及相应解析了。大体结构如下

/*
这个scope表示的是上一个value的界值范围
这个text解析的文本大于scope也是超过上个value范围了不属于上个value了
就可以解析新的section和key了
*/
if (text >= scope)
{
    /* 以 [ 开头的表示section的范围 */
    if (*text == '[')
    {
        /* 
        section是独占一行的以最外围的 [] 括起来的section名 
        所以在这里就找最外围的 [] ,左边已经找到了,就找右边的
        找最右的,首先就直接定位到行末,再从行末往回找
        跳过无用的字符,找到右面 ]
        */
        ...
        ...
        ...
    }
    else
    {
        /*
        key是必须在行首不是section部分那就是key部分了
        key范围的确定也就是找到第一个分割符 = 或者 : 了
        找到了分割符分割符前面的就是key的范围后面就是value的范围
        因为value的范围是可以换行的所以这里要另外算value的范围
        value换行的前提是新行的缩进需要大于key的缩进这里新行是空白行另算
        然后找个哨兵往前面探探到不属于value的范围位置
        */
        ...
        ...
        ...
    }
}
else 
{
    /*
    还是属于上个value范围的时候
    规定接下来每一行哪些字符块是有用的,也就是在这一行掐头去尾,把无用字符去掉
    掐头去尾完之后就把真正有意义的字符串追加到value后面
    */
    ...
    ...
    ...
}

这里特别说明下获取value的范围

static const char* value_scope(const char* text, int depth)
{
	const char *s = text;
	const char *sentinel = NULL;

	s = lend(s); // 范围直接包含分割符 : = 后面的这行
	while (*s)
	{   
		sentinel = skip(s + 1); // 让哨兵走到新行的有意义字符
		/* skip comments */
		if (iscomment(*sentinel))
		{
			s = lend(sentinel);
			continue;
		}
        /* 当前行的缩进大于key的缩进这一行也纳入范围 */
		if (sentinel - s - 1 > depth)
		{
			s = sentinel;
			s = lend(s);
		}
        /* 缩进变小了,但不一定不属于范围 */
		else  
		{
            /* 碰到了有意义字符了就不属于value范围了 */
			if (*sentinel != 0 && *sentinel != '\n') break;
            /* 那就直接到行末了这行是空白行还是属于value范围 */
			else s = sentinel;
		}
	}
    /* 倒退回去去掉那些尾部是空白行的不把这些空白行归属value范围 */
	while (*s == 0 || *s == '\n')
	{
		sentinel = rskip(s - 1, text);
		if (*sentinel != 0 && *sentinel != '\n') break;
		s = sentinel;
	}

	return s;
}

ini的增删改查说明

剩下的针对ini增删改查的其实已经没什么特别说明的了就是对链表数据结构的基本操作这里不着重说明了