當前位置: 妍妍網 > 碼農

構建SatelliteRpc:基於Kestrel的RPC框架(整體設計篇)

2024-02-25碼農

背景

之前在.NET 效能最佳化群內交流時,我們發現很多朋友對於高效能網路框架有需求,需要建立自己的訊息伺服器、遊戲伺服器或者物聯網閘道器。但是大多數小夥伴只知道 DotNetty,雖然 DotNetty 是一個非常優秀的網路框架,廣泛套用於各種網路伺服器中,不過因為各種原因它已經不再有新的特性支持和更新,很多小夥伴都在尋找替代品。

這一切都不用擔心,在.NET Core 以後的時代,我們有了更快、更強、更好的 Kestrel 網路框架,正如其名,Kestrel 中文轉譯為**紅隼(hóng sǔn)**封面就是紅隼的樣子,是一種飛行速度極快的猛禽。Kestrel 是 ASPNET Core 成為.NET 平台效能最強 Web 服務框架的原因之一,但是很多人還覺得 Kestrel 只是用於 ASPNET Core 的網路框架,但是其實它是一個高效能的通用網路框架。

我和擁有多個.NET 千星開源計畫作者九哥一拍即合,為了讓更多的人了解 Kestrel,計劃寫一系列的文章來介紹它,九哥已經寫了一系列的文章來介紹如何使用Kestrel來建立網路服務,我覺得他寫的已經很深入和詳細了,於是沒有編寫的計劃。

不過最近發現還是有很多朋友在群裏面問這樣的問題,還有群友提到如何使用Kestrel來實作一個RPC框架,剛好筆者在前面一段時間研究了一下這個,所以這一篇文章也作為Kestrel的套用篇寫給大家,目前來說想分為幾篇文章來釋出,大體的脈絡如下所示,後續看自己的時間和讀者們感興趣的點再調整內容。

  • 整體設計

  • Kestrel伺服端實作

  • 請求、響應序列化及反序列化

  • 單連結多路復用實作

  • 效能最佳化

  • Client實作

  • 程式碼生成技術

  • 待定……

  • 計畫

    本文對應的計畫源碼已經開源在Github上,由於時間倉促,筆者只花了幾天時間設計和實作這個RPC框架,所以裏面肯定有一些設計不合理或者存在BUG的地方,還需要大家幫忙查缺補漏。

    SatelliteRpc: https://github.com/InCerryGit/SatelliteRpc

    如果對您有幫助,歡迎點個star~

    再次提醒註意:該計畫只作為學習、演示使用,沒有經過生產環境的檢驗。

    計畫資訊

    編譯環境

    要求 .NET 7.0 SDK 版本,Visual Studio 和 Rider 對應版本都可以。

    目錄結構

    ├─samples // 範例計畫
    │ ├─Client // 客戶端範例
    │ │ └─Rpc // RPC客戶端服務
    │ └─Server // 伺服端範例
    │ └─Services // RPC伺服端服務
    ├─src // 原始碼
    │ ├─SatelliteRpc.Client // 客戶端
    │ │ ├─Configuration // 客戶端配置資訊
    │ │ ├─Extensions // 針對HostBuilder和ServiceCollection的擴充套件
    │ │ ├─Middleware // 客戶端中介軟體,包含客戶端中介軟體的構造器
    │ │ └─Transport // 客戶端傳輸層,包含請求上下文,預設的客戶端和Rpc連結的實作
    │ ├─SatelliteRpc.Client.SourceGenerator // 客戶端程式碼生成器,用於生成客戶端的呼叫程式碼
    │ ├─SatelliteRpc.Protocol // 協定層,包含協定的定義,協定的序列化和反序列化,協定的轉換器
    │ │ ├─PayloadConverters // 承載數據的序列化和反序列化,包含ProtoBuf
    │ │ └─Protocol // 協定定義,請求、響應、狀態和給出的Login.proto
    │ ├─SatelliteRpc.Server // 伺服端
    │ │ ├─Configuration // 伺服端配置資訊,還有RpcServer的構造器
    │ │ ├─Exceptions // 伺服端一些異常
    │ │ ├─Extensions // 針對HostBuilder、ServiceCollection、WebHostBuilder的擴充套件
    │ │ ├─Observability // 伺服端的可觀測性支持,目前實作了中介軟體
    │ │ ├─RpcService // 伺服端的具體Rpc服務的實作
    │ │ │ ├─DataExchange // 數據交換,包含Rpc服務的數據序列化
    │ │ │ ├─Endpoint // Rpc服務的端點,包含Rpc服務的端點,尋址,管理
    │ │ │ └─Middleware // 包含Rpc服務的中介軟體的構造器
    │ │ └─Transport // 伺服端傳輸層,包含請求上下文,伺服端的預設實作,Rpc連結的實作,連結層中介軟體構建器
    │ └─SatelliteRpc.Shared // 共享層,包含一些共享的類
    │ ├─Application // 套用層中介軟體構建基礎類別,客戶端和伺服端中介軟體構建都依賴它
    │ └─Collections // 一些集合類
    └─tests // 測試計畫
    ├─SatelliteRpc.Protocol.Tests
    ├─SatelliteRpc.Server.Tests
    └─SatelliteRpc.Shared.Tests

    演示

    安裝好SDK和下載計畫以後, samples 目錄是對應的演示計畫,這個計畫就是透過我們的RPC框架呼叫Server端建立的一些服務,先啟動Server然後再啟動Client就可以得到如下的執行結果:

    設計方案

    下面簡單的介紹一下總體的設計方案:

    傳輸協定設計

    傳輸協定的主要程式碼在 SatelliteRpc.Protocol 計畫中,協定的定義在 Protocol 目錄下。針對RPC的請求和響應建立了兩個類,一個是 AppRequest 另一個是 AppResponse

    在程式碼註釋中,描述了協定的具體內容,這裏簡單的介紹一下,請求協定定義如下:

    [請求總長度][請求Id][請求的路徑(字串)]['\0'分隔符][請求數據序列化型別][請求體]

    響應協定定義如下:

    [響應總長度][請求Id][響應狀態][響應數據序列化型別][響應體]

    其中主要的參數和數據在各自請求響應體中,請求體和響應體的序列化型別是透過 PayloadConverters 中的序列化器進行序列化和反序列化的。

    在響應時使用了 請求Id ,這個請求Id是ulong型別,是一個 連結唯一的 自增的值,每次請求都會自增,這樣就可以保證每次請求的Id都是唯一的,這樣就可以在客戶端和伺服端進行匹配,從而找到對應的請求,從而實作多路復用的請求和響應匹配功能。

    當ulong型別的值超過最大值時,會從0開始重新計數,由於ulong型別的值是64位元的,值域非常大,所以在正常的情況下,同一連線下不可能出現請求Id重復的情況。

    客戶端設計

    客戶端的階層如下所示,最底層是傳輸層的中介軟體,它由 RpcConnection 生成,它用於TCP網路連線和最終的發送接受請求,中介軟體構建器保證了它是整個中介軟體序列的最後的中介軟體,然後上層就是使用者自訂的中介軟體。

    預設的客戶端實作 DefaultSatelliteRpcClient ,目前只提供了幾個Invoke方法,用於不同傳參和返參的服務,在這裏會執行中介軟體序列,最後就是具體的 LoginClient 實作,這裏方法定義和 ILoginClient 一致,也和伺服端定義一致。

    最後就是呼叫的程式碼,現在有一個 DemoHostedService 的後台服務,會呼叫一下方法,輸出日誌資訊。

    下面是一個階層圖:

    [使用者層程式碼]
    |
    [LoginClient]
    |
    [DefaultSatelliteRpcClient]
    |
    [使用者自訂中介軟體]
    |
    [RpcConnection]
    |
    [TCP Client]

    所以整個RCP Client的關鍵實體的轉換如下圖所示:

    請求:[使用者PRC 請求響應契約][CallContext - AppRequest&AppResponse][字節流]
    響應:[字節流][CallContext - AppRequest&AppResponse][使用者PRC 請求響應契約]

    多路復用

    上文提到,多路復用主要是使用ulong型別的Id來匹配Request和Response,主要程式碼在 RpcConnection ,它不僅提供了一個最終用於發送請求的方法, 在裏面聲明了一個 TaskCompletionSource 的字典,用於儲存請求Id和 TaskCompletionSource 的對應關系,這樣就可以在收到響應時,透過請求Id找到對應的 TaskCompletionSource ,從而完成請求和響應的匹配。

    由於請求可能是並行的,所以在 RpcConnection 中聲明了 Channel<AppRequest> ,將並行的請求放入到Channel中,然後在 RpcConnection 中有一個後台執行緒,用於從Channel單執行緒的中取出請求,然後發送請求,避免並行呼叫遠端介面時,底層字節流的混亂。

    擴充套件性

    客戶端不僅僅支持 ILoginClient 這一個契約,使用者可以自行添加其他契約,只要保障伺服端有相同的介面實作即可。也支持增加其它proto檔,Protobuf.Tools會自動生成對應的實體類。

    中介軟體

    該計畫的擴充套件性類似ASP.NET Core的中介軟體,可以自行加入中介軟體處理請求和響應,中介軟體支持Delegate形式,也支持自訂中介軟體類的形式,如下程式碼所示:

    public classMyMiddleware : IRpcClientMiddleware
    {
    publicasync Task InvokeAsync(ApplicationDelegate<CallContext> next, CallContext next)
    {
    // do something
    await next(context);
    // do something
    }
    }

    在客戶端中介軟體中,可以透過 CallContext 獲取到請求和響應的數據,然後可以對數據進行處理,然後呼叫 next 方法,這樣就可以實作中介軟體的鏈式呼叫。

    同樣也可以進行阻斷操作,比如在某個中介軟體中,直接返回響應,這樣就不會繼續呼叫後面的中介軟體;或者記錄請求響應日誌,或者進行一些其他的操作,類似於ASP.NET Core中介軟體都可以實作。

    序列化

    序列化的擴充套件性主要是透過 PayloadConverters 來實作的,內部實作了抽象了一個介面 IPayloadConverter ,只要實作對應PayloadType的序列化和反序列化方法即可,然後註冊到DI容器中,便可以使用。

    由於時間關系,只列出了Protobuf和Json兩種序列化器,實際上可以支持使用者自訂序列化器,只需要在請求響應協定中添加標識,然後由使用者註入到DI容器即可。

    其它

    其它一些類的實作基本都是透過介面和依賴註入的方式實作,使用者可以很方便的進行擴充套件,在DI容器中替換預設實作即可。如: IRpcClientMiddlewareBuilder IRpcConnection ISatelliteRpcClient 等。

    另外也可以自行添加其他的服務,因為程式碼生成器會自動掃描介面,然後生成對應的呼叫程式碼,所以只需要在介面上添加 SatelliteRpcAttribute ,聲明好方法契約,就能實作。

    伺服端設計

    伺服端的設計總體和客戶端設計差不多,中間唯一有一點區別的地方就是伺服端的中介軟體有兩種:

  • 一種是針對連線層的 RpcConnectionApplicationHandler 中介軟體,設計它的目的主要是為了靈活處理連結請求,由於可以直接存取原始數據,還沒有做路由和參數繫結,後續可觀測性指標和一些效能最佳化在這裏做會比較方便。

  • 比如為了應對RPC呼叫,定義了一個名為 RpcServiceHandler RpcConnectionApplicationHandler 中介軟體,放在整個連線層中介軟體的最後,這樣可以保證最後執行的是RPC Service層的邏輯。

  • 另外一種是針對業務邏輯層的 RpcServiceMiddleware ,這裏就是類似ASP.NET Core的中介軟體,此時上下文中已經有了路由資訊和參數繫結,可以在這做一些AOP編程,也能直接呼叫對應的服務方法。

  • 在RPC層,我們需要完成路由,參數繫結,執行目標方法等功能,這裏就是定義了一個名為 EndpointInvokeMiddleware 的中介軟體,放在整個RPC Service層中介軟體的最後,這樣可以保證最後執行的是RPC Service層的邏輯。

  • 下面是一個階層圖:

    [使用者層程式碼]
    |
    [LoginService]
    |
    [使用者自訂的RpcServiceMiddleware]
    |
    [RpcServiceHandler]
    |
    [使用者自訂的RpcConnectionApplicationHandler]
    |
    [RpcConnectionHandler]
    |
    [Kestrel]

    整個RPC Server的關鍵實體的轉換如下圖所示:

    請求:[字節流][RpcRawContext - AppRequest&AppResponse][ServiceContext][使用者PRC Service 請求契約]
    響應:[使用者PRC Service 響應契約][ServiceContext][AppRequest&AppResponse][字節流]

    多路復用

    伺服端對於多路復用的支持就簡單的很多,這裏是在讀取到一個完整的請求以後,直接使用Task.Run執行後續的邏輯,所以能做到同一連結多個請求並行執行, 對於響應為了避免混亂,使用了 Channel<HttpRawContext> ,將響應放入到Channel中,然後在後台執行緒中單執行緒的從Channel中取出響應,然後返回響應。

    終結點

    在伺服端中有一個終結點的概念,這個概念和ASP.NET Core中的概念類似,它具體的實作類是 RpcServiceEndpoint ;在程式開始啟動以後; 便會掃描入口程式集(當然這塊可以最佳化),然後找到所有的 RpcServiceEndpoint ,然後註冊到DI容器中,然後由 RpcServiceEndpointDataSource 統一管理, 最後在進行路由時有 IEndpointResolver 根據路徑進行路由,這只提供了預設實作,使用者也可以自訂實作,只需要實作 IEndpointResolver 介面,然後替換DI容器中的預設實作即可。

    擴充套件性

    伺服端的擴充套件性也是在 中介軟體 序列化 其它介面 上,可以透過DI容器很方便的替換預設實作,增加AOP切面等功能,也可以直接添加新的Service服務,因為會預設去掃描入口程式集中的 RpcServiceEndpoint ,然後註冊到DI容器中。

    最佳化

    現階段做的效能最佳化主要是以下幾個方面:

  • Pipelines

  • 在客戶端的請求和伺服端處理(Kestrel底層使用)中都使用了Pipelines,這樣不僅可以降低編程的復雜性,而且由於直接讀寫Buffer,可以減少記憶體拷貝,提高效能。

  • 運算式樹

  • 在動態呼叫目標服務的方法時,使用了運算式樹,這樣可以減少反射的效能損耗,在實際場景中可以設定一個快慢閾值,當方法呼叫次數超過閾值時,就可以使用運算式樹來呼叫方法,這樣可以提高效能。

  • 程式碼生成

  • 在客戶端中,使用了程式碼生成技術,這個可以讓使用者使用起來更加簡單,無需理解RPC的底層實作,只需要定義好介面,然後使用程式碼生成器生成對應的呼叫程式碼即可;另外實作了客戶端自動註入,避免執行時反射註入的效能損耗。

  • 記憶體復用

  • 對於RPC框架來說,最大的記憶體開銷基本就在請求和響應體上,建立了PooledArray和PooledList,兩個池化的底層都是使用的ArrayPool,請求和響應的Payload都是使用的池化的空間。

  • 減少記憶體拷貝

  • RPC框架消耗CPU的地方是記憶體拷貝,上文提到了客戶端和伺服端均使用Pipelines,在讀取響應和請求的時候直接使用 ReadOnlySequence<byte> 讀取網路層數據,避免拷貝。

  • 客戶端請求和伺服端響應建立了PayloadWriter類,透過 IBufferWriter<byte> 直接將序列化的結果寫入網路Buffer中,減少記憶體拷貝,雖然會引入閉包開銷,但是相對於記憶體拷貝來說,幾乎可以忽略。

  • 對於這個最佳化實際應該設定一個閾值,當序列化的數據超過閾值時,才使用PayloadWriter,否則使用記憶體拷貝的方式,需要Benchmark測試支撐閾值設定。

  • 其它更多的效能最佳化需要Benchmark的數據支持,由於時間比較緊,沒有做更多的最佳化。

    待辦

    計劃做,但是沒有時間去實作的:

  • 伺服端程式碼生成

  • 現階段伺服端的路由是透過字典匹配實作,方法呼叫使用的運算式樹,實際上這一塊可以使用程式碼生成來實作,這樣可以提高效能。

  • 另外一個地方就是Endpoint註冊是透過反射掃描入口程式集實作的,實際上這一步可以放在編譯階段處理,在編譯時就可以讀取到所有的服務,然後生成程式碼,這樣可以減少執行時的反射。

  • 客戶端取消請求

  • 目前客戶端的請求取消只是在客戶端本身,取消並不會傳遞到伺服端,這一塊可以透過協定來實作,在請求協定中添加一個標識,傳遞Cancel請求,然後在伺服端進行判斷,如果是取消請求,則伺服端也根據ID取消對應的請求。

  • Context 和 AppRequest\AppResponse 池化

  • 目前的Context和AppRequest\AppResponse都是每次請求都會建立,對於這些小物件可以使用池化的方式來實作復用,其中AppRequest、AppResponse已經實作了復用的功能,但是沒有時間去實作池化,Context也可以實作池化,但是目前沒有實作。

  • 堆外記憶體、FOH管理

  • 目前的記憶體管理都是使用的堆記憶體,對於那些有明顯作用域的物件和緩存空間可以使用堆外記憶體或FOH來實作,這樣可以減少GC在掃描時的壓力。

  • AsyncTask的記憶體最佳化

  • 目前是有一些地方使用的ValueTask,對於這些地方也是記憶體分配的最佳化方向,可以使用 PoolingAsyncValueTaskMethodBuilder 來池化ValueTask,這樣可以減少記憶體分配。

  • TaskCompletionSource也是可以最佳化的,後續可以使用 AwaitableCompletionSource 來降低分配。

  • 客戶端連線池化

  • 目前客戶端的連線還是單連結,實際上可以使用連線池來實作,這樣可以減少TCP連結的建立和銷毀,提高效能。

  • 異常場景處理

  • 目前對於伺服端和客戶端來說,沒有詳細的測試,針對TCP連結斷開,封包錯誤,伺服器異常等場景的重試,熔斷等策略都沒有實作。

  • .NET效能最佳化交流群

    相信大家在開發中經常會遇到一些效能問題,苦於沒有有效的工具去發現效能瓶頸,或者是發現瓶頸以後不知道該如何最佳化。之前一直有讀者朋友詢問有沒有技術交流群,但是由於各種原因一直都沒建立,現在很高興的在這裏宣布,我建立了一個專門交流.NET效能最佳化經驗的群組,主題包括但不限於:

  • 如何找到.NET效能瓶頸,如使用APM、dotnet tools等工具

  • .NET框架底層原理的實作,如垃圾回收器、JIT等等

  • 如何編寫高效能的.NET程式碼,哪些地方存在效能陷阱

  • 希望能有更多誌同道合朋友加入,分享一些工作中遇到的.NET效能問題和寶貴的效能分析最佳化經驗。 目前一群已滿,現在開放二群。

    如果提示已經達到200人,可以加我微信,我拉你進群: ls1075

    另外也建立了 QQ群 ,群號: 687779078,歡迎大家加入。