利用 ASP.NET Core 開發單機應用

前言

現在是分佈式微服務開發的時代,除了小工具和遊戲之類剛需本地運行的程序已經很少見到純單機應用。現在流行的Web應用由於物理隔離天然形成了分佈式架構,核心業務由服務器運行,邊緣業務由客戶端運行。對於消費終端應用,爲了應付龐大的流量,服務端本身也要進行再切分以滿足多實例和不同業務獨立運行的需要。

在單機應用中,架構設計的必要性則弱很多,精心設計架構的應用基本是爲適應團隊開發的需要。單機程序因爲沒有物理隔離很容易寫成耦合的代碼,給未來的發展埋下隱患。如果能利用Web應用的思路設計應用,可以輕鬆做到最基本的模塊化,把界面和數據傳輸同核心業務邏輯分離。Web服務的分佈式架構等設計也能用最簡單的方式複用到單機程序。

ASP.NET Core爲這個設想提供了原生支持。基本思路是利用TestServer承載服務,然後用TestServer提供的用內存流直接和服務通信的特殊HttpClient完成交互。這樣就擺脫了網絡和進程間通信的基本開銷以最低的成本實現虛擬的C/S架構。

新書宣傳

有關新書的更多介紹歡迎查看《C#與.NET6 開發從入門到實踐》上市,作者親自來打廣告了!
image

正文

TestServer本是爲ASP.NET Core集成測試而開發的特殊IServer實現,這個服務器並不使用任何網絡資源,因此也無法從網絡訪問。訪問TestServer的唯一途徑是使用由TestServer的成員方法創建的特殊HttpClient,這個Client的底層不使用SocketsHttpMessageHandler而是使用專用Handler由內存流傳輸數據。

TestServerMicrosoft.AspNetCore.TestHost包中定義,可以用於集成測試,但是官方建議使用Microsoft.AspNetCore.Mvc.Testing包來進行測試。這個包在基礎包之上進行了一些封裝,簡化了單元測試類的定義,併爲Client增加了自動重定向和Cookie處理以兼容帶重定向和Cookie的測試。筆者之前也一直在研究如何用這個包實現目標,但是無奈這個包的一些強制規則不適用測試之外的情況。最終只能用基礎包來開發。

爲了實現集成測試包的額外Client功能,從源代碼中複製這些類的代碼來用。開源項目就是好啊!

特殊Client在本地使用時有非常大的優勢,但是如果其中的某些情況需要和真實網絡交互就做不到了。爲此筆者開發了一個使用網絡通信的HttpMessageHandler來處理這種情況。

RedirectHandler

/// <summary>
/// A <see cref="DelegatingHandler"/> that follows redirect responses.
/// </summary>
public class RedirectHandler : DelegatingHandler
{
    internal const int DefaultMaxRedirects = 7;

    /// <summary>
    /// Creates a new instance of <see cref="RedirectHandler"/>.
    /// </summary>
    public RedirectHandler()
        : this(maxRedirects: DefaultMaxRedirects)
    {
    }

    /// <summary>
    /// Creates a new instance of <see cref="RedirectHandler"/>.
    /// </summary>
    /// <param name="maxRedirects">The maximum number of redirect responses to follow. It must be
    /// equal or greater than 0.</param>
    public RedirectHandler(int maxRedirects)
    {
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxRedirects);

        MaxRedirects = maxRedirects;
    }

    /// <summary>
    /// Gets the maximum number of redirects this handler will follow.
    /// </summary>
    public int MaxRedirects { get; }

    /// <inheritdoc />
    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        var remainingRedirects = MaxRedirects;
        var redirectRequest = new HttpRequestMessage();
        var originalRequestContent = HasBody(request) ? await DuplicateRequestContentAsync(request) : null;
        CopyRequestHeaders(request.Headers, redirectRequest.Headers);
        var response = await base.SendAsync(request, cancellationToken);
        while (IsRedirect(response) && remainingRedirects > 0)
        {
            remainingRedirects--;
            UpdateRedirectRequest(response, redirectRequest, originalRequestContent);
            originalRequestContent = HasBody(redirectRequest) ? await DuplicateRequestContentAsync(redirectRequest) : null;
            response = await base.SendAsync(redirectRequest, cancellationToken);
        }

        return response;
    }

    protected internal static bool HasBody(HttpRequestMessage request) =>
        request.Method == HttpMethod.Post || request.Method == HttpMethod.Put;

    protected internal static async Task<HttpContent?> DuplicateRequestContentAsync(HttpRequestMessage request)
    {
        if (request.Content == null)
        {
            return null;
        }
        var originalRequestContent = request.Content;
        var (originalBody, copy) = await CopyBody(request);

        var contentCopy = new StreamContent(copy);
        request.Content = new StreamContent(originalBody);

        CopyContentHeaders(originalRequestContent, request.Content, contentCopy);

        return contentCopy;
    }

    protected internal static void CopyContentHeaders(
        HttpContent originalRequestContent,
        HttpContent newRequestContent,
        HttpContent contentCopy)
    {
        foreach (var header in originalRequestContent.Headers)
        {
            contentCopy.Headers.TryAddWithoutValidation(header.Key, header.Value);
            newRequestContent.Headers.TryAddWithoutValidation(header.Key, header.Value);
        }
    }

    protected internal static void CopyRequestHeaders(
        HttpRequestHeaders originalRequestHeaders,
        HttpRequestHeaders redirectRequestHeaders)
    {
        foreach (var header in originalRequestHeaders)
        {
            // Avoid copying the Authorization header to match the behavior
            // in the HTTP client when processing redirects
            // https://github.com/dotnet/runtime/blob/69b5d67d9418d672609aa6e2c418a3d4ae00ad18/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpHandler.cs#L509-L517
            if (!header.Key.Equals(HeaderNames.Authorization, StringComparison.OrdinalIgnoreCase))
            {
                redirectRequestHeaders.TryAddWithoutValidation(header.Key, header.Value);
            }
        }
    }

    protected internal static async Task<(Stream originalBody, Stream copy)> CopyBody(HttpRequestMessage request)
    {
        var originalBody = await request.Content!.ReadAsStreamAsync();
        var bodyCopy = new MemoryStream();
        await originalBody.CopyToAsync(bodyCopy);
        bodyCopy.Seek(0, SeekOrigin.Begin);
        if (originalBody.CanSeek)
        {
            originalBody.Seek(0, SeekOrigin.Begin);
        }
        else
        {
            originalBody = new MemoryStream();
            await bodyCopy.CopyToAsync(originalBody);
            originalBody.Seek(0, SeekOrigin.Begin);
            bodyCopy.Seek(0, SeekOrigin.Begin);
        }

        return (originalBody, bodyCopy);
    }

    protected internal static void UpdateRedirectRequest(
        HttpResponseMessage response,
        HttpRequestMessage redirect,
        HttpContent? originalContent)
    {
        Debug.Assert(response.RequestMessage is not null);

        var location = response.Headers.Location;
        if (location != null)
        {
            if (!location.IsAbsoluteUri && response.RequestMessage.RequestUri is Uri requestUri)
            {
                location = new Uri(requestUri, location);
            }

            redirect.RequestUri = location;
        }

        if (!ShouldKeepVerb(response))
        {
            redirect.Method = HttpMethod.Get;
        }
        else
        {
            redirect.Method = response.RequestMessage.Method;
            redirect.Content = originalContent;
        }

        foreach (var property in response.RequestMessage.Options)
        {
            var key = new HttpRequestOptionsKey<object?>(property.Key);
            redirect.Options.Set(key, property.Value);
        }
    }

    protected internal static bool ShouldKeepVerb(HttpResponseMessage response) =>
        response.StatusCode == HttpStatusCode.RedirectKeepVerb ||
            response.StatusCode == HttpStatusCode.PermanentRedirect;

    protected internal static bool IsRedirect(HttpResponseMessage response) =>
        response.StatusCode == HttpStatusCode.MovedPermanently ||
            response.StatusCode == HttpStatusCode.Redirect ||
            response.StatusCode == HttpStatusCode.RedirectMethod ||
            response.StatusCode == HttpStatusCode.RedirectKeepVerb ||
            response.StatusCode == HttpStatusCode.PermanentRedirect;
}

這是從原項目複製後修改的重定向處理器,主要是把部分方法的訪問級別稍微放寬。從代碼可以看出這個處理器使用內存流複製來實現消息體複製和重定向,如果請求包含大文件上傳可能出現複製操作把文件內容緩衝到內存導致內存溢出。不過這種情況應該非常少見,這裏不考慮處理這種情況。

RemoteLocalAutoSwitchWithRedirectHandler

public class RemoteLocalAutoSwitchWithRedirectHandler : DelegatingHandler
{
    private readonly Uri _localAddress;
    private readonly RedirectHandler? _localRedirectHandler;
    private readonly string _nameOfNamedClient;
    private readonly IServiceScope _scope;
    private volatile bool _disposed;

    private HttpClient _remoteHttpClient;
    private HttpClient? _localHttpClient;

    public RemoteLocalAutoSwitchWithRedirectHandler(
        Uri localAddress,
        RedirectHandler? localRedirectHandler,
        IServiceScope scope,
        string nameOfNamedClient)
    {
        ArgumentNullException.ThrowIfNull(localAddress);
        ArgumentNullException.ThrowIfNull(scope);

        _localAddress = localAddress;
        _localRedirectHandler = localRedirectHandler;
        _scope = scope;
        _nameOfNamedClient = nameOfNamedClient;

        _remoteHttpClient = _scope.ServiceProvider
            .GetRequiredService<IHttpClientFactory>()
            .CreateClient(_nameOfNamedClient);
    }

    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        ObjectDisposedException.ThrowIf(_disposed, this);

        if (IsLocalAddress(request.RequestUri, _localAddress))
        {
            return await base.SendAsync(request, cancellationToken);
        }
        else
        {
            var response = await _remoteHttpClient.SendAsync(request, cancellationToken);

            if (_localRedirectHandler is null) return response;

            var remainingRedirects = _localRedirectHandler.MaxRedirects;
            var redirectRequest = new HttpRequestMessage();
            var originalRequestContent = RedirectHandler.HasBody(request) ? await RedirectHandler.DuplicateRequestContentAsync(request) : null;
            RedirectHandler.CopyRequestHeaders(request.Headers, redirectRequest.Headers);
            while (RedirectHandler.IsRedirect(response) && remainingRedirects > 0)
            {
                remainingRedirects--;
                RedirectHandler.UpdateRedirectRequest(response, redirectRequest, originalRequestContent);
                originalRequestContent = RedirectHandler.HasBody(request) ? await RedirectHandler.DuplicateRequestContentAsync(request) : null;
                RedirectHandler.CopyRequestHeaders(request.Headers, redirectRequest.Headers);

                if (IsLocalAddress(response.Headers.Location, _localAddress))
                {
                    _localHttpClient ??= new HttpClient(_localRedirectHandler);
                    response = await _localHttpClient.SendAsync(redirectRequest, cancellationToken);
                }
                else
                {
                    response = await _remoteHttpClient.SendAsync(redirectRequest, cancellationToken);
                }
            }

            return response;
        }
    }

    protected override void Dispose(bool disposing)
    {
        if (disposing && !_disposed)
        {
            _disposed = true;

            _scope.Dispose();
        }

        base.Dispose(disposing);
    }

    private static bool IsLocalAddress(Uri? uri, Uri? localAddress) =>
        uri is not null && localAddress is not null
            && uri.Scheme == localAddress.Scheme
            && uri.Host == localAddress.Host
            && uri.Port == localAddress.Port;
}

這是筆者爲處理網絡請求編寫的處理器,並且這個處理器自帶重定向功能,邏輯基本是抄的官方代碼。然後做了一些本地請求和外部網絡請求的區分處理。

網絡請求處理器從主機的依賴注入服務獲取客戶端,因此要提前在主機服務中註冊客戶端,並且要關閉網絡客戶端自帶的重定向。

TestServerClientHandlerOptions

/// <summary>
/// The default options to use to when creating
/// <see cref="HttpMessageHandler"/> instances by calling
/// <see cref="TestServerExtensions.CreateHandlers(TestServer, TestServerClientHandlerOptions)"/>.
/// </summary>
public class TestServerClientHandlerOptions
{
    public const string DefaultTestServerRemoteRequestClientName = "DefaultTestServerRemoteRequestClient";

    /// <summary>
    /// Initializes a new instance of <see cref="TestServerClientHandlerOptions"/>.
    /// </summary>
    public TestServerClientHandlerOptions()
    {
    }

    // Copy constructor
    internal TestServerClientHandlerOptions(TestServerClientHandlerOptions clientOptions)
    {
        AllowAutoRedirect = clientOptions.AllowAutoRedirect;
        MaxAutomaticRedirections = clientOptions.MaxAutomaticRedirections;
        HandleCookies = clientOptions.HandleCookies;
        ProcessRemoteRequest = clientOptions.ProcessRemoteRequest;
        RemoteRequestClientName = clientOptions.RemoteRequestClientName;
    }

    /// <summary>
    /// Gets or sets whether or not <see cref="HttpMessageHandler"/> instances created by calling
    /// <see cref="TestServerExtensions.CreateHandlers(TestServer, TestServerClientHandlerOptions)"/>
    /// should automatically follow redirect responses.
    /// The default is <c>true</c>.
    /// </summary>
    public bool AllowAutoRedirect { get; set; } = true;

    /// <summary>
    /// Gets or sets the maximum number of redirect responses that <see cref="HttpMessageHandler"/> instances
    /// created by calling <see cref="TestServerExtensions.CreateHandlers(TestServer, TestServerClientHandlerOptions)"/>
    /// should follow.
    /// The default is <c>7</c>.
    /// </summary>
    public int MaxAutomaticRedirections { get; set; } = RedirectHandler.DefaultMaxRedirects;

    /// <summary>
    /// Gets or sets whether <see cref="HttpMessageHandler"/> instances created by calling
    /// <see cref="TestServerExtensions.CreateHandlers(TestServer, TestServerClientHandlerOptions)"/>
    /// should handle cookies.
    /// The default is <c>true</c>.
    /// </summary>
    public bool HandleCookies { get; set; } = true;

    public bool ProcessRemoteRequest { get; set; } = false;

    public string? RemoteRequestClientName { get; set; } = DefaultTestServerRemoteRequestClientName;
}

這是從集成測試包中複製後改造的處理器選項類,用於控制客戶端實例化時要啓用的功能。ProcessRemoteRequest控制是否啓用網絡請求處理。RemoteRequestClientName用於指定在主機中註冊的命名客戶端的名字。

TestServerClientOptions

/// <summary>
/// The default options to use to when creating
/// <see cref="HttpClient"/> instances by calling
/// <see cref="TestServerExtensions.GetTestClient(IHost, TestServerClientOptions)"/>.
/// </summary>
public class TestServerClientOptions : TestServerClientHandlerOptions
{
    /// <summary>
    /// Initializes a new instance of <see cref="TestServerClientOptions"/>.
    /// </summary>
    public TestServerClientOptions() { }

    // Copy constructor
    internal TestServerClientOptions(TestServerClientOptions clientOptions)
        : base(clientOptions)
    {
        BaseAddress = clientOptions.BaseAddress;
        DefaultRequestVersion = clientOptions.DefaultRequestVersion;
    }

    /// <summary>
    /// Gets or sets the base address of <see cref="HttpClient"/> instances created by calling
    /// <see cref="TestServerExtensions.GetTestClient(IHost, TestServerClientOptions)"/>.
    /// The default is <c>http://localhost</c>.
    /// </summary>
    public Uri BaseAddress { get; set; } = new Uri("http://localhost");

    public Version DefaultRequestVersion { get; set; } = new Version(2, 0);
}

這是對應的客戶端選項類,繼承處理器選項並增加HttpClient相關的內容。

TestServerExtensions

public static class TestServerExtensions
{
    public static Action<IWebHostBuilder> ConfigureTestServer(
        Action<IWebHostBuilder>? configureTestWebBuilder = null,
        RemoteRequestClientOptions? options = null
    ) =>
        webBuilder =>
        {
            configureTestWebBuilder?.Invoke(webBuilder);

            webBuilder.ConfigureAppConfiguration(configurationBuilder =>
            {
                List<KeyValuePair<string, string?>> memoryAppConfiguration = [new("HostInTestServer", "true")];
                configurationBuilder.AddInMemoryCollection(memoryAppConfiguration);
            });

            webBuilder.UseTestServer();
            webBuilder.ConfigureServices(services =>
            {
                var testServerRemoteRequestClientBuilder = services.AddHttpClient(options?.RemoteRequestClientName ?? TestServerClientHandlerOptions.DefaultTestServerRemoteRequestClientName)
                    .SetHandlerLifetime(TimeSpan.FromMinutes(5))
                    .ConfigurePrimaryHttpMessageHandler(provider =>
                    {
                        return new SocketsHttpHandler()
                        {
                            // 禁用內置的自動重定向,由 RemoteLocalAutoSwitchWithRedirectHandler 處理重定向實現本地請求和遠程請求之間的相互重定向
                            AllowAutoRedirect = false,
                            PooledConnectionLifetime = TimeSpan.FromMinutes(2),
                        };
                    });

                foreach (var func in options?.AppendHttpMessageHandlers ?? Enumerable.Empty<Func<IServiceProvider, DelegatingHandler>>())
                {
                    testServerRemoteRequestClientBuilder.AddHttpMessageHandler(func);
                }

                if(options?.ConfigureAdditionalHttpMessageHandlers is not null)
                    testServerRemoteRequestClientBuilder.ConfigureAdditionalHttpMessageHandlers(options.ConfigureAdditionalHttpMessageHandlers);
            });
        };

    public static HttpClient CreateTestClient(this TestServer server, TestServerClientOptions options)
    {
        HttpClient client;
        var handlers = server.CreateHandlers(options);
        if (handlers == null || handlers.Length == 0)
        {
            client = server.CreateClient();
        }
        else
        {
            for (var i = handlers.Length - 1; i > 0; i--)
            {
                handlers[i - 1].InnerHandler = handlers[i];
            }

            var testServerHandler = server.CreateHandler(options);

            client = new HttpClient(testServerHandler)
            {
                BaseAddress = options.BaseAddress,
                DefaultRequestVersion = options.DefaultRequestVersion
            };
        }

        return client;
    }

    public static HttpClient GetTestClient(this IHost host, TestServerClientOptions options)
    {
        return host.GetTestServer().CreateTestClient(options);
    }

    public static HttpMessageHandler CreateHandler(
        this TestServer server,
        TestServerClientHandlerOptions options,
        Action<HttpContext>? additionalContextConfiguration = null)
    {
        HttpMessageHandler handler;
        var handlers = server.CreateHandlers(options);
        if (handlers == null || handlers.Length == 0)
        {
            handler = additionalContextConfiguration is null
                ? server.CreateHandler()
                : server.CreateHandler(additionalContextConfiguration);
        }
        else
        {
            for (var i = handlers.Length - 1; i > 0; i--)
            {
                handlers[i - 1].InnerHandler = handlers[i];
            }

            var testServerHandler = additionalContextConfiguration is null
                ? server.CreateHandler()
                : server.CreateHandler(additionalContextConfiguration);

            handlers[^1].InnerHandler = testServerHandler;
            handler = handlers[0];
        }

        return handler;
    }

    internal static DelegatingHandler[] CreateHandlers(this TestServer server,TestServerClientHandlerOptions options)
    {
        return CreateHandlersCore(server, options).ToArray();

        static IEnumerable<DelegatingHandler> CreateHandlersCore(TestServer server, TestServerClientHandlerOptions options)
        {
            RedirectHandler? redirectHandler = null;
            if (options.AllowAutoRedirect)
            {
                redirectHandler = new RedirectHandler(options.MaxAutomaticRedirections);
                yield return redirectHandler;
            }

            if (options.ProcessRemoteRequest)
            {
                if (string.IsNullOrEmpty(options.RemoteRequestClientName))
                    throw new ArgumentException($"{nameof(options.RemoteRequestClientName)} must have content when {nameof(options.ProcessRemoteRequest)} is true.", nameof(options));

                yield return new RemoteLocalAutoSwitchWithRedirectHandler(
                    server.BaseAddress,
                    redirectHandler,
                    server.Services.CreateScope(),
                    options.RemoteRequestClientName);
            }

            if (options.HandleCookies)
            {
                yield return new CookieContainerHandler();
            }
        }
    }
}

public class RemoteRequestClientOptions
{
    public string? RemoteRequestClientName { get; set; }
    public IEnumerable<Func<IServiceProvider, DelegatingHandler>>? AppendHttpMessageHandlers { get; set; }
    public Action<IList<DelegatingHandler>, IServiceProvider>? ConfigureAdditionalHttpMessageHandlers { get; set; }
}

這是用於配置TestServer主機的擴展。其中定義的幾個委託用於追加自定義配置提高靈活性。

MyHub

public class MyHub : Hub
{
    public async Task SendMessage(string user, string message)
    {
        await Clients.All.SendAsync("ReceiveMessage", user, message);
    }

    public async Task SendBinary(string user, byte[] bytes)
    {
        await Clients.All.SendAsync("ReceiveBinary", user, bytes);
    }
}

爲了測試單機模式下是否能使用SignalR功能,寫了一個簡單的集線器。

Startup(節選)

// 服務註冊部分
services.AddSignalR(options => options.StatefulReconnectBufferSize = 100_000);

// 管道配置部分
var hostInTestServer = configuration.GetValue("HostInTestServer", false);
if (!hostInTestServer)
{
    app.UseHsts();
    app.UseHttpsRedirection();
}

// 端點配置部分
endpoints.MapHub<MyHub>("MyHub", options =>
{
    options.AllowStatefulReconnects = true;
});

var redirectToHome = static (HttpContext context) => Task.FromResult(Results.Redirect("/"));
endpoints.Map("/re", redirectToHome);

var redirectToBaidu = static (HttpContext context) => Task.FromResult(Results.Redirect("https://www.baidu.com/"));
endpoints.Map("/reBaidu", redirectToBaidu);

var redirectToOutRe = static (HttpContext context) => Task.FromResult(Results.Redirect("https://localhost:7215/inRe", preserveMethod: true));
endpoints.Map("/outRe", redirectToOutRe);

var redirectToInRe = static (HttpContext context, TestParam? param) => Task.FromResult(Results.Redirect($"http://localhost/{param?.Path?.TrimStart('/')}", preserveMethod: true));
endpoints.Map("/inRe", redirectToInRe);

Startup只是在RazorPages模版的基礎上追加了以上內容,爲了方便使用沒有使用新模版的寫法。新模版完全是對老模版的包裝,還導致了少量功能無法使用,筆者這邊的用法剛好是新模版不好用的情況。

爲了避免不必要的HTTPS重定向,在單機模式下不註冊跳轉中間件和嚴格傳輸模式中間件。

Program

public class Program
{
    public static async Task Main(string[] args)
    {
        using var kestrelServerHost = CreateHostBuilder(args).Build();
        await kestrelServerHost.StartAsync();

        using var testServerHost = CreateHostBuilder(args, ConfigureTestServer()).Build();
        await testServerHost.StartAsync();

        var testServer = testServerHost.GetTestServer();
        var testServerClient = testServerHost.GetTestClient(new()
        {
            ProcessRemoteRequest = true,
            DefaultRequestVersion = new(3, 0)
        });

        var multiRedirectResponse = await testServerClient.PostAsJsonAsync("/outRe", new TestParam { Path = "/reBaidu" });
        var multiRedirectContent = await multiRedirectResponse.Content.ReadAsStringAsync();
        Console.WriteLine(multiRedirectContent);

        var connection = new HubConnectionBuilder()
            .WithUrl(
                new Uri(testServer.BaseAddress, "/MyHub"),
                HttpTransportType.WebSockets,
                options =>
                {
                    options.HttpMessageHandlerFactory = handler =>
                    {
                        var newHandler = testServer.CreateHandler(options: new());
                        return newHandler;
                    };
                    options.WebSocketFactory = (context, cancellationToken) =>
                    {
                        var webSocketClient = testServer.CreateWebSocketClient();
                        var webSocket = webSocketClient.ConnectAsync(context.Uri, cancellationToken);
                        return new(webSocket);
                    };
                }
            )
            .WithStatefulReconnect()
            .WithAutomaticReconnect()
            .Build();

        connection.On<string, string>("ReceiveMessage", (user, message) =>
        {
            var newMessage = $"{user}: {message}";
            Console.WriteLine(newMessage);
        });

        var times = 0;
        connection.On<string, byte[]>("ReceiveBinary", (user, bytes) =>
        {
            Interlocked.Increment(ref times);
            var newMessage = $"{user}: No.{times,10}: {bytes.Length} bytes";
            Console.WriteLine(newMessage);
        });

        await connection.StartAsync();
        await connection.InvokeAsync("SendMessage", "ConsoleClient", "ConsoleClientMessage");

        Console.WriteLine("內存壓力測試開始");
        Stopwatch sw = Stopwatch.StartNew();
        var tenMinutes = TimeSpan.FromMinutes(10);
        while (sw.Elapsed < tenMinutes)
        {
            await connection.InvokeAsync("SendBinary", "ConsoleClient", new byte[1024 * 10]);
            await Task.Delay(10);
        }
        Console.WriteLine("內存壓力測試結束");

        Console.Write("按任意鍵繼續...");
        Console.ReadKey();

        await connection.StopAsync();
        await testServerHost.StopAsync();
        await kestrelServerHost.StopAsync();
    }

    public static IHostBuilder CreateHostBuilder(string[] args) => CreateHostBuilder(args, null);

    public static IHostBuilder CreateHostBuilder(string[] args, Action<IWebHostBuilder>? configureWebBuilder) =>
        Host.CreateDefaultBuilder(args)
            .ConfigureWebHostDefaults(webBuilder =>
            {
                webBuilder
                    .UseStartup<Startup>();
                configureWebBuilder?.Invoke(webBuilder);
            });
}

public class TestParam
{
    public string? Path { get; set; }
}

這裏使用Post一個Json到/outRe的請求測試連續相互跳轉。其中的Json用於測試是否能正常處理多次請求流的數據發送。outRe會返回一個到網絡主機的地址的重定向,網絡主機又會返回到單機主機的/inRe地址的重定向,這裏會讀取Json的內容決定最後一次跳轉的地址,兩個跳轉地址分別用來測試本地跳轉和網絡跳轉。

然後連接SignalR測試是否能連接成功以及內存泄漏測試,其中內存泄漏測試用VS的診斷面板來看比較方便。

效果測試

全部準備完成後就可以測試效果了。經過實測,本地SignalR客戶端在連接單機WebSocket時無法處理HTTPS跳轉,TestServer創建的WebSocketClient沒有配置途徑,內置Handler沒有處理重定向請求。每秒100次每次10K的二進制數據傳輸的10分鐘測試也沒有出現內存泄漏,內存會在一定增長後保持穩定。根據SignalR的測試結果和官網文檔,gRPC理論上應該也能完整支持。最後是刻意構造的帶數據Post的多次本地、網絡交叉重定向測試,結果驗證成功。

測試本地、網絡相互跳轉是打開一個監聽本地端口的普通主機來提供從網絡跳轉回本地的服務。而這個普通主機只是個沒有調用過TestServer配置的原始版本。從這裏也可以看出單機主機和網絡主機的切換非常方便。

image
image

結語

使用這個方法可以在單機程序中虛構出一個C/S架構,利用特製的HttpClient強制隔離業務邏輯和界面數據。這樣還能獲得一個免費的好處,如果將來要把程序做成真的網絡應用,幾乎可以0成本完成遷移改造。同樣的,熟悉網絡程序的開發者也可以在最大程度上利用已有經驗開發單機應用。

又是很久沒有寫文章了,一直沒有找到什麼好選題,難得找到一個,經過將近1周的研究開發終於搞定了。

代碼包:InProcessAspNetCoreApp.rar

代碼包調整了直接運行exe的一些設置,主要和HTTPS有關,製作證書還是比較麻煩的,所以直接關閉了HTTPS。當然方法很簡單粗暴,理論上應該通過主機設置來調整,演示就用偷懶方法處理了。

QQ羣

讀者交流QQ羣:540719365
image

歡迎讀者和廣大朋友一起交流,如發現本書錯誤也歡迎通過博客園、QQ羣等方式告知筆者。

本文地址:https://www.cnblogs.com/coredx/p/17998563.html

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章