现在是分布式微服务开发的时代,除了小工具和游戏之类刚需本地运行的程序已经很少见到纯单机应用。现在流行的Web应用由于物理隔离天然形成了分布式架构,核心业务由服务器运行,边缘业务由客户端运行。对于消费终端应用,为了应付庞大的流量,服务端本身也要进行再切分以满足多实例和不同业务独立运行的需要。
在单机应用中,架构设计的必要性则弱很多,精心设计架构的应用基本是为适应团队开发的需要。单机程序因为没有物理隔离很容易写成耦合的代码,给未来的发展埋下隐患。如果能利用Web应用的思路设计应用,可以轻松做到最基本的模块化,把界面和数据传输同核心业务逻辑分离。Web服务的分布式架构等设计也能用最简单的方式复用到单机程序。
ASP.NET Core为这个设想提供了原生支持。基本思路是利用TestServer
承载服务,然后用TestServer
提供的用内存流直接和服务通信的特殊HttpClient
完成交互。这样就摆脱了网络和进程间通信的基本开销以最低的成本实现虚拟的C/S架构。
TestServer
本是为ASP.NET Core集成测试而开发的特殊IServer
实现,这个服务器并不使用任何网络资源,因此也无法从网络访问。访问TestServer
的唯一途径是使用由TestServer
的成员方法创建的特殊HttpClient
,这个Client的底层不使用SocketsHttpMessageHandler
而是使用专用Handler由内存流传输数据。
TestServer
在Microsoft.AspNetCore.TestHost
包中定义,可以用于集成测试,但是官方建议使用Microsoft.AspNetCore.Mvc.Testing
包来进行测试。这个包在基础包之上进行了一些封装,简化了单元测试类的定义,并为Client增加了自动重定向和Cookie处理以兼容带重定向和Cookie的测试。笔者之前也一直在研究如何用这个包实现目标,但是无奈这个包的一些强制规则不适用测试之外的情况。最终只能用基础包来开发。
为了实现集成测试包的额外Client功能,从源代码中复制这些类的代码来用。开源项目就是好啊!
特殊Client在本地使用时有非常大的优势,但是如果其中的某些情况需要和真实网络交互就做不到了。为此笔者开发了一个使用网络通信的HttpMessageHandler来处理这种情况。
///
/// A that follows redirect responses.
///
public class RedirectHandler : DelegatingHandler
{
internal const int DefaultMaxRedirects = 7;
///
/// Creates a new instance of .
///
public RedirectHandler()
: this(maxRedirects: DefaultMaxRedirects)
{
}
///
/// Creates a new instance of .
///
/// The maximum number of redirect responses to follow. It must be
/// equal or greater than 0.
public RedirectHandler(int maxRedirects)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxRedirects);
MaxRedirects = maxRedirects;
}
///
/// Gets the maximum number of redirects this handler will follow.
///
public int MaxRedirects { get; }
///
protected override async Task 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 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
这是从原项目复制后修改的重定向处理器,主要是把部分方法的访问级别稍微放宽。从代码可以看出这个处理器使用内存流复制来实现消息体复制和重定向,如果请求包含大文件上传可能出现复制操作把文件内容缓冲到内存导致内存溢出。不过这种情况应该非常少见,这里不考虑处理这种情况。
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()
.CreateClient(_nameOfNamedClient);
}
protected override async Task 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;
}
这是笔者为处理网络请求编写的处理器,并且这个处理器自带重定向功能,逻辑基本是抄的官方代码。然后做了一些本地请求和外部网络请求的区分处理。
网络请求处理器从主机的依赖注入服务获取客户端,因此要提前在主机服务中注册客户端,并且要关闭网络客户端自带的重定向。
///
/// The default options to use to when creating
/// instances by calling
/// .
///
public class TestServerClientHandlerOptions
{
public const string DefaultTestServerRemoteRequestClientName = "DefaultTestServerRemoteRequestClient";
///
/// Initializes a new instance of .
///
public TestServerClientHandlerOptions()
{
}
// Copy constructor
internal TestServerClientHandlerOptions(TestServerClientHandlerOptions clientOptions)
{
AllowAutoRedirect = clientOptions.AllowAutoRedirect;
MaxAutomaticRedirections = clientOptions.MaxAutomaticRedirections;
HandleCookies = clientOptions.HandleCookies;
ProcessRemoteRequest = clientOptions.ProcessRemoteRequest;
RemoteRequestClientName = clientOptions.RemoteRequestClientName;
}
///
/// Gets or sets whether or not instances created by calling
///
/// should automatically follow redirect responses.
/// The default is true .
///
public bool AllowAutoRedirect { get; set; } = true;
///
/// Gets or sets the maximum number of redirect responses that instances
/// created by calling
/// should follow.
/// The default is 7 .
///
public int MaxAutomaticRedirections { get; set; } = RedirectHandler.DefaultMaxRedirects;
///
/// Gets or sets whether instances created by calling
///
/// should handle cookies.
/// The default is true .
///
public bool HandleCookies { get; set; } = true;
public bool ProcessRemoteRequest { get; set; } = false;
public string? RemoteRequestClientName { get; set; } = DefaultTestServerRemoteRequestClientName;
}
这是从集成测试包中复制后改造的处理器选项类,用于控制客户端实例化时要启用的功能。ProcessRemoteRequest
控制是否启用网络请求处理。RemoteRequestClientName
用于指定在主机中注册的命名客户端的名字。
///
/// The default options to use to when creating
/// instances by calling
/// .
///
public class TestServerClientOptions : TestServerClientHandlerOptions
{
///
/// Initializes a new instance of .
///
public TestServerClientOptions() { }
// Copy constructor
internal TestServerClientOptions(TestServerClientOptions clientOptions)
: base(clientOptions)
{
BaseAddress = clientOptions.BaseAddress;
DefaultRequestVersion = clientOptions.DefaultRequestVersion;
}
///
/// Gets or sets the base address of instances created by calling
/// .
/// The default is http://localhost .
///
public Uri BaseAddress { get; set; } = new Uri("http://localhost");
public Version DefaultRequestVersion { get; set; } = new Version(2, 0);
}
这是对应的客户端选项类,继承处理器选项并增加HttpClient
相关的内容。
public static class TestServerExtensions
{
public static Action ConfigureTestServer(
Action? configureTestWebBuilder = null,
RemoteRequestClientOptions? options = null
) =>
webBuilder =>
{
configureTestWebBuilder?.Invoke(webBuilder);
webBuilder.ConfigureAppConfiguration(configurationBuilder =>
{
List> 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>())
{
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? 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 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>? AppendHttpMessageHandlers { get; set; }
public Action, IServiceProvider>? ConfigureAdditionalHttpMessageHandlers { get; set; }
}
这是用于配置TestServer主机的扩展。其中定义的几个委托用于追加自定义配置提高灵活性。
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功能,写了一个简单的集线器。
// 服务注册部分
services.AddSignalR(options => options.StatefulReconnectBufferSize = 100_000);
// 管道配置部分
var hostInTestServer = configuration.GetValue("HostInTestServer", false);
if (!hostInTestServer)
{
app.UseHsts();
app.UseHttpsRedirection();
}
// 端点配置部分
endpoints.MapHub("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重定向,在单机模式下不注册跳转中间件和严格传输模式中间件。
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("ReceiveMessage", (user, message) =>
{
var newMessage = $"{user}: {message}";
Console.WriteLine(newMessage);
});
var times = 0;
connection.On("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? configureWebBuilder) =>
Host.CreateDefaultBuilder(args)
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder
.UseStartup();
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配置的原始版本。从这里也可以看出单机主机和网络主机的切换非常方便。
使用这个方法可以在单机程序中虚构出一个C/S架构,利用特制的HttpClient
强制隔离业务逻辑和界面数据。这样还能获得一个免费的好处,如果将来要把程序做成真的网络应用,几乎可以0成本完成迁移改造。同样的,熟悉网络程序的开发者也可以在最大程度上利用已有经验开发单机应用。
又是很久没有写文章了,一直没有找到什么好选题,难得找到一个,经过将近1周的研究开发终于搞定了。
代码包:InProcessAspNetCoreApp.rar
代码包调整了直接运行exe的一些设置,主要和HTTPS有关,制作证书还是比较麻烦的,所以直接关闭了HTTPS。当然方法很简单粗暴,理论上应该通过主机设置来调整,演示就用偷懒方法处理了。
文章转载自:coredx
原文链接:https://www.cnblogs.com/coredx/p/17998563
体验地址:引迈 - JNPF快速开发平台_低代码开发平台_零代码开发平台_流程设计器_表单引擎_工作流引擎_软件架构