dotnet 替換 ASP.NET Core 的底層通訊為命名管道的 IPC 庫

lindexi發表於2022-02-09

這是一個用於本機多程式進行 IPC 通訊的庫,此庫的頂層 API 是採用 ASP.NET Core 的 MVC 框架,其底層通訊不是傳統的走網路的方式,而是通過 dotnetCampus.Ipc 開源專案提供的基於 NamedPipeStream 命名管道的方式進行通訊。相當於替換掉 ASP.NET Core 的底層通訊方式,從走網路換成命名管道的方式。本庫的優勢是可以使用設計非常好的 ASP.NET Core 的 MVC 框架作為頂層呼叫 API 層,底層通訊採用可提升傳輸效能的命名管道,如此可以做到不走網路通訊從而極大減少網路埠占用問題和減少使用者端網路環境帶來的問題

背景

本機內多程式通訊 IPC 不同於跨裝置系統的 RPC 通訊方式,大多數的 IPC 通訊都需要處理複雜的使用者端環境問題。對於 RPC 通訊來說,大部分時候,服務端都在開發者完全管控的環境下執行。但 IPC 通訊則無論是服務端還是客戶端都可能是在使用者端執行的。然而使用者端上,無論是系統還是其他環境都是十分複雜的,特別是在國內的,魔改的系統,凶狠的防毒軟體,這些都會讓 IPC 通訊受到非預期的打斷

傳統的 dotnet 系的 IPC 手段有很多個,提供給開發使用的頂層框架也有很多,如 .NET Remoting 和 WCF 等。但是在遷移到 dotnet core 時,由於底層執行時機制的變更,如透明代理不再支援類物件只能支援介面的行為變更,就讓 .NET Remoting 從機制性不受支援。為了方便將應用遷移到 dotnet core 框架上,可採用 dotnet campus 組織基於最友好的 MIT 協議開源的 dotnetCampus.Ipc 開源庫進行本機內多程式通訊

dotnetCampus.Ipc 開源庫底層可基於命名管道進行通訊,經過了約 600 萬臺裝置近半年的測試,發現通過此方式的通訊穩定性極高。開源倉庫地址:https://github.com/dotnet-campus/dotnetCampus.Ipc

無論是 RPC 還是 IPC 通訊,其頂層提供給開發者使用的 API 層,主流上有兩個設計陣營。一個是如 .NET Remoting 一樣的傳輸類物件的方式,此方法可以極大隱藏 RPC 或 IPC 的細節,呼叫遠端程式的物件就和呼叫本機程式一樣。另一個陣營是本文的主角,如 ASP.NET Core 的 MVC 模式,通過路由配合引數傳遞,進行控制器處理的模式,此方式的優良設計已被 ASP.NET Core 所證明,本文也就不多說了

預設下,如此妙的 ASP.NET Core 的 MVC 層框架是僅提供網路傳輸的方式。然而在詭異的使用者端環境下,將有層出不窮的網路通訊問題,如埠被佔用,特殊的軟體阻止上網等等。讓 ASP.NET Core 從走網路的方式,替換為走命名管道的方式,可以極大提升在使用者端的穩定性

再次表揚 ASP.NET Core 的優秀設計,在 ASP.NET Core 裡,各個模組分層明確,這也就讓更換 ASP.NET Core 裡的“通訊傳輸”(其實本意是 IServer 層)這個工作十分簡單

在採用 ASP.NET Core 作為 IPC 的頂層呼叫時,那此時的通訊方式一定就是 服務端-客戶端 的形式。服務端可以採用替換 ASP.NET Core 的“通訊傳輸”為 dotnetCampus.Ipc 的基於命名管道的傳輸方式。客戶端呢?對 ASP.NET Core 來說,最期望客戶端的行為是通過 HttpClient 來進行發起呼叫。剛好 dotnet 下預設的 HttpClient 是支援注入具體的訊息傳輸實現,通過將 dotnetCampus.Ipc 封裝為 HttpClient 的訊息傳輸 HttpMessageHandler 就可以讓客戶端也走 dotnetCampus.Ipc 的傳輸。如此封裝,相當於在 服務端和客戶端 的底層傳輸,全部都在 dotnetCampus.Ipc 層內,分層圖如下,通過 dotnetCampus.Ipc 維持穩定的傳輸從而隱藏具體的 IPC 細節,業務端可以完全複用原有的知識,無須引入額外的 IPC 知識

充當 IPC 裡的服務端和客戶端的業務程式碼將分別與 ASP.NET Core 和 HttpClient 對接。而 ASP.NET Core 和 HttpClient 又與 dotnetCampus.Ipc 層對接,一切的跨程式通訊邏輯都在 dotnetCampus.Ipc 這一層內完成,由 dotnetCampus.Ipc 層維持穩定的 IPC 傳輸。下面來看看如何使用此方式開發應用

使用方法

接下來將使用 PipeMvcServerDemo 和 PipeMvcClientDemo 這兩個例子專案來演示如何使用 ASP.NET Core 的 MVC 層框架加命名管道 NamedPipeStream 做通訊傳輸的本機內多程式的跨程式通訊 IPC 方式

按照慣例,在 dotnet 系的應用上使用庫之前,先通過 NuGet 進行安裝。從業務上人為分為服務端和業務端的兩個專案,分別安裝給服務端用的 dotnetCampus.Ipc.PipeMvcServer 庫,和給客戶端用的 dotnetCampus.Ipc.PipeMvcClient

新建的 PipeMvcServerDemo 和 PipeMvcClientDemo 這兩個基於 .NET 6 的例子專案都是先基於 WPF 的專案模板建立,從業務上人為分為服務端和業務端的兩個專案其實都是執行在相同的一個計算機內,只是為了方便敘述,強行將 PipeMvcServerDemo 稱為服務端專案,將 PipeMvcClientDemo 稱為客戶端專案

服務端

先從 PipeMvcServerDemo 服務端專案開始寫起,在安裝完成 dotnetCampus.Ipc.PipeMvcServer 庫之後,為了使用上 ASP.NET Core 的 MVC 框架,需要在此 WPF 應用裡面初始化 ASP.NET Core 框架

初始化的邏輯,和純放在伺服器上的 ASP.NET Core 服務應用只有一點點的差別,那就是在初始化時,需要呼叫 UsePipeIpcServer 擴充套件方法,注入 IPC 的服務替換掉預設的 ASP.NET Core 的“通訊傳輸”(IServer)層。程式碼如下

using dotnetCampus.Ipc.PipeMvcServer;

    private static void RunMvc(string[] args)
    {
        var builder = WebApplication.CreateBuilder(args);

        // 下面一句是關鍵邏輯
        builder.WebHost.UsePipeIpcServer("PipeMvcServerDemo");

        builder.Services.AddControllers();
        var app = builder.Build();
        app.MapControllers();
        app.Run();
    }

呼叫 UsePipeIpcServer 擴充套件方法,需要額外加上 using dotnetCampus.Ipc.PipeMvcServer; 名稱空間。在 UsePipeIpcServer 方法裡面需要傳入一個引數,此引數用於開啟的 IPC 服務所使用的服務名,也就是作為命名管道的管道名。服務名的字串要求是在當前機器上唯一不重複,推薦採用屬性的命名法對其命名傳入。此後,客戶端的程式碼即可採用此服務名連線上服務端

也僅僅只需加上 UsePipeIpcServer 擴充套件方法即可完成對服務端的 IPC 的所有配置

客戶端

完成服務端的配置之後,可以開始對客戶端的配置邏輯,客戶端只需要知道服務端的服務名,即如上例子的 "PipeMvcServerDemo" 字串,即可建立和服務端的通訊。在此庫的設計上,可以認為服務端的服務名和傳統的 C/S 端應用的服務端地址是等同的,至少需要知道服務端的地址才能連線上

在客戶端的任意程式碼裡,可採用 IpcPipeMvcClientProvider 提供的 CreateIpcMvcClientAsync 靜態方法傳入服務名,拿到可以和服務端通訊的 HttpClient 物件,如以下程式碼

using dotnetCampus.Ipc.PipeMvcClient;

        HttpClient ipcPipeMvcClient = await IpcPipeMvcClientProvider.CreateIpcMvcClientAsync("PipeMvcServerDemo");

以上程式碼拿到的 ipcPipeMvcClient 物件即可和傳統的邏輯一樣,進行服務端的請求邏輯,如下文所演示的例子。可以看到客戶端的配置邏輯,也只有在初始化時,獲取 HttpClient 的邏輯不同

如上面演示的程式碼,可以看到,無論是客戶端還是服務端,初始化的程式碼都是一句話,沒有很多的細節邏輯,方便入手

呼叫

下面開始演示服務端和客戶端呼叫的例子。為了讓客戶端能呼叫到客戶端對應的服務內容,需要先在服務端建立對應的服務邏輯。以下將演示 GET 和 POST 方法和對應的路由和引數呼叫方法

在服務端 PipeMvcServerDemo 專案上新增一個 FooController 控制器,程式碼如下

[Route("api/[controller]")]
[ApiController]
public class FooController : ControllerBase
{
    public FooController(ILogger<FooController> logger)
    {
        Logger = logger;
    }

    public ILogger<FooController> Logger { get; }
}

在 FooController 新增 Get 方法,程式碼如下

    [HttpGet]
    public IActionResult Get()
    {
        Logger.LogInformation("FooController_Get");
        return Ok(DateTime.Now.ToString());
    }

根據 ASP.NET Core 的路由知識,可以在客戶端通過 api/Foo 路徑訪問到以上的 Get 方法。接下來編寫客戶端的邏輯,先在客戶端上的 XAML 介面上新增按鈕,程式碼如下

            <Button x:Name="GetFooButton" Margin="10,10,10,10" Click="GetFooButton_Click">Get</Button>

GetFooButton_Click 方法裡面,使用預先拿到的 HttpClient 進行通訊,程式碼如下

using System.Net.Http;

    private async void MainWindow_Loaded(object sender, RoutedEventArgs e)
    {
        Log($"Start create PipeMvcClient.");

        var ipcPipeMvcClient = await IpcPipeMvcClientProvider.CreateIpcMvcClientAsync("PipeMvcServerDemo");
        _ipcPipeMvcClient = ipcPipeMvcClient;

        Log($"Finish create PipeMvcClient.");
    }

    private HttpClient? _ipcPipeMvcClient;

    private async void GetFooButton_Click(object sender, RoutedEventArgs e)
    {
        if (_ipcPipeMvcClient is null)
        {
            return;
        }

        Log($"[Request][Get] IpcPipeMvcServer://api/Foo");
        var response = await _ipcPipeMvcClient.GetStringAsync("api/Foo");
        Log($"[Response][Get] IpcPipeMvcServer://api/Foo {response}");
    }

以上的 Log 方法將輸出日誌到介面的 TextBlock 控制元件

以上程式碼通過 await _ipcPipeMvcClient.GetStringAsync("api/Foo"); 訪問到服務端的 Get 方法,執行效果如下

如上圖可以看到,客戶端成功呼叫了服務端,從服務端拿到了返回值

接下來的例子是在 GET 請求帶上引數,如實現遠端呼叫計算服務功能,在客戶端傳送兩個 int 數給服務端進行計算相加的值。服務端的程式碼如下

public class FooController : ControllerBase
{
    [HttpGet("Add")]
    public IActionResult Add(int a, int b)
    {
        Logger.LogInformation($"FooController_Add a={a};b={b}");
        return Ok(a + b);
    }
}

客戶端在 XAML 介面新增對應按鈕的程式碼省略,按鈕的事件裡呼叫方法程式碼如下

    private async void GetFooWithArgumentButton_Click(object sender, RoutedEventArgs e)
    {
        Log($"[Request][Get] IpcPipeMvcServer://api/Foo/Add");
        var response = await _ipcPipeMvcClient.GetStringAsync("api/Foo/Add?a=1&b=1");
        Log($"[Response][Get] IpcPipeMvcServer://api/Foo/Add {response}");
    }

執行效果如下

可以看到客戶端成功呼叫了服務端執行了計算,拿到了返回值

通過以上的例子可以看到,即使底層更換為 IPC 通訊,對於上層業務程式碼,呼叫服務端的邏輯,依然沒有引入任何新的 IPC 知識,都是對 HttpClient 的呼叫

接下來是 POST 呼叫的程式碼,服務端在 FooController 類上新增 Post 方法,加上 HttpPostAttribute 特性,程式碼如下

    [HttpPost]
    public IActionResult Post()
    {
        Logger.LogInformation("FooController_Post");
        return Ok($"POST {DateTime.Now}");
    }

客戶端編寫 PostFooButton 按鈕,在按鈕點選事件新增如下程式碼用於請求服務端

    private async void PostFooButton_Click(object sender, RoutedEventArgs e)
    {
        Log($"[Request][Post] IpcPipeMvcServer://api/Foo");
        var response = await _ipcPipeMvcClient.PostAsync("api/Foo", new StringContent(""));
        var m = await response.Content.ReadAsStringAsync();
        Log($"[Response][Post] IpcPipeMvcServer://api/Foo {response.StatusCode} {m}");
    }

執行效果如下圖

如上圖可以看到客戶端成功採用 POST 方法請求到服務端

接下來將採用 POST 方法帶引數方式請求服務端,服務端處理客戶端請求過來的引數執行實際的業務邏輯,服務端的程式碼依然放在 FooController 類裡

    [HttpPost("PostFoo")]
    public IActionResult PostFooContent(FooContent foo)
    {
        Logger.LogInformation($"FooController_PostFooContent Foo1={foo.Foo1};Foo2={foo.Foo2 ?? "<NULL>"}");
        return Ok($"PostFooContent Foo1={foo.Foo1};Foo2={foo.Foo2 ?? "<NULL>"}");
    }

以上程式碼採用 FooContent 作為引數,型別定義如下

public class FooContent
{
    public string? Foo1 { set; get; }
    public string? Foo2 { set; get; }
}

客戶端程式碼如下,為了給出更多細節,我將不使用 PostAsJsonAsync 方法,而是先建立 FooContent 物件,將 FooContent 物件序列化為 json 字串,再 POST 請求

    private async void PostFooWithArgumentButton_Click(object sender, RoutedEventArgs e)
    {
        Log($"[Request][Post] IpcPipeMvcServer://api/Foo");

        var json = JsonSerializer.Serialize(new FooContent
        {
            Foo1 = "Foo PostFooWithArgumentButton",
            Foo2 = null,
        });
        StringContent content = new StringContent(json, Encoding.UTF8, "application/json");
        var response = await _ipcPipeMvcClient.PostAsync("api/Foo/PostFoo", content);
        var m = await response.Content.ReadAsStringAsync();
        Log($"[Response][Post] IpcPipeMvcServer://api/Foo/PostFoo {response.StatusCode} {m}");
    }

執行效果如下圖

如上圖,客戶端成功將 FooContent 引數傳給服務端

以上就是 GET 和 POST 的例子,幾乎看不出來加上 IPC 前後對 ASP.NET Core 應用呼叫的差別,除了要求需要使用特定的 HttpClient 物件之外,其他的邏輯都相同。以上的例子專案,可以從本文末尾獲取

如關注此庫的實現原理,請繼續閱讀下文

原理

先從客戶端方向開始,在客戶端裡使用的 HttpClient 是被注入了使用 IPC 底層框架通訊的 IpcNamedPipeClientHandler 物件,此 IpcNamedPipeClientHandler 物件是一個繼承 HttpMessageHandler 型別的物件

在 IpcNamedPipeClientHandler 重寫了 HttpMessageHandler 型別的 SendAsync 方法,可以讓所有使用 HttpClient 傳送的請求,進入 IpcNamedPipeClientHandler 的邏輯。在此方法裡面,將序列化請求,將請求通過 dotnetCampus.Ipc 傳送到服務端,再通過 dotnetCampus.Ipc 提供的訊息請求機制,等待收到服務端對此請求的返回值。等收到服務端的返回值之後,封裝成為 HttpResponseMessage 返回值,讓此返回值接入到 HttpClient 的機制框架,從而實現呼叫 HttpClient 傳送的請求是通過 dotnetCampus.Ipc 層傳輸而不是走網路。進入 dotnetCampus.Ipc 層是被設計為對等層,對客戶端來說,進入 dotnetCampus.Ipc 層具體是走到 ASP.NET Core 的 MVC 或者是其他框架都是不需要關注的。對客戶端來說,只需要知道進入 dotnetCampus.Ipc 層的請求,可以進行非同步等待請求,細節邏輯不需要關注

以下是 IpcNamedPipeClientHandler 的實現程式碼

    class IpcNamedPipeClientHandler : HttpMessageHandler
    {
        protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
        {
            // 序列化請求訊息,準備通過 IPC 層傳送
            var message = HttpMessageSerializer.Serialize(request);

            // 建立 IPC 訊息的 Tag 內容,此 Tag 內容僅用來除錯和記錄日誌
            var ipcMessageTag = request.RequestUri?.ToString() ?? request.Method.ToString();

            // 在 dotnetCampus.Ipc 層,採用 P2P 模型,沒有具體的服務端和客戶端
            // 但是 P2P 模型是可以模擬 C/S 模型的,只需要讓某個端(Peer)充當服務端,另外的端充當客戶端即可
            // 在 dotnetCampus.Ipc 庫裡,採用 PeerProxy 表示某個端
            // 這裡的端表示的是 IPC 的某個端,大部分時候可以認為是一個程式
            // 以下的 ServerProxy 就是充當服務端的一個端,將在此框架內被初始化建立

            // 通過 PeerProxy 傳送 IPC 請求,此時的 IPC 請求將會被 PipeMvcServer 處理
            // 在 PipeMvcServer 裡面,將通過 ASP.NET Core MVC 框架層進行排程,分發到對應的控制器處理
            // 控制器處理完成之後,將由 MVC 框架層將控制器的輸出交給 PipeMvcServer 層
            // 在 PipeMvcServer 層收到控制器的輸出之後,將通過 IPC 框架,將輸出返回給 PipeMvcClient 端
            // 當 PipeMvcClient 收到輸出返回值後,以下的 await 方法將會返回
            var response = await ServerProxy.GetResponseAsync(new IpcMessage(ipcMessageTag, message));

            // 將 IPC 返回的訊息反序列化為 HttpResponseMessage 用於接入 HttpClient 框架
            return HttpMessageSerializer.DeserializeToResponse(response.Body);
        }

        private PeerProxy ServerProxy { get; }

        // 忽略其他程式碼
    }

這就是為什麼客戶端需要通過 IpcPipeMvcClientProvider 的 CreateIpcMvcClientAsync 拿到 HttpClient 的原因。在 CreateIpcMvcClientAsync 方法,不僅需要建立 HttpClient 物件,還需要先嚐試連線服務端。儘管從 HttpClient 的設計上,應該是發起請求時才去連線服務端,但因為這是 IPC 通訊,且為了解決 IPC 初始化邏輯的多程式資源競爭,當前版本採用在獲取 HttpClient 也就是發起具體請求之前,連線服務端

    /// <summary>
    /// 提供給客戶端呼叫 MVC 的 Ipc 服務的功能
    /// </summary>
    public static class IpcPipeMvcClientProvider
    {
        /// <summary>
        /// 獲取訪問 Mvc 的 Ipc 服務的物件
        /// </summary>
        /// <param name="ipcPipeMvcServerName">對方 Ipc 服務名</param>
        /// <param name="clientIpcProvider">可選,用來進行 Ipc 連線的本地服務。如不傳或是空,將建立新的 Ipc 連線服務</param>
        /// <returns></returns>
        public static async Task<HttpClient> CreateIpcMvcClientAsync(string ipcPipeMvcServerName, IpcProvider? clientIpcProvider = null)
        {
            if (clientIpcProvider == null)
            {
                clientIpcProvider = new IpcProvider();
                clientIpcProvider.StartServer();
            }

            var peer = await clientIpcProvider.GetAndConnectToPeerAsync(ipcPipeMvcServerName);

            return new HttpClient(new IpcNamedPipeClientHandler(peer, clientIpcProvider))
            {
                BaseAddress = new Uri(IpcPipeMvcContext.BaseAddressUrl),
            };
        }
    }

在 dotnetCampus.Ipc 層是採用 P2P 方式設計的,因此客戶端也需要建立自己的 IpcProvider 物件。客戶端可選傳入已有的 IpcProvider 物件進行復用,就如 HttpClient 複用邏輯一樣。但建立 IpcProvider 物件是很便宜的,不會佔用多少資源,是否複用在效能上沒有多少影響。但是支援傳入 IpcProvider 更多是可以方便開發者對 IpcProvider 進行的定製邏輯,例如注入自己的陣列池和日誌等

以上就是客戶端的邏輯。關於如何序列化請求訊息等,這些就屬於細節了,無論採用什麼方法,只需要能將請求和響應與二進位制 byte 陣列進行序列化和反序列化即可。細節內容還請自行在本文末尾獲取原始碼進行閱讀

服務端的邏輯相對複雜一些,在服務端的 dotnetCampus.Ipc 層收到客戶端的請求後,服務端將構建一個虛擬的訪問請求,此訪問請求將通過 繼承 IServer 介面的 IpcServer 物件,在 ASP.NET Core 框架內發起請求,通過 MVC 框架層處理之後將響應返回到 IpcServer 物件裡交給 dotnetCampus.Ipc 層傳輸給客戶端

在 IpcServer 物件的啟動函式,也就是 StartAsync 函式裡面,將會同步初始化 IpcPipeMvcServerCore 物件。在 IpcPipeMvcServerCore 物件裡面將初始化建立 dotnetCampus.Ipc 層的通訊機制。程式碼如下

    public class IpcServer : IServer
    {
        public IpcServer(IServiceProvider services, IFeatureCollection featureCollection, IOptions<IpcServerOptions> optionsAccessor)
        {
        	// 忽略程式碼
            var ipcCore = Services.GetRequiredService<IpcPipeMvcServerCore>();
            IpcPipeMvcServerCore = ipcCore;
        }

        Task IServer.StartAsync<TContext>(IHttpApplication<TContext> application, CancellationToken cancellationToken)
        {
            // 忽略程式碼
            IpcPipeMvcServerCore.Start();
        }

        private IpcPipeMvcServerCore IpcPipeMvcServerCore { get; }

        // 忽略程式碼
    }

而 IpcPipeMvcServerCore 和 IpcServer 物件都是在呼叫 builder.WebHost.UsePipeIpcServer(xxx); 被注入,如以下程式碼

    public static class WebHostBuilderExtensions
    {
        /// <summary>
        /// Enables the <see cref="IpcServer" /> service. 啟用命名管道IPC服務
        /// </summary>
        /// <param name="builder">The <see cref="IWebHostBuilder"/>.</param>
        /// <param name="ipcPipeName">設定 Ipc 服務的管道名</param>
        /// <returns>The <see cref="IWebHostBuilder"/>.</returns>
        public static IWebHostBuilder UsePipeIpcServer(this IWebHostBuilder builder, string ipcPipeName)
        {
            return builder.ConfigureServices(services =>
            {
            	// 忽略程式碼
                services.AddSingleton<IServer, IpcServer>();

                services.AddSingleton<IpcPipeMvcServerCore>(s => new IpcPipeMvcServerCore(s, ipcPipeName));
            });
        }
    }

依靠 ASP.NET Core 的機制,將會在主機啟動,呼叫 IServer 的 StartAsync 方法。通過 IpcServer 的 StartAsync 方法啟動 IpcPipeMvcServerCore 的邏輯

在 IpcPipeMvcServerCore 裡,將初始化 IpcProvider 服務。這裡的 IpcProvider 服務是 dotnetCampus.Ipc 提供的服務對外的介面,通過 IpcProvider 可以和 dotnetCampus.Ipc 層的其他 Peer 進行通訊。剛好在客戶端也相同的初始化 IpcProvider 服務,通過 ipcPipeName 管道名可以將客戶端和服務端關聯

    class IpcPipeMvcServerCore
    {
        public IpcPipeMvcServerCore(IServiceProvider serviceProvider, string? ipcServerName)
        {
            ipcServerName ??= "IpcPipeMvcServer" + Guid.NewGuid().ToString("N");

            IpcServer = new IpcProvider(ipcServerName, new IpcConfiguration()
            {
                DefaultIpcRequestHandler = new DelegateIpcRequestHandler(async context =>
                {
                    // 核心程式碼
                })
            });
        }

        public void Start() => IpcServer.StartServer();
        public IpcProvider IpcServer { set; get; }
    }

在 dotnetCampus.Ipc 層提供了請求響應框架,可以通過傳入 DefaultIpcRequestHandler 物件用來接收其他端傳送過來的請求,處理完成之後返回給對方。上面程式碼的核心就是 DelegateIpcRequestHandler 的處理邏輯,在 context 裡讀取客戶端的請求資訊,反序列化為 HttpRequestMessage 物件,通過內部邏輯進入到 ASP.NET Core 層,再通過 MVC 框架之後拿到請求的返回值,將返回值封裝為 IpcResponseMessageResult 返回給客戶端

            IpcServer = new IpcProvider(ipcServerName, new IpcConfiguration()
            {
                DefaultIpcRequestHandler = new DelegateIpcRequestHandler(async context =>
                {
                	// 將請求反序列化為 HttpRequestMessage 物件
                	// 用於傳入到 ASP.NET Core 層
                    System.Net.Http.HttpRequestMessage? requestMessage = HttpMessageSerializer.DeserializeToRequest(context.IpcBufferMessage.Body);

                    // 建立虛擬的請求,進入到 ASP.NET Core 框架裡
                    var server = (IpcServer) serviceProvider.GetRequiredService<IServer>();
                    var clientHandler = (ClientHandler) server.CreateHandler();
                    var response = await clientHandler.SendInnerAsync(requestMessage, CancellationToken.None);

                    // 拿到的返回值序列化為 IpcResponseMessageResult 放入 dotnetCampus.Ipc 層用來返回客戶端
                    var responseByteList = HttpMessageSerializer.Serialize(response);
                    return new IpcResponseMessageResult(new IpcMessage($"[Response][{requestMessage.Method}] {requestMessage.RequestUri}", responseByteList));
                })
            });

建立虛擬的請求,進入 ASP.NET Core 框架裡的邏輯是服務端最複雜的部分。在 IpcServer 的 CreateHandler 方法裡面,將建立 ClientHandler 物件。此 ClientHandler 物件是用來構建虛擬的請求,相當於在當前程式內發起請求而不是通過網路層發起請求,程式碼如下

    public class IpcServer : IServer
    {
        /// <summary>
        /// Creates a custom <see cref="HttpMessageHandler" /> for processing HTTP requests/responses with the test server.
        /// </summary>
        public HttpMessageHandler CreateHandler()
        {
        	// 忽略程式碼
            return new ClientHandler(BaseAddress, Application) { AllowSynchronousIO = AllowSynchronousIO, PreserveExecutionContext = PreserveExecutionContext };
        }
    }

在也是繼承 HttpMessageHandler 的 ClientHandler 裡,也重寫了 SendInnerAsync 方法,此方法將會負責建立 HttpContextBuilder 物件,由 HttpContextBuilder 執行具體的呼叫 ASP.NET Core 層的邏輯

        public async Task<HttpResponseMessage> SendInnerAsync(HttpRequestMessage request, CancellationToken cancellationToken)
        {
       
            // 建立 HttpContextBuilder 物件
            var contextBuilder = new HttpContextBuilder(_application, AllowSynchronousIO, PreserveExecutionContext);

            var requestContent = request.Content;

            if (requestContent != null)
            {
            	// 以下是對 HttpContextBuilder 的初始化邏輯

                // Read content from the request HttpContent into a pipe in a background task. This will allow the request
                // delegate to start before the request HttpContent is complete. A background task allows duplex streaming scenarios.
                contextBuilder.SendRequestStream(async writer =>
                {
                    // 忽略初始化邏輯
                });
            }

            contextBuilder.Configure((context, reader) =>
            {
                // 忽略初始化邏輯
            });

            // 忽略其他程式碼

            // 執行實際的呼叫 ASP.NET Core 框架邏輯
            var httpContext = await contextBuilder.SendAsync(cancellationToken);

            // 建立 HttpResponseMessage 物件用於返回
            var response = new HttpResponseMessage();

            // 以下是對 HttpResponseMessage 的初始化邏輯,從 httpContext 裡獲取返回值
            response.StatusCode = (HttpStatusCode) httpContext.Response.StatusCode;
            response.ReasonPhrase = httpContext.Features.Get<IHttpResponseFeature>()!.ReasonPhrase;
            response.RequestMessage = request;
            response.Version = request.Version;
            response.Content = new StreamContent(httpContext.Response.Body);

            // 忽略其他程式碼

            return response;
        }

在 HttpContextBuilder 裡,將在 SendAsync 邏輯裡呼叫 ApplicationWrapper 的 ProcessRequestAsync 方法從而調入 ASP.NET Core 框架內。這裡的 ApplicationWrapper 是對 Microsoft.AspNetCore.Hosting.HostingApplication 的封裝,因為此 HostingApplication 型別是不對外公開的。以上這幾個型別的定義邏輯,都是現有的 https://github.com/dotnet/aspnetcore 開源倉庫的程式碼

通過當前程式發起請求而不通過網路層的邏輯,其實在 ASP.NET Core 開源倉庫裡面有預設的一個實現的提供。那就是為了單元測試編寫的 TestHost 機制

在 TestHost 機制裡,開發者可以在單元測試裡面開啟 ASP.NET Core 主機,但是不需要監聽任何網路的埠,所有對此主機的測試完全通過 TestHost 機制走程式內的模擬請求發起。對於業務程式碼來說,大多數時候不需要關注請求的發起方具體是誰,因此單元測試上可以使用 TestHost 方便進行測試業務程式碼,或者是在整合測試上測試呼叫邏輯。使用 TestHost 可以讓單元測試或整合測試不需要關注網路的監聽,防止測試錯服務,方便在 CI 里加入測試邏輯

剛好此機制的程式碼也是本庫所需要的,通過拷貝了 https://github.com/dotnet/aspnetcore 開源倉庫的關於 TestHost 的機制程式碼,即可用來實現 IpcServer 的邏輯

也如放在 IpcServer 的 CreateHandler 函式上的程式碼註釋,這就是原本的 TestHost 裡對應函式的程式碼

相當於在 TestHost 機制上再加上一層,這一層就是基於 dotnetCampus.Ipc 層做通訊,通過 TestHost 層建立虛擬的請求,進入 ASP.NET Core 框架

為了方便開發者接入,也為了防止開發者接入了 dotnetCampus.Ipc 層的 IpcNamedPipeStreamMvcServer 之後,再接入 TestHost 進行單元測試的衝突,本倉庫更改了所有從 https://github.com/dotnet/aspnetcore 開源倉庫的關於 TestHost 的機制程式碼的名稱空間,對入口呼叫函式和型別也進行重新命名。在每個拷貝的檔案上都加上了 // Copy From: https://github.com/dotnet/aspnetcore 的註釋

程式碼

本文所有程式碼都放在 https://github.com/dotnet-campus/dotnetCampus.Ipc 開源倉庫裡,歡迎訪問

參考文件

HttpRequestMessage C# (CSharp)程式碼示例 - HotExamples

c# - How to send a Post body in the HttpClient request in Windows Phone 8? - Stack Overflow

HttpRequestOptions Class (System.Net.Http)

c# - Serialize and deserialize HttpRequestMessage objects - Stack Overflow

Byte Rot: Serialising request and response in ASP.NET Web API

Efficient post calls with HttpClient and JSON.NET

c# - NamedPipe with ASP.Net - Stack Overflow

wcf - Using "named pipes" in ASP.NET HttpModule - Stack Overflow

相關文章