一、序
文章开始,先聊一聊自己的一些经历。
客户端和服务端打交道,首先要确定协议,包括选取数据协议和约定字段。
说到消息协议,大家可能会想到xml、json,或许还了解protobuf, protostuff, thrift, msgpack, avro ……
记得刚从学校出来去实习的时候,还真写过用XML协议去请求服务数据的接口。
当然后来json大行其道,渐渐地替换掉了xml,作为客户端和服务端的主要数据协议。
刚开始是直接用JSONObject/JSONArray解析报文,后来Gson/FastJson/JackJson等解析框架涌现,还转门开了评审会来评选引入哪一个。
再后来,kotlin普及,而kotlin自带的kotlinx.serialization也能做数据解析。
而且,无论是Gson等框架还是kotlinx.serialization,其作用不单单是消息的封装和解析了:
因为能够做json字符串和对象之间的转换,也就是序列化和反序列化,那就能够替代Serializable来存储对象了。
可以说,无论是消息传输,还是对象存储,json都相当的统治力。
但是作为一种文本协议,其性能还是有一定的局限,即使优化得再好,和一些实现得比较好的二进制协议框架还是有差距的。
当然,在数据量不是很大的情况下,json是够用的。
但总有些情况下需要性能更好的二进制协议。
我们在一个业务中就碰到过这样的情况:
这个业务的数据量比较大,数据在一定的时机才会触发上传,在此之前会累积。
最开始时候我们是所有数据一起打包成json字符串,后来发现有的OOM的情况,就改为分片打包上传。
虽然解决了OOM的问题,但是这样大的数据量,迫使我们寻求性能更好的方案。
这时候protobuf, protostuff, thrift, avro等走入了我们视野,最终技术负责人决定用protobuf。
protobuf也不负众望,替换json后性能提升不少。
当然只是该场景替换用了protobuf, 其他业务
但是protobuf的使用是真的麻烦,需要编写.proto文件,下载编译软件,生成java文件,拷贝文件到项目,项目中还要引入一个不小的SDK……
kotlinx.serialization其实也提供了一个protobuf的实现,但性能难堪大用。
换了工作后,新项目中没有消息数据特别大的业务,json协议基本够用。
但是寻求一个好用的序列化方案的念头一直萦绕不去,最终,还是决定自己实现一个。
在查了各种资料,耗费了许多时日之后,终于实现了一种既高效又易用的序列化方案。
搞了许久,是骡子是马,总得拉出来溜溜吧。
项目取名Packable, 是参考Android序列化方案Parcelable取的名字,实现上也参考了一些。
不过协议的设计是参考Protobuf的。
当然,只是参考,不尽相同,其中有不少改进。
二、用法
2.1 常规用法
序列化/反序列化对象时,实现如上接口,然后调用编码/解码方法即可。
用例如下:
static class Data implements Packable {
String msg;
Item[] items;
@Override
public void encode(PackEncoder encoder) {
encoder.putString(0, msg)
.putPackableArray(1, items);
}
public static final PackCreator<Data> CREATOR = decoder -> {
Data data = new Data();
data.msg = decoder.getString(0);
data.items = decoder.getPackableArray(1, Item.CREATOR);
return data;
};
}
static class Item implements Packable {
int a;
long b;
Item(int a, long b) {
this.a = a;
this.b = b;
}
@Override
public void encode(PackEncoder encoder) {
encoder.putInt(0, a);
encoder.putLong(1, b);
}
static final PackArrayCreator<Item> CREATOR = new PackArrayCreator<Item>() {
@Override
public Item[] newArray(int size) {
return new Item[size];
}
@Override
public Item decode(PackDecoder decoder) {
return new Item(
decoder.getInt(0),
decoder.getLong(1)
);
}
};
}
static void test() {
Data data = new Data();
// 序列化
byte[] bytes = PackEncoder.marshal(data);
// 反序列化
Data data_2 = PackDecoder.unmarshal(bytes, Data.CREATOR);
}
序列化
1、声明 implements Packable 接口;
2、实现encode()方法,编码各个字段(PackEncoder提供了各种类型的API);
3、调用PackEncoder.marshal()方法,传入对象, 得到字节数组。反序列化
1、创建一个静态对象,该对象为PackCreator的实例;
2、实现decode()方法,解码各个字段,赋值给对象;
3、调用PackDecoder.unmarshal(), 传入字节数组以及PackCreator实例,得到对象。
如果需要反序列化一个对象数组, 需要创建PackArrayCreator的实例。
PackArrayCreator继承于PackCreator,多了一个newArray方法,简单地创建对应类型对象数组返回即可。
用过Parcelable的朋友应该对这个写法很熟悉。
不同之处在于,Packable的put/get需要填index,加index是因为需要支持增减字段时能正确读取;
而Parcelable是直接依次写入value, 读和写的字段需完全一致,所以用于内存的数据交换可以,但不建议做持久化。
2.2 直接编码
上面的举例只是范例之一,具体使用过程中,可以灵活运用。
1、PackCreator不一定要在需要反序列化的类中创建,在其他地方也可以,可任意命名。
2、如果只需要序列化(发送方),则只实现Packable即可,不需要实现PackCreator,反之亦然。
3、如果没有类定义,或者不方便改写类,也可以直接编码/解码。
static void test2() {
String msg = "message";
int a = 100;
int b = 200;
PackEncoder encoder = new PackEncoder();
encoder.putString(0, msg)
.putInt(1, a)
.putInt(2, b);
byte[] bytes = encoder.getBytes();
PackDecoder decoder = PackDecoder.newInstance(bytes);
String dMsg = decoder.getString(0);
int dA = decoder.getInt(1);
int dB = decoder.getInt(2);
decoder.recycle();
}
2.3 自定义编码
比方说下面这样一个类:
class Info {
public long id;
public String name;
public Rectangle rect;
}
Rectangle是JDK的一个类),有四个字段:
class Rectangle {
int x, y, width, height;
}
当然,有很多方案去实现(让Rectangle实现Packable不在其中,因为不能修改JDK)。
packable提供的一种高效(执行效率)的方法:
public static class Info implements Packable {
public long id;
public String name;
public Rectangle rect;
@Override
public void encode(PackEncoder encoder) {
encoder.putLong(0, id)
.putString(1, name);
// 返回PackEncoder的buffer
EncodeBuffer buf = encoder.putCustom(2, 16); // 4个int, 占16字节
buf.writeInt(rect.x);
buf.writeInt(rect.y);
buf.writeInt(rect.width);
buf.writeInt(rect.height);
}
public static final PackCreator<Info> CREATOR = decoder -> {
Info info = new Info();
info.id = decoder.getLong(0);
info.name = decoder.getString(1);
DecodeBuffer buf = decoder.getCustom(2);
if (buf != null) {
info.rect = new Rectangle(
buf.readInt(),
buf.readInt(),
buf.readInt(),
buf.readInt());
}
return info;
};
}
通常情况下,大对象嵌套一些固定字段的小对象还是挺常见的。
用此方法,可以减少递归层次,以及减少index的解析,能提升不少效率,
2.4 类型支持
以上是packable的序列化/反序列化的整体用法。
具体到PackEncoder/PackDecoder,又提供了哪些接口呢(支持什么类型)?
以PackEncoder为例,部分接口如下:
三、性能测试
除了protobuf之外,还选择了gson来做下比较。
空间方面,序列化后数据大小如下:
数据大小(byte) | |
---|---|
packable | 2537191 (57%) |
protobuf | 2614001 (59%) |
gson | 4407901 (100%) |
耗时方面,分别在PC和手机上测试了两组数据:
- Macbook Pro
序列化耗时 (ms) | 反序列化耗时(ms) | |
---|---|---|
packable | 9 | 8 |
protobuf | 19 | 11 |
gson | 67 | 46 |
- 荣耀20S
序列化耗时 (ms) | 反序列化耗时(ms) | |
---|---|---|
packable | 32 | 21 |
protobuf | 81 | 38 |
gson | 190 | 128 |
四、总结
Packable的设计和实现参考了Parcelable和Protobuf,但是又有所不同。
相比于Protobuf,Packable使用更方便,性能更好;
相比于Parcelable,Packable支持版本兼容,支持跨平台,可用于数据持久化和网络传输。
说到跨平台,目前Packable实现了Java、C++,C#,Objective-C, GO等语言。
Java平台,目前已发布到maven仓库,可以直接引入,开箱即用。
dependencies {
implementation 'io.github.billywei001:packable:1.0.1'
}