【源码解读】Vue与ASP.NET Core WebAPI的集成

DDGarfield 2022-06-23 17:58:48 阅读数:894

源码vueASP.NET解读asp

在前面博文【Vue】Vue 与 ASP.NET Core WebAPI 的集成中,介绍了集成原理:在中间件管道中注册SPA终端中间件,整个注册过程中,终端中间件会调用node,执行npm start命令启动vue开发服务器,向中间件管道添加路由匹配,即非 api 请求(请求静态文件,jscsshtml)都代理转发至SPA开发服务器。

注册代码如下:

public void Configure(Microsoft.AspNetCore.Builder.IApplicationBuilder app, IWebHostEnvironment env)
{
#region +Endpoints
// Execute the matched endpoint.
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});
app.UseSpa(spa =>
{
spa.Options.SourcePath = "ClientApp";
if (env.IsDevelopment())
{
//spa.UseReactDevelopmentServer(npmScript: "start");
spa.UseVueCliServer(npmScript: "start");
//spa.UseProxyToSpaDevelopmentServer("http://localhost:8080");
}
});
#endregion
}

“可以看到先注册了能够匹配API请求的属性路由。 ”

如果上面的属性路由无法匹配,请求就会在中间件管道中传递,至下一个中间件:SPA的终端中间件

以上便是集成原理。接下来我们对其中间件源码进行解读。整体还是有蛮多值得解读学习的知识点:

  • 异步编程
  • 内联中间件
  • 启动进程
  • 事件驱动

1.异步编程-ContinueWith

我们先忽略调用npm start命令执行等细节。映入我们眼帘的便是异步编程。众所周知,vue执行npm start(npm run dev)的一个比较花费时间的过程。要达成我们完美集成的目的:我们注册中间件,就需要等待vue前端开发服务器启动后,正常使用,接收代理请求至这个开发服务器。这个等待后一个操作完成后再做其他操作,这就是一个异步编程。

  • 建立需要返回npm run dev结果的类:
class VueCliServerInfo
{
public int Port { get; set; }
}
  • 编写异步代码,启动前端开发服务器
private static async Task<VueCliServerInfo> StartVueCliServerAsync(
string sourcePath, string npmScriptName, ILogger logger)
{
//省略代码
}

1.1 ContinueWith

  • 编写继续体

ContinueWith本身就会返回一个Task

var vueCliServerInfoTask = StartVueCliServerAsync(sourcePath, npmScriptName, logger);
//继续体
var targetUriTask = vueCliServerInfoTask.ContinueWith(
task =>
{
return new UriBuilder("http", "localhost", task.Result.Port).Uri;
});

1.2 内联中间件

  • 继续使用这个继续体返回的 task,并applicationBuilder.Use()配置一个内联中间件,即所有请求都代理至开发服务器
SpaProxyingExtensions.UseProxyToSpaDevelopmentServer(spaBuilder, () =>
{
var timeout = spaBuilder.Options.StartupTimeout;
return targetUriTask.WithTimeout(timeout,
$"The Vue CLI process did not start listening for requests " +
$"within the timeout period of {timeout.Seconds} seconds. " +
$"Check the log output for error information.");
});
public static void UseProxyToSpaDevelopmentServer(
this ISpaBuilder spaBuilder,
Func<Task<Uri>> baseUriTaskFactory)
{
var applicationBuilder = spaBuilder.ApplicationBuilder;
var applicationStoppingToken = GetStoppingToken(applicationBuilder);
//省略部分代码
// Proxy all requests to the SPA development server
applicationBuilder.Use(async (context, next) =>
{
var didProxyRequest =
await SpaProxy.PerformProxyRequest(
context, neverTimeOutHttpClient, baseUriTaskFactory(), applicationStoppingToken,
proxy404s: true);
});
}
  • 所有的后续请求,都会类似 nginx 一样的操作:
public static async Task<bool> PerformProxyRequest(
HttpContext context,
HttpClient httpClient,
Task<Uri> baseUriTask,
CancellationToken applicationStoppingToken,
bool proxy404s)
{
//省略部分代码...
//获取task的结果,即开发服务器uri
var baseUri = await baseUriTask;
//把请求代理至开发服务器
//接收开发服务器的响应 给到 context,由asp.net core响应
}

2.启动进程-ProcessStartInfo

接下来进入StartVueCliServerAsync的内部,执行node进程,执行npm start命令。

2.1 确定 vue 开发服务器的端口

确定一个随机的、可用的开发服务器端口,代码如下:

internal static class TcpPortFinder
{
public static int FindAvailablePort()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
try
{
return ((IPEndPoint)listener.LocalEndpoint).Port;
}
finally
{
listener.Stop();
}
}
}

2.2 执行 npm 命令

确定好可用的端口,根据前端项目目录spa.Options.SourcePath = "ClientApp";

private static async Task<VueCliServerInfo> StartVueCliServerAsync(
string sourcePath, string npmScriptName, ILogger logger)
{
var portNumber = TcpPortFinder.FindAvailablePort();
logger.LogInformation($"Starting Vue/dev-server on port {portNumber}...");
//执行命令
var npmScriptRunner = new NpmScriptRunner(
//sourcePath, npmScriptName, $"--port {portNumber}");
sourcePath, npmScriptName, $"{portNumber}");
}

NpmScriptRunner内部便在开始调用 node 执行 cmd 命令:

internal class NpmScriptRunner
{
public EventedStreamReader StdOut { get; }
public EventedStreamReader StdErr { get; }
public NpmScriptRunner(string workingDirectory, string scriptName, string arguments)
{
var npmExe = "npm";
var completeArguments = $"run {scriptName} {arguments ?? string.Empty}";
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
npmExe = "cmd";
completeArguments = $"/c npm {completeArguments}";
}
var processStartInfo = new ProcessStartInfo(npmExe)
{
Arguments = completeArguments,
UseShellExecute = false,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
WorkingDirectory = workingDirectory
};
var process = LaunchNodeProcess(processStartInfo);
//读取文本输出流
StdOut = new EventedStreamReader(process.StandardOutput);
//读取错误输出流
StdErr = new EventedStreamReader(process.StandardError);
}
}
private static Process LaunchNodeProcess(ProcessStartInfo startInfo)
{
try
{
var process = Process.Start(startInfo);
process.EnableRaisingEvents = true;
return process;
}
catch (Exception ex)
{
var message = $"Failed to start 'npm'. To resolve this:.\n\n"
+ "[1] Ensure that 'npm' is installed and can be found in one of the PATH directories.\n"
+ $" Current PATH enviroment variable is: { Environment.GetEnvironmentVariable("PATH") }\n"
+ " Make sure the executable is in one of those directories, or update your PATH.\n\n"
+ "[2] See the InnerException for further details of the cause.";
throw new InvalidOperationException(message, ex);
}
}
internal class EventedStreamReader
{
public delegate void OnReceivedChunkHandler(ArraySegment<char> chunk);
public delegate void OnReceivedLineHandler(string line);
public delegate void OnStreamClosedHandler();
public event OnReceivedChunkHandler OnReceivedChunk;
public event OnReceivedLineHandler OnReceivedLine;
public event OnStreamClosedHandler OnStreamClosed;
private readonly StreamReader _streamReader;
private readonly StringBuilder _linesBuffer;
//构造函数中启动线程读流
public EventedStreamReader(StreamReader streamReader)
{
_streamReader = streamReader ?? throw new ArgumentNullException(nameof(streamReader));
_linesBuffer = new StringBuilder();
Task.Factory.StartNew(Run);
}
private async Task Run()
{
var buf = new char[8 * 1024];
while (true)
{
var chunkLength = await _streamReader.ReadAsync(buf, 0, buf.Length);
if (chunkLength == 0)
{
//触发事件的方法
OnClosed();
break;
}
//触发事件的方法
OnChunk(new ArraySegment<char>(buf, 0, chunkLength));
var lineBreakPos = Array.IndexOf(buf, '\n', 0, chunkLength);
if (lineBreakPos < 0)
{
_linesBuffer.Append(buf, 0, chunkLength);
}
else
{
_linesBuffer.Append(buf, 0, lineBreakPos + 1);
//触发事件的方法
OnCompleteLine(_linesBuffer.ToString());
_linesBuffer.Clear();
_linesBuffer.Append(buf, lineBreakPos + 1, chunkLength - (lineBreakPos + 1));
}
}
}
private void OnChunk(ArraySegment<char> chunk)
{
var dlg = OnReceivedChunk;
dlg?.Invoke(chunk);
}
private void OnCompleteLine(string line)
{
var dlg = OnReceivedLine;
dlg?.Invoke(line);
}
private void OnClosed()
{
var dlg = OnStreamClosed;
dlg?.Invoke();
}
}

2.3 读取并输出 npm 命令执行的日志

npmScriptRunner.AttachToLogger(logger);

注册OnReceivedLineOnReceivedChunk事件,由读文本流和错误流触发:

internal class EventedStreamReader
{
public void AttachToLogger(ILogger logger)
{
StdOut.OnReceivedLine += line =>
{
if (!string.IsNullOrWhiteSpace(line))
{
logger.LogInformation(StripAnsiColors(line));
}
};
StdErr.OnReceivedLine += line =>
{
if (!string.IsNullOrWhiteSpace(line))
{
logger.LogError(StripAnsiColors(line));
}
};
StdErr.OnReceivedChunk += chunk =>
{
var containsNewline = Array.IndexOf(
chunk.Array, '\n', chunk.Offset, chunk.Count) >= 0;
if (!containsNewline)
{
Console.Write(chunk.Array, chunk.Offset, chunk.Count);
}
};
}
}

2.4 读取输出流至开发服务器启动成功

正常情况下,Vue开发服务器启动成功后,如下图:

所以代码中只需要读取输入流中的http://localhost:port,这里使用了正则匹配:

Match openBrowserLine;
openBrowserLine = await npmScriptRunner.StdOut.WaitForMatch(
new Regex("- Local: (http:\\S+/)", RegexOptions.None, RegexMatchTimeout));

2.5 异步编程-TaskCompletionSource

**TaskCompletionSource也是一种创建Task的方式。**这里的异步方法WaitForMatch便使用了TaskCompletionSource,会持续读取流,每一行文本输出流,进行正则匹配:

  • 匹配成功便调用SetResult()Task完成信号
  • 匹配失败便调用SetException()Task异常信号
internal class EventedStreamReader
{
public Task<Match> WaitForMatch(Regex regex)
{
var tcs = new TaskCompletionSource<Match>();
var completionLock = new object();
OnReceivedLineHandler onReceivedLineHandler = null;
OnStreamClosedHandler onStreamClosedHandler = null;
//C#7.0 本地函数
void ResolveIfStillPending(Action applyResolution)
{
lock (completionLock)
{
if (!tcs.Task.IsCompleted)
{
OnReceivedLine -= onReceivedLineHandler;
OnStreamClosed -= onStreamClosedHandler;
applyResolution();
}
}
}
onReceivedLineHandler = line =>
{
var match = regex.Match(line);
//匹配成功
if (match.Success)
{
ResolveIfStillPending(() => tcs.SetResult(match));
}
};
onStreamClosedHandler = () =>
{
//一直到文本流结束
ResolveIfStillPending(() => tcs.SetException(new EndOfStreamException()));
};
OnReceivedLine += onReceivedLineHandler;
OnStreamClosed += onStreamClosedHandler;
return tcs.Task;
}
}

2.6 确保开发服务器访问正常

并从正则匹配结果获取uri,即使在Vue CLI提示正在监听请求之后,如果过快地发出请求,在很短的一段时间内它也会给出错误(可能就是代码层级才会出现)。所以还得继续添加异步方法WaitForVueCliServerToAcceptRequests()确保开发服务器的的确确准备好了。

private static async Task<VueCliServerInfo> StartVueCliServerAsync(
string sourcePath, string npmScriptName, ILogger logger)
{
var portNumber = TcpPortFinder.FindAvailablePort();
logger.LogInformation($"Starting Vue/dev-server on port {portNumber}...");
//执行命令
var npmScriptRunner = new NpmScriptRunner(
//sourcePath, npmScriptName, $"--port {portNumber}");
sourcePath, npmScriptName, $"{portNumber}");
npmScriptRunner.AttachToLogger(logger);
Match openBrowserLine;
//省略部分代码
openBrowserLine = await npmScriptRunner.StdOut.WaitForMatch(
new Regex("- Local: (http:\\S+/)", RegexOptions.None, RegexMatchTimeout));
var uri = new Uri(openBrowserLine.Groups[1].Value);
var serverInfo = new VueCliServerInfo { Port = uri.Port };
await WaitForVueCliServerToAcceptRequests(uri);
return serverInfo;
}
private static async Task WaitForVueCliServerToAcceptRequests(Uri cliServerUri)
{
var timeoutMilliseconds = 1000;
using (var client = new HttpClient())
{
while (true)
{
try
{
await client.SendAsync(
new HttpRequestMessage(HttpMethod.Head, cliServerUri),
new CancellationTokenSource(timeoutMilliseconds).Token);
return;
}
catch (Exception)
{
//它创建Task,但并不占用线程
await Task.Delay(500);
if (timeoutMilliseconds < 10000)
{
timeoutMilliseconds += 3000;
}
}
}
}
}

Task.Delay()的魔力:创建 Task,但并不占用线程,相当于异步版本的Thread.Sleep,且可以在后面编写继续体:ContinueWith ”

3.总结

3.1 异步编程

  • 通过ContinueWiht继续体返回Task的特性创建Task,并在后续配置内联中间件时使用这个Task
app.Use(async (context, next)=>{
});

使ASP.NET Core的启动与中间件注册顺滑。

  • 通过TaskCompletionSource可以在稍后开始和结束的任意操作中创建Task,这个Task,可以手动指示操作何时结束(SetResult),何时发生故障(SetException),这两种状态都意味着Task完成tcs.Task.IsCompleted,对经常需要等 IO-Bound 类工作比较理想。
版权声明:本文为[DDGarfield]所创,转载请带上原文链接,感谢。 https://cloud.tencent.com/developer/article/2028962