1、背景
在项目中经常会遇到将对象序列化,然后进行后续的操作(持久化、传输),本文将介绍一下最近几年用到的一些序列化的技术和对序列化方案选取的思考。
假设我们要序列花的对象是如下格式:
2 、常用的序列化方案
根据序列化的结果,可以简单的分为可读文本和二进制两种方案。
2.1 基于可读文本的序列化方案
2.1.1、xlm
这个算是我接触最早的数据格式了,非常的易于读,同时,还可以支持丰富的格式(schema)校验(虽然,一般在开发的时候也用不到这个逼格很高的技巧,但是,支持呀!)。对于上述数据,可以使用如下的方式进行存储
<data>
<iv>BASE64 OR HEX OF iv</iv>
<data>BASE64 OR HEX OF data</data>
<version>ver</version>
<crc>crc32</crc>
</data>
由于xml只能存储可打印数据,因此,对于不可以打印字符,得通过某种手段转化一下,比如常见的base64 和hex方式,由于base64 在转化的时候只增加33%的数据,所以一般用的比较多,当然也有更多base97编码,这个需要根据具体的业务使用场景进行选择(比如,base96 一定会比base64好么?)
2.1.2、json
json以优雅、简单,易学的方式深受大家喜爱,这里不多介绍,对于不可打印字符也同xml一样,需要序列化成可打印的字符。
2.1.3、其他转成可见字符方案
由于json的噪音比较大,因此有人对json做了优化,比如json5,比json更加的简单,同时语法噪音还比较的小。
可见各位仁人志士在降噪+可读性的路上走的有多么的丰富多彩了。
(除了上面的方案,还可以选择 yaml,.ini 等方案)
2.2、二进制方案
在可见字符方案中,会引入一些数据噪音,如果在系统内容支持以二进制的方式进行传递数据,比如本地的binder调用,或者能够直接读写文件等场景,可以考虑使用二进制方案。
2.2.1、直接裸二进制方案,代码如下
这样实现的好处是,几乎没有噪音。数据非常的紧凑,无一个字节浪费。这里有隐含的一些东西:
- iv 是定长的加密向量
- crc、version 的长度为int类型(4字节)
由于上面的数据长度是固定的,因此可以将不固定大小的数据放在最后面,方便数据的解析。如果数据的结构中有多个可变长度,就需要引入len的来表示数据的长度了。
2.2.2、 数据中有非常多的可变长度(TLV)
通过与定义可以枚举的几种数据类型,就可以任意对对数据进行扩展了。
解析的时候直接一个while循环就可以解析到底了。
2.2.3、 protobuffer
这个是Google出品的一个数据格式的封装,现在广泛用于rpc之间作为数据的信使,然而Google发明这个东西最初的本心还是为了做更好的数据存储,由于该工具的广泛使用,在各端的工具也比较的成熟,格式定义也比较的清晰(可读性高):
syntax = "proto3";
package test;
message Data {
bytes iv = 1;
bytes data = 2;
}
给我们生成的工具类也很好用
val encoded = DataFormat.Data.newBuilder().apply {
iv = ByteString.copyFrom("hello", Charset.defaultCharset())
data = ByteString.copyFrom("data", Charset.defaultCharset())
}.build()
.toByteArray()
val decoded = DataFormat.Data.parseFrom(encoded);
从protobuffer的源码来看也是基于对tlv的封装, 但是提供了非常丰富的工具类。
2.2.4、 基于asn.1 的格式封装
ASN.1(Abstract Syntax Notation dotone),抽象语法标记。是定义抽象数据类型形式的标准,描绘了与任何表示数据的编码技术无关的通用数据结构。抽象语法使得人们能够定义数据类型,并指明这些类型的值。抽象语法只描述数据的结构形式,与具体的编码格式无关,同时也不涉及这些数据结构在计算机内如何存放。
它是(OSI: open source interconnection)使用的描述对象的语言规范。
是一个抽象语法标记,那和我们有啥关系呢?
我们序列化一个对象,就是将一个对象通过某种格式进行重新书写、编码,方便后续接受方(可以是以后的自己、也可以是约定的三方),这个过程就是将对象的描述进行转化的过程,根据asn.1的出发点,是可以使用的。
其次,asn.1中定义了丰富的数据抽象的数据类型,基于这些抽象的数据类型来进行定义数据,能过有效的提高数据格式的可读性和健壮性,同时由于又了同一个标准,各个系统间的数据的处理就比较的明朗。
最后,这个工具目前已经被广泛的应用于协议(TCP/IP、证书、openssl、5G)的定义中,相关的工具的存在已经非常的成熟,因此在开发的过程中,会有丰富的工具可以使用,基本上稍微看一下文档就能实现应用。
统一抽象,是这个工具的的最大的优点。
抽象,可以用来描述,具体的实现,还是要有具体的描述,因此会涉及到BER(basic encoding rules,基础编码规则)和DER(distinguished encoding rules,唯一编码规则,有的文章翻译成可辨识,我认为wiki上的唯一翻译的更好些)
BER
The first specified encoding rules. Encodes elements as tag-length-value (TLV) sequences. Typically provides several options as to how data values are to be encoded. This is one of the more flexible encoding rules.
这是最早制订的编码规则。 将元素编码为标签长度值 (TLV) 序列。 通常提供几个关于如何编码数据值的选项。 是比较灵活的编码规则之一。
DER,是对BER的内容进行进一步的限制,每一个值都有唯一的编码,是最简单的编码之一,就是这么一个简单的编码的东西,就够我们用的了。
fun TlvData.encode(): ByteArray {
val out = ByteArrayOutputStream()
val ans1Out = ASN1OutputStream.create(out)
this.apply {
ans1Out.writeObject(DEROctetString(iv))
ans1Out.writeObject(DEROctetString(data))
}
ans1Out.flush()
return out.toByteArray()
}
companion object {
fun decode(input: ByteArray): TlvData {
val inStream = ASN1InputStream(input)
val iv = (inStream.readObject() as DEROctetString).octets
val data = (inStream.readObject() as DEROctetString).octets
return TlvData(iv, data)
}
}
2.2.5、 基于ANTLR 的语法分析(太难、不写)
看到这里还想了解基于自定义语法分析器的,由于这个工具使用的场景主要是dsl,在这里有点大材小用了,因此不做介绍,该兴趣的可以查看相关的资料。
3、我该怎么选?
所有的技术方案都应该是为了解决问题的,首先应该定义当前遇到的问题是什么,然后根据具体场景进行选择
3.1、数据的可读性很重要
如果数据需要有较强的可读性,并且要求易于理解的话,采取文本方案,并且对应的工具非常的丰富,还非常易于调试;出现什么问题,一眼就能看出来,如果非不想用眼看,也有很多的diff工具帮你来对比,甚至能够格式化后进行对比,还要啥自行车?
3.2 数据的大小很重要
如果对于数据的量有要求,也就是不能增加太多的数据噪音,二进制方案就是比较好的选择
3.2.1 数据私有
对于我自己的数据,不与外界进行任何的交流,只有我一个人用,后续,也不会对这个数据进行变化,属于一次性消耗品,那么直接自己撸一个tlv,是比较合适,也比较简单的:直接撸,简单直接,撸完代码回家不好?
3.2.2、rpc间调用
protobuf 是优选,因为,这个家伙,在这个场景中已经大显身手了,在rpc的领域内,变化是永恒的主题,protobuff则非常擅长处理变化,而proto buffer的本质也是在处理这个问题。(为啥叫buffer的链接)
3.2.3 有变化,但是频率比较低
ans.1非常适合使用系统间的协议的定义,因此如果属于协议性质的内容,可以考虑用这个来定义。
3.3 对应的生态是否足够?
也就是说,调试起来是否足够的简单,人生的苦短,把时间花在思考上,花在陪伴家人身上不必一直debug 一个int是四个字节还是8个字节,该到底怎么排位子更好么?
无论是protobuffer 还是ans.1都有良好的调试输出工具,能够提升我们的调试效率,只需要专注于自己的实现,减少我们的沟通成本。
3.4、我想要一个万能的锤子
无论以后看到啥都能砸上去,并且能砸平,同时能够体现我高超的技艺,试试antlr?这个工具也有丰富的资料,如果你用的有IDE,你的开发的工具很可能就是用这个东西来实现部分功能的。
4、总结:为啥会有这篇文章
在这几年的开发中,不论数据是通过如何存储的,数据的本身还是比较重要的,早期做过蓝牙的数据传输(客户端:Android、iOS,服务端:java, 硬件采集终端:C/C++),也做过数据的多端共享(C/C++ 做底层的数据处理,上层用java/OC进行包装处理),这些都是涉及数据本身的存储和传输,涉及到具体数据在具体的编码(大端?小端),在和各同事进行各种技术方案细节上的探讨的时候,甚至连大小端这种基础的都要讲好久,居然还会有分歧(我知道是我描述的不清楚),因此在我的内心深处一直期望能有一个不用管这种序列化细节的方案,能够直接按部就班的开发,各司其职,这篇文章就是我对这些内容的思考和总结。