.NET 開源專案 StreamJsonRpc 介紹[下篇]

精緻碼農發表於2020-07-16

閱讀本文大概需要 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,這幾篇文章包括示例程式碼應該有值得參考的地方。

相關文章