聊聊字節(jié)跳動 Node.js RPC 的設(shè)計實現(xiàn)
點擊上方 程序員成長指北,關(guān)注公眾號
回復1,加入高級Node交流群
背景
大家好,我們是字節(jié)跳動 Web Infra 團隊,目前團隊主要專注的方向包括現(xiàn)代 Web 開發(fā)解決方案、低代碼搭建、Serverless、跨端解決方案、終端基礎(chǔ)體驗、ToB 等等。Node.js 基礎(chǔ)設(shè)施建設(shè)是我們負責的方向之一,包括但不限于:
服務(wù)發(fā)現(xiàn):Consul 服務(wù)治理:Logger、Metrics、Trace 服務(wù)調(diào)用:HTTP ( Fetch )、RPC ( Thrift ) 數(shù)據(jù)庫:MySQL ( Sequelize / TypeORM )、Redis、ClickHouse 消息隊列: Kafka、RocketMQ 結(jié)合框架,提供遵循公司流量調(diào)度等規(guī)范的 Node.js 插件 支持 Node.js、Golang 等后端語言的性能分析平臺 維護 Node.js 應用的容器鏡像
在 2021 年上半年,由于現(xiàn)有的 Node.js RPC 實現(xiàn)逐漸跟不上字節(jié)跳動業(yè)務(wù)發(fā)展節(jié)奏,我們決定對其進行重構(gòu),在本文將會介紹到 RPC 重構(gòu)過程中的設(shè)計思路以及落地中所遇到的問題。
什么是 RPC?
RPC ( Remote Procedure Call ) 是一種通用的網(wǎng)絡(luò)調(diào)用方式,其廣泛應用于后端服務(wù)之間,像 Dubbo、SOAP、Thrift、gRPC、RESTful 等,從廣義上來說都是一種 RPC 的實現(xiàn)。幾乎可以這么說,只要公司達到一定量級,其后端服務(wù)之間必定會采用 RPC 而非簡單 HTTP 的形式來進行互相調(diào)用。因此,對于想做全?;蛘吆蠖?Node.js 的同學來說,早點了解與使用 RPC 是非常有必要的。
既然 RPC 這么重要,那么到底該怎么去理解它呢?
按照上面的說法,RPC 是一種通用的網(wǎng)絡(luò)調(diào)用方式,是一個抽象的概念,那么直接對其進行理解是行不通的。所以我們需要把 RPC 映射到我們的現(xiàn)實生活中,這樣就會發(fā)現(xiàn),我們的每一次交談、打字、打電話其實都是一次 “RPC 調(diào)用”,RPC 是一種 “溝通” 方式。
現(xiàn)狀 & 需求
在字節(jié)跳動內(nèi),由于各種原因,存在有多種序列化協(xié)議、網(wǎng)絡(luò)協(xié)議,這導致我們沒有辦法直接使用開源的 Apache Thrift、gRPC,只能選擇自建 RPC 實現(xiàn)。而對于 RPC 實現(xiàn),我們希望可以做到以下幾點:
支持多種序列化協(xié)議,如 Thrift、Protobuf、JSON。 支持多種網(wǎng)絡(luò)協(xié)議,如 TCP、HTTP、HTTP/2。 盡量復用老代碼。
設(shè)計 RPC
DDD (Domain Driven Design)
在開始介紹之前,考慮到部分同學可能對于后面使用到的概念不太了解,所以我們需要先科普一下使用到的方法論,有相關(guān)經(jīng)驗的同學可以跳過這一節(jié)。
摘自 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:
placing the project's primary focus on the core domain and domain logic; basing complex designs on a model of the domain; initiating a creative collaboration between technical and domain experts to iteratively refine a conceptual model that addresses particular domain problems.
領(lǐng)域驅(qū)動設(shè)計 (DDD) 是一種將代碼結(jié)構(gòu)、命名與業(yè)務(wù)領(lǐng)域概念相匹配的方法論。領(lǐng)域驅(qū)動設(shè)計基于以下幾個目標:
將項目重心放在核心領(lǐng)域與領(lǐng)域邏輯上 以領(lǐng)域模型為基礎(chǔ)進行復雜設(shè)計 讓技術(shù)專家與領(lǐng)域?qū)<疫M行合作,以迭代的方式來解決特性領(lǐng)域的概念模型
說白了就是由在某個領(lǐng)域摸爬滾打了多年的專家來梳理業(yè)務(wù)邏輯,與技術(shù)人員合作設(shè)計領(lǐng)域模型,然后再由技術(shù)人員根據(jù)領(lǐng)域模型進行實現(xiàn)的一套軟件設(shè)計與迭代方法。
在下文中,我們將會利用領(lǐng)域驅(qū)動設(shè)計的思路來探討 RPC 該如何進行設(shè)計。
分解 RPC
在進行設(shè)計之前,我們必須要先對 RPC 進行分解,了解其基礎(chǔ)是什么?
上文說過了,RPC 是一個抽象的概念,所以直接分析其基礎(chǔ)是行不通的,只能透過現(xiàn)實場景來進行分析。就拿交談這個簡單場景來說:我們跟什么人、說什么話,其實都是不確定的,但是可以確定的是,我們說話的聲音是通過空氣振動傳達給對方的 ( 物理原理 ),如果沒有空氣振動,那么聲音也傳達不到對方的耳朵 ( 真空環(huán)境 )。所以可以得出空氣振動 ( 傳播途徑 ) 是交談的一個重要基礎(chǔ)。
除此之外,其實還有一個很重要的基礎(chǔ),那就是語言互通。如果語言不通,那么驢唇不對馬嘴也是很正常的事情。所以我們見到中國人會下意識的說普通話,見到外國人會下意識的說英語,見到家里人也會下意識的說方言 (如果有的話)。
從上面的推斷,我們可以得到交談的兩個重要基礎(chǔ):
傳播途徑:存在空氣震動。 語言互通:同樣說普通話 / 英語 / 方言。
同理的,在 RPC 的場景下,也必然會有它們的一席之地。
其實在 RPC 中,網(wǎng)絡(luò)協(xié)議就相當于傳播途徑,用于傳輸數(shù)據(jù),而序列化協(xié)議則相當于語言,用于轉(zhuǎn)換傳輸?shù)臄?shù)據(jù)。所以我們可以做一個假設(shè):對于一個 RPC 實現(xiàn)來說,有兩個很重要的基礎(chǔ)因素:
網(wǎng)絡(luò)協(xié)議:用于傳輸數(shù)據(jù)。 序列化協(xié)議:用于轉(zhuǎn)換數(shù)據(jù)。
模型構(gòu)建
接下來,我們就根據(jù)上面的假設(shè)構(gòu)建一個理論模型。
網(wǎng)絡(luò)協(xié)議 ( Network Protocol ),其重點在 Network 上,說到 Network 就不得不讓人聯(lián)想到連接 ( Connection ) 了,它在許多網(wǎng)絡(luò)協(xié)議中都有體現(xiàn),比如:TCP 協(xié)議的 Socket,HTTP 協(xié)議的 Request & Response,所以我們就以 Connection 作為網(wǎng)絡(luò)協(xié)議的模型。同時為了避免與序列化協(xié)議相混淆,我們還需要為 Connection 模型上一道限制,即網(wǎng)絡(luò)協(xié)議只關(guān)心網(wǎng)絡(luò) IO 讀寫與 IO 事件處理,不關(guān)心任何序列化相關(guān)的事情。說到 Connection 那自然就逃不過 read / write 了,所以可以建立一個簡單的 Connection 模型如下:
interface Connection {
read(): Promise<Buffer>;
write(buf: Buffer): Promise<void>;
}
序列化協(xié)議 ( Serialization Protocol ),就詞組上來看,重點是在 Serialization 上,但如果用 Protocol 來表示,也好像差的不太多,所以這里就取更短的 Protocol 作為序列化協(xié)議的模型。序列化協(xié)議也肯定都會有 encode / decode,所以可以建立一個簡單的 Protocol 模型如下:
interface Protocol {
encode(): Promise<void>;
decode(): Promise<void>;
}
接下來的問題就是怎么組合使用這兩個模型了。一般來說,根據(jù)人的習慣,都是先想好說什么然后再開口說話的,所以我們把 Protocol 模型放在 Connection 模型之前,就可以得到如下的一條調(diào)用路徑:

上面的調(diào)用路徑雖然看起來簡潔,但太過于簡單,并沒有說明具體是怎樣的調(diào)用方式。而在實際的 RPC 調(diào)用中,可能會存在多種調(diào)用方式,比如:TCP Socket 隨意讀寫,HTTP 一次 Request 對應一次 Response,HTTP2 一次 Request 對應多次 Response,所以這里必定還缺了些什么東西來抽象這些具體的 RPC 調(diào)用方式。在這里我們使用 Handle 來作為調(diào)用方式的模型抽象,它代表的是一次 RPC 該如何去調(diào)用,其模型如下:
interface Handle {
execute(): Promise<any>;
}
這樣就可以將調(diào)用路徑改成如下的形式:

到這里我們就可以根據(jù)上面的調(diào)用路徑寫一下偽代碼了,偽代碼如下:
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 () => {
/**
* 內(nèi)部執(zhí)行
* 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' };
/**
* 內(nèi)部執(zhí)行
* 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 實現(xiàn)都是可以自由替換的,換句話說,我們只需要實現(xiàn)了 TCP Connection、HTTP Connection、Thrift Protocol、Protobuf Protocol,就可以做到 Thrift on TCP、Protobuf on TCP、Thrift on HTTP、Protobuf on HTTP。
但在后續(xù)的實現(xiàn)過程中,我們遇到了一個問題:由于創(chuàng)建 Socket 與監(jiān)聽 Server 都是比較復雜的行為,特別是還需要考慮到服務(wù)發(fā)現(xiàn)、Service Mesh、連接池等的存在,這導致了 Connection 的實現(xiàn)代碼變得極其復雜,并且與 Server 實現(xiàn)嚴重耦合,稍微有一點不同就會產(chǎn)生大量冗余代碼。
因此為了解決這個問題,我們?yōu)?Connection 模型引入了 ConnectionProvider 模型,讓其負責 Connection 的創(chuàng)建與回收,考慮服務(wù)發(fā)現(xiàn)、Service Mesh、連接池等問題。
Tips:出于對稱設(shè)計原則的考慮,也為 Protocol 模型引入了 ProtocolProvider 模型。
模型總覽
最終所有涉及到的模型如下:
Connection:網(wǎng)絡(luò)協(xié)議,專注于網(wǎng)絡(luò) IO 性能,只關(guān)心網(wǎng)絡(luò) IO 讀寫與 IO 事件處理,不關(guān)心任何序列化相關(guān)的事情。 Protocol:序列化協(xié)議,專注于運算 CPU 性能,不關(guān)心任何網(wǎng)絡(luò)相關(guān)的事情。 Handle:RPC 調(diào)用方式,用于描述一次 RPC 該如何去調(diào)用。 ConnectionProvider:網(wǎng)絡(luò)協(xié)議生產(chǎn)者,用于解除 Client、Server 與具體 Connection 模型實現(xiàn)之間的耦合。 ProtocolProvider:序列化協(xié)議生產(chǎn)者,出于對稱設(shè)計考慮,暫時沒有太多的作用。 Context:RPC 調(diào)用上下文,是整個 RPC 調(diào)用過程的信息載體。 ConfigCenter:配置中心,用于遠程配置擴展。 Middleware:中間件,用于外部功能擴展。

Tips:Unit Handle 是為了實現(xiàn) Service、Method 級別中間件而加入的設(shè)計,本質(zhì)上與 Handle 一樣。
遇到的問題
創(chuàng)建 Client 與 Server
從上面的模型總覽中來看,涉及到的模型還是比較多的,這也導致了 Client 與 Server 的創(chuàng)建過程會比較繁瑣,不容易理解,所以在我們的 RPC 實現(xiàn)中,同時對外提供了兩套 API。
對于普通業(yè)務(wù)開發(fā)同學,可以使用封裝好的 createClient() 與 createServer() API,自動集成了字節(jié)跳動內(nèi)大多數(shù)基建 ( Logger、Metrics、Trace、Service Mesh 等 )。 對于有定制化需求的同學,可以使用 Client 與 Server API,來獲得更自由的 RPC 使用體驗。
多協(xié)議嵌套
在實際應用中,我們發(fā)現(xiàn) Protocol 模型還是太過于簡單了。根據(jù)公司體量的大小、技術(shù)債的積累程度,最終都會不可避免的會出現(xiàn)協(xié)議變種、協(xié)議組合的情況,這時需要實現(xiàn)的協(xié)議可能就會出現(xiàn)成倍的增長。以我們內(nèi)部情況為例:在字節(jié)跳動的 RPC 調(diào)用中,同時存在著 Binary Thrift、TTHeader + Binary Thrift、Framed Thrift、Mesh + TTHeader + Binary Thrift 等情況。
所以我們通過人為的將 Protocol 模型分為 HeaderProtocol 與 PayloadProtocol 模型,并通過 connection.cork、connection.uncork 與動態(tài) buffer 技巧實現(xiàn)了多協(xié)議組合,偽代碼如下:
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: 由于是人為規(guī)定的分類,所以:
HeaderProtocol 模型不意味著實現(xiàn)沒有 payload 部分,只是經(jīng)常作為在外部的序列化協(xié)議使用。 PayloadProtocol 模型不意味著實現(xiàn)沒有 header 部分,只是經(jīng)常作為在內(nèi)部的序列化協(xié)議使用。
Context 擴展性能
在后續(xù)的性能測試中,我們發(fā)現(xiàn)在 Middleware 中對 Context 進行擴展時,消耗了大量性能。通過排查發(fā)現(xiàn),是由于業(yè)務(wù)屬性需要,大量運用了 Object.defineProperty()、Object.defineProperties() 等 API 所導致的。比如我們需要在 Context 上動態(tài)創(chuàng)建 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 的獲取與設(shè)置方式,那么消耗的性能將翻一倍,因此這種做法的性能基本上好不到哪里去。但如果通過 class extend 的形式擴展,又會陷入實現(xiàn)與具體 Middleware 耦合的尷尬境地。所以我們參考 fastify.decorate()[1] 的實現(xiàn),通過動態(tài)修改 Context 原型,來實現(xiàn)了近乎零消耗的 Context 擴展能力。
總結(jié)
在本文中,我們聊到了 RPC 的設(shè)計細節(jié),從最基礎(chǔ)的 RPC 分解,到模型設(shè)計,再到落地中遇到的問題。但如果要實現(xiàn)一個完善的 RPC 庫,所涉及到的細節(jié)將遠非這些,同時也會有許多其它概念將會對現(xiàn)有的模型造成沖擊,這需要我們耐心分析其本質(zhì),并將這些概念逐步的融入到設(shè)計實現(xiàn)中。
在我們內(nèi)部的 RPC 實現(xiàn)中,已經(jīng)支持了 Thrift 序列化協(xié)議與 TCP、HTTP 網(wǎng)絡(luò)協(xié)議,在不久后的將來也會支持 JSON、Protobuf 與 HTTP/2,甚至可能會將這套設(shè)計搬上瀏覽器,讓前端可以直接在瀏覽器上發(fā)起 RPC 調(diào)用,來豐富前后端調(diào)用技術(shù)選型,促進框架生態(tài)發(fā)展。


“分享、點贊、在看” 支持一波 
