前言
用 libyaml 将 C 对象序列化为 YAML 文档的方法,我们已基本掌握了。此事并不容易,幸好有宏的帮助。事实上,如果你知道 C 编译器的一些扩展或者最新的 C 标准,那些宏还可以写得更为简约一些。
即使你不用宏,甚至完全不用 libyaml,将一些 C 对象序列化为 YAML 文档也不会太难,无非是注意一下块风格的缩进,或流风格的花括号嵌套。不过,若是将 YAML 反序列化为 C 对象,亦即用 C 程序解析 YAML 文档,这有些难,需要你熟悉编译原理,然后写出来 6000 余行核心代码,算是一个小型 C 项目。不过,即便是小型项目,我也写不出来,只好接受 libyaml,理解它的用法,再结合自己的需要而设法简化。
对于 YAML 文档的解析,libyaml 提供了三种方式,记号式(token-based),事件式(event-based)和文档式(document-based)。不必担心它们很难,我会用最简单的例子,解析只含有一个标量的 YAML 文档,从而直观呈现这三种方式的基本用法,然后再上升到序列和映射的解析。
解析器对象
libyaml 对 YAML 文档的三种解析方式皆以一个解析器对象为根基。解析器对象即 yaml_parser_t
类型的对象。以下代码定义了一个解析器对象,对其初始化,并将其关联到待解析的 YAML 文件。
FILE *yaml_file;
yaml_parser_t parser;
YAH_OPEN_FILE(yaml_file, "foo.yaml", "w");
if (!yaml_parser_initialize(&parser)) { /* 初始化 parser */
fprintf(stderr, "failed to initialize yaml parser!\n");
exit(EXIT_FAILURE);
}
yaml_parser_set_input_file(&parser, yaml_file);
代码太多,我们可以故伎重演,用宏封装。
#define YAH_YAML_PARSER_INIT(parser, yaml_file) do { \
if (!yaml_parser_initialize(parser)) { \
fprintf(stderr, "failed to initialize yaml parser!\n"); \
exit(EXIT_FAILURE); \
} \
yaml_parser_set_input_file(parser, yaml_file); \
} while (0)
于是,打开待解析的 YAML 文件以及构造解析器对象的过程可写为
FILE *yaml_file;
yaml_parser_t parser;
YAH_OPEN_FILE(yaml_file, "foo.yaml", "w");
YAH_YAML_PARSER_INIT(&parser, yaml_file);
上述代码中 parser
并非指针,但它是一个结构体,其中有些成员是指针类型,libyaml 会为这些指针分配内存,故而当 YAML 文件的解析过程完成后,解析器对象需要释放,否则会导致内存泄露。以下代码可释放 parser
,
yaml_parser_delete(&parser);
以上便是 libyaml 的一个解析器对象从生到死的全过程。YAML 文件的解析过程位于 YAH_YAML_PARSER_INIT
与 yaml_parser_delete
之间。
记号式解析
libyaml 的记号式解析过程,是遍历 YAML 文档对应的记号流的过程,访问的每个记号的类型与值可暂存到类型为 yaml_token_t
的对象。以下代码定义了 yaml_token_t
对象,并围绕它构造了记号遍历过程,只是在该过程中未对任何记号予以处理。
yaml_token_t token;
yaml_token_type_t type;
do {
YAH_YAML_PARSER_SCAN(&parser, &token, type);
switch(type) {
default: ;
}
yaml_token_delete(&token);
} while (type != YAML_STREAM_END_TOKEN);
上述代码为 libyaml 记号解析过程构造了一个常规框架,可称之为 YAML 记号流状态机。该状态机遇到 YAML_STREAM_END_TOKEN
类型的记号便终止运行。宏 YAH_YAML_PARSER_SCAN
的定义如下:
#define YAH_YAML_PARSER_SCAN(parser, token, type) do { \
if (!yaml_parser_scan(parser, token)) { \
fprintf(stderr, "yaml_parser_scan error!\n"); \
exit(EXIT_FAILURE); \
} \
type = (token)->type; \
} while (0)
在 YAML 记号流状态机中,yaml_parser_scan
函数从 YAML 文档中每次解析一个记号,便将结果保存在 token
里,若该函数执行成功,返回 1,否则返回 0。在 switch
语句里从 token
获取所需数据,便完成了一个记号的解析。token
是结构体类型,yaml_parser_scan
会为 token
中的一些指针类型成员分配内存,在一个记号的解析过程完成后,需通过 yaml_token_delete
予以释放。
上述代码的 switch
语句只有 default
分支,而该分支忽略了一切记号,故而这段代码什么都不解析,或者它什么都解析了。若向其加入将以下 case
语句,且这些语句在 default
分支之前,便可解析 YAML 流的开始与结束。
case YAML_STREAM_START_TOKEN: /* 流开始 */
printf("STREAM START\n");
break;
case YAML_STREAM_END_TOKEN: /* 流结束 */
printf("STREAM END\n");
break;
如果你已经熟悉 libyaml 将 C 对象序列化为 YAML,根据上述的分支名,很容易写出其他 YAML 元素的分支。例如
/* 文档起止 */
YAML_DOCUMENT_START_TOKEN
YAML_DOCUMENT_END_TOKEN
/* 标量 */
YAML_SCALAR_TOKEN
/* 块风格的序列起止 */
YAML_BLOCK_SEQUENCE_START_TOKEN
YAML_BLOCK_SEQUENCE_END_TOKEN
/* 流风格的序列起止 */
YAML_FLOW_SEQUENCE_START_TOKEN
YAML_FLOW_SEQUENCE_END_TOKEN
/* 块风格的映射起止 */
YAML_BLOCK_MAPPING_START_TOKEN
YAML_BLOCK_MAPPING_END_TOKEN
/* 流风格的映射起止 */
YAML_FLOW_MAPPING_START_TOKEN
YAML_FLOW_MAPPING_END_TOKEN
libyaml 的记号流也提供了键值对记号类型:
YAML_KEY_TOKEN
YAML_VALUE_TOKEN
不过,上述类型的记号,除了标量,其他都是 YAML 的结构元素,我们所需的数据皆存储在标量类型的记号里。你只需要知道如何从标量记号里获取数据,结合 YAML 结构元素的记号类型,便可实现 YAML 文件的解析。
假设所解析的 foo.yaml 文件里只含有一个标量:
Hello world!
则以下代码可解析该标量:
case YAML_SCALAR_TOKEN:
printf("%s type = %d\n",
token.data.scalar.value,
token.data.scalar.style);
break;
上述代码可输出:
Hello world! type = 1
token
的 data
成员是共用体类型(union),该共用体类型中对我们最有用的便是 scalar
成员。scalar
是一个结构体对象,含有以下三个成员:
value
:YAML 的标量,即yaml_char_t *
类型的变量,其真实类型是unsigned char *
。length
:value
的长度,即字节数。style
:用于记录标量形式,例如我们常用的普通标量(YAML_PLAIN_SCALAR_STYLE
),带双引号的标量(YAML_DOUBLE_QUOTED_SCALAR_STYLE
)和带单引号的标量。上述代码输出的type = 1
,便表示Hello world!
是普通标量。
每个记号里包含着一些其他信息。这些信息,通常情况下无需关注,若需要对其深入了解,可阅读 yaml.h 中 yaml_token_t
的定义。
如果你将上述宏定义添加到 yah-yaml.h 中,我可以给出解析上述只含有一个标量的 foo.yaml 文件的完整程序,如下:
#include
int main(void) {
/* 构造解析器对象 */
FILE *yaml_file;
yaml_parser_t parser;
YAH_OPEN_FILE(yaml_file, "foo.yaml", "w");
YAH_YAML_PARSER_INIT(&parser, yaml_file);
/* YAML 记号流状态机 */
yaml_token_t token;
yaml_token_type_t type;
do {
YAH_YAML_PARSER_SCAN(&parser, &token, type);
switch(type) {
case YAML_SCALAR_TOKEN:
printf("%s type = %d\n",
token.data.scalar.value,
token.data.scalar.style);
break;
default: ;
}
yaml_token_delete(&token);
} while (type != YAML_STREAM_END_TOKEN);
/* 任务完成,清场 */
yaml_parser_delete(&parser);
fclose(yaml_file);
return EXIT_SUCCESS;
}
在 YAML 中,标量可以单独存在,也可以作为序列中的值,也可以作为映射中的键和值。在 YAML 记号流状态机中,当前记号为标量,需要结合状态机的历史状态,方能确定该标量所具有的 YAML 语义。这意味着,我们需要对记号流状态机运行过程中的状态有所记录,方能捕获我们所需要的数据,亦即 YAML 记号式解析,只是为我们提供了一个记号流状态机,至于如何使用它,由我们自己决定。
事件式解析
事件式解析过程,与记号式相似,解析器对象不变,只是状态机变为 YAML 事件流状态机,记号变为事件。例如,以下代码可以解析 YAML 流的起止:
yaml_event_t event;
yaml_event_type_t type;
do {
YAH_YAML_PARSER_CHEW(&parser, &event, type);
switch(type) {
case YAML_STREAM_START_EVENT:
printf("STREAM START\n");
break;
case YAML_STREAM_END_EVENT:
printf("STREAM END\n"); break;
default: ;
}
yaml_event_delete(&event);
} while (type != YAML_STREAM_END_EVENT);
宏 YAH_YAML_PARSER_CHEW
的定义如下:
#define YAH_YAML_PARSER_CHEW(parser, event, type) do { \
if (!yaml_parser_parse(parser, event)) { \
fprintf(stderr, "yaml_parser_parser error!\n"); \
exit(EXIT_FAILURE); \
} \
type = (event)->type; \
} while (0)
在 YAML 事件流状态机在宏,yaml_parser_parse
每次从 YAML 文档中解析一个事件,解析结果保存在 yaml_event_t
类型的对象里。上述代码中的 event
便用于保存每个事件的解析结果。只要将记号流状态机每个记号类型中的 TOKEN
后缀更换为 EVENT
便是每个事件的类型。不过,事件类型的数量要少于记号类型,例如像 YAML_KEY_TOKEN
和 YAML_VALUE_TOKEN
这样的记号,就没有对应的事件,亦即事件流状态机要比记号流状态机简单一些。
标量事件类型是 YAML_SCALAR_EVENT
,可以通过 event.data.scalar
获取标量对象,该对象包含 value
,length
亦即 style
成员。以下代码基于事件流状态机解析只含有一个标量的 YAML 文件。
#include
int main(void) {
/* 构造解析器对象 */
FILE *yaml_file ;
yaml_parser_t parser;
YAH_OPEN_FILE(yaml_file, "foo.yaml", "w");
YAH_YAML_PARSER_INIT(&parser, yaml_file);
/* YAML 事件流状态机 */
yaml_event_t event;
yaml_event_type_t type;
do {
YAH_YAML_PARSER_CHEW(&parser, &event, type);
switch(type) {
case YAML_SCALAR_EVENT:
printf("%s type = %d\n",
event.data.scalar.value,
event.data.scalar.style);
break;
default: ;
}
yaml_event_delete(&event);
} while (type != YAML_STREAM_END_EVENT);
/* 任务完成,清场 */
yaml_parser_delete(&parser);
fclose(yaml_file);
return EXIT_SUCCESS;
}
与记号式解析同理,事件式解析方式也只是为我们提供了一个事件流状态机,至于如何使用它,由我们自己决定。
标量流
libyaml 对 YAML 的解析,无论是记号式,还是事件式,只是为我们创造了记号流或事件流。以更为简单的事件流为例,单纯的事件流,其本身并无太大用处,但是如果让它流经我们精心设计的一个状态机,它便有用了。下面我以 YAML 的嵌套映射结构为例,讲述这种方法。
假设 foo.yaml 内容如下:
---
新手村:
地名: 小沛
位置:
x: 136
y: 77
村长: 刘备
为了这个新手村的坐标,我特意打开了好久没玩的三国志 11。我们要构造一个状态机,让 YAML 事件流经过它,为以下嵌套的结构体对象填充数据。
struct {
char *name;
struct {
int x;
int y;
} location;
char *mayor;
} novice_area = {NULL, {0, 0}, NULL};
我们的状态机,拥有以下状态:
enum {
INIT,
NOVICE_AREA,
NAME,
LOCATION,
COORD_X,
COORD_Y,
MAYOR,
END
} status = INIT;
上述结构体和枚举类型都是匿名的,C11 及其之后的标准支持这种语法。另外,枚举类型的成员,我故意写成与 foo.yaml 和 novice_area
的形式一致,这只是行为艺术,并非语法。
下面是我们的状态机的实现,你可以根据代码中的状态转换画出状态转换图,理解起来会容易很多。
yaml_event_t event;
yaml_event_type_t type;
const char *a; /* 用于记录标量 */
do {
YAH_YAML_PARSER_CHEW(&parser, &event, type);
/* 只关心标量 */
if (type == YAML_SCALAR_EVENT) {
a = (const char *)event.data.scalar.value;
} else { /* 不处理非标量事件 */
yaml_event_delete(&event);
continue;
}
/* 让标量流过状态机 */
switch(status) {
case INIT:
if (strcmp(a, "新手村") == 0) status = NOVICE_AREA;
break;
case NOVICE_AREA:
if (strcmp(a, "地名") == 0) {status = NAME; break;}
if (strcmp(a, "位置") == 0) {status = LOCATION; break;}
if (strcmp(a, "村长") == 0) {status = MAYOR; break;}
break;
case NAME:
novice_area.name = strdup(a); status = NOVICE_AREA; break;
case LOCATION:
if (strcmp(a, "x") == 0) {status = COORD_X; break;}
if (strcmp(a, "y") == 0) {status = COORD_Y; break;}
break;
case COORD_X:
novice_area.location.x = atoi(a); status = LOCATION; break;
case COORD_Y:
novice_area.location.y = atoi(a); status = NOVICE_AREA; break;
case MAYOR:
novice_area.mayor = strdup(a); status = END; break;
case END: break;
default:
fprintf(stderr, "Unknown status!\n");
exit(EXIT_FAILURE);
}
yaml_event_delete(&event);
} while (status != END && type != YAML_STREAM_END_EVENT);
上述状态机,只让事件流中的标量部分进入状态机,我们不关心 YAML 的结构,例如序列、映射之类,只是根据我们的的结构体逻辑切换状态。这种解析方式,即使 YAML 文件中有一些额外的数据,也不会干扰解析过程。例如,如果将 foo.yaml 修改为
---
新手村:
地名: 小沛
位置:
x: 136
y: 77
z: 0
村长: 刘备
会计: 陈群
解析结果会保持不变。不过,如果状态机所需要的标量,YAML 文档中不存在,会导致结构体 novice_area
的某些成员为空值。这种情况,可在解析过程结束后,检测 novice_area
的成员有效性,以保证程序的安全。例如
if (novice_area.name && novice_area.mayor) {
printf("%s, (%d, %d), %s\n",
novice_area.name,
novice_area.location.x,
novice_area.location.y,
novice_area.mayor);
}
如果将 foo.yaml 修改为块风格和流风格混合的形式,例如
---
新手村:
地名: 小沛
位置: [x: 136, y: 77]
村长: 刘备
解析结果依然正确。原因是,我们的解析过程并不依赖块风格和流风格,它只依赖标量流。
文档式解析
libyaml 也可以直接将 YAML 文件解析为树结构,我们将其称为 YAML 树吧。
对于已存在的解析器对象 parser
,以下代码可将 parser
关联的 YAML 文件解析为树结构。
yaml_document_t doc;
YAH_YAML_LOAD(&parser, &doc);
YAH_YAML_LOAD
的定义为
#define YAH_YAML_LOAD(parser, doc) do { \
if (!yaml_parser_load(parser, doc)) { \
fprintf(stderr, "yaml_parser_load error!\n"); \
exit(EXIT_FAILURE); \
} \
} while (0)
上述代码中的 doc
即为 YAML 树,有了它,便用不着 parser
了,除非所解析的 YAML 文件里里存在多份 YAML 文档——希望你还没有忘记,一个 YAML 流可以含有多个 YAML 文档,每个文档皆以 ---
开始。亦即,yaml_parser_load
函数可以多次载入 YAML 文档。
既然是树,就意味着我们能够以递归的方式遍历它,前提是,我们需要熟悉如何从 doc
中获取 YAML 树的节点。libyaml 库为 YAML 树的节点定义的类型是 yaml_node_t
。每个 yaml_node_t
对象只可能是节点、标量和映射这三种形式中的一种,我们可以通过节点的 type
成员予以判定。例如,假设 YAML 树的某个节点为 x
——类型为 yaml_node_t *
,可通过以下代码判断其形式:
switch (x->type) {
case YAML_SCALAR_NODE:
printf("x 为标量节点\n"); break;
case YAML_SEQUENCE_NODE:
printf("x 为序列节点\n"); break;
case YAML_MAPPING_NODE:
printf("x 为映射节点\n"); break;
default:
fprintf(stderr, "程序应该出错了\n");
}
标量形式的节点,是不可能有子节点的,它们必定是位于 YAML 树的叶层。中间结点只可能是序列节点或映射节点,二者必定含有一些子节点,二者的子节点遍历方式有所不同。
序列节点的子节点遍历方式如下:
if (x->type == YAML_SEQUENCE_NODE) {
yaml_node_item_t *it;
for (it = x->data.sequence.items.start;
it < x->data.sequence.items.top;
it++) {
/* y 为 x 的子节点 */
yaml_node_t *y = yaml_document_get_node(doc, *it);
/* 可以对 y 作一些处理 */
... ... ...
}
}
映射节点的子节点遍历方式如下:
if (x->type == YAML_MAPPING_NODE) {
yaml_node_item_t *it;
for (it = x->data.mapping.pairs.start;
it < x->data.mapping.pairs.top;
it++) {
/* k 和 v 皆为为 x 的子节点 */
yaml_node_t *k = yaml_document_get_node(doc, it->key);
/* 可以对 k 作一些处理 */
... ... ...
yaml_node_t *v = yaml_document_get_node(doc, it->value);
/* 可以对 v 作一些处理 */
... ... ...
}
}
YAML 树的根节点,可能标量形式,例如 YAML 文档只有一个标量时,也可能是序列,也可能映射。从文档中获取根节点,需使用 yaml_document_get_root_node
函数。例如
yaml_node_t *root = yaml_document_get_root_node(&doc);
综合运用上述知识,便可写出 YAML 树的递归遍历函数,如下:
void doc_traverse(yaml_document_t *doc, yaml_node_t *node) {
if (!doc || !node ) return;
/* 标量 */
if (node->type == YAML_SCALAR_NODE) {
printf("scalar node\n");
return;
}
/* 序列 */
if (node->type == YAML_SEQUENCE_NODE) {
printf("sequence node\n");
yaml_node_item_t *it;
for (it = node->data.sequence.items.start;
it < node->data.sequence.items.top;
it++) {
yaml_node_t *x = yaml_document_get_node(doc, *it);
doc_traverse(doc, x);
}
printf("sequence end\n");
}
/* 映射 */
if (node->type == YAML_MAPPING_NODE) {
printf("mapping node start\n");
yaml_node_pair_t *it;
for (it = node->data.mapping.pairs.start;
it < node->data.mapping.pairs.top;
it++) {
yaml_node_t *k = yaml_document_get_node(doc, it->key);
doc_traverse(doc, k);
yaml_node_t *v = yaml_document_get_node(doc, it->value);
doc_traverse(doc, v);
}
printf("mapping node end\n");
}
}
假设已存在 YAML 树 doc
,doc_traverse
的用法如下:
doc_traverse(&doc, yaml_document_get_root_node(&doc));
对于以下 YAML 文档
---
新手村:
地名: 小沛
位置: [x: 136, y: 77]
村长: 刘备
doc_traverse
的遍历结果如下:
mapping node start
scalar node
mapping node start
scalar node
scalar node
scalar node
sequence node
mapping node start
scalar node
scalar node
mapping node end
mapping node start
scalar node
scalar node
mapping node end
sequence end
scalar node
scalar node
mapping node end
mapping node end
若是在 YAML 树的遍历过程中获取我们所需的信息,依然可以像上文那样,我们可以让标量流过一个状态机。我们只需要将状态机放在 doc_traverse
的以下代码部分:
if (node->type == YAML_SCALAR_NODE) {
/* 在此构造状态机 */
return;
}
也可以在一个单独的函数实现状态机,然后以回调函数的形式传递给 doc_traverse
函数。
忘记说了,若 yaml_node_t *
类型的指针 x
指向一个标量节点,可通过 x->data.scalar
获得标量结构体,该结构体的 value
成员是标量的值,length
成员是标量长度,style
成员是标量形式——普通标量、双引号标量、单引号标量等。
总结
libyaml 用起来,并不愉快,为了简化它的使用,我动用了我所掌握的宏的一切技能。我不是在炫耀一些 C 编程技巧。实际上,C 更鼓励我们用心去解决问题,而不是用技巧。一门除了宏,就剩下指针的语言,它还有什么技巧可言呢?C 语言的技巧,通常体现在一些缺乏可移植性的代码上,诸如使用 gcc 的一些扩展,或者在 C 代码里里嵌入汇编代码。libyaml 正是因为没有用什么技巧,故而用起来有些繁琐,但它的逻辑是清晰的,便于我们能够结合自己的需求用一些办法简化其用法。
倘若觉得 libyaml 不好用,但是又的确有在 C 程序中解析 YAML 文档的需求,也可以考虑用 libcyaml 库。这个库基于 libyaml 实现,提供了模式化机制,可建立 C 对象与 YAML 语义之间的练习,从而实现 C 对象的 YAML 序列化和反序列化。不过,用起来也不是太容易。我之前学习过,但长时间不用,就不知道该如何为 C 对象写模式了。相比之下, libyaml 的事件流或文档树在机制上反而更为简单一些,状态机本应该是编程基础,这种途径更适合我。
不过,若是为 C 程序选择一门配置语言,我还是决定用 Lua,不过要是在程序间交换数据,我还是要用 YAML。