• 【源码解读】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:.
    
    "
                + "[1] Ensure that 'npm' is installed and can be found in one of the PATH directories.
    "
                + $"    Current PATH enviroment variable is: { Environment.GetEnvironmentVariable("PATH") }
    "
                + "    Make sure the executable is in one of those directories, or update your PATH.
    
    "
                + "[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, '
    ', 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, '
    ', 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/

    本文版权归作者所有,未经许可禁止转载,否则保留追究法律责任的权利,若有需要请联系287572291@qq.com

  • 相关阅读:
    基于XMPP协议的Android即时通信系
    codeforces 830 B Cards Sorting
    [SCOI2007]修车
    spoj GSS4
    hdu 3652 B-number
    【模版】多项式乘法 FFT
    hdu 3642 Get The Treasury
    hdu 1255 覆盖的面积
    hdu 4553 约会安排
    UVA-11992 Fast Matrix Operations
  • 原文地址:https://www.cnblogs.com/RandyField/p/14182356.html
Copyright © 2020-2023  润新知