在前面博文【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
的終端中間件
以上便是集成原理。接下來我們對其中間件源碼進行解讀。整體還是有蠻多值得解讀學習的知識點:
- 異步編程
- 內聯中間件
- 啓動進程
- 事件驅動
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);
註冊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);
}
};
}
}
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類工作比較理想。
作者:Garfield
同步更新至個人博客:http://www.randyfield.cn/
本文版權歸作者和博客園共有,未經許可禁止轉載,否則保留追究法律責任的權利,若有需要請聯繫[email protected].