本文共 12878 字,大约阅读时间需要 42 分钟。
在前面博文【Vue】Vue 与 ASP.NET Core WebAPI 的集成中,介绍了集成原理:在中间件管道中注册SPA
终端中间件,整个注册过程中,终端中间件会调用node
,执行npm start
命令启动vue
开发服务器,向中间件管道添加路由匹配,即非 api 请求(请求静态文件,js
css
html
)都代理转发至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
的终端中间件
以上便是集成原理。接下来我们对其中间件源码进行解读。整体还是有蛮多值得解读学习的知识点:
异步编程
内联中间件
启动进程
事件驱动
我们先忽略调用npm start
命令执行等细节。映入我们眼帘的便是异步编程。众所周知,vue
执行npm start
(npm run dev
)的一个比较花费时间的过程。要达成我们完美集成的目的:我们注册中间件,就需要等待vue
前端开发服务器启动后,正常使用,接收代理请求至这个开发服务器。这个等待后一个操作完成后再做其他操作,这就是一个异步编程。
建立需要返回npm run dev
结果的类:
class VueCliServerInfo{ public int Port { get; set; }}
编写异步代码,启动前端开发服务器
private static async TaskStartVueCliServerAsync( string sourcePath, string npmScriptName, ILogger logger){ //省略代码}
编写继续体
ContinueWith
本身就会返回一个Task
var vueCliServerInfoTask = StartVueCliServerAsync(sourcePath, npmScriptName, logger);//继续体var targetUriTask = vueCliServerInfoTask.ContinueWith( task => { return new UriBuilder("http", "localhost", task.Result.Port).Uri; });
继续使用这个继续体返回的 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> 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 TaskPerformProxyRequest( HttpContext context, HttpClient httpClient, Task baseUriTask, CancellationToken applicationStoppingToken, bool proxy404s){ //省略部分代码... //获取task的结果,即开发服务器uri var baseUri = await baseUriTask; //把请求代理至开发服务器 //接收开发服务器的响应 给到 context,由asp.net core响应}
接下来进入StartVueCliServerAsync
的内部,执行node
进程,执行npm start
命令。
确定一个随机的、可用的开发服务器端口,代码如下:
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(); } }}
确定好可用的端口,根据前端项目目录spa.Options.SourcePath = "ClientApp";
private static async TaskStartVueCliServerAsync( 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(ArraySegmentchunk); 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 (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 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(); }}
npmScriptRunner.AttachToLogger(logger);
注册OnReceivedLine
与OnReceivedChunk
事件,由读文本流和错误流触发:
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); } }; }}
正常情况下,Vue
开发服务器启动成功后,如下图:
所以代码中只需要读取输入流中的http://localhost:port
,这里使用了正则匹配:
Match openBrowserLine;openBrowserLine = await npmScriptRunner.StdOut.WaitForMatch( new Regex("- Local: (http:\\S+/)", RegexOptions.None, RegexMatchTimeout));
**TaskCompletionSource
也是一种创建Task
的方式。**这里的异步方法WaitForMatch
便使用了TaskCompletionSource
,会持续读取流,每一行文本输出流,进行正则匹配:
匹配成功便调用SetResult()
给Task
完成信号
匹配失败便调用SetException()
给Task
异常信号
internal class EventedStreamReader{ public TaskWaitForMatch(Regex regex) { var tcs = new TaskCompletionSource (); 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; }}
并从正则匹配结果获取uri
,即使在Vue CLI
提示正在监听请求之后,如果过快地发出请求,在很短的一段时间内它也会给出错误(可能就是代码层级才会出现)。所以还得继续添加异步方法WaitForVueCliServerToAcceptRequests()
确保开发服务器的的确确准备好了。
private static async TaskStartVueCliServerAsync( 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
通过ContinueWiht
继续体返回Task
的特性创建Task
,并在后续配置内联中间件时使用这个Task
app.Use(async (context, next)=>{});
使ASP.NET Core
的启动与中间件注册顺滑。
通过TaskCompletionSource
可以在稍后开始和结束的任意操作中创建Task
,这个Task
,可以手动指示操作何时结束(SetResult
),何时发生故障(SetException
),这两种状态都意味着Task
完成tcs.Task.IsCompleted
,对经常需要等 IO-Bound 类工作比较理想。
转载地址:http://akudi.baihongyu.com/