架构小试之IDL

为什么IDL的介绍也放在这里呢?一方面是我想不到放哪里,另一方面是之前说到,“架构”即“设计”,那么IDL、RPC框架也算是设计的一部分。不合理的选型在后续维护上会带来不小的麻烦。

本文主要介绍我用过的一些IDL,并结合真实案例,分析他们的优劣。

IDL的作用

在我接手第一个项目的时候,就问了一个问题:这个idl文件夹是做什么的?

一年之后,当对新人介绍我们项目结构的时候,我都会忍不住试探的问句,你知道idl是什么意思吗?发现大家和我一样不了解,我才心满意足的解释一番。

IDL其实有很多的含义,在这里一般可以理解为接口描述语言(Interface description language),即描述服务的接口,类似我们C程序的接口声明,包含:接口名和输入输出的数据结构

一般每个服务均有自己的IDL文件(也可以是多个服务依赖相同的IDL文件,因为懒,或者其他巧妙的目的),比如我现在公司常用的服务是基于C++和Go的,使用Thrift作为IDL。

Thrift提供了工具,可以根据IDL编译生成服务端和客户端的代码:

  • 对于服务端而言,我们只需要继承生成的Server类,然后实现具体的接口的内容即可。
  • 客户端(即调用方),IDL可以生成Client类,方便的进行调用。

因此,一个接口的声明,不仅指导当前服务的实现,同时也是对上游服务的约定。因此一般公司会将所有服务的IDL文件统一维护。这样只需要知道服务名和接口声明,即可完成RPC服务的接入。

像Thrift这种IDL可以定义数据结构和接口,而有些IDL只可以定义数据结构。IDL生成的数据结构一般均支持序列化和反序列化,并且跨端、跨语言。这种本身不定义接口的IDL,也可以以string的方式搭配其他的RPC框架来使用(Thrift,gRPC等)。

这里我们主要介绍几种典型的IDL:JSON、ProtoBuf、Thrift。当然IDL还有XML、FlatBuffer、BSON等,感兴趣可以自行查阅。

几类常见的IDL

JSON

JSON,JavaScript Object Notation,这个大家应该都了解,结构简单,可读性好,一般在Web开发中最常用到,是RESTFul API的首选。

JSON只支持Object,Array和数值三种结构,Object和Array支持相互嵌套,标准的JSON的数值仅有:double/boolean/string这三种。以下是个例子:

{
	"name": "miao",
	"age": 18,
	"skill": [
		{
			"name": "paint",
			"level": 1
		},
        	{
			"name": "coding",
			"level": 2
		}
	]
}

像C++的项目,一般直接使用RapidJSON这个库,他的性能是十分优秀的,并且支持拓展的数据类型。如果是纯C的项目,可以考虑cJSON,我曾经还提过MR😁。

这里有个有意思的事情是,我之前编写过一个工具,可以将程序的中间结果Dump成JSON格式用于Debug。但是有同事通过JSON的在线格式化工具查看的时候,数值看起来都被截断了,数值的后几位都是0。 最后发现是因为网页版的工具只支持double,而RapidJSON可以准确的序列化出int64的数据,int64到double的转换导致了精度的丢失。闹了个乌龙。

那么公司内部服务间的通信使用JSON是一个好的选择吗?

我的观点是,这不是一个好的选择。(虽然现实是,我所在的公司经常在服务间传JSON)

有以下几个原因:

  1. 没有Schema
  2. 带宽占用大
  3. 序列化和反序列化的时间开销
  4. 解析复杂

首先,JSON没有标准的Schema(RapidJSON提供了定义Schema的机制,但是校验JSON的开销也很大),比如我们在拿到数据之前,是不知道这个string中存在哪些数据,也不能假定任意数据是存在的。这会造成我们在获取任意的数据时,必须做各种判断,设置兜底值。

JSON序列化的string一般也会很长,尤其数字的序列化,3.14159265359,这需要13个字节来存放。而实际上它是一个double,至多8个字节即可。

JSON的序列化和反序列化也相比其他IDL要慢了一些,比如上面的数字,理论上仅对二进制进行操作即可,而JSON必须转成string。其次JSON序列化需要填充key和一些,[]{}的字符。如果需要传输二进制数据的话,JSON一般会需要转成Base64编码,整体的编码和体积又会进一步增大。

最后是解析很复杂,由于没有Schema,导致每个字段都需要做解析和判断。另外很多JSON的解析库,对于Object和Array,底层使用链表来实现的,查询效率是线性的。

Protobuf

Protocol Buffers,简称PB,是一种数据描述的工具,它可以定义丰富的数据结构,支持基础数据类型(int, float, string等)、常用容器list和map,以及自定义的组合数据类型(Message)。

PB有2和3两个版本,二者并不兼容,以下是PB2的Schema的定义:

syntax = "proto2";

package med;                  // 包名,相对于C++的namespace

message Skill {
  required string name = 1;
  required int32 level = 2;
}

message User {
  required string name = 1;   // required表示该字段必须要有
  optional int32 age = 2;     // optional表示该字段可选
  repeated Skill skill = 3;   // 多个Skill结构
}

通过protoc user.proto —python_out=. 编译生成了user_pb2.py文件。

我们简单使用一下这个IDL,这里使用的Proto2生成的:

"""
pip3 install -i https://pypi.douban.com/simple/ protobuf
"""

import user_pb2
import json

# raw data
user = {
    'name': 'miao',
    'age':18,
    'skill': [
        {
            'name': 'paint',
            'level': 1
        },
        {
            'name': 'coding',
            'level': 2
        },
    ]
}

# convert to pb
pb_user = user_pb2.User()
pb_user.name = user['name']
pb_user.age = user['age']

for skill in user['skill']:
    pb_skill = user_pb2.Skill()
    pb_skill.name = skill['name']
    pb_skill.level = skill['level']
    pb_user.skill.append(pb_skill)

# convert to JSON
#  the given separators will make it compact
json_user = json.dumps(user, separators=(',', ':'))

print("============ JSON ============")

print("Size: {}\nContent:\n\t{}".format(len(json_user), json_user))

print("============  PB  ============")
print('Size: {}\nContext:\n\t{}'.format(pb_user.ByteSize(), pb_user.SerializeToString()))

'''
OUTPUT:
============ JSON ============
Size: 89
Content:
{"name":"miao","age":18,"skill":[{"name":"paint","level":1},{"name":"coding","level":2}]}
============  PB  ============
Size: 31
Context:
b'\n\x04miao\x10\x12\x1a\t\n\x05paint\x10\x01\x1a\n\n\x06coding\x10\x02'
'''

可以看出,首先PB是有Schema的,任何人只要拿到Schema,就可以容易的解析PB数据。

PB序列化出的数据比JSON小了很多。只有大约1/3的大小。(这里主要是节省了JSON的Key的部分)。同时一般情况下,PB的序列化和反序列化的速度比JSON更快(有没有PB更慢的情况呢?后续案例会提到)。

在读取值的情况下,JSON需要根据key去查找具体的数据,而PB的每个成员定义最终都是一个函数(C++中是函数,Python更像是成员变量),可以用调用函数的方式去取值,节省了一次查找的开销,因此读取的速度极高。

另外PB支持反射,既可以输入一个string,可以通过反射的方式获取到他的值,但是PB反射的用法比较复杂,这个可以单独写篇博客来介绍。

关于PB,其实也有许多坑的地方。比如PB2和PB3不兼容,PB3没有optional字段,PB的库版本不匹配容易出错等。所以我们尽量把PB2和3看成两个工具,一开始就决定好使用哪个。

与PB十分相似的有个IDL是FlatBuffer,他和PB支持的数据类型基本一致,但在构建对象的时候,保证了数据是原始数据且内存分布和IDL定义一致。带来的好处是,FlatBuffer序列化的字符串,可以直接读取,而不需要反序列的操作,因此解码时间可以理解为0,在游戏行业应用较多。

Thrift

Thrift和上面两个存在本质的不同。

Thrift不仅可以定义数据结构,这一点和PB相同,同时还可以定义RPC的接口。使用相关的工具,可以方便的生成RPC的Server和Client的代码。

struct Skill {
    1: string name,
    2: i32 level,
}

struct User {
    1: string name,
    2: i32 age,
    3: list<Skill> skill,
}

struct Req {
    1: string log_id,
    2: User user,
}

struct Rsp {
    1: string log_id,
    2: string data,
}

service EstimateServer {
    Rsp estimate(1: Req),
}

thrift --gen py demo.thrift 命令可以生成对应的python代码,这里默认在gen-py文件夹。

from thrift.transport import TSocket
from thrift.transport import TTransport
from thrift.protocol import TBinaryProtocol
from thrift.server import TServer
import sys

sys.path.append("./gen-py/")
from demo import EstimateServer

class EstimateHandler:
    def __init__(self):
        pass

    def estimate(self, req):
        user = req.user
        rsp = EstimateServer.Rsp(log_id=req.log_id)
        msg = 'hi~ {}, Your Ability: \r\n'.format(user.name)
        for skill in user.skill:
            msg += '    skill: {} level: {}\r\n'.format(skill.name, skill.level)
        rsp.data = msg
        return rsp

if __name__ == '__main__':
    # 创建处理器
    handler = EstimateHandler()
    processor = EstimateServer.Processor(handler)

    # 监听端口
    transport = TSocket.TServerSocket(host="0.0.0.0", port=9999)

    # 选择传输层
    tfactory = TTransport.TBufferedTransportFactory()

    # 选择传输协议
    pfactory = TBinaryProtocol.TBinaryProtocolFactory()

    # 创建服务端
    server = TServer.TThreadPoolServer(processor, transport, tfactory, pfactory)

    # 设置连接线程池数量
    server.setNumThreads(5)

    # 启动服务
    server.serve()
from thrift import Thrift
from thrift.transport import TSocket
from thrift.transport import TTransport
from thrift.protocol import TBinaryProtocol
import sys
sys.path.append("./gen-py/")

from demo import EstimateServer

if __name__ == '__main__':
    transport = TSocket.TSocket('127.0.0.1', 9999)
    transport = TTransport.TBufferedTransport(transport)
    protocol = TBinaryProtocol.TBinaryProtocol(transport)
    client = EstimateServer.Client(protocol)

    user = EstimateServer.User(name='miao', age=18)
    user.skill = [
        EstimateServer.Skill(name='paint', level=1),
        EstimateServer.Skill(name='coding', level=2)
    ]

    # 连接服务端
    transport.open()

    rsp = client.estimate(EstimateServer.Req(log_id="10086", user=user))
    print('log_id: {}'.format(rsp.log_id))
    print(rsp.data)

    # 断连服务端
    transport.close()

"""
log_id: 10086
hi~ miao, Your Ability: 
    skill: paint level: 1
    skill: coding level: 2
"""

Thrift的序列化有点复杂,感兴趣的可以查看client.estimate的源代码,我们大致可以知道,Thrift的序列化的体积和PB应该类似。

Thrift和PB支持的数据类型基本上一致,但是同时支持了RPC接口的定义。但是比较遗憾的是Thrift不支持反射。当字段太多的时候,想支持参数解析的配置化,就比较麻烦。

IDL之间的对比和选择

首先给出上面三种IDL的各类情况:

IDL 编解码 体积 反射 RPC接口 Schema 可读性
PB 支持 不支持 支持 需解码
Thrift 不支持 支持 支持 需解码
JSON 支持 不支持 -

由于这里Thrift是用来定义服务的,因此一定会被用到,这里主要讨论的是一次RPC调用时,内部的具体数据的选择。

以下我们分场景讨论。

AB参

AB参指是我们通过实验平台下发实验的参数。一般我们在开发完一个功能之后,并不一定会立刻上线推全,而是在线上保留新旧两套逻辑,再通过平台下发参数来控制分别启用新旧逻辑。用于做对比实验。

一般AB参会随着请求下发到每个服务。如果AB实验得到了具体的结论,就可以固化AB参(删掉旧代码,或者全量新的AB参)。

那么一个合格的AB参选型需要满足:

  1. 易于构造
  2. 体积小
  3. 组织灵活
  4. 解析速度快
  5. Schema简单

先说结论,这里优先考虑JSON和PB,PB依赖一些额外的工作。单纯使用Thrift不可行。

这里排除直接使用PB和Thrift的Map结构的情况,因为这样和JSON几乎等价,表达能力却不如JSON。

首先,JSON是很适合的选择。它的构造很简单,组织灵活,如果数据量不大的话,解析速度也还可以。同时由于支持反射,一些逻辑的配置化也比较方便的实现。并且基本上所有的语言都可以很好的支持。原生支持数据透传,不依赖上下游的服务升级。

缺点是当数据量比较大的时候,JSON会占用很大一部分服务的CPU和带宽。

那么PB和Thrift有什么问题呢?核心是数据传递的完整性。另外Thrift不支持反射也是个硬伤。

假设服务调用是A->B->C,C是最下游的服务,我们的代码写在C中。新增AB参时,我们在IDL中增加一个字段。在开发上线完C后,A、B可能也需要同步升级以支持透传参数。不然在开实验时,A、B无法将数据透传到下游,影响实验的发布。Thrift的参数直接体现在RPC接口中,更新字段必须重新上线,因此这里Thrift就不太适合。

而PB本身可以序列化成String放在请求里面,因此如果是透传全量的AB参,这是可以保证的。

另一种情况是,B这个服务对AB参做了拆分,然后仅透传其中的一部分给C。那么如果B的IDL是旧版的,那么还能完成透传吗?这里其实PB是有相关的支持的。

PB2直接支持低版本透传高版本的字段。

PB2

Any new fields that you add should be optional or repeated. This means that any messages serialized by code using your "old" message format can be parsed by your new generated code, as they won't be missing any required elements. You should set up sensible default values for these elements so that new code can properly interact with messages generated by old code. Similarly, messages created by your new code can be parsed by your old code: old binaries simply ignore the new field when parsing. However, the unknown fields are not discarded, and if the message is later serialized, the unknown fields are serialized along with it – so if the message is passed on to new code, the new fields are still available.

PB3,在3.5之前会丢弃新字段,3.5及以后会透传。

PB3

Originally, proto3 messages always discarded unknown fields during parsing, but in version 3.5 we reintroduced the preservation of unknown fields to match the proto2 behavior. In versions 3.5 and later, unknown fields are retained during parsing and included in the serialized output.

当然这个特性是PB所支持的,如果使用其他的IDL,也需要提前调研一下。

其实还有个问题是实验平台的支持。

一般公司会都有个实验平台,在上面我们通过可视化的方式即可进行实验的配置。使用PB的话,意味着新增AB参时,都需要在平台进行注册,否则平台不认识,无法正确写入字段。当然对AB参的更严格的监管,其实也是好事,可以为整个服务链路做更好的监控,这取决于公司是否愿意投入人力去解决。

正排

我们经常听到倒排索引这个概念,其实正排更常见。比如存放用户的信息,一般就是一个map,key是user_id,val是用户的具体信息。

提到KV存储,我们很容易想到Redis,Memcached,LMDB等工具,具体的选择以后再讨论。一般正排是独立的一个服务,对于正排的查询就会是一次RPC请求。因此,正排中的val一般是序列化好的字符串,以减少再次序列化的开销。

这里就是PB的极好的应用场景。

对于一个正排服务,一般会将数据分shard然后放进内存,RPC是直接读取了内存的数据。这种服务一般瓶颈容易出现在内存和带宽上,压缩率越高,就意味着更少的资源。PB拥有极高的压缩率,序列化和反序列化均很快,又支持反射。

另外,如果一个val存放了过多的字段,而我们只想获取少部分字段时,由于服务端不方便做解码,我们必须一次请求所有的数据,这样就会带来带宽上的浪费。一般的解决方案是将正排的val做拆分。大val时,数据库的选型也是个问题,比如Redis对大的val支持并不好。这个我们后续会再介绍。

稀疏字段的数据

这是指一个数据的定义有1000个字段,但是一条记录可能只会填充其中的几十个字段的情况。

常见于埋点数据,还有上面AB参(随着时间推移,很多无用的AB参未及时清理)。

这种情况下,PB和JSON哪个更好的?我们没有一个比较明确的答案。

这里碰到了一个案例,有同事将埋点数据从JSON改成了PB,然后重构了整条链路之后,发现优化前后CPU和内存均持平。

推测原因是,一条JSON只保存了几十个字段的KV,而PB保存了所有字段的状态和数据(PB2会记录每个字段是否被set),因此存储上PB有浪费。解析也同理。

写在最后

上述的案例的答案可能并不适用于其他场景,仅供大家了解。这里的目的是,希望在大家选择IDL时,多一种思考的角度。

本文写了真的好久,总算是写完啦~