大家好,我们是字节跳动 Web Infra 团队,目前团队主要专注的方向包括现代 Web 开发解决方案、低代码搭建、Serverless、跨端解决方案、终端基础体验、ToB 等等。Node.js 基础设施建设是我们负责的方向之一,包括但不限于:
在 2021 年上半年,由于现有的 Node.js RPC 实现逐渐跟不上字节跳动业务发展节奏,我们决定对其进行重构,在本文将会介绍到 RPC 重构过程中的设计思路以及落地中所遇到的问题。
RPC ( Remote Procedure Call ) 是一种通用的网络调用方式,其广泛应用于后端服务之间,像 Dubbo、SOAP、Thrift、gRPC、RESTful 等,从广义上来说都是一种 RPC 的实现。几乎可以这么说,只要公司达到一定量级,其后端服务之间必定会采用 RPC 而非简单 HTTP 的形式来进行互相调用。因此,对于想做全栈或者后端 Node.js 的同学来说,早点了解与使用 RPC 是非常有必要的。
既然 RPC 这么重要,那么到底该怎么去理解它呢?
按照上面的说法,RPC 是一种通用的网络调用方式,是一个抽象的概念,那么直接对其进行理解是行不通的。所以我们需要把 RPC 映射到我们的现实生活中,这样就会发现,我们的每一次交谈、打字、打电话其实都是一次 “RPC 调用”,RPC 是一种 “沟通” 方式。
在字节跳动内,由于各种原因,存在有多种序列化协议、网络协议,这导致我们没有办法直接使用开源的 Apache Thrift、gRPC,只能选择自建 RPC 实现。而对于 RPC 实现,我们希望可以做到以下几点:
在开始介绍之前,考虑到部分同学可能对于后面使用到的概念不太了解,所以我们需要先科普一下使用到的方法论,有相关经验的同学可以跳过这一节。
摘自 Wikipedia
Domain-driven design ( DDD ) is the concept that the structure and language of software code ( class names, class methods, class variables ) should match the business domain.
Domain-driven design is predicated on the following goals:
领域驱动设计 (DDD) 是一种将代码结构、命名与业务领域概念相匹配的方法论。领域驱动设计基于以下几个目标:
说白了就是由在某个领域摸爬滚打了多年的专家来梳理业务逻辑,与技术人员合作设计领域模型,然后再由技术人员根据领域模型进行实现的一套软件设计与迭代方法。
在下文中,我们将会利用领域驱动设计的思路来探讨 RPC 该如何进行设计。
在进行设计之前,我们必须要先对 RPC 进行分解,了解其基础是什么?
上文说过了,RPC 是一个抽象的概念,所以直接分析其基础是行不通的,只能透过现实场景来进行分析。就拿交谈这个简单场景来说:我们跟什么人、说什么话,其实都是不确定的,但是可以确定的是,我们说话的声音是通过空气振动传达给对方的 ( 物理原理 ),如果没有空气振动,那么声音也传达不到对方的耳朵 ( 真空环境 )。所以可以得出空气振动 ( 传播途径 ) 是交谈的一个重要基础。
除此之外,其实还有一个很重要的基础,那就是语言互通。如果语言不通,那么驴唇不对马嘴也是很正常的事情。所以我们见到中国人会下意识的说普通话,见到外国人会下意识的说英语,见到家里人也会下意识的说方言 (如果有的话)。
从上面的推断,我们可以得到交谈的两个重要基础:
同理的,在 RPC 的场景下,也必然会有它们的一席之地。
其实在 RPC 中,网络协议就相当于传播途径,用于传输数据,而序列化协议则相当于语言,用于转换传输的数据。所以我们可以做一个假设:对于一个 RPC 实现来说,有两个很重要的基础因素:
接下来,我们就根据上面的假设构建一个理论模型。
网络协议 ( Network Protocol ),其重点在 Network 上,说到 Network 就不得不让人联想到连接 ( Connection ) 了,它在许多网络协议中都有体现,比如:TCP 协议的 Socket,HTTP 协议的 Request & Response,所以我们就以 Connection 作为网络协议的模型。同时为了避免与序列化协议相混淆,我们还需要为 Connection 模型上一道限制,即网络协议只关心网络 IO 读写与 IO 事件处理,不关心任何序列化相关的事情。说到 Connection 那自然就逃不过 read / write 了,所以可以建立一个简单的 Connection 模型如下:
interface Connection {
read(): Promise<Buffer>;
write(buf: Buffer): Promise<void>;
}
序列化协议 ( Serialization Protocol ),就词组上来看,重点是在 Serialization 上,但如果用 Protocol 来表示,也好像差的不太多,所以这里就取更短的 Protocol 作为序列化协议的模型。序列化协议也肯定都会有 encode / decode,所以可以建立一个简单的 Protocol 模型如下:
interface Protocol {
encode(): Promise<void>;
decode(): Promise<void>;
}
接下来的问题就是怎么组合使用这两个模型了。一般来说,根据人的习惯,都是先想好说什么然后再开口说话的,所以我们把 Protocol 模型放在 Connection 模型之前,就可以得到如下的一条调用路径:
上面的调用路径虽然看起来简洁,但太过于简单,并没有说明具体是怎样的调用方式。而在实际的 RPC 调用中,可能会存在多种调用方式,比如:TCP Socket 随意读写,HTTP 一次 Request 对应一次 Response,HTTP2 一次 Request 对应多次 Response,所以这里必定还缺了些什么东西来抽象这些具体的 RPC 调用方式。在这里我们使用 Handle 来作为调用方式的模型抽象,它代表的是一次 RPC 该如何去调用,其模型如下:
interface Handle {
execute(): Promise<any>;
}
这样就可以将调用路径改成如下的形式:
到这里我们就可以根据上面的调用路径写一下伪代码了,伪代码如下:
createServer((socket) => {
const connection = new ServerConnection(socket);
const protocol = new ServerProtocol();
const handle = new ServerHandle((ctx) => {
console.log('Server got', ctx.request);
ctx.response = { pong: 'pong' };
});
const ctx = { connection, protocol, handle } as Context;
(async () => {
/**
* 内部执行
* const buf = await ctx.connection.read();
* ctx.request = ctx.protocol.decode(buf);
* ...
* const buf = ctx.protocol.encode(ctx.response);
* await ctx.connection.write(buf);
*/
await handle.execute(ctx);
})();
}).listen(3000);
(async () => {
const socket = connect({ port: 3000 });
const connection = new ClientConnection(socket);
const protocol = new ClientProtocol();
const handle = new ClientHandle();
const ctx = { connection, protocol, handle } as Context;
ctx.request = { ping: 'ping' };
/**
* 内部执行
* const buf = ctx.protocol.encode(ctx.request);
* await ctx.connection.write(buf);
* ...
* const buf = await ctx.connection.read();
* ctx.response = ctx.protocol.decode(buf);
*/
await handle.execute(ctx);
console.log('Client got', ctx.response);
})();
在这个伪代码中,Handle、Protocol、Connection 实现都是可以自由替换的,换句话说,我们只需要实现了 TCP Connection、HTTP Connection、Thrift Protocol、Protobuf Protocol,就可以做到 Thrift on TCP、Protobuf on TCP、Thrift on HTTP、Protobuf on HTTP。
但在后续的实现过程中,我们遇到了一个问题:由于创建 Socket 与监听 Server 都是比较复杂的行为,特别是还需要考虑到服务发现、Service Mesh、连接池等的存在,这导致了 Connection 的实现代码变得极其复杂,并且与 Server 实现严重耦合,稍微有一点不同就会产生大量冗余代码。
因此为了解决这个问题,我们为 Connection 模型引入了 ConnectionProvider 模型,让其负责 Connection 的创建与回收,考虑服务发现、Service Mesh、连接池等问题。
Tips:出于对称设计原则的考虑,也为 Protocol 模型引入了 ProtocolProvider 模型。
最终所有涉及到的模型如下:
Tips:Unit Handle 是为了实现 Service、Method 级别中间件而加入的设计,本质上与 Handle 一样。
从上面的模型总览中来看,涉及到的模型还是比较多的,这也导致了 Client 与 Server 的创建过程会比较繁琐,不容易理解,所以在我们的 RPC 实现中,同时对外提供了两套 API。
在实际应用中,我们发现 Protocol 模型还是太过于简单了。根据公司体量的大小、技术债的积累程度,最终都会不可避免的会出现协议变种、协议组合的情况,这时需要实现的协议可能就会出现成倍的增长。以我们内部情况为例:在字节跳动的 RPC 调用中,同时存在着 Binary Thrift、TTHeader + Binary Thrift、Framed Thrift、Mesh + TTHeader + Binary Thrift 等情况。
所以我们通过人为的将 Protocol 模型分为 HeaderProtocol 与 PayloadProtocol 模型,并通过 connection.cork、connection.uncork 与动态 buffer 技巧实现了多协议组合,伪代码如下:
class DynamicBuffer {}
connection.cork(new DynamicBuffer());
await payloadProtocol.encode(ctx);
let payload = connection.uncork();
for (let i = headerProtocols.length - 1; i >= 1; i--) {
const headerProtocol = headerProtocols[i];
connection.cork(new DynamicBuffer());
await headerProtocol.encode(ctx, payload);
payload = connection.uncork();
}
await headerProtocols[0].encode(ctx, payload);
Tips: 由于是人为规定的分类,所以:
在后续的性能测试中,我们发现在 Middleware 中对 Context 进行扩展时,消耗了大量性能。通过排查发现,是由于业务属性需要,大量运用了 Object.defineProperty()、Object.defineProperties() 等 API 所导致的。比如我们需要在 Context 上动态创建 ctx.logId 属性,并将它存储到 ctx.tags.log_id,那么代码基本上需要写成这样:
const ctx = { tags: { log_id: '' } };
Object.defineProperty(ctx, 'logId', {
get() {
return ctx.tags.log_id;
},
set(logId: string) {
ctx.tags.log_id = logId;
},
});
需要消耗一次 Object.defineProperty() 的性能。如果这时还需要同时兼容 ctx.log_id 的获取与设置方式,那么消耗的性能将翻一倍,因此这种做法的性能基本上好不到哪里去。但如果通过 class extend 的形式扩展,又会陷入实现与具体 Middleware 耦合的尴尬境地。所以我们参考 fastify.decorate()[1] 的实现,通过动态修改 Context 原型,来实现了近乎零消耗的 Context 扩展能力。
在本文中,我们聊到了 RPC 的设计细节,从最基础的 RPC 分解,到模型设计,再到落地中遇到的问题。但如果要实现一个完善的 RPC 库,所涉及到的细节将远非这些,同时也会有许多其它概念将会对现有的模型造成冲击,这需要我们耐心分析其本质,并将这些概念逐步的融入到设计实现中。
在我们内部的 RPC 实现中,已经支持了 Thrift 序列化协议与 TCP、HTTP 网络协议,在不久后的将来也会支持 JSON、Protobuf 与 HTTP/2,甚至可能会将这套设计搬上浏览器,让前端可以直接在浏览器上发起 RPC 调用,来丰富前后端调用技术选型,促进框架生态发展。
本文由哈喽比特于3年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/fwUctEJGKIlIkd1PufrntQ
京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。
日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。
据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。
今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。
日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。
近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。
据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。
9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...
9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。
据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。
特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。
据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。
近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。
据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。
9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。
《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。
近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。
社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”
2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。
罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。