|
软件工程也是一种工程学(engineering)。现实世界做工程设计,一定会面对各式各样不同的约束条件。即使是看起来好像一模一样的问题,在不同的约束条件下也可能产生迥然不同的解决方案。
Protobuf是一个非常典型的,针对特定约束,在设计上做出特定妥协(tradeoff)的例子,它的某些设计甚至是反直觉的。例如:
- 零值/空值字段,在序列化以后,完全不存在于payload里。这意味着反序列化时,这些字段都会被自动填充零值。你无法分辨某个字段是发送端没有(或忘了)赋值,还是赋值为零。
- 从Protobuf 2.0到3.0,取消了optional字段支持,所有字段都是必需。当然,如上一条所述,在构造消息时,所有字段你都可以不赋值,因为它们都会(在反序列化时)自动获得零值填充。
- 综上两条所述,如果你确实需要表示一个optional的primitive类型字段,比如一个整数,你需要区分它是没有设置还是设置成0,你就得把它包装在某个wrapper类型里,例如Google官方的wrappers。
更正: @祖与占指出pb 3在release 3.15.0里已经把optional支持加回来了。这个修改是2021年初发布的,但Google官方的pb3 spec到目前(2022.11)都还没把optional加回来d。我猜在实现上是给官方的wrapper类型做了个语法糖,简化使用。
<hr/>很多新接触Protobuf的人会在这些设计上吃到苦头。但为什么Google要这么设计?
如果你在同一个企业内部服务上工作过足够长的时间,你一定会知道,经过一段时间的演变,那怕是最初设计很简单的API,请求/响应消息中字段数量也可能会大量膨胀,而其中一定又会有大量的可选字段,平时根本不会用到。因为gRPC/Protobuf是Google内部的RPC调用标准,减小payload 10%的大小,就意味着节省10%的网络带宽和存储容量。按照Google的规模,你可以想像一下这样的设计能带来的成本节省。
默认0值填充,还突显了另一个特点(或者说workaround):在消息schema的演进上,尽可能强迫API(或者说API实现者)向后/向前兼容。例如v1版的消息有a, b两个字段,v2版有a, b, c三个字段。v1版客户端向v2版服务端发请求,v2版仍然能正常解出一个c为零的v2版消息。而反过来,则是v1版接受v2版消息时也不会出错,只是不能直接看到新字段c。
如果你用过Thrift,你会知道protobuf在应对schema变化时要宽容得多。在Thrift中新增字段,如果另一端IDL没有更新,就会在运行时出错。
换句话说,因为数据合法性的验证常常不能独立于业务逻辑完成,那还不如不要在传输和序列化层面去强求,而是留给业务逻辑层去做。
服务的向后/向前兼容性是个非常大的话题,但凡因为breaking API吃过苦头的人,看到protobuf的这些设计决定,可能会心一笑,原来Google的人也不是神仙,也是这么一步一步踩坑过来的。鄙司内部有个老服务还在用JSON而不是强类型的IDL,前阵子就因为改服务的人没这个意识,搞出API breaking change的严重incident。用Thrift或Protobuf这样的IDL,至少API接口层面的breaking change更容易在code review时发现。
<hr/>其实是想说,protobuf固然有序列化快速,payload小的优点,但并不是适合所有场景。它的学习曲线一定比JSON来得陡峭,有很多坑需要开发者去踩,而它的优点未必适合所有人。
比如,如果你的服务访问量不大,和/或payload不大,用JSON就省掉了踩protobuf坑的风险。即使消息很大,还有gzip transfer-encoding不是。schema和API兼容嘛,就加强测试吧……
没有放之四海而皆准的解决方案,做工程就要做tradeoff。选择一些东西,常常意味着要放弃一些别的东西。 |
|