1. 在 xml 中嵌入二进制数据的几种方法

  1. 通过外部实体和标记法的方式表示二进制数据;

  2. 使用 MINE 数据类型来表示二进制数据(并把数据用 Base64 编码后放入CDATA节中);

  3. 将二进制数据嵌入 CDATA 节中,编码格式由用户自己定义。

其中,第一种方法可以使用 XML 中的 DTD 规范来指定一个外部的 dtd 文件;第二种方法是把二进制数据用 Base64 编码后,保存在 CDATA 节里;第三种方法,跟第二种类似,也是把数据保存在 CDATA 节里,不过编码的算法不一定使用 Base64,可以使用自己定义的编/解码算法。

XML 中 在 CDATA 节中写入 Base64 编码的格式如下:

.<![CDATA[/9j/4AAQSkZJRgABAAEAyADIAAD//g]]> <![CDATA[/9j/4AAQSkZQtnAqamNC0UAFFAH//Z]]>.

2. xml 中 CDATA 的作用

在 XML 中:

所有 XML 文档中的文本均会被解析器解析,只有 CDATA 区段(CDATA section)中的文本会被解析器忽略。

由于 XML 跟 HTML 类似,主要使用 “<“, “>” 作为语言的标记,所以不能在 XML 文档中随意使用这些字符。

转义字符

非法的 XML 字符必须被替换为实体引用(entity reference)。

假如您在 XML 文档中放置了一个类似 “<” 字符,那么这个文档会产生一个错误,这是因为解析器会把它解释为新元素的开始。因此你不能这样写:

.<message>if salary< 1000then</message>.

为了避免此类错误,需要把字符 “<” 替换为实体引用,就像这样:

.<message>if salary < 1000 then</message>.

将二进制数据嵌入json的几种方法_json

注释:严格地讲,在 XML 中只有字符 “<” 和 “&” 是非法的。单引号、引号和大于号是合法的,但是把它们替换为实体引用是个好的习惯,这可能跟 XML 大多数情况下还是用于 HTML 或别的 Web 服务有很大的关系。

所以,CDATA 的作用就是把其中包含的数据(文本)当作 raw 数据,不做任何解析,原样输出。

例如下面的 JavaScript 代码:

<script><![CDATA[function matchwo(a,b) {  if (a < b && a < 0) {
    return 1;
  }
  else {
    return 0;
  }
}
]]></script>

3. 其它语言中 raw 字符串的用法

3.1在 PHP 中,这个用法叫做 Nowdoc 结构,具体格式是:

以 <<< 开头,后面紧跟单引号括起来的标识符,例如:

.<<<'EOT'
.

标识符 EOT 的名字是可以随意取的,结尾和开头的标识符对应即可,完整的例子:

<?php$str = <<<'EOD'
Exampleofstring
spanningmultiplelines
usingnowdocsyntax.
EOD;?>

PHP 中还有一个叫做 Heredoc 结构的东西,他跟 Newdoc 的区别是标识符没有单引号,包含的内容也不转义,但变量的值可以被替换。

3.2C++ 11 中也有 raw 字符串的用法,格式是:

.
R"foo(xxxxxxxx)foo"
.

其中的 xxxxxxxx 就是不需要转义的内容,跟 PHP 的 Newdoc 类似,它可以包含回车换行,TAB字符等,例如:

char text[] = R"foo(Example of string
spanning multiple lines
using nowdoc syntax.
)foo";

其中的标识符 foo 可以是别的名字,也可以省略。

4. 在 json 中嵌入二进制数据

跟 XML 类似,JSON 的 string 类型里,也存在必须转义为实体引用的转义字符,下面是 JSON 中 string 的定义:

将二进制数据嵌入json的几种方法_json_02

(以上图片截取自 http://www.json.org/json-zh.html )

我们可以看到,除了 ” 和 \ 字符,以及各种控制字符(\n,\r,\t,\b,\f等),还有转义符 / 自己本身,还包括了 \u#### 这样的 UNICODE 字符表示法。以上这些转义,在 C++ 中,除了 / 字符的转义不支持以外,其它都适用于 C++。但是 C++ 不仅仅包括这些,还支持 ‘\0’,’\8’,’\x0d’, ‘\005′ 的用法,这是 JSON 里没有的。

我们要把二进制数据嵌入到 JSON 当中,就是把数据用 raw 的方式保存在一个 string 的值里面,JSON 中 string 的格式是:

.
"string-name": "The raw string value", ......
.

可是,JSON 并不支持 raw string 的用法,那么我们就需要把二进制数据转换成纯文本的字符流,至于转换的算法是怎样的,接下来我们就来研究一下这个问题。

4.1 Base64

最常规的做法,就是把二进制数据转换成 Base64 格式,跟 XML 中的用法类似。

Base64 标准中的编码字符集:

.
base64_enc_table[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
.

Base64 的原理是把3个Byte(即 3 x 8bit = 24 bit)的数据转换为4个Byte(即 4 x 6bit = 24 bit),转换后的每一位Byte只用了前面的 6 个bit,即 2^6 = 64,这也是 Base64 名字的由来。如果原始数据的大小是 1.0,那么转换后的数据大小是 1.0 * (4 / 3) ≈ 1.333,也就是说转换后会比原数据大小多出约 0.333 倍。

在 JSON 中用 Base64 表示二进制数据,用法如下:

.
"binary-data": "qYjQusPQ91k28JULD119Iqdy8-\", ......
.
  • 优点 :编码后的字符里只有 ‘/’ 字符是需要再转义的(而且只在 JSON 标准里才这么规定,很多 JSON 库并不对 ‘/’ 字符转义)。而且编码后的大小是固定的,可计算出来的,并且也不会很大。

  • 缺点 :无。

  • 转换的难度 :适中。虽然 Base64 使用的是大端表示法,这跟编码和解码带来了一些不便,但由于算法比较简单,编码和解码都可以通过位移和查表来完成,所以转换较容易,效率尚可。

  • 编码的大小 :约为原数据的 1.333 倍。

4.2 十六进制表示法

很直接的,我们也会想到 HEX 这种格式,即常用的十六进制表示法。例如:

.# 字符串This a HEX string.
 # 十六进制表示法54 68 69 73 20 61 20 48 45 58 20 73 74 72 69 6E 67 2E
.

从某种意义上来讲,如果跟 Base64 相比,它也可以被称为 Base16 ,你可以把 ‘0-9′, ‘A-F’ 这 16 个字符看作是它的编码字符集,如下所示:

.
hex_enc_table[] = "0123456789ABCDEF";
.

在 JSON 中用十六进制来表示二进制数据,是这样的:

.
"binary-data": "5468697320612048455820737472696E672E", ......
.
  • 优点 :每一个字节会被表示成两个字符,且编码后的字符里完全没有需要再转义的字符,而且编码和解码都可以使用最简单的位移和查表法完成,因此效率比较高。编码后的大小也是固定的,是原数据的两倍,不多也不少。

  • 缺点 :因为是用空间换时间,所以转换后的大小是最大的,固定是原数据的两倍,虽然编码效率高,但用于网络传输时不太有利。

  • 转换的难度 :低。编码和解码简单易懂,代码简单,执行速度也快。

  • 编码的大小 :是原数据的 2.0 倍。

4.3 ‘\0′ 字符反转义

把二进制数据转换成字符串流,我们遇到的最大的麻烦,是要如何处理 ‘\0′ 这个字符,因为字符串以 ‘\0′ 作为终止符,所以这个字符肯定不能出现在编码后的字符串里,必须用某个字符或字符串代替。如果解决了这个问题,其它字符再按照 JSON 正常的转义转换,问题基本上就搞定了。

当在看 MessagePack 的相关讨论的时候,有人提到 “\u0000″ 这样的方式,所以我留意了一下这个方案。这里有个需要注意的地方是,”\u0000″ 并不是指 00 00 这样的两个字节,而是 Unicode 里 ‘\0′ 字符的表示法,其实它只表示 00 一个字节。

."\u0000"(Unicode表示法) = '\0' = "00" (十六进制)
.

也许你会说,我也可以把 ‘\0′ 字符替换成任何想要的字符或字符串,比如 “<null>”(这也是很多人最早能想到的办法,我也一样)。可是如果我原来的二进制数据里本来就包含 “<null>” 这个字符串,该怎么办?这的确是我们担心的问题,为了避免冲突,我们只好把替换的字符串弄得复杂一点,可是这样始终解决不了问题,还是存在冲突的可能性。

为什么用 “\u0000″ 这样的字符串替换 ‘\0′ 不存在这个问题?如果原来的二进制数据里存在 “\u0000″ 这个字符串,由于数据存入 JSON string 的时候需要做一次转义,那么它就会变成 “\\u0000″,解码的时候就会还原成 “\u0000″,而不是 ‘\0’,而 “\u0000″ 则会还原成 ‘\0’,并不存在冲突。

实际上,除了 “\u0000″ 这种方案,我还考虑了直接转义为 “\0″(分别是 ‘\’ ‘0’ 两个字符)的方案,为什么呢?因为,最坏的情况下,例如你的二进制数据全部都是 00,那么此种情况下,编码后的大小将会是原始数据大小的 6 倍。也就是说,为了避免在编码的过程中可能要对缓冲区不断扩容,导致内存碎片和效率降低,我们必须在一开始就分配原始数据 6 倍大小的缓冲区,这有点太大了,而且很不科学(虽然 rapidjson 也是这么干的,但它那是没有办法的办法),因为大多数情况下我们实际只会用到其中的 1.05 ~ 1.1 倍左右的缓冲区大小。

把 ‘\0′ 字符直接转义成 ‘\’ ‘0’ 两个字符的方案,它类似于 C/C++ 的反转义,好处只有一个,就是缓冲区只需要分配为原来的 2 倍即可,处理逻辑也相对简单一些。但原生的 JSON 对 ‘\0′ 转义是不支持的,大多数 JSON 库也都没有考虑这种情况,替换成 “\u0000″ 则是 JSON 默认就支持的,通用性和兼容性比较好。这种方案牺牲的是通用性和兼容性,想要正确的解码,也只能用自己的库。这里说的兼容性,是因为 “\0″ 这种表示法,在 JSON 里是未定义行为,有可能被认为格式错误,或被忽略掉,也有可能只输出 “0” 这个字符,结果是未知的。

所以,如果你生成的 JSON 数据需要别的语言的 JSON 库或同一种语言的不同的 JSON 库交互,则应该采用 “\u0000″ 这种方案,否则的话,可以采用对 ‘\0′ 直接反转义的方式,这样效率更高一点,但使用别的库解析你的 JSON 结果的时候可能会有问题,不过不是一定会有问题,你可以测试一下。

这一切的根本原因,是因为 JSON 里没有定义一个像 XML 的 CDATA 那样的 raw string literal 表示法,当然我们可以自己给它定义和实现一个,但其它 JSON 库识别不了,没办法做到通用意义也不是很大。

在 JSON 中用字符转义来表示二进制数据,大概是这样的(这是 “\0″ 的方案,并且做了二次转义后的结果):

.
"binary-data": "321\\\/3u3\\\\\\\/273u1y2u1\\\\n\\\\0\\\\r
                \\\\\\\"\\\\tabcdefgji  jkad\\\\u0000\\\\u5345
                \\\\u6167abc\\0\\0fghji\\r\\n782\\\/312u1327\\r\\n
                723\\r\\n2\\\"3213\\\"\\\"2321ad  ew qw\\r\\ndasd\\r\\n", ......
.

“\u0000″ 方案

  • 优点 :如果二进制数据里需要转义的字符比较少,那么编码后所增加的数据长度也比较少,总体的编码长度是比较小的(视数据而定)。

  • 缺点 :初始缓冲区必须分配为原始数据的 6 倍,浪费比较大。处理逻辑相对比较复杂,效率是三种方法里最低的,但差距也不是特别大。

  • 转换的难度 :最难。编码/解码处理逻辑相对比较复杂,比下面的 “\0″ 方案还要稍微复杂一点,效率最低。

  • 编码的大小 :视数据而定,一般的文件,大小约为原始文件的 1.05 ~ 1.10 倍左右,如果是两次转义,则会更大一点,约 1.10 ~ 1.20 倍左右,编码大小是三种方法中最小的。

“\0″ 反转义方案

  • 优点 :如果二进制数据里需要转义的字符比较少,那么编码后所增加的数据长度也比较少,总体的编码长度是比较小的(视数据而定)。

  • 缺点 :初始缓冲区必须分配为原始数据的 2 倍。处理逻辑相对比较复杂,效率是三种方法里最低的,但差距也不是特别大。

  • 转换的难度 :较难。编码/解码处理逻辑相对比较复杂,效率不太高。

  • 编码的大小 :视数据而定,一般的文件,大小约为原始文件的 1.05 ~ 1.10 倍左右,如果是两次转义,则会更大一点,约 1.10 ~ 1.20 倍左右,编码大小是三种方法中最小的。

4.4 三种方法的总结

综合来看,Base64 最全面,编码长度跟原数据相比增加不多,编码/解码速度也还可以(如果采用更高效的编码/解码库将会获得更好的效果);Hex 十六进制转换速度最快,但编码长度最大,鱼与熊掌不可兼得;’\0′ 反转义的方法,编码长度最小,但编码/解码速度相对最慢,但并没有慢到差一个数量级,如果对于数据传输大小有比较严苛的要求,可以考虑这种方案。

所以,如果没有太特别的要求,一般推荐使用 Base64 编码方案,通用性比较好。

下面是三种方案的比较(第三种方案分为了两种):

将二进制数据嵌入json的几种方法_json_03

下面分别是十六进制表示法、Base64 以及 ‘\0′ 字符转义三种方案对一个约 17MB 的 PDF 文件编码/解码耗时的比较:

将二进制数据嵌入json的几种方法_json_04

可以看到,十六进制法用的时间最短,Base64 其次,’\0′ 字符转义最慢,但相差都不算很大,以上测试不是很全面(每个算法都只测了一下,测试结果的摇摆性比较大),但大概已经可以看出差别。

5. 关于 ‘\0′ 反转义的二次转义

很重要也很不重要,我还没有理清楚怎么写。。。(待补)

6. 关于 MessagePack

MessagePack 是个什么东西?MessagePack 是一个高效的二进制序列化格式,它可以像 JSON 那样在各个语言间交换数据,但它比 JSON 更快、更小。

为什么小?

首先注意一点,MessagePack 其实是一个二进制序列化格式,它不是面向文本的,而 JSON 是面向文本流的,我们来看看 MessagePack 主页 http://msgpack.org/ 上的一个演示列子:

将二进制数据嵌入json的几种方法_json_05

我们可以看到,它没有了 “{” 和 “}”,0x80 代表是 element map,而 0x82 的意思是这个 element map 的元素个数是 2 个(它其实是叫 fixmap,固定大小 map,表示范围是 0x80 ~ 0x8F)。如果是元素个数更多的 element map 则会用 0xDE + 两个字节的元素个数表示,这叫做 map16,如果是元素个数超过两个字节的,则用 0xDF + 四个自己的元素个数来表示,这叫做 map32。

类似的,0xA0 表示 fixed string (fixstr),表示范围是从 0xA0 ~ 0xBF,可以表示字符串长度是从 0 ~ 31,超过这个范围的是使用 str8 (0xD9),str16 (0xDA),str32 (0xDB) 表示。

对于整型,则把 0x00 ~ 0x7F 保留了用来表示正整数 0 ~ 127。0xC2 表示逻辑 false,0xC3 表示逻辑 true。

对于字符串,虽然里面的内容还是跟 JSON 一样,原样的没变,但由于一开始就指定了长度,所以也不用头和尾都用引号包起来。由于这些举措,你可以看到,MessagePack 跟 JSON 相比,节省了不少字节,这就是它比 JSON 小的原因,当然也有可能小不了多少,但总体上会小一些,一般测试显示可以小 10% 左右。

关于 MessagePack 的格式定义,可以查阅其 github 上的具体定义: https://github.com/msgpack/msgpack/blob/master/spec.md

下面是部分定义的截图:

将二进制数据嵌入json的几种方法_json_06

从上图也可以看到,MessagePack 处理二进制数据的方式,它分为 bin8、bin16、bin32,分别表示二进制数据长度为 0~255,256~65535,65535~(2^32-1),也就是说,格式是:

.
标志位(bin8, bin16, bin32) + 二进制数据长度 + 二进制数据
.

可以看到,MessagePack 处理二进制数据只是加了个标记和数据长度,数据是原样输出的,所以既快也方便。因为 rapidjson 的 issue 里有人提到了 JSON 嵌入二进制数据可以参考 MessagePack 的设计,这里拿来比较一下还是有意义的。因为 JSON 是纯文本流,所以不可能支持这种设计方式,除非它定义了 raw string literal。

为什么快?

前面我们也看到了,MessagePack 的格式是标志位 + 数据长度这样的方式,这样给处理 Value 对象时分配缓冲区带来了便利,JSON 里是无法预先知道后面的字符串到底有多长,到什么时候结束,现在在解析字符串的时候可以提前就知道了,如果字符串比较长,可以避免不断对字符串扩容带来的效率损失。当然也不止字符串,很多地方都会因此而获益,比如 map,array 的元素个数也是可以解析头部即可得知,同样对于缓冲区的分配和管理会带来不错的效果。

对于 integer 和 float 的格式,因为是二进制格式,所以它可以直接从内存里原样输出,不过可惜的是,对于 integer,它使用的大端格式,这对于一般的支持小端的 CPU 会浪费几条指令,也许是考虑到网络传输一般都是用大端格式的原样吧,其实实际使用中,支持小端格式的硬件比例更高一些。对于 float 则是标准的 IEEE754 表示法,而这个标准是小端格式的,因为这个是 CPU 直接支持的,所以格式相对是固定的。

所以,综上几点,在 C/C++ 里,MessagePack 肯定是要比 JSON 快一点的,至少快多少,不太好说,看具体的数据格式和大小。

并不一定快

但是也有一些例外,比如在 JS 中,因为 JSON 是原生支持的,MessagePack 是 JS 里写的库,所以在 JS 里,解析 MessagePack 反而会比解析 JSON 慢很多。这也很容易理解,如果 JS 中也原生支持 MessagePack 的话,那么相信还是 MessagePack 更快一些,毕竟 JS 的用户代码是不可能跟原生调用比的。与此类似的是,如果那些语言里不能原生支持 MessagePack 的话,而是在语言里用函数库自己实现 MessagePack 解析的话,效率和内存方面都是不可能跟原生支持相比的,也许这也是 MessagePack 没有被广泛应用的原因之一吧。

此外,MessagePack 是二进制格式,在某些语言里,也许会给解析和使用带来一定的麻烦,可能也是其中一个原因。

7. 关于 Bson

SON 这个格式是专门为 MongoDB 而开发的,类似 JSON 的一种二进制格式,不一定比 JSON 存储的文件小(有时候可能还大不少),优点是解释快(事实上部分格式的解析跟 MessagePack 比可能还是要慢一些)。

BSON 的官网: http://bsonspec.org

其格式定义可以参阅: http://bsonspec.org/spec.html

下面是部分格式定义:

将二进制数据嵌入json的几种方法_json_07

其思想跟 MessagePack 非常类似的,对于 document,会记录其 element list 的元素个数,然后才是具体的 list 数据。字符串的处理也非常类似,integer 和 float 也差不多,不过对于 integer 的处理,它用的是小端格式(明智多了)。

不过 BSON 并没有像 MessagePack 那样对较常见的短字符串,小整型等做一些优化,即 Fixed 类型(固定大小类型),对于一个字符串,不管长度是多少,BSON 基本是一视同仁的,长度都是用一个 4 字节的整型表示,并且格式是小端格式,也许这样解析的代码简单一些,但并不见得就一定好,至少数据长度比 MessagePack 的 Fixed 类型多 4 个字节,因为很多时候,我们的 key 和 name,还有一些其它值类型,长度一般都是很小的,基本都会在 Fixed 类型能表示的范围内,所以节省的数据量还是比较可观的。

BSON 表示一个 6 个字节的字符串值,格式是这样的:

.
"\x06\x00\x00\x00world\x00"
.

不过有一点是值得注意的,这里的 \x06 是 C/C++ 里还未转义之前的表示法,它在内存里实际上是 06 这样的一个字节而已,不要被蒙骗了。但是 BSON 一开始的 document 长度的确是按未转义之前的字符长度计算的,也就是整个 BSON 串的总字符个数,便于分配缓冲区。

上面的字符串如果在内存里,是这样的:

.06 00 00 00 77 6F 72 6C 64 00.

下面这个 JSON 转换为 BSON 后的格式是:

.
JSON: {"hello": "world"}  ==> BSON: "\x16\x00\x00\x00\x02hello\x00\x06\x00\x00\x00world\x00\x00".

\x16\x00\x00\x00 是整个 BSON 字符串的长度,0x02 是 utf-8 string,也就是键值,它是不需要定义长度的,因为 BSON 认为键值的字符串不会特别长,可以省略,后面的 \x06\x00\x00\x00 是 world 的字符串长度,包含 ‘\0′ 字符,最后以一个 ‘\0′ 字符结尾。

BSON 跟 MessagePack 还是非常类似的,只不过 MessagePack 对一些长度比较段且较常见的数据做了一些特殊处理,以减少数据的长度。如果 BSON 一开始的 document 除了整个串的长度,像 MessagePack 那样还加一个元素个数更好一点,这对整个树的遍历是有一定的好处的。

BSON 中的二进制数据格式是这么定义的:

binary  ::=    int32subtype (byte*)  Binary - Theint32is thenumberofbytesin the (byte*).
subtype ::=    "\x00"  Genericbinarysubtype
    |  "\x01"  Function    |  "\x02"  Binary (Old)    |  "\x03"  UUID (Old)    |  "\x04"  UUID    |  "\x05"  MD5    |  "\x80"  Userdefined

下面演示一下 BSON 是如果嵌入二进制数据的:

.
BSON: "\x44\x00\x00\x00\x05binary-data\x00\x18\x00\x00\x00\0x02\abcdefg\x00"
.

以上使用的 subtype \x02 Binary (Old) 格式,其实跟 MessagePack 非常类似,就是标识头 + 子类型 + 二进制数据(原样)+ ‘\0’,当然跟 MessagePack 有点区别的是,定义 key name 的时候,也必须定义为 Binary 格式,而 MessagePack 不用,定义为一般的字符串 key 即可。

从总体上来看,MessagePack 好像设计得更好一点,BSON 有些不足,但它追求的是代码和处理逻辑相对的简单,有兴趣的朋友可以做一下比较。

8. 思考

面对 JSON, MessagePack 和 BSON,我们到底应该选择文本流还是二进制流?或者使用 Protocol Buffer?也许应该好好弄清楚我们的需求,必须要在各种不同的语言间交互数据吗?这些语言都能很好的支持我们定义的数据格式吗?是否在意升级消息格式以后的维护成本?

如果说 MessagePack 和 BSON 相当于自带了 DSL 描述的Protocol Buffer,但却没有 DSL 中数据结构的内存布局顺序(解析以后,JSON 或 二进制 JSON 的元素一般用哈希表存储的,所以一般是没有顺序的)。虽然 JSON 非常灵活,但是 JSON 对于消息格式的变动,依然还是要修改服务器端和客户端的代码并且重新编译,除非那个变动本来就是你定义消息格式的时候就考虑到了的,这个对于 Protocol Buffer 也可以通过 options 来实现。JSON 是无状态的,但依然无法实现改动消息格式却不想重新编译和发行新版本的构想。

最理想的方式是在更改消息格式以后,不需要重新编译,服务器端和客户端也不用修改,目前可以实现的办法是用动态语言实现热更新的方式。也许还有别的更好的办法,方案还是有一些的,实现起来比较困难,只要能解决 ABI 二进制兼容问题就好办多了,有知道的朋友请不吝赐教。

9. 关于 Base64 编码/解码库

Base64 算法还是可以做一些优化的,国人以前有人研究过这个,可以参考:

代码优化之-Base64编码函数的极限优化挑战

http://blog.csdn.net/housisong/article/details/1711153

此外,还可以参考这个 github 仓库(老外写的),里面有 Plain 普通版本,以及 SSE3 和 AVX2 的优化版本。

https://github.com/aklomp/base64

10. 测试代码 github 仓库

以上三种方案的实现代码的 github 地址是:

https://github.com/shines77/json_binary

注:由于时间、精力、能力和知识面有限,难免会有错误和遗漏的地方,欢迎批评指正,代码也会慢慢完善。