<kbd id="afajh"><form id="afajh"></form></kbd>
<strong id="afajh"><dl id="afajh"></dl></strong>
    <del id="afajh"><form id="afajh"></form></del>
        1. <th id="afajh"><progress id="afajh"></progress></th>
          <b id="afajh"><abbr id="afajh"></abbr></b>
          <th id="afajh"><progress id="afajh"></progress></th>

          聊聊字節(jié)跳動(dòng) Node.js RPC 的設(shè)計(jì)實(shí)現(xiàn)

          共 10257字,需瀏覽 21分鐘

           ·

          2021-08-17 09:21

          背景

          大家好,我們是字節(jié)跳動(dòng) Web Infra 團(tuán)隊(duì),目前團(tuán)隊(duì)主要專注的方向包括現(xiàn)代 Web 開發(fā)解決方案、低代碼搭建、Serverless、跨端解決方案、終端基礎(chǔ)體驗(yàn)、ToB 等等。Node.js 基礎(chǔ)設(shè)施建設(shè)是我們負(fù)責(zé)的方向之一,包括但不限于:

          • 服務(wù)發(fā)現(xiàn):Consul
          • 服務(wù)治理:Logger、Metrics、Trace
          • 服務(wù)調(diào)用:HTTP ( Fetch )、RPC ( Thrift )
          • 數(shù)據(jù)庫:MySQL ( Sequelize / TypeORM )、Redis、ClickHouse
          • 消息隊(duì)列: Kafka、RocketMQ
          • 結(jié)合框架,提供遵循公司流量調(diào)度等規(guī)范的 Node.js 插件
          • 支持 Node.js、Golang 等后端語言的性能分析平臺(tái)
          • 維護(hù) Node.js 應(yīng)用的容器鏡像

          在 2021 年上半年,由于現(xiàn)有的 Node.js RPC 實(shí)現(xiàn)逐漸跟不上字節(jié)跳動(dòng)業(yè)務(wù)發(fā)展節(jié)奏,我們決定對(duì)其進(jìn)行重構(gòu),在本文將會(huì)介紹到 RPC 重構(gòu)過程中的設(shè)計(jì)思路以及落地中所遇到的問題。

          什么是 RPC?

          RPC ( Remote Procedure Call ) 是一種通用的網(wǎng)絡(luò)調(diào)用方式,其廣泛應(yīng)用于后端服務(wù)之間,像 Dubbo、SOAP、Thrift、gRPC、RESTful 等,從廣義上來說都是一種 RPC 的實(shí)現(xiàn)。幾乎可以這么說,只要公司達(dá)到一定量級(jí),其后端服務(wù)之間必定會(huì)采用 RPC 而非簡(jiǎn)單 HTTP 的形式來進(jìn)行互相調(diào)用。因此,對(duì)于想做全棧或者后端 Node.js 的同學(xué)來說,早點(diǎn)了解與使用 RPC 是非常有必要的。

          既然 RPC 這么重要,那么到底該怎么去理解它呢?

          按照上面的說法,RPC 是一種通用的網(wǎng)絡(luò)調(diào)用方式,是一個(gè)抽象的概念,那么直接對(duì)其進(jìn)行理解是行不通的。所以我們需要把 RPC 映射到我們的現(xiàn)實(shí)生活中,這樣就會(huì)發(fā)現(xiàn),我們的每一次交談、打字、打電話其實(shí)都是一次 “RPC 調(diào)用”,RPC 是一種 “溝通” 方式。

          現(xiàn)狀 & 需求

          在字節(jié)跳動(dòng)內(nèi),由于各種原因,存在有多種序列化協(xié)議、網(wǎng)絡(luò)協(xié)議,這導(dǎo)致我們沒有辦法直接使用開源的 Apache Thrift、gRPC,只能選擇自建 RPC 實(shí)現(xiàn)。而對(duì)于 RPC 實(shí)現(xiàn),我們希望可以做到以下幾點(diǎn):

          • 支持多種序列化協(xié)議,如 Thrift、Protobuf、JSON。
          • 支持多種網(wǎng)絡(luò)協(xié)議,如 TCP、HTTP、HTTP/2。
          • 盡量復(fù)用老代碼。

          設(shè)計(jì) RPC

          DDD (Domain Driven Design)

          在開始介紹之前,考慮到部分同學(xué)可能對(duì)于后面使用到的概念不太了解,所以我們需要先科普一下使用到的方法論,有相關(guān)經(jīng)驗(yàn)的同學(xué)可以跳過這一節(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ū)動(dòng)設(shè)計(jì) (DDD) 是一種將代碼結(jié)構(gòu)、命名與業(yè)務(wù)領(lǐng)域概念相匹配的方法論。領(lǐng)域驅(qū)動(dòng)設(shè)計(jì)基于以下幾個(gè)目標(biāo):

          • 將項(xiàng)目重心放在核心領(lǐng)域與領(lǐng)域邏輯上
          • 以領(lǐng)域模型為基礎(chǔ)進(jìn)行復(fù)雜設(shè)計(jì)
          • 讓技術(shù)專家與領(lǐng)域?qū)<疫M(jìn)行合作,以迭代的方式來解決特性領(lǐng)域的概念模型

          說白了就是由在某個(gè)領(lǐng)域摸爬滾打了多年的專家來梳理業(yè)務(wù)邏輯,與技術(shù)人員合作設(shè)計(jì)領(lǐng)域模型,然后再由技術(shù)人員根據(jù)領(lǐng)域模型進(jìn)行實(shí)現(xiàn)的一套軟件設(shè)計(jì)與迭代方法。

          在下文中,我們將會(huì)利用領(lǐng)域驅(qū)動(dòng)設(shè)計(jì)的思路來探討 RPC 該如何進(jìn)行設(shè)計(jì)。

          分解 RPC

          在進(jìn)行設(shè)計(jì)之前,我們必須要先對(duì) RPC 進(jìn)行分解,了解其基礎(chǔ)是什么?

          上文說過了,RPC 是一個(gè)抽象的概念,所以直接分析其基礎(chǔ)是行不通的,只能透過現(xiàn)實(shí)場(chǎng)景來進(jìn)行分析。就拿交談這個(gè)簡(jiǎn)單場(chǎng)景來說:我們跟什么人、說什么話,其實(shí)都是不確定的,但是可以確定的是,我們說話的聲音是通過空氣振動(dòng)傳達(dá)給對(duì)方的 ( 物理原理 ),如果沒有空氣振動(dòng),那么聲音也傳達(dá)不到對(duì)方的耳朵 ( 真空環(huán)境 )。所以可以得出空氣振動(dòng) ( 傳播途徑 ) 是交談的一個(gè)重要基礎(chǔ)。

          除此之外,其實(shí)還有一個(gè)很重要的基礎(chǔ),那就是語言互通。如果語言不通,那么驢唇不對(duì)馬嘴也是很正常的事情。所以我們見到中國(guó)人會(huì)下意識(shí)的說普通話,見到外國(guó)人會(huì)下意識(shí)的說英語,見到家里人也會(huì)下意識(shí)的說方言 (如果有的話)。

          從上面的推斷,我們可以得到交談的兩個(gè)重要基礎(chǔ):

          • 傳播途徑:存在空氣震動(dòng)。
          • 語言互通:同樣說普通話 / 英語 / 方言。

          同理的,在 RPC 的場(chǎng)景下,也必然會(huì)有它們的一席之地。

          其實(shí)在 RPC 中,網(wǎng)絡(luò)協(xié)議就相當(dāng)于傳播途徑,用于傳輸數(shù)據(jù),而序列化協(xié)議則相當(dāng)于語言,用于轉(zhuǎn)換傳輸?shù)臄?shù)據(jù)。所以我們可以做一個(gè)假設(shè):對(duì)于一個(gè) RPC 實(shí)現(xiàn)來說,有兩個(gè)很重要的基礎(chǔ)因素:

          • 網(wǎng)絡(luò)協(xié)議:用于傳輸數(shù)據(jù)。
          • 序列化協(xié)議:用于轉(zhuǎn)換數(shù)據(jù)。

          模型構(gòu)建

          接下來,我們就根據(jù)上面的假設(shè)構(gòu)建一個(gè)理論模型。

          網(wǎng)絡(luò)協(xié)議 ( Network Protocol ),其重點(diǎn)在 Network 上,說到 Network 就不得不讓人聯(lián)想到連接 ( Connection ) 了,它在許多網(wǎng)絡(luò)協(xié)議中都有體現(xiàn),比如:TCP 協(xié)議的 Socket,HTTP 協(xié)議的 Request & Response,所以我們就以 Connection 作為網(wǎng)絡(luò)協(xié)議的模型。同時(shí)為了避免與序列化協(xié)議相混淆,我們還需要為 Connection 模型上一道限制,即網(wǎng)絡(luò)協(xié)議只關(guān)心網(wǎng)絡(luò) IO 讀寫與 IO 事件處理,不關(guān)心任何序列化相關(guān)的事情。說到 Connection 那自然就逃不過 read / write 了,所以可以建立一個(gè)簡(jiǎn)單的 Connection 模型如下:

          interface Connection {
              read(): Promise<Buffer>;
              write(buf: Buffer): Promise<void>;
          }

          序列化協(xié)議 ( Serialization Protocol ),就詞組上來看,重點(diǎn)是在 Serialization 上,但如果用 Protocol 來表示,也好像差的不太多,所以這里就取更短的 Protocol 作為序列化協(xié)議的模型。序列化協(xié)議也肯定都會(huì)有 encode / decode,所以可以建立一個(gè)簡(jiǎn)單的 Protocol 模型如下:

          interface Protocol {
              encode(): Promise<void>;
              decode(): Promise<void>;
          }

          接下來的問題就是怎么組合使用這兩個(gè)模型了。一般來說,根據(jù)人的習(xí)慣,都是先想好說什么然后再開口說話的,所以我們把 Protocol 模型放在 Connection 模型之前,就可以得到如下的一條調(diào)用路徑:

          上面的調(diào)用路徑雖然看起來簡(jiǎn)潔,但太過于簡(jiǎn)單,并沒有說明具體是怎樣的調(diào)用方式。而在實(shí)際的 RPC 調(diào)用中,可能會(huì)存在多種調(diào)用方式,比如:TCP Socket 隨意讀寫,HTTP 一次 Request 對(duì)應(yīng)一次 Response,HTTP2 一次 Request 對(duì)應(yīng)多次 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({ port3000 });
              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);
          })();

          在這個(gè)偽代碼中,Handle、Protocol、Connection 實(shí)現(xiàn)都是可以自由替換的,換句話說,我們只需要實(shí)現(xiàn)了 TCP Connection、HTTP Connection、Thrift Protocol、Protobuf Protocol,就可以做到 Thrift on TCP、Protobuf on TCP、Thrift on HTTP、Protobuf on HTTP。

          但在后續(xù)的實(shí)現(xiàn)過程中,我們遇到了一個(gè)問題:由于創(chuàng)建 Socket 與監(jiān)聽 Server 都是比較復(fù)雜的行為,特別是還需要考慮到服務(wù)發(fā)現(xiàn)、Service Mesh、連接池等的存在,這導(dǎo)致了 Connection 的實(shí)現(xiàn)代碼變得極其復(fù)雜,并且與 Server 實(shí)現(xiàn)嚴(yán)重耦合,稍微有一點(diǎn)不同就會(huì)產(chǎn)生大量冗余代碼。

          因此為了解決這個(gè)問題,我們?yōu)?Connection 模型引入了 ConnectionProvider 模型,讓其負(fù)責(zé) Connection 的創(chuàng)建與回收,考慮服務(wù)發(fā)現(xiàn)、Service Mesh、連接池等問題。

          Tips:出于對(duì)稱設(shè)計(jì)原則的考慮,也為 Protocol 模型引入了 ProtocolProvider 模型。

          模型總覽

          最終所有涉及到的模型如下:

          • Connection:網(wǎng)絡(luò)協(xié)議,專注于網(wǎng)絡(luò) IO 性能,只關(guān)心網(wǎng)絡(luò) IO 讀寫與 IO 事件處理,不關(guān)心任何序列化相關(guān)的事情。
          • Protocol:序列化協(xié)議,專注于運(yùn)算 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 模型實(shí)現(xiàn)之間的耦合。
          • ProtocolProvider:序列化協(xié)議生產(chǎn)者,出于對(duì)稱設(shè)計(jì)考慮,暫時(shí)沒有太多的作用。
          • Context:RPC 調(diào)用上下文,是整個(gè) RPC 調(diào)用過程的信息載體。
          • ConfigCenter:配置中心,用于遠(yuǎn)程配置擴(kuò)展。
          • Middleware:中間件,用于外部功能擴(kuò)展。

          Tips:Unit Handle 是為了實(shí)現(xiàn) Service、Method 級(jí)別中間件而加入的設(shè)計(jì),本質(zhì)上與 Handle 一樣。

          遇到的問題

          創(chuàng)建 Client 與 Server

          從上面的模型總覽中來看,涉及到的模型還是比較多的,這也導(dǎo)致了 Client 與 Server 的創(chuàng)建過程會(huì)比較繁瑣,不容易理解,所以在我們的 RPC 實(shí)現(xiàn)中,同時(shí)對(duì)外提供了兩套 API。

          • 對(duì)于普通業(yè)務(wù)開發(fā)同學(xué),可以使用封裝好的 createClient() 與 createServer() API,自動(dòng)集成了字節(jié)跳動(dòng)內(nèi)大多數(shù)基建 ( Logger、Metrics、Trace、Service Mesh 等 )。
          • 對(duì)于有定制化需求的同學(xué),可以使用 Client 與 Server API,來獲得更自由的 RPC 使用體驗(yàn)。

          多協(xié)議嵌套

          在實(shí)際應(yīng)用中,我們發(fā)現(xiàn) Protocol 模型還是太過于簡(jiǎn)單了。根據(jù)公司體量的大小、技術(shù)債的積累程度,最終都會(huì)不可避免的會(huì)出現(xiàn)協(xié)議變種、協(xié)議組合的情況,這時(shí)需要實(shí)現(xiàn)的協(xié)議可能就會(huì)出現(xiàn)成倍的增長(zhǎng)。以我們內(nèi)部情況為例:在字節(jié)跳動(dòng)的 RPC 調(diào)用中,同時(shí)存在著 Binary Thrift、TTHeader + Binary Thrift、Framed Thrift、Mesh + TTHeader + Binary Thrift 等情況。

          所以我們通過人為的將 Protocol 模型分為 HeaderProtocol 與 PayloadProtocol 模型,并通過 connection.cork、connection.uncork 與動(dòng)態(tài) buffer 技巧實(shí)現(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 模型不意味著實(shí)現(xiàn)沒有 payload 部分,只是經(jīng)常作為在外部的序列化協(xié)議使用。
          • PayloadProtocol 模型不意味著實(shí)現(xiàn)沒有 header 部分,只是經(jīng)常作為在內(nèi)部的序列化協(xié)議使用。

          Context 擴(kuò)展性能

          在后續(xù)的性能測(cè)試中,我們發(fā)現(xiàn)在 Middleware 中對(duì) Context 進(jìn)行擴(kuò)展時(shí),消耗了大量性能。通過排查發(fā)現(xiàn),是由于業(yè)務(wù)屬性需要,大量運(yùn)用了 Object.defineProperty()、Object.defineProperties() 等 API 所導(dǎo)致的。比如我們需要在 Context 上動(dòng)態(tài)創(chuàng)建 ctx.logId 屬性,并將它存儲(chǔ)到 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() 的性能。如果這時(shí)還需要同時(shí)兼容 ctx.log_id 的獲取與設(shè)置方式,那么消耗的性能將翻一倍,因此這種做法的性能基本上好不到哪里去。但如果通過 class extend 的形式擴(kuò)展,又會(huì)陷入實(shí)現(xiàn)與具體 Middleware 耦合的尷尬境地。所以我們參考 fastify.decorate()[1] 的實(shí)現(xiàn),通過動(dòng)態(tài)修改 Context 原型,來實(shí)現(xiàn)了近乎零消耗的 Context 擴(kuò)展能力。

          總結(jié)

          在本文中,我們聊到了 RPC 的設(shè)計(jì)細(xì)節(jié),從最基礎(chǔ)的 RPC 分解,到模型設(shè)計(jì),再到落地中遇到的問題。但如果要實(shí)現(xiàn)一個(gè)完善的 RPC 庫,所涉及到的細(xì)節(jié)將遠(yuǎn)非這些,同時(shí)也會(huì)有許多其它概念將會(huì)對(duì)現(xiàn)有的模型造成沖擊,這需要我們耐心分析其本質(zhì),并將這些概念逐步的融入到設(shè)計(jì)實(shí)現(xiàn)中。

          在我們內(nèi)部的 RPC 實(shí)現(xiàn)中,已經(jīng)支持了 Thrift 序列化協(xié)議與 TCP、HTTP 網(wǎng)絡(luò)協(xié)議,在不久后的將來也會(huì)支持 JSON、Protobuf 與 HTTP/2,甚至可能會(huì)將這套設(shè)計(jì)搬上瀏覽器,讓前端可以直接在瀏覽器上發(fā)起 RPC 調(diào)用,來豐富前后端調(diào)用技術(shù)選型,促進(jìn)框架生態(tài)發(fā)展。

          參考資料

          [1]

          fastify.decorate(): https://github.com/fastify/fastify/blob/main/docs/Decorators.md

          - END -



          內(nèi)推社群


          我組建了一個(gè)氛圍特別好的騰訊內(nèi)推社群,如果你對(duì)加入騰訊感興趣的話(后續(xù)有計(jì)劃也可以),我們可以一起進(jìn)行面試相關(guān)的答疑、聊聊面試的故事、并且在你準(zhǔn)備好的時(shí)候隨時(shí)幫你內(nèi)推。下方加 winty 好友回復(fù)「面試」即可。


          瀏覽 41
          點(diǎn)贊
          評(píng)論
          收藏
          分享

          手機(jī)掃一掃分享

          分享
          舉報(bào)
          評(píng)論
          圖片
          表情
          推薦
          點(diǎn)贊
          評(píng)論
          收藏
          分享

          手機(jī)掃一掃分享

          分享
          舉報(bào)
          <kbd id="afajh"><form id="afajh"></form></kbd>
          <strong id="afajh"><dl id="afajh"></dl></strong>
            <del id="afajh"><form id="afajh"></form></del>
                1. <th id="afajh"><progress id="afajh"></progress></th>
                  <b id="afajh"><abbr id="afajh"></abbr></b>
                  <th id="afajh"><progress id="afajh"></progress></th>
                  看一级片视频 | 超碰自拍网 | 色综合区| 亲子乱AV一区二区 | 国产怕怕怕 |