【源碼解讀】Vue與ASP.NET Core WebAPI的集成


在前面博文【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);

註冊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類工作比較理想。

作者:Garfield

同步更新至個人博客:http://www.randyfield.cn/

本文版權歸作者和博客園共有,未經許可禁止轉載,否則保留追究法律責任的權利,若有需要請聯繫[email protected].

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