series_member:true,parts::design;;
引言
现实世界中有千千万万的数据格式,数据交换格式(Data Exchange Format),如 JSON、YAML、TOML 等,是其中非常常见又重要的一类。它们在不同系统、不同编程语言之间传递数据,或者作为配置文件被程序读取和解析。在使用上,应用程序通常不会直接自行解析或生成这些格式的数据,而会使用成品序列化/反序列化库(如 Rust 中的 serde 等)将其转换为程序内部的数据结构,或者将数据结构转换为数据交换格式,以方便处理。
对于大部分的使用场景,使用通用的数据交换格式,和现成的序列化/反序列化库已经足以满足需求了,像 Go 中的 json.Marshal/Unmarshal、Rust 中的 serde_json::to_string/from_str 已经非常方便好用,无需程序员关心任何底层细节。不过,总还是有一些程序员,可能是因为足够空闲、足够好奇,或是确实有需求,想要深挖背后的原理,甚至自己动手设计一种数据交换格式,并实现相关的序列化/反序列化库。本文的作者正是其中之一,他出于好奇,设计了一种新的数据交换格式,并仿照 serde 的设计思路,实现了相应的序列化/反序列化库;并且他还萌生了将这一过程记录下来的想法,作为对数据交换格式、序列化/反序列化相关知识的总结和分享。
本文为系列文章的第一篇,主要介绍一种新的数据交换格式的设计思路,包括数据类型设计、表示语法设计等内容。
明确设计思路
首先我们自然是要设计一种新的数据交换格式。设计格式不是闭门造车或异想天开,而是要有具体的应用场景和设计思路,至少要回答清楚以下几个问题:
- 需要表示什么数据?
- 如何表示这些数据?
- 面向哪些使用场景?
- 还有哪些特殊偏好?
例如 JSON、YAML、TOML 都是面向通用数据类型的文本格式,但偏向不同:JSON 偏向简洁和易于机器处理,YAML 偏向可读性和人类友好,TOML 则偏向配置文件的易用性。Bson 是面向通用数据类型的二进制格式,偏向高效存储和传输。CSV 是面向表格数据的文本格式,偏向简单可读和易于处理。不同的设计思路所诞生的格式千差万别。
作为例子,本文希望设计一种文本表示的,面向通用数据类型的数据交换格式,主要面向短小简单的单行配置与数据,需要尽量减少文本长度,并且方便人手编写。同时,为了突出其独特之处(毕竟已经有太多现成的格式了,没有一点独特之处谁会在意呢?),我们把核心设计目标定为必须使用最少的语法字符来表示数据结构。
显然,JSON是一个不错的参考对象,它也是面向通用数据类型的文本格式,并且设计得相当简洁。本文的作者决定以 JSON 为参考对象,借鉴其数据类型设计和部分语法,同时在此基础上进行简化和修改,以满足本文的设计目标。
不算字符串使用的引号和字符串内表示转义的反斜杠,JSON 使用了 6 个语法字符({}[],:),这个数量显然太多了;既然我们的目标是越少越好,就自然要对 JSON 的语法进行大胆的修改和简化。当然,使用两个字符的组合表达另外的含义(如使用 [: 表示 {)这种「作弊」的方式是不行的,我们只能使用单个字符来表示不同的语法含义。
挑选数据类型
既然决定了要以 JSON 为参考对象,那么我们可以直接借鉴 JSON 的数据类型设计。JSON 支持以下几种数据类型:空值(Null)、布尔值(Boolean)、数字(Number)、字符串(String)、数组(Array)和对象(Object)。这些数据类型已经足够通用,能够表达绝大多数的数据结构(事实上也被大部分数据交换格式所采用),因此我们直接「拿来主义」,采用相同的数据类型设计。
不过既然是在 Rust 语言中实现,为了配合对 Rust 语言和开发者的刻板印象,本文的作者慎重决定,对数据类型开展「RIIR」行动——给部分类型足够「锈化」的名称,将 Array 改为 Vector,将 Object 改为 Map,然后保持其他类型名称不变,并宣称它们已经足够「Rusty」了。
决定表示语法
空值与布尔值
在确定了数据类型之后,下一步就是决定如何将每种数据类型表示为文本。同样地,我们可以在 JSON 的数据表示方法上进行简化和修改。最简单的是空值和布尔值,我们直接使用 null、true 和 false 即可;为了保持解析简单,也为了避免伤害挪威的朋友们,我们严守 JSON 标准,空值和布尔值的其他变体(如 nil、yes、no 等)都不予考虑,首字母/全字母大写的形式也不支持。
数字
数字的表示也很直观,包括整数(123)、小数(3.14)和科学计数法(1.23e4),以及可选的正负号(-42、+3.14),这些也都是 JSON 所支持的。十六进制数(0x2A)以及特殊值 NaN/Inf,虽然 JSON 并不支持,但也并不罕见,因此也值得我们支持。至于其他表示形式,如八进制和二进制数,以及省略小数点前后0的小数(.5、2.),出于保持简洁和清晰的原因,我们仍然不予支持。同样为了保持解析简单以及拼写一致性,NaN 和 Inf 都只支持小写形式(nan、inf、+inf、-inf)。
字符串
字符串是一个值得改进的地方,JSON 双引号确实清晰,但也确实太占空间了。无引号的键值在各类配置文件,以及 YAML 和 TOML 等格式中都很常见,因此,我们决定允许无引号字符串的使用,但是为了避免歧义,无引号字符串的内容不能是其他类型的字面量(null、true、false、nan、inf),不能以数字或正负号开头(以避免解析时与数字字面量冲突),不能包含空白字符(因为空白字符对提升 Vector 和 Map 的可读性非常重要),也不能包含表示 Vector 和 Map 结构的字符(见下文)。如果需要表示这些内容,则必须使用双引号括起来的字符串。
对了,字符串里还有一个非常重要的点:转义字符。首先我们肯定要支持 JSON 标准的转义字符 "\/bfnrt 以及 Unicode 转义 \uXXXX;此外,考虑到可能有使用者需要存储 BLOB 数据,十六进制字节转义 \xXX 是值得支持的;最后,为了更好地支持 emoji 等等非 BMP 字符,支持超过 4 位的 Unicode 转义 \u{XXXXXX} 也是必要的。
Map 与 Vector
Map 和 Vector 的表示是设计的重点。JSON 使用 {} 和 [] 分别表示 Object 和 Array,这种表示方法清晰且易于解析,但一是占用了 4 个字符,这和我们减少语法字符的目标简直是背道而驰,必须要进行改进。首先我们不妨大胆地直接去掉所有括号,只使用逗号 , 作为元素分隔符,冒号 : 作为键值分隔符;解析时通过逗号的存在来判断是否为 Map 或 Vector,通过冒号的存在来判断是否为 Map,例如:
a:b, c:d, e:f <= Map
a, b, c, d, e <= Vector
看起来不错!这样只需要两个语法字符(:,)就够了。不过仔细思考不难发现,这样的表示法存在歧义: a:b:c,d:e 对应的到底是 {a:{b:c},d:e} 还是 {a:{b:c,d:e}}?a:b,c 到底是 [{a:b},c] 还是 {a:[b,c]}?原因在于,直接去掉括号会不可避免地丢失 Map 和 Vector 的结束信息,既然如此,补充一个符号表示 Map 和 Vector 的结束是否可以呢?例如使用分号 ; 作为结束符号:
a:b:c;,d:e; = {a:{b:c},d:e}
a:b:c,d:e;; = {a:{b:c,d:e}}
a:b;,c; = [{a:b},c]
a:b,c;; = {a:[b,c]}
确实清晰了不少,虽然 ;, 看起来有点别扭,但歧义的问题确实解决了,好在在现实生活中嵌套的情况并不多见,因此这样的写法尚属可接受。不幸的是,这里还存在一个很隐蔽的问题:a:b;; 是 {a:[b]} 还是 [{a:b}]?丢掉 Map 和 Vector 的开始信息还是导致了问题。不过幸运的是,本文的作者还是找到了一个不增加新字符的方案:复用 : 作为 Vector 的开始符号,Map 仍然不使用开始符号,这样就解决了上述的歧义:
a::b;; = {a:[b]}
:a:b;; = [{a:b}]
虽然上面的写法相比最初的设计复杂了不少,但至少解决了歧义问题,且只使用了 3 个语法字符(:,;),就支持了任意嵌套的 Map 和 Vector 结构,已经相当不错了。下面是一些示例:
bind:"0.0.0.0", ports::80,443;, log_level:info, max_connections:1000;
meta:format:twic, version:1.0.0;, payload::"Hello, World!","Data No. 2";;
可读性的确有些下降,但尚在可接受的范围内,双冒号也让人想起了 TOML 中双方括号 [[table]] 的写法,也不算太突兀。
不得不说,这样的设计确实为了减少语法字符付出了可读性的代价,不过既然设计目标已定,并且最后的结果也还算可以接受,那就姑且一试吧。
定义形式语法
最后,我们可以得到一个「不太形式的形式语法」定义,如下所示:
value = null | boolean | number | string | vector | map
null = "null"
boolean = "true" | "false"
number = decimal | hex | special
decimal = [ "+" | "-" ] int [ frac ] [ exp ]
hex = [ "+" | "-" ] "0x" hex_digit { hex_digit }
special = "nan" | [ "+" | "-" ] "inf"
int = digit { digit }
frac = "." digit { digit }
exp = ( "e" | "E" ) [ "+" | "-" ] digit { digit }
hex_digit = digit | "a" | "b" | "c" | "d" | "e" | "f" | "A" | "B" | "C" | "D" | "E" | "F"
digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"
string = unquoted_string | quoted_string
unquoted_string = (? any identifier-like string that does not conflict with other types ?)
quoted_string = (? double-quoted string with escape sequences ?)
vector = ":" [ value { "," value } ] ";"
map = [ key_value { "," key_value } ] ";"
key_value = string ":" value
不太形式化的地方主要在于,其一,未对空白字符进行定义和说明,实际上空白字符可以出现在任意两个 value 之间,以及在 Map 和 Vector 的组成部分之间,这是非常直观的;其二,未对无引号字符串和双引号字符串的具体内容进行形式化定义,实际上无引号字符串的定义比较复杂,而双引号字符串则需要定义转义字符等内容,这里就不赘述了。
最重要的事情:起一个好名字
OK,现在终于来到了最重要也是最困难的环节:给这种新格式起一个好名字。名字要简洁易记,最好能直接拼读出来,还要能反映出这种格式的特点和用途。经过一番斟酌,本文的作者最终决定将这种格式命名为 twic:Tiny Writable Inline Config,意为「微型易写单行配置」,同时可以读若「tweak」,意为「微调」,寓意这种格式适合用来进行简单的配置和数据交换。
本文的作者在这里认真地推荐所有有着起名困难症的读者使用 AI 解决这个问题,详细描述你的格式设计思路和特点,然后让 AI 帮你生成一些名字供你选择。
结语
至此,我们完成了一种新的数据交换格式 twic 的设计工作。虽然这种格式在可读性上有所妥协,但它确实实现了使用极少的语法字符来表示通用数据类型的目标,适合用于短小简单的单行配置与数据。在下一篇文章中,我们将继续深入,介绍如何在 Rust 语言中实现 twic 的数据结构定义,以及基础的序列化和反序列化功能。