ngchain Serialization Selection: ngchain的序列化方案选择

实在是苦于protobuf,不论是g家的老版本还是新版本,还是gogo,都逃离不了protobuf是非常面向对象的事实。
而我们需要的仅仅只是一个简单的blob存储/传输方案。

https://ethresear.ch/t/blob-serialisation/1705

protobuf

至于为什么会走向protobuf这个歪路,是因为真的在benchmark里它的速度太诱人了。
golang那个benchmark对比里它也是被推荐的。
那时候新版还没出,gogo的速度在一众序列化方案里简直快到爆。
而且protobuf序列化之后的字节也非常小。
所以当时就觉得这个可以有。

但是随着项目一点点成型,protobuf的定位和项目需求的矛盾也越来越明显。
对于一个到处都是serde,到处在不顾兼容性地通过修改数据原型优化系统的一个底层应用,
protobuf的操作实在是过于繁琐且束缚过大

  • protobuf没有继承关系。
    protobuf的各种feature在和golang结合的时候会让人很难受。
    不止是单纯的用户定义的message之间继承不了,比如说这里希望message A继承bytesmessage B继承map<string, string>这种就都做不到。
    而且本身protobuf的(优化)集成也很麻烦(尤其在bazel里)。其对特殊类型(any啥的)的支持在golang下让人不太敢用,尤其是apiv2之后。

  • 我想要的大部分情况下只是简单的把一个list序列化,但是protobuf只处理generated code里的那些message
    例如raw, err := proto.Marshal([][]byte{[]byte{“wasd”}})
    就会提示cannot use ([][]byte literal) (value of type [][]byte) as protoreflect.ProtoMessage value in argument to proto.Marshal: missing method ProtoReflect。
    正确的serde做法就是建立一个新的message,然后这个message里带一个这个bytes list类型的字段。

  • 我想要序列化过程可定制,但是官方新版本不可以
    为什么需要可定制?因为一个struct中并非全部字段需要serde。
    一般的解决方法是将要序列化的字段public,而不需要的private,通过func来调用private内容。
    但是这里还有个痛点:生成的代码不应该修改,我根本没法把我的私有字段加到struct里。
    v0.0.19之前,我们是通过func直接生成值,例如GetHash()相当于每次调用都进行hash。
    即便是sha3.256这样的简单hash,都消耗了大量资源。
    后面v0.0.20的实践是加上对应的public字段。
    但是hash值这样的内容导致了值和其他内容填充的步调不一:需要先有内容且确定才能得到hash,修改内容后也得重新hash。
    v0.0.21带来了更优实践:使用自定义struct继承生成的struct。
    这样既可以保存自定义的辅助性字段(比如hash)(在自定义struct之中),
    又可以serde(通过父struct,即生成的)。
    但是实际体验并没有那么美好,自定义struct即便是继承了,也不是protoreflect.ProtoMessage,没法直接serde。
    注意这里是protoreflect.ProtoMessage,不是proto.Message,这是新版和旧版(or gogo)的区别所在。
    然而更坑的是goland会告诉你自定义的子类可以serde……因此需要一个个排查。
    最后来到我们的最后一个方案,为了快速排查,我们放弃了直接继承,选择了将原型作为自定义struct字段,通过GetProto()来获取原型以及LoadProto()进行serde。
    这样改掉一堆错误之后看似安静和谐了很多,但是,我们被框架限制住了。
    这样妥协的方式,让我们很难在types.proto之外继续利用serde来扩展。
    很明显,protobuf是给那些有准备的人的,而我们只是到处碰运气的赌徒罢了。

rlp

作为一个有在geth上debug经验的开发,我也不是第一次使用rlp。在过去golang使用还不熟练的经历里,rlp真的是不好用。
首先从直观来看rlp不像别的那样有Marshal和Unmarshal。
其次IDE上的提示(lint)也不尽如人意,经常会出现看起来啥问题都没有跑起来到处不能encode的问题。
同样Decode也有问题,见这里vb说的例子。这个帖子说的序列化还在优化更新中,而prysm用的是protobuf,所以现在不适合跟上ethereum的设计。
再者,缺少对map的支持,不过这个很好理解,有map之后就很难deterministic,像protobuf一样可能不同的实现有着不同的deterministic规则,仅在同一语言上统一。
此外如果直接type Uint uint这样建立type(很常见,比如NetworkType,TxType等等),那么rlp是不支持把他当uint这样的原始类型看的,就需要自己实现一个Encoder+Decoder。

但是首先这个数据大小很香,真的很小。根据github上benchmark来看是比protobuf要小。

其次原理简洁易懂。说白了就是万物list or str。这部分根据rlp的En/Decoder来讲。

En/Decoder

和json的返回值不同,rlp的内容是直接写入io.Writter。
一般我们就直接使用rlp.Encode(writter, 【自定义好的结构】)就可以

前面也说到了rlp原理简单,这里就举点案例:

比如说一个Nonce是个8字节的定长[]byte,需要把它encode to rlp只需要在这个8字节前面写一个prefix来描述它就可以用136(=0x80+8)来表示8这个长度。

具体的rlp的规则:

  • 对于单个byte(char),如果值是在0x00-0x7f(127,也就是常用ascii),那么就直接放着就行
  • 此外,对于0-55长度的bytes,rlp就用0x80(128)+长度作为其前缀来描述后面的列表(or单byte>127)。这个前缀byte值在0x80(128)-0xb7(183)
  • 如果是比55还长,那么肯定就需要不止一个byte来修饰。为了支持巨长的内容,前缀的第一个字符来描述后面>55的长度。例如一个1024字节的bytes/str,会被encode成[0xb9, 0x04, 0x00, byte_1, ... , byte_1024],这里0xb9=0xb7+2,这个2就表示后面有俩字节(0x04,0x00)是拿来描述长度的。而长度0x04,0x00则是1024的big-endian encode。这部分的首字节被限制在0xb8-0xbf,即最大支持8字节的长度描述,最大支持2^(8*4)字节长度的bytes
  • 另外还有个概念就是list,就相当于bytes或者其他list的一个容器,例如[[‘h’, ‘e’], [‘l’, ‘p’]]。如果这个list的总payload(子元素,包括了前缀,的长度加起来)是0-55bytes,那么就单用一个0xc0(192)来表述。
  • 和前面一样,如果超过55,那就用0xf7+长度。首字节范围在0xf8-0xff,也是最大支持2^(8*4)字节长度的bytes。

Blob

在翻rlp资料时候看到了eth社区提出的blob serialisation。这里说下至今
https://github.com/prysmaticlabs/prysm/pull/92
https://github.com/prysmaticlabs/prysm/pull/147
现在状况就是人家折腾半天然后跑去拥抱protobuf了。

备注:

serde=SERializing & DEserializing, rust里的framework就叫这个