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

背景

之前在.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 class MyMiddleware : IRpcClientMiddleware
{
    public async 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
IRpcConnectionISatelliteRpcClient等。

另外也可以自行添加其他的服務,因爲代碼生成器會自動掃描接口,然後生成對應的調用代碼,所以只需要在接口上添加SatelliteRpcAttribute,聲明好方法契約,就能實現。

服務端設計

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

  • 一種是針對連接層的RpcConnectionApplicationHandler中間件,設計它的目的主要是爲了靈活處理鏈接請求,由於可以直接訪問原始數據,還沒有做路由和參數綁定,後續可觀測性指標和一些性能優化在這裏做會比較方便。
    • 比如爲了應對RPC調用,定義了一個名爲RpcServiceHandlerRpcConnectionApplicationHandler中間件,放在整個連接層中間件的最後,這樣可以保證最後執行的是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鏈接斷開,數據包錯誤,服務器異常等場景的重試,熔斷等策略都沒有實現。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章