閱讀本文大概需要 9 分鍾。
大家好,這是 .NET 開源項目 StreamJsonRpc 介紹的最后一篇。上篇介紹了一些預備知識,包括 JSON-RPC 協議介紹,StreamJsonRpc 是一個實現了 JSON-RPC 協議的庫,它基於 Stream、WebSocket 和自定義的全雙工管道傳輸。中篇通過示例講解了 StreamJsonRpc 如何使用全雙工的 Stream 作為傳輸管道實現 RPC 通訊。本篇(下篇)將繼續通過示例講解如何基於 WebSocket 傳輸管道實現 RPC 通訊。
准備工作
為了示例的完整性,本文示例繼續在中篇創建的示例基礎上進行。該示例的 GitHub 地址為:
github.com/liamwang/StreamJsonRpcSamples
我們繼續添加三個項目,一個是名為 WebSocketSample.Client 的 Console 應用,一個是名為 WebSocketSample.Server 的 ASP.NET Core 應用,還有一個名為 Contract 的契約類庫(和 gRPC 類似)。

你可以直接復制並執行下面的命令一鍵完成大部分准備工作:
dotnet new console -n WebSocketSample.Client # 建新客戶端應用
dotnet new webapi -n WebSocketSample.Server # 新建服務端應用
dotnet new classlib -n Contract # 新建契約類庫
dotnet sln add WebSocketSample.Client WebSocketSample.Server Contract # 將項目添加到解決方案
dotnet add WebSocketSample.Client package StreamJsonRpc # 為客戶端安裝 StreamJsonRpc 包
dotnet add WebSocketSample.Server package StreamJsonRpc # 為服務端安裝 StreamJsonRpc 包
dotnet add WebSocketSample.Client reference Contract # 添加客戶端引用 Common 引用
dotnet add WebSocketSample.Server reference Contract # 添加服務端引用 Common 引用
為了把重點放在實現上,這次我們依然以一個簡單的功能作為示例。該示例實現客戶端向服務端發送一個問候數據,然后服務端響應一個消息。為了更貼合實際的場景,這次使用強類型進行操作。為此,我們在 Contract 項目中添加三個類用來約定客戶端和服務端通訊的數據結構和接口。
用於客戶端發送的數據的 HelloRequest 類:
public class HelloRequest
{
public string Name { get; set; }
}
用於服務端響應的數據的 HelloResponse 類:
public class HelloResponse
{
public string Message { get; set; }
}
用於約定服務端和客戶端行為的 IGreeter 接口:
public interface IGreeter
{
Task<HelloResponse> SayHelloAsync(HelloRequest request);
}
接下來和中篇一樣,通過建立連接、發送請求、接收請求、斷開連接這四個步驟演示和講解一個完整的基於 WebSocket 的 RPC 通訊示例。
建立連接
上一篇講到要實現 JSON-RPC 協議的通訊,要求傳輸管道必須是全雙工的。而 WebSocket 就是標准的全雙工通訊,所以自然可以用來實現 JSON-RPC 協議的通訊。.NET 本身就有現成的 WebSocket 實現,所以在建立連接階段和 StreamJsonRpc 沒有關系。我們只需要把 WebSocket 通訊管道架設好,然后再使用 StreamJsonRpc 來發送和接收請求即可。
客戶端使用 WebSocket 建立連接比較簡單,使用 ClientWebSocket 來實現,代碼如下:
using (var webSocket = new ClientWebSocket())
{
Console.WriteLine("正在與服務端建立連接...");
var uri = new Uri("ws://localhost:5000/rpc/greeter");
await webSocket.ConnectAsync(uri, CancellationToken.None);
Console.WriteLine("已建立連接");
}
服務端建立 WebSocket 連接最簡單的方法就是使用 ASP.NET Core,借助 Kestrel 和 ASP.NET Core 的中間件機制可以輕松搭建基於 WebSocket 的 RPC 服務。只要簡單的封裝還可以實現同一套代碼同時提供 RPC 服務和 Web API 服務。
首先在服務端項目的 Startup.cs 類的 Configure 方法中引入 WebSocket 中間件:
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
app.UseRouting();
app.UseWebSockets(); // 增加此行,引入 WebSocket 中間件
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});
}
再新建一個 Controller 並定義一個 Action 用來路由映射 WebSocket 請求:
public class RpcController : ControllerBase
{
...
[Route("/rpc/greeter")]
public async Task<IActionResult> Greeter()
{
if (!HttpContext.WebSockets.IsWebSocketRequest)
{
return new BadRequestResult();
}
var socket = await HttpContext.WebSockets.AcceptWebSocketAsync();
...
}
}
這里的 Greeter 提供的服務既能接收 HTTP 請求也能接收 WebSocket 請求。HttpContext 中的 WebSockets 屬性是一個 WebSocketManager 對象,它可以用來判斷當前請求是否為一個 WebSocket 請求,也可以用來等待和接收 WebSocket 連接,即上面代碼中的 AcceptWebSocketAsync 方法。另外客戶端的 WebSocket 的 Uri 路徑需要與 Router 指定的路徑對應。
連接已經建立,現在到了 StreamJsonRpc 發揮作用的時候了。
發送請求
客戶端通過 WebSocket 發送請求的方式和前一篇講的 Stream 方式是一樣的。還記得前一篇講到的 JsonRpc 類的 Attach 靜態方法嗎?它告訴 StreamJsonRpc 如何傳輸數據,並返回一個用於調用 RPC 的客戶端,它除了可以接收 Stream 參數外還有多個重載方法。比如:
public static T Attach<T>(Stream stream);
public static T Attach<T>(IJsonRpcMessageHandler handler);
第二個重載方法可以實現更靈活的 Attach 方式,你可以 Attach 一個交由 WebSocket 傳輸數據的管道,也可以 Attach 給一個自定義實現的 TCP 全雙工傳輸管道(此方式本文不講,但文末會直接給出示例)。現在我們需要一個實現了 IJsonRpcMessageHandler 接口的處理程序,StreamJsonRpc 已經實現好了,它是 WebSocketMessageHandler 類。通過 Attach 該實例,可以拿到一個用於調用 RPC 服務的對象。代碼示例如下:
Console.WriteLine("開始向服務端發送消息...");
var messageHandler = new WebSocketMessageHandler(webSocket);
var greeterClient = JsonRpc.Attach<IGreeter>(messageHandler);
var request = new HelloRequest { Name = "精致碼農" };
var response = await greeterClient.SayHelloAsync(request);
Console.WriteLine($"收到來自服務端的響應:{response.Message}");
你會發現,定義客戶端和服務端契約的好處是可以實現強類型編程。接下來看服務端如何接收並處理客戶端發送的消息。
接收請求
和前一篇一樣,我們先定義一個 GreeterServer 類用來處理接收到的客戶端消息。
public class GreeterServer : IGreeter
{
private readonly ILogger<GreeterServer> _logger;
public GreeterServer(ILogger<GreeterServer> logger)
{
_logger = logger;
}
public Task<HelloResponse> SayHelloAsync(HelloRequest request)
{
_logger.LogInformation("收到並回復了客戶端消息");
return Task.FromResult(new HelloResponse
{
Message = $"您好, {request.Name}!"
});
}
}
同樣,WebSocket 服務端也需要使用 Attach 來告訴 StreamJsonRpc 數據如何通訊,而且使用的也是 WebSocketMessageHandler 類,方法與客戶端類似。在前一篇中,我們 Attach 一個 Stream 調用的方法是:
public static JsonRpc Attach(Stream stream, object? target = null);
同理,我們推測應該也有一個這樣的靜態重載方法:
public static JsonRpc Attach(IJsonRpcMessageHandler handler, object? target = null);
可惜,StreamJsonRpc 並沒有提供這個靜態方法。既然 Attach 方法返回的是一個 JsonRpc 對象,那我們是否可以直接實例化該對象呢?查看該類的定義,我們發現是可以的,而且有我們需要的構造函數:
public JsonRpc(IJsonRpcMessageHandler messageHandler, object? target);
接下來就簡單了,一切和前一篇的 Stream 示例都差不多。在 RpcController 的 Greeter Action 中實例化一個 JsonRpc,然后開啟消息監聽。
public class RpcController : ControllerBase
{
private readonly ILogger<RpcController> _logger;
private readonly GreeterServer _greeterServer;
public RpcController(ILogger<RpcController> logger, GreeterServer greeterServer)
{
_logger = logger;
_greeterServer = greeterServer;
}
[Route("/rpc/greeter")]
public async Task<IActionResult> Greeter()
{
if (!HttpContext.WebSockets.IsWebSocketRequest)
{
return new BadRequestResult();
}
_logger.LogInformation("等待客戶端連接...");
var socket = await HttpContext.WebSockets.AcceptWebSocketAsync();
_logger.LogInformation("已與客戶端建立連接");
var handler = new WebSocketMessageHandler(socket);
using (var jsonRpc = new JsonRpc(handler, _greeterServer))
{
_logger.LogInformation("開始監聽客戶端消息...");
jsonRpc.StartListening();
await jsonRpc.Completion;
_logger.LogInformation("客戶端斷開了連接");
}
return new EmptyResult();
}
}
看起來和我們平時寫 Web API 差不多,區別僅僅是對請求的處理方式。但需要注意的是,WebSocket 是長連接,如果客戶端沒有事情可以處理了,最好主動斷開與服務端的連接。如果客戶客戶沒有斷開連接,執行的上下文就會停在 await jsonRpc.Completion 處。
斷開連接
通常斷開連接是由客戶端主動發起的,所以服務端不需要做什么處理。服務端響應完消息后,只需使用 jsonRpc.Completion 等待客戶端斷開連接即可,上一節的代碼示例中已經包含了這部分代碼,就不再累述了。如果特殊情況下服務端需要斷開連接,調用 JsonRpc 對象的 Dispose 方法即可。
不管是 Stream 還是 WebSocket,其客戶端對象都提供了 Close 或 Dispose 方法,連接會隨着對象的釋放自動斷開。但最好還是主動調用 Close 方法斷開連接,以確保服務端收到斷開的請求。對於 ClientWebSocket,需要調用 CloseAsync 方法。客戶端完整示例代碼如下:
static async Task Main(string[] args)
{
using (var webSocket = new ClientWebSocket())
{
Console.WriteLine("正在與服務端建立連接...");
var uri = new Uri("ws://localhost:5000/rpc/greeter");
await webSocket.ConnectAsync(uri, CancellationToken.None);
Console.WriteLine("已建立連接");
Console.WriteLine("開始向服務端發送消息...");
var messageHandler = new WebSocketMessageHandler(webSocket);
var greeterClient = JsonRpc.Attach<IGreeter>(messageHandler);
var request = new HelloRequest { Name = "精致碼農" };
var response = await greeterClient.SayHelloAsync(request);
Console.WriteLine($"收到來自服務端的響應:{response.Message}");
Console.WriteLine("正在斷開連接...");
await webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "斷開連接", CancellationToken.None);
Console.WriteLine("已斷開連接");
}
Console.ReadKey();
}
在實際項目中可能還需要因異常而斷開連接的情況做處理,比如網絡不穩定可能導致連接中斷,這種情況可能需要加入重試機制。
運行示例
由於服務端使用的是 ASP.NET Core 模板,VS 默認使用 IIS Express 啟動,啟動后會自動打開網頁,這樣看不到 Console 的日志信息。所以需要把服務端項目 WebSocketSample.Server 的啟動方式改成自啟動。

另外,為了更方便地同時運行客戶端和服務端應用,可以把解決方案設置成多啟動。右鍵解決方案,選擇“Properties”,把對應的項目設置“Start”即可。

如果你用的是 VS Code,也是支持多啟動調試的,具體方法你自行 Google。如果你用的是 dotnet run 命令運行項目可忽略以上設置。
項目運行后的截圖如下:

你也可以自定義實現 TCP 全雙工通訊管道,但比較復雜而且也很少這么做,所以就略過不講了。但我在 GitHub 的示例代碼也放了一個自定義全雙工管道實現的示例,感興趣的話你可以克隆下來研究一下。

該示例運行截圖:

本篇總結
本文通過示例演示了如何使用 StreamJsonRpc 基於 WebSocket 數據傳輸實現 JSON-RPC 協議的 RPC 通訊。其中客戶端和服務端有共同的契約部分,實現了強類型編程。通過示例我們也清楚了 StreamJsonRpc 這個庫為了實現 RPC 通訊做了哪些工作,其實它就是在現有傳輸管道(Stream、WebSocket 和 自定義 TCP 連接)上進行數據通訊。正如前一篇所說,由於 StreamJsonRpc 把大部分我們不必要知道的細節做了封裝,所以在示例中感覺不到 JSON-RPC 協議帶來的統一規范,也沒看到具體的 JSON 格式的數據。其實只要遵循了 JSON-RPC 協議實現的客戶端或服務端,不管是用什么語言實現,都是可以互相通訊的。
希望這三篇關於 StreamJsonRpc 的介紹能讓你有所收獲,如果你在工作中計划使用 StreamJsonRpc,這幾篇文章包括示例代碼應該有值得參考的地方。
