阅读 306

推荐一款好用的序列化框架

一、序

文章开始,先聊一聊自己的一些经历。

客户端和服务端打交道,首先要确定协议,包括选取数据协议和约定字段。
说到消息协议,大家可能会想到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, 其他业务还是用json.

但是protobuf的使用是真的麻烦,需要编写.proto文件,下载编译软件,生成java文件,拷贝文件到项目,项目中还要引入一个不小的SDK……
kotlinx.serialization其实也提供了一个protobuf的实现,但性能难堪大用。

换了工作后,新项目中没有消息数据特别大的业务,json协议基本够用。
但是寻求一个好用的序列化方案的念头一直萦绕不去,最终,还是决定自己实现一个。
在查了各种资料,耗费了许多时日之后,终于实现了一种既高效又易用的序列化方案。

搞了许久,是骡子是马,总得拉出来溜溜吧。
项目取名Packable, 是参考Android序列化方案Parcelable取的名字。

二、用法

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)
packable2537191 (57%)
protobuf2614001 (59%)
gson4407901 (100%)

耗时方面,分别在PC和手机上测试了两组数据:

  1. Macbook Pro
序列化耗时 (ms)反序列化耗时(ms)
packable98
protobuf1911
gson6746
  1. 荣耀20S
序列化耗时 (ms)反序列化耗时(ms)
packable3221
protobuf8138
gson190128

四、总结

Packable的设计和实现参考了Parcelable和Protobuf,但是又有所不同。
相比于Protobuf,Packable使用更方便,性能更好;
相比于Parcelable,Packable支持版本兼容,支持跨平台,可用于数据持久化和网络传输。
说到跨平台,目前Packable实现了Java、C++,C#,Objective-C, GO等语言。

Java平台,目前已发布到maven仓库,可以直接引入,开箱即用。

dependencies {
    implementation 'io.github.billywei01:packable:1.0.2'
}
复制代码

源码地址:github.com/BillyWei001…

文章分类
Android
文章标签