本文转载自我自己的博客,感兴趣的老爷们可以关注~:https://www.miaoerduo.com/2021/11/16/arch-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时,多一种思考的角度。

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

架构小试之IDL的更多相关文章

  1. vb.net小试三层架构

    在对三层架构有了初步了解后,用vb.net做了一个小的程序,真的很小,仅仅是为了体现一下三层之间机制.下面是我设计的操作界面: 还有程序集和类的分布情况, 接下来是数据的设计,数据库用到的是SQL S ...

  2. WeText项目:一个基于.NET实现的DDD、CQRS与微服务架构的演示案例

    最近出于工作需要,了解了一下微服务架构(Microservice Architecture,MSA).我经过两周业余时间的努力,凭着自己对微服务架构的理解,从无到有,基于.NET打造了一个演示微服务架 ...

  3. 异构SOA系统架构之Asp.net实现(兼容dubbo)

    我们公司技术部门情况比较复杂,分到多个集团,每个集团又可能分为几个部门,每个部门又可能分为多个小组,组织架构比较复杂,开发人员比较多. 使用的编程语言也有点复杂,主流语言有.net(C#).Java. ...

  4. Thrift架构~windows下安装和Hello World及编码引起的错误

    最近开始正式接触Thrift架构,很牛B的技术,它被apache收纳了,属于开源中的一员,呵呵. 概念: Thrift源于大名鼎鼎的facebook之手,在2007年facebook提交Apache基 ...

  5. [Java Web] 2、Web开发中的一些架构

    1.企业开发架构: 企业平台开发大量采用B/S开发模式,不管采用何种动态Web实现手段,其操作形式都是一样的,其核心操作的大部分都是围绕着数据库进行的.但是如果使用编程语言进行数据库开发,要涉及很多诸 ...

  6. 由浅入深了解Thrift之微服务化应用架构

    为什么选择微服务 一般情况下,业务应用我们都会采用模块化的分层式架构,所有的业务逻辑代码最终会在一个代码库中并统一部署,我们称这种应用架构为单体应用. 单体应用的问题是,全部开发人员会共享一个代码库, ...

  7. NET实现的DDD、CQRS与微服务架构

    WeText项目:一个基于.NET实现的DDD.CQRS与微服务架构的演示案例 最近出于工作需要,了解了一下微服务架构(Microservice Architecture,MSA).我经过两周业余时间 ...

  8. Micro 架构与设计

    Micro 架构与设计 翻译自 Micro architecture & design patterns for microservices 注: 原文作者即 Micro 框架的开发者. 过去 ...

  9. SimpleRpc-系统边界以及整体架构

    系统边界 什么是系统边界?系统边界就是在系统设计之初,对系统所要实现的功能进行界定,不乱添加,不多添加.这么做的好处就是,系统简单明了,主旨明确,方便开发和用户使用.举个例子,一个自动售货机的本职工作 ...

随机推荐

  1. .NET 排序 Array.Sort<T> 实现分析

    System.Array.Sort<T> 是.NET内置的排序方法, 灵活且高效, 大家都学过一些排序算法,比如冒泡排序,插入排序,堆排序等,不过你知道这个方法背后使用了什么排序算法吗? ...

  2. TypeScript 条件类型精读与实践

    在大多数程序中,我们必须根据输入做出决策.TypeScript 也不例外,使用条件类型可以描述输入类型与输出类型之间的关系. 本文同步首发在个人博客中,欢迎订阅.交流. 用于条件判断时的 extend ...

  3. Git学习笔记02-配置

    安装好Git之后,做的就是需要配置Git了 第一步,配置自己的名称和邮箱 打开Git Bash 输入命令 git config --global user.name "用户名" g ...

  4. kvm安装window系统及使用NFS动态迁移

    验证是否开启虚拟化 # grep -E 'svm|vmx' /proc/cpuinfo - vmx is for Intel processors - svm is for AMD processor ...

  5. selenium 4.0 发布

    我们非常高兴地宣布Selenium 4的发布.这适用于Java..net.Python.Ruby和Javascript.你可以从你最喜欢的包管理器或GitHub下载它! https://github. ...

  6. md5验证文件上传,确保信息传输完整一致

    注:因为是公司项目,仅记录方法和思路以及可公开的代码. 最近在公司的项目中,需要实现一个上传升级包到服务器的功能: 在往服务器发送文件的时候,需要确保 文件从开始发送,到存入服务器磁盘的整个传输的过程 ...

  7. exe图标消失的解决方案

    步骤 win + r组合键打开运行窗口 输入cmd,回车 在终端窗口右键粘贴即可 taskkill /im explorer.exe /f cd /d %userprofile%\appdata\lo ...

  8. Python技法3:匿名函数、回调函数和高阶函数

    1.定义匿名或内联函数 如果我们想提供一个短小的回调函数供sort()这样的函数用,但不想用def这样的语句编写一个单行的函数,我们可以借助lambda表达式来编写"内联"式的函数 ...

  9. mysql的一些配置操作

    mysql的一些配置操作 一.背景 二.mysql配置 三.慢查询日志 1.命令行临时生效 2.配置文件修改永久生效 3.慢查询日志解释 4.mysqldumpdlow查看慢查询日志 四.查看索引为何 ...

  10. 2021.10.10考试总结[NOIP模拟73]

    T1 小L的疑惑 对于\(P_i\),如果所有比\(P_i\)小的数加起来也达不到\(P_i-1\),那么值域肯定不连续.否则设原来值域最大值为\(mx\),则\(P_i\)会让值域最大值增致\(mx ...