背景
之前在.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
、
IRpcConnection
、ISatelliteRpcClient
等。
另外也可以自行添加其他的服務,因爲代碼生成器會自動掃描接口,然後生成對應的調用代碼,所以只需要在接口上添加SatelliteRpcAttribute
,聲明好方法契約,就能實現。
服務端設計
服務端的設計總體和客戶端設計差不多,中間唯一有一點區別的地方就是服務端的中間件有兩種:
- 一種是針對連接層的
RpcConnectionApplicationHandler
中間件,設計它的目的主要是爲了靈活處理鏈接請求,由於可以直接訪問原始數據,還沒有做路由和參數綁定,後續可觀測性指標和一些性能優化在這裏做會比較方便。- 比如爲了應對RPC調用,定義了一個名爲
RpcServiceHandler
的RpcConnectionApplicationHandler
中間件,放在整個連接層中間件的最後,這樣可以保證最後執行的是RPC Service層的邏輯。
- 比如爲了應對RPC調用,定義了一個名爲
- 另外一種是針對業務邏輯層的
RpcServiceMiddleware
,這裏就是類似ASP.NET Core的中間件,此時上下文中已經有了路由信息和參數綁定,可以在這做一些AOP編程,也能直接調用對應的服務方法。- 在RPC層,我們需要完成路由,參數綁定,執行目標方法等功能,這裏就是定義了一個名爲
EndpointInvokeMiddleware
的中間件,放在整個RPC Service層中間件的最後,這樣可以保證最後執行的是RPC Service層的邏輯。
- 在RPC層,我們需要完成路由,參數綁定,執行目標方法等功能,這裏就是定義了一個名爲
下面是一個層次結構圖:
[用戶層代碼]
|
[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測試支撐閾值設置。
- RPC框架消耗CPU的地方是內存拷貝,上文提到了客戶端和服務端均使用Pipelines,在讀取響應和請求的時候直接使用
其它更多的性能優化需要Benchmark的數據支持,由於時間比較緊,沒有做更多的優化。
待辦
計劃做,但是沒有時間去實現的:
- 服務端代碼生成
- 現階段服務端的路由是通過字典匹配實現,方法調用使用的表達式樹,實際上這一塊可以使用代碼生成來實現,這樣可以提高性能。
- 另外一個地方就是Endpoint註冊是通過反射掃描入口程序集實現的,實際上這一步可以放在編譯階段處理,在編譯時就可以讀取到所有的服務,然後生成代碼,這樣可以減少運行時的反射。
- 客戶端取消請求
- 目前客戶端的請求取消只是在客戶端本身,取消並不會傳遞到服務端,這一塊可以通過協議來實現,在請求協議中添加一個標識,傳遞Cancel請求,然後在服務端進行判斷,如果是取消請求,則服務端也根據ID取消對應的請求。
- Context 和 AppRequest\AppResponse 池化
- 目前的Context和AppRequest\AppResponse都是每次請求都會創建,對於這些小對象可以使用池化的方式來實現複用,其中AppRequest、AppResponse已經實現了複用的功能,但是沒有時間去實現池化,Context也可以實現池化,但是目前沒有實現。
- 堆外內存、FOH管理
- 目前的內存管理都是使用的堆內存,對於那些有明顯作用域的對象和緩存空間可以使用堆外內存或FOH來實現,這樣可以減少GC在掃描時的壓力。
- AsyncTask的內存優化
- 目前是有一些地方使用的ValueTask,對於這些地方也是內存分配的優化方向,可以使用
PoolingAsyncValueTaskMethodBuilder
來池化ValueTask,這樣可以減少內存分配。 - TaskCompletionSource也是可以優化的,後續可以使用
AwaitableCompletionSource
來降低分配。
- 目前是有一些地方使用的ValueTask,對於這些地方也是內存分配的優化方向,可以使用
- 客戶端連接池化
- 目前客戶端的連接還是單鏈接,實際上可以使用連接池來實現,這樣可以減少TCP鏈接的創建和銷燬,提高性能。
- 異常場景處理
- 目前對於服務端和客戶端來說,沒有詳細的測試,針對TCP鏈接斷開,數據包錯誤,服務器異常等場景的重試,熔斷等策略都沒有實現。