ASP.NET Core MVC 修改視圖的默認路徑及其實現原理解析

本章將和大傢分享如何在ASP.NET Core MVC中修改視圖的默認路徑,以及它的實現原理。

導語:在日常工作過程中你可能會遇到這樣的一種需求,就是在訪問同一個頁面時PC端和移動端顯示的內容和風格是不一樣(類似兩個不一樣的主題),但是它們的後端代碼又是差不多的,此時我們就希望能夠使用同一套後端代碼,然後由系統自動去判斷到底是PC端訪問還是移動端訪問,如果是移動端訪問就優先匹配移動端的視圖,在沒有匹配到的情況下才去匹配PC端的視圖。

下面我們就來看下這個功能要如何實現,Demo的目錄結構如下所示:

本Demo的Web項目為ASP.NET Core Web 應用程序(目標框架為.NET Core 3.1) MVC項目。

首先需要去擴展視圖的默認路徑,如下所示:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc.Razor;

namespace NETCoreViewLocationExpander.ViewLocationExtend
{
    /// <summary>
    /// 視圖默認路徑擴展
    /// </summary>
    public class TemplateViewLocationExpander : IViewLocationExpander
    {
        /// <summary>
        /// 擴展視圖默認路徑(PS:並非每次請求都會執行該方法)
        /// </summary>
        /// <param name="context"></param>
        /// <param name="viewLocations"></param>
        /// <returns></returns>
        public IEnumerable<string> ExpandViewLocations(ViewLocationExpanderContext context, IEnumerable<string> viewLocations)
        {
            var template = context.Values["template"] ?? TemplateEnum.Default.ToString();
            if (template == TemplateEnum.WeChatArea.ToString())
            {
                string[] weChatAreaViewLocationFormats = {
                    "/Areas/{2}/WeChatViews/{1}/{0}.cshtml",
                    "/Areas/{2}/WeChatViews/Shared/{0}.cshtml",
                    "/WeChatViews/Shared/{0}.cshtml"
                };
                //weChatAreaViewLocationFormats值在前--優先查找weChatAreaViewLocationFormats(即優先查找移動端目錄)
                return weChatAreaViewLocationFormats.Union(viewLocations);
            }
            else if (template == TemplateEnum.WeChat.ToString())
            {
                string[] weChatViewLocationFormats = {
                    "/WeChatViews/{1}/{0}.cshtml",
                    "/WeChatViews/Shared/{0}.cshtml"
                };
                //weChatViewLocationFormats值在前--優先查找weChatViewLocationFormats(即優先查找移動端目錄)
                return weChatViewLocationFormats.Union(viewLocations);
            }

            return viewLocations;
        }

        /// <summary>
        /// 往ViewLocationExpanderContext.Values裡面添加鍵值對(PS:每次請求都會執行該方法)
        /// </summary>
        /// <param name="context"></param>
        public void PopulateValues(ViewLocationExpanderContext context)
        {
            var userAgent = context.ActionContext.HttpContext.Request.Headers["User-Agent"].ToString();
            var isMobile = IsMobile(userAgent);
            var template = TemplateEnum.Default.ToString();
            if (isMobile)
            {
                var areaName = //區域名稱
                    context.ActionContext.RouteData.Values.ContainsKey("area")
                    ? context.ActionContext.RouteData.Values["area"].ToString()
                    : "";
                var controllerName = //控制器名稱
                    context.ActionContext.RouteData.Values.ContainsKey("controller")
                    ? context.ActionContext.RouteData.Values["controller"].ToString()
                    : "";
                if (!string.IsNullOrEmpty(areaName) &&
                    !string.IsNullOrEmpty(controllerName)) //訪問的是區域
                {
                    template = TemplateEnum.WeChatArea.ToString();
                }
                else
                {
                    template = TemplateEnum.WeChat.ToString();
                }
            }

            context.Values["template"] = template; //context.Values會參與ViewLookupCache緩存Key(cacheKey)的生成
        }

        /// <summary>
        /// 判斷是否是移動端
        /// </summary>
        /// <param name="userAgent"></param>
        /// <returns></returns>
        protected bool IsMobile(string userAgent)
        {
            userAgent = userAgent.ToLower();
            if (userAgent == "" ||
                userAgent.IndexOf("mobile") > -1 ||
                userAgent.IndexOf("mobi") > -1 ||
                userAgent.IndexOf("nokia") > -1 ||
                userAgent.IndexOf("samsung") > -1 ||
                userAgent.IndexOf("sonyericsson") > -1 ||
                userAgent.IndexOf("mot") > -1 ||
                userAgent.IndexOf("blackberry") > -1 ||
                userAgent.IndexOf("lg") > -1 ||
                userAgent.IndexOf("htc") > -1 ||
                userAgent.IndexOf("j2me") > -1 ||
                userAgent.IndexOf("ucweb") > -1 ||
                userAgent.IndexOf("opera mini") > -1 ||
                userAgent.IndexOf("android") > -1 ||
                userAgent.IndexOf("transcoder") > -1)
            {
                return true;
            }

            return false;
        }
    }

    /// <summary>
    /// 模板枚舉
    /// </summary>
    public enum TemplateEnum
    {
        Default = 1,
        WeChat = 2,
        WeChatArea = 3
    }
}

接著修改Startup.cs類,如下所示:

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

using NETCoreViewLocationExpander.ViewLocationExtend;

namespace NETCoreViewLocationExpander
{
    public class Startup
    {
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        public IConfiguration Configuration { get; }

        // This method gets called by the runtime. Use this method to add services to the container.
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddControllersWithViews();

            services.Configure<RazorViewEngineOptions>(options =>
            {
                options.ViewLocationExpanders.Add(new TemplateViewLocationExpander()); //視圖默認路徑擴展
            });
        }

        // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }
            else
            {
                app.UseExceptionHandler("/Home/Error");
            }
            app.UseStaticFiles();

            app.UseRouting();

            app.UseAuthorization();

            app.UseEndpoints(endpoints =>
            {
                endpoints.MapControllerRoute(
                    name: "areas",
                    pattern: "{area:exists}/{controller=Home}/{action=Index}/{id?}");

                endpoints.MapControllerRoute(
                    name: "default",
                    pattern: "{controller=Home}/{action=Index}/{id?}");
            });
        }
    }
}

此外,Demo中還準備瞭兩套視圖:

其中PC端視圖如下所示:

其中移動端視圖如下所示:

最後,我們分別使用PC端和移動端 來訪問相關頁面,如下所示:

1、訪問 /App/Home/Index 頁面

使用PC端訪問,運行結果如下:

使用移動端訪問,運行結果如下:

此時沒有對應的移動端視圖,所以都返回PC端的視圖內容。

2、訪問 /App/Home/WeChat 頁面

使用PC端訪問,運行結果如下:

使用移動端訪問,運行結果如下:

此時有對應的移動端視圖,所以當使用移動端訪問時返回的是移動端的視圖內容,而使用PC端訪問時返回的則是PC端的視圖內容。

下面我們結合ASP.NET Core源碼來分析下其實現原理:

ASP.NET Core源碼下載地址:https://github.com/dotnet/aspnetcore

點擊Source code下載,下載完成後,點擊Release:

可以將這個extensions源碼一起下載下來,下載完成後如下所示:

解壓後我們重點來關註Razor視圖引擎(RazorViewEngine.cs):

RazorViewEngine.cs 源碼如下所示:

// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.Linq;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.AspNetCore.Mvc.ViewEngines;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Primitives;

namespace Microsoft.AspNetCore.Mvc.Razor
{
    /// <summary>
    /// Default implementation of <see cref="IRazorViewEngine"/>.
    /// </summary>
    /// <remarks>
    /// For <c>ViewResults</c> returned from controllers, views should be located in
    /// <see cref="RazorViewEngineOptions.ViewLocationFormats"/>
    /// by default. For the controllers in an area, views should exist in
    /// <see cref="RazorViewEngineOptions.AreaViewLocationFormats"/>.
    /// </remarks>
    public class RazorViewEngine : IRazorViewEngine
    {
        public static readonly string ViewExtension = ".cshtml";

        private const string AreaKey = "area";
        private const string ControllerKey = "controller";
        private const string PageKey = "page";

        private static readonly TimeSpan _cacheExpirationDuration = TimeSpan.FromMinutes(20);

        private readonly IRazorPageFactoryProvider _pageFactory;
        private readonly IRazorPageActivator _pageActivator;
        private readonly HtmlEncoder _htmlEncoder;
        private readonly ILogger _logger;
        private readonly RazorViewEngineOptions _options;
        private readonly DiagnosticListener _diagnosticListener;

        /// <summary>
        /// Initializes a new instance of the <see cref="RazorViewEngine" />.
        /// </summary>
        public RazorViewEngine(
            IRazorPageFactoryProvider pageFactory,
            IRazorPageActivator pageActivator,
            HtmlEncoder htmlEncoder,
            IOptions<RazorViewEngineOptions> optionsAccessor,
            ILoggerFactory loggerFactory,
            DiagnosticListener diagnosticListener)
        {
            _options = optionsAccessor.Value;

            if (_options.ViewLocationFormats.Count == 0)
            {
                throw new ArgumentException(
                    Resources.FormatViewLocationFormatsIsRequired(nameof(RazorViewEngineOptions.ViewLocationFormats)),
                    nameof(optionsAccessor));
            }

            if (_options.AreaViewLocationFormats.Count == 0)
            {
                throw new ArgumentException(
                    Resources.FormatViewLocationFormatsIsRequired(nameof(RazorViewEngineOptions.AreaViewLocationFormats)),
                    nameof(optionsAccessor));
            }

            _pageFactory = pageFactory;
            _pageActivator = pageActivator;
            _htmlEncoder = htmlEncoder;
            _logger = loggerFactory.CreateLogger<RazorViewEngine>();
            _diagnosticListener = diagnosticListener;
            ViewLookupCache = new MemoryCache(new MemoryCacheOptions());
        }

        /// <summary>
        /// A cache for results of view lookups.
        /// </summary>
        protected IMemoryCache ViewLookupCache { get; }

        /// <summary>
        /// Gets the case-normalized route value for the specified route <paramref name="key"/>.
        /// </summary>
        /// <param name="context">The <see cref="ActionContext"/>.</param>
        /// <param name="key">The route key to lookup.</param>
        /// <returns>The value corresponding to the key.</returns>
        /// <remarks>
        /// The casing of a route value in <see cref="ActionContext.RouteData"/> is determined by the client.
        /// This making constructing paths for view locations in a case sensitive file system unreliable. Using the
        /// <see cref="Abstractions.ActionDescriptor.RouteValues"/> to get route values
        /// produces consistently cased results.
        /// </remarks>
        public static string GetNormalizedRouteValue(ActionContext context, string key)
            => NormalizedRouteValue.GetNormalizedRouteValue(context, key);

        /// <inheritdoc />
        public RazorPageResult FindPage(ActionContext context, string pageName)
        {
            if (context == null)
            {
                throw new ArgumentNullException(nameof(context));
            }

            if (string.IsNullOrEmpty(pageName))
            {
                throw new ArgumentException(Resources.ArgumentCannotBeNullOrEmpty, nameof(pageName));
            }

            if (IsApplicationRelativePath(pageName) || IsRelativePath(pageName))
            {
                // A path; not a name this method can handle.
                return new RazorPageResult(pageName, Enumerable.Empty<string>());
            }

            var cacheResult = LocatePageFromViewLocations(context, pageName, isMainPage: false);
            if (cacheResult.Success)
            {
                var razorPage = cacheResult.ViewEntry.PageFactory();
                return new RazorPageResult(pageName, razorPage);
            }
            else
            {
                return new RazorPageResult(pageName, cacheResult.SearchedLocations);
            }
        }

        /// <inheritdoc />
        public RazorPageResult GetPage(string executingFilePath, string pagePath)
        {
            if (string.IsNullOrEmpty(pagePath))
            {
                throw new ArgumentException(Resources.ArgumentCannotBeNullOrEmpty, nameof(pagePath));
            }

            if (!(IsApplicationRelativePath(pagePath) || IsRelativePath(pagePath)))
            {
                // Not a path this method can handle.
                return new RazorPageResult(pagePath, Enumerable.Empty<string>());
            }

            var cacheResult = LocatePageFromPath(executingFilePath, pagePath, isMainPage: false);
            if (cacheResult.Success)
            {
                var razorPage = cacheResult.ViewEntry.PageFactory();
                return new RazorPageResult(pagePath, razorPage);
            }
            else
            {
                return new RazorPageResult(pagePath, cacheResult.SearchedLocations);
            }
        }

        /// <inheritdoc />
        public ViewEngineResult FindView(ActionContext context, string viewName, bool isMainPage)
        {
            if (context == null)
            {
                throw new ArgumentNullException(nameof(context));
            }

            if (string.IsNullOrEmpty(viewName))
            {
                throw new ArgumentException(Resources.ArgumentCannotBeNullOrEmpty, nameof(viewName));
            }

            if (IsApplicationRelativePath(viewName) || IsRelativePath(viewName))
            {
                // A path; not a name this method can handle.
                return ViewEngineResult.NotFound(viewName, Enumerable.Empty<string>());
            }

            var cacheResult = LocatePageFromViewLocations(context, viewName, isMainPage);
            return CreateViewEngineResult(cacheResult, viewName);
        }

        /// <inheritdoc />
        public ViewEngineResult GetView(string executingFilePath, string viewPath, bool isMainPage)
        {
            if (string.IsNullOrEmpty(viewPath))
            {
                throw new ArgumentException(Resources.ArgumentCannotBeNullOrEmpty, nameof(viewPath));
            }

            if (!(IsApplicationRelativePath(viewPath) || IsRelativePath(viewPath)))
            {
                // Not a path this method can handle.
                return ViewEngineResult.NotFound(viewPath, Enumerable.Empty<string>());
            }

            var cacheResult = LocatePageFromPath(executingFilePath, viewPath, isMainPage);
            return CreateViewEngineResult(cacheResult, viewPath);
        }

        private ViewLocationCacheResult LocatePageFromPath(string executingFilePath, string pagePath, bool isMainPage)
        {
            var applicationRelativePath = GetAbsolutePath(executingFilePath, pagePath);
            var cacheKey = new ViewLocationCacheKey(applicationRelativePath, isMainPage);
            if (!ViewLookupCache.TryGetValue(cacheKey, out ViewLocationCacheResult cacheResult))
            {
                var expirationTokens = new HashSet<IChangeToken>();
                cacheResult = CreateCacheResult(expirationTokens, applicationRelativePath, isMainPage);

                var cacheEntryOptions = new MemoryCacheEntryOptions();
                cacheEntryOptions.SetSlidingExpiration(_cacheExpirationDuration);
                foreach (var expirationToken in expirationTokens)
                {
                    cacheEntryOptions.AddExpirationToken(expirationToken);
                }

                // No views were found at the specified location. Create a not found result.
                if (cacheResult == null)
                {
                    cacheResult = new ViewLocationCacheResult(new[] { applicationRelativePath });
                }

                cacheResult = ViewLookupCache.Set(
                    cacheKey,
                    cacheResult,
                    cacheEntryOptions);
            }

            return cacheResult;
        }

        private ViewLocationCacheResult LocatePageFromViewLocations(
            ActionContext actionContext,
            string pageName,
            bool isMainPage)
        {
            var controllerName = GetNormalizedRouteValue(actionContext, ControllerKey);
            var areaName = GetNormalizedRouteValue(actionContext, AreaKey);
            string razorPageName = null;
            if (actionContext.ActionDescriptor.RouteValues.ContainsKey(PageKey))
            {
                // Only calculate the Razor Page name if "page" is registered in RouteValues.
                razorPageName = GetNormalizedRouteValue(actionContext, PageKey);
            }

            var expanderContext = new ViewLocationExpanderContext(
                actionContext,
                pageName,
                controllerName,
                areaName,
                razorPageName,
                isMainPage);
            Dictionary<string, string> expanderValues = null;

            var expanders = _options.ViewLocationExpanders;
            // Read interface .Count once rather than per iteration
            var expandersCount = expanders.Count;
            if (expandersCount > 0)
            {
                expanderValues = new Dictionary<string, string>(StringComparer.Ordinal);
                expanderContext.Values = expanderValues;

                // Perf: Avoid allocations
                for (var i = 0; i < expandersCount; i++)
                {
                    expanders[i].PopulateValues(expanderContext);
                }
            }

            var cacheKey = new ViewLocationCacheKey(
                expanderContext.ViewName,
                expanderContext.ControllerName,
                expanderContext.AreaName,
                expanderContext.PageName,
                expanderContext.IsMainPage,
                expanderValues);

            if (!ViewLookupCache.TryGetValue(cacheKey, out ViewLocationCacheResult cacheResult))
            {
                _logger.ViewLookupCacheMiss(cacheKey.ViewName, cacheKey.ControllerName);
                cacheResult = OnCacheMiss(expanderContext, cacheKey);
            }
            else
            {
                _logger.ViewLookupCacheHit(cacheKey.ViewName, cacheKey.ControllerName);
            }

            return cacheResult;
        }

        /// <inheritdoc />
        public string GetAbsolutePath(string executingFilePath, string pagePath)
        {
            if (string.IsNullOrEmpty(pagePath))
            {
                // Path is not valid; no change required.
                return pagePath;
            }

            if (IsApplicationRelativePath(pagePath))
            {
                // An absolute path already; no change required.
                return pagePath;
            }

            if (!IsRelativePath(pagePath))
            {
                // A page name; no change required.
                return pagePath;
            }

            if (string.IsNullOrEmpty(executingFilePath))
            {
                // Given a relative path i.e. not yet application-relative (starting with "~/" or "/"), interpret
                // path relative to currently-executing view, if any.
                // Not yet executing a view. Start in app root.
                var absolutePath = "/" + pagePath;
                return ViewEnginePath.ResolvePath(absolutePath);
            }

            return ViewEnginePath.CombinePath(executingFilePath, pagePath);
        }

        // internal for tests
        internal IEnumerable<string> GetViewLocationFormats(ViewLocationExpanderContext context)
        {
            if (!string.IsNullOrEmpty(context.AreaName) &&
                !string.IsNullOrEmpty(context.ControllerName))
            {
                return _options.AreaViewLocationFormats;
            }
            else if (!string.IsNullOrEmpty(context.ControllerName))
            {
                return _options.ViewLocationFormats;
            }
            else if (!string.IsNullOrEmpty(context.AreaName) &&
                !string.IsNullOrEmpty(context.PageName))
            {
                return _options.AreaPageViewLocationFormats;
            }
            else if (!string.IsNullOrEmpty(context.PageName))
            {
                return _options.PageViewLocationFormats;
            }
            else
            {
                // If we don't match one of these conditions, we'll just treat it like regular controller/action
                // and use those search paths. This is what we did in 1.0.0 without giving much thought to it.
                return _options.ViewLocationFormats;
            }
        }

        private ViewLocationCacheResult OnCacheMiss(
            ViewLocationExpanderContext expanderContext,
            ViewLocationCacheKey cacheKey)
        {
            var viewLocations = GetViewLocationFormats(expanderContext);

            var expanders = _options.ViewLocationExpanders;
            // Read interface .Count once rather than per iteration
            var expandersCount = expanders.Count;
            for (var i = 0; i < expandersCount; i++)
            {
                viewLocations = expanders[i].ExpandViewLocations(expanderContext, viewLocations);
            }

            ViewLocationCacheResult cacheResult = null;
            var searchedLocations = new List<string>();
            var expirationTokens = new HashSet<IChangeToken>();
            foreach (var location in viewLocations)
            {
                var path = string.Format(
                    CultureInfo.InvariantCulture,
                    location,
                    expanderContext.ViewName,
                    expanderContext.ControllerName,
                    expanderContext.AreaName);

                path = ViewEnginePath.ResolvePath(path);

                cacheResult = CreateCacheResult(expirationTokens, path, expanderContext.IsMainPage);
                if (cacheResult != null)
                {
                    break;
                }

                searchedLocations.Add(path);
            }

            // No views were found at the specified location. Create a not found result.
            if (cacheResult == null)
            {
                cacheResult = new ViewLocationCacheResult(searchedLocations);
            }

            var cacheEntryOptions = new MemoryCacheEntryOptions();
            cacheEntryOptions.SetSlidingExpiration(_cacheExpirationDuration);
            foreach (var expirationToken in expirationTokens)
            {
                cacheEntryOptions.AddExpirationToken(expirationToken);
            }

            return ViewLookupCache.Set(cacheKey, cacheResult, cacheEntryOptions);
        }

        // Internal for unit testing
        internal ViewLocationCacheResult CreateCacheResult(
            HashSet<IChangeToken> expirationTokens,
            string relativePath,
            bool isMainPage)
        {
            var factoryResult = _pageFactory.CreateFactory(relativePath);
            var viewDescriptor = factoryResult.ViewDescriptor;
            if (viewDescriptor?.ExpirationTokens != null)
            {
                var viewExpirationTokens = viewDescriptor.ExpirationTokens;
                // Read interface .Count once rather than per iteration
                var viewExpirationTokensCount = viewExpirationTokens.Count;
                for (var i = 0; i < viewExpirationTokensCount; i++)
                {
                    expirationTokens.Add(viewExpirationTokens[i]);
                }
            }

            if (factoryResult.Success)
            {
                // Only need to lookup _ViewStarts for the main page.
                var viewStartPages = isMainPage ?
                    GetViewStartPages(viewDescriptor.RelativePath, expirationTokens) :
                    Array.Empty<ViewLocationCacheItem>();

                return new ViewLocationCacheResult(
                    new ViewLocationCacheItem(factoryResult.RazorPageFactory, relativePath),
                    viewStartPages);
            }

            return null;
        }

        private IReadOnlyList<ViewLocationCacheItem> GetViewStartPages(
            string path,
            HashSet<IChangeToken> expirationTokens)
        {
            var viewStartPages = new List<ViewLocationCacheItem>();

            foreach (var filePath in RazorFileHierarchy.GetViewStartPaths(path))
            {
                var result = _pageFactory.CreateFactory(filePath);
                var viewDescriptor = result.ViewDescriptor;
                if (viewDescriptor?.ExpirationTokens != null)
                {
                    for (var i = 0; i < viewDescriptor.ExpirationTokens.Count; i++)
                    {
                        expirationTokens.Add(viewDescriptor.ExpirationTokens[i]);
                    }
                }

                if (result.Success)
                {
                    // Populate the viewStartPages list so that _ViewStarts appear in the order the need to be
                    // executed (closest last, furthest first). This is the reverse order in which
                    // ViewHierarchyUtility.GetViewStartLocations returns _ViewStarts.
                    viewStartPages.Insert(0, new ViewLocationCacheItem(result.RazorPageFactory, filePath));
                }
            }

            return viewStartPages;
        }

        private ViewEngineResult CreateViewEngineResult(ViewLocationCacheResult result, string viewName)
        {
            if (!result.Success)
            {
                return ViewEngineResult.NotFound(viewName, result.SearchedLocations);
            }

            var page = result.ViewEntry.PageFactory();

            var viewStarts = new IRazorPage[result.ViewStartEntries.Count];
            for (var i = 0; i < viewStarts.Length; i++)
            {
                var viewStartItem = result.ViewStartEntries[i];
                viewStarts[i] = viewStartItem.PageFactory();
            }

            var view = new RazorView(this, _pageActivator, viewStarts, page, _htmlEncoder, _diagnosticListener);
            return ViewEngineResult.Found(viewName, view);
        }

        private static bool IsApplicationRelativePath(string name)
        {
            Debug.Assert(!string.IsNullOrEmpty(name));
            return name[0] == '~' || name[0] == '/';
        }

        private static bool IsRelativePath(string name)
        {
            Debug.Assert(!string.IsNullOrEmpty(name));

            // Though ./ViewName looks like a relative path, framework searches for that view using view locations.
            return name.EndsWith(ViewExtension, StringComparison.OrdinalIgnoreCase);
        }
    }
}

我們從用於尋找視圖的 FindView 方法開始閱讀:

/// <inheritdoc />
public ViewEngineResult FindView(ActionContext context, string viewName, bool isMainPage)
{
    if (context == null)
    {
        throw new ArgumentNullException(nameof(context));
    }

    if (string.IsNullOrEmpty(viewName))
    {
        throw new ArgumentException(Resources.ArgumentCannotBeNullOrEmpty, nameof(viewName));
    }

    if (IsApplicationRelativePath(viewName) || IsRelativePath(viewName))
    {
        // A path; not a name this method can handle.
        return ViewEngineResult.NotFound(viewName, Enumerable.Empty<string>());
    }

    var cacheResult = LocatePageFromViewLocations(context, viewName, isMainPage);
    return CreateViewEngineResult(cacheResult, viewName);
}

接著定位找到LocatePageFromViewLocations 方法:

private ViewLocationCacheResult LocatePageFromViewLocations(
    ActionContext actionContext,
    string pageName,
    bool isMainPage)
{
    var controllerName = GetNormalizedRouteValue(actionContext, ControllerKey);
    var areaName = GetNormalizedRouteValue(actionContext, AreaKey);
    string razorPageName = null;
    if (actionContext.ActionDescriptor.RouteValues.ContainsKey(PageKey))
    {
        // Only calculate the Razor Page name if "page" is registered in RouteValues.
        razorPageName = GetNormalizedRouteValue(actionContext, PageKey);
    }

    var expanderContext = new ViewLocationExpanderContext(
        actionContext,
        pageName,
        controllerName,
        areaName,
        razorPageName,
        isMainPage);
    Dictionary<string, string> expanderValues = null;

    var expanders = _options.ViewLocationExpanders;
    // Read interface .Count once rather than per iteration
    var expandersCount = expanders.Count;
    if (expandersCount > 0)
    {
        expanderValues = new Dictionary<string, string>(StringComparer.Ordinal);
        expanderContext.Values = expanderValues;

        // Perf: Avoid allocations
        for (var i = 0; i < expandersCount; i++)
        {
            expanders[i].PopulateValues(expanderContext);
        }
    }

    var cacheKey = new ViewLocationCacheKey(
        expanderContext.ViewName,
        expanderContext.ControllerName,
        expanderContext.AreaName,
        expanderContext.PageName,
        expanderContext.IsMainPage,
        expanderValues);

    if (!ViewLookupCache.TryGetValue(cacheKey, out ViewLocationCacheResult cacheResult))
    {
        _logger.ViewLookupCacheMiss(cacheKey.ViewName, cacheKey.ControllerName);
        cacheResult = OnCacheMiss(expanderContext, cacheKey);
    }
    else
    {
        _logger.ViewLookupCacheHit(cacheKey.ViewName, cacheKey.ControllerName);
    }

    return cacheResult;
}

從此處可以看出,每次查找視圖的時候都會調用 ViewLocationExpander.PopulateValues 方法,並且最終的這個 expanderValues 會參與ViewLookupCache 緩存key(cacheKey)的生成。

此外還可以看出,如果從 ViewLookupCache 這個緩存中能找到數據的話,它就直接返回瞭,不會再去調用ViewLocationExpander.ExpandViewLocations 方法。

這也就解釋瞭為什麼我們Demo中是在 PopulateValues 方法裡面去設置context.Values[“template”] 的值,而不是直接在 ExpandViewLocations 方法裡面去設置這個值。

下面我們接著找到用於生成 cacheKey 的ViewLocationCacheKey 類,如下所示:

// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System;
using System.Collections.Generic;
using Microsoft.Extensions.Internal;

namespace Microsoft.AspNetCore.Mvc.Razor
{
    /// <summary>
    /// Key for entries in <see cref="RazorViewEngine.ViewLookupCache"/>.
    /// </summary>
    internal readonly struct ViewLocationCacheKey : IEquatable<ViewLocationCacheKey>
    {
        /// <summary>
        /// Initializes a new instance of <see cref="ViewLocationCacheKey"/>.
        /// </summary>
        /// <param name="viewName">The view name or path.</param>
        /// <param name="isMainPage">Determines if the page being found is the main page for an action.</param>
        public ViewLocationCacheKey(
            string viewName,
            bool isMainPage)
            : this(
                  viewName,
                  controllerName: null,
                  areaName: null,
                  pageName: null,
                  isMainPage: isMainPage,
                  values: null)
        {
        }

        /// <summary>
        /// Initializes a new instance of <see cref="ViewLocationCacheKey"/>.
        /// </summary>
        /// <param name="viewName">The view name.</param>
        /// <param name="controllerName">The controller name.</param>
        /// <param name="areaName">The area name.</param>
        /// <param name="pageName">The page name.</param>
        /// <param name="isMainPage">Determines if the page being found is the main page for an action.</param>
        /// <param name="values">Values from <see cref="IViewLocationExpander"/> instances.</param>
        public ViewLocationCacheKey(
            string viewName,
            string controllerName,
            string areaName,
            string pageName,
            bool isMainPage,
            IReadOnlyDictionary<string, string> values)
        {
            ViewName = viewName;
            ControllerName = controllerName;
            AreaName = areaName;
            PageName = pageName;
            IsMainPage = isMainPage;
            ViewLocationExpanderValues = values;
        }

        /// <summary>
        /// Gets the view name.
        /// </summary>
        public string ViewName { get; }

        /// <summary>
        /// Gets the controller name.
        /// </summary>
        public string ControllerName { get; }

        /// <summary>
        /// Gets the area name.
        /// </summary>
        public string AreaName { get; }

        /// <summary>
        /// Gets the page name.
        /// </summary>
        public string PageName { get; }

        /// <summary>
        /// Determines if the page being found is the main page for an action.
        /// </summary>
        public bool IsMainPage { get; }

        /// <summary>
        /// Gets the values populated by <see cref="IViewLocationExpander"/> instances.
        /// </summary>
        public IReadOnlyDictionary<string, string> ViewLocationExpanderValues { get; }

        /// <inheritdoc />
        public bool Equals(ViewLocationCacheKey y)
        {
            if (IsMainPage != y.IsMainPage ||
                !string.Equals(ViewName, y.ViewName, StringComparison.Ordinal) ||
                !string.Equals(ControllerName, y.ControllerName, StringComparison.Ordinal) ||
                !string.Equals(AreaName, y.AreaName, StringComparison.Ordinal) ||
                !string.Equals(PageName, y.PageName, StringComparison.Ordinal))
            {
                return false;
            }

            if (ReferenceEquals(ViewLocationExpanderValues, y.ViewLocationExpanderValues))
            {
                return true;
            }

            if (ViewLocationExpanderValues == null ||
                y.ViewLocationExpanderValues == null ||
                (ViewLocationExpanderValues.Count != y.ViewLocationExpanderValues.Count))
            {
                return false;
            }

            foreach (var item in ViewLocationExpanderValues)
            {
                if (!y.ViewLocationExpanderValues.TryGetValue(item.Key, out var yValue) ||
                    !string.Equals(item.Value, yValue, StringComparison.Ordinal))
                {
                    return false;
                }
            }

            return true;
        }

        /// <inheritdoc />
        public override bool Equals(object obj)
        {
            if (obj is ViewLocationCacheKey)
            {
                return Equals((ViewLocationCacheKey)obj);
            }

            return false;
        }

        /// <inheritdoc />
        public override int GetHashCode()
        {
            var hashCodeCombiner = HashCodeCombiner.Start();
            hashCodeCombiner.Add(IsMainPage ? 1 : 0);
            hashCodeCombiner.Add(ViewName, StringComparer.Ordinal);
            hashCodeCombiner.Add(ControllerName, StringComparer.Ordinal);
            hashCodeCombiner.Add(AreaName, StringComparer.Ordinal);
            hashCodeCombiner.Add(PageName, StringComparer.Ordinal);

            if (ViewLocationExpanderValues != null)
            {
                foreach (var item in ViewLocationExpanderValues)
                {
                    hashCodeCombiner.Add(item.Key, StringComparer.Ordinal);
                    hashCodeCombiner.Add(item.Value, StringComparer.Ordinal);
                }
            }

            return hashCodeCombiner;
        }
    }
}

我們重點來看下其中的 Equals 方法,如下所示:

/// <inheritdoc />
public bool Equals(ViewLocationCacheKey y)
{
    if (IsMainPage != y.IsMainPage ||
        !string.Equals(ViewName, y.ViewName, StringComparison.Ordinal) ||
        !string.Equals(ControllerName, y.ControllerName, StringComparison.Ordinal) ||
        !string.Equals(AreaName, y.AreaName, StringComparison.Ordinal) ||
        !string.Equals(PageName, y.PageName, StringComparison.Ordinal))
    {
        return false;
    }

    if (ReferenceEquals(ViewLocationExpanderValues, y.ViewLocationExpanderValues))
    {
        return true;
    }

    if (ViewLocationExpanderValues == null ||
        y.ViewLocationExpanderValues == null ||
        (ViewLocationExpanderValues.Count != y.ViewLocationExpanderValues.Count))
    {
        return false;
    }

    foreach (var item in ViewLocationExpanderValues)
    {
        if (!y.ViewLocationExpanderValues.TryGetValue(item.Key, out var yValue) ||
            !string.Equals(item.Value, yValue, StringComparison.Ordinal))
        {
            return false;
        }
    }

    return true;
}

從此處可以看出,如果 expanderValues 字典中 鍵/值對的數目不同或者其中任意一個值不同,那麼這個 cacheKey 就是不同的。

我們繼續往下分析, 從上文中我們知道,如果從ViewLookupCache 緩存中沒有找到數據,那麼它就會執行OnCacheMiss 方法。

我們找到OnCacheMiss 方法,如下所示:

private ViewLocationCacheResult OnCacheMiss(
    ViewLocationExpanderContext expanderContext,
    ViewLocationCacheKey cacheKey)
{
    var viewLocations = GetViewLocationFormats(expanderContext);

    var expanders = _options.ViewLocationExpanders;
    // Read interface .Count once rather than per iteration
    var expandersCount = expanders.Count;
    for (var i = 0; i < expandersCount; i++)
    {
        viewLocations = expanders[i].ExpandViewLocations(expanderContext, viewLocations);
    }

    ViewLocationCacheResult cacheResult = null;
    var searchedLocations = new List<string>();
    var expirationTokens = new HashSet<IChangeToken>();
    foreach (var location in viewLocations)
    {
        var path = string.Format(
            CultureInfo.InvariantCulture,
            location,
            expanderContext.ViewName,
            expanderContext.ControllerName,
            expanderContext.AreaName);

        path = ViewEnginePath.ResolvePath(path);

        cacheResult = CreateCacheResult(expirationTokens, path, expanderContext.IsMainPage);
        if (cacheResult != null)
        {
            break;
        }

        searchedLocations.Add(path);
    }

    // No views were found at the specified location. Create a not found result.
    if (cacheResult == null)
    {
        cacheResult = new ViewLocationCacheResult(searchedLocations);
    }

    var cacheEntryOptions = new MemoryCacheEntryOptions();
    cacheEntryOptions.SetSlidingExpiration(_cacheExpirationDuration);
    foreach (var expirationToken in expirationTokens)
    {
        cacheEntryOptions.AddExpirationToken(expirationToken);
    }

    return ViewLookupCache.Set(cacheKey, cacheResult, cacheEntryOptions);
}

仔細觀察之後你就會發現:

1、首先它是通過GetViewLocationFormats 方法獲取初始的 viewLocations視圖位置集合。

2、接著它會按順序依次調用所有的ViewLocationExpander.ExpandViewLocations 方法,經過一系列聚合操作後得到最終的viewLocations 視圖位置集合。

3、然後遍歷 viewLocations 視圖位置集合,按順序依次去指定的路徑中查找對應的視圖,隻要找到符合條件的第一個視圖就結束循環,不再往下查找,最後設置緩存返回結果。

4、視圖位置字符串(例如:“/Areas/{2}/WeChatViews/{1}/{0}.cshtml”)中的占位符含義:“{0}” 表示視圖名稱,“{1}” 表示控制器名稱,“{2}” 表示區域名稱。

下面我們繼續找到GetViewLocationFormats 方法,如下所示:

// internal for tests
internal IEnumerable<string> GetViewLocationFormats(ViewLocationExpanderContext context)
{
    if (!string.IsNullOrEmpty(context.AreaName) &&
        !string.IsNullOrEmpty(context.ControllerName))
    {
        return _options.AreaViewLocationFormats;
    }
    else if (!string.IsNullOrEmpty(context.ControllerName))
    {
        return _options.ViewLocationFormats;
    }
    else if (!string.IsNullOrEmpty(context.AreaName) &&
        !string.IsNullOrEmpty(context.PageName))
    {
        return _options.AreaPageViewLocationFormats;
    }
    else if (!string.IsNullOrEmpty(context.PageName))
    {
        return _options.PageViewLocationFormats;
    }
    else
    {
        // If we don't match one of these conditions, we'll just treat it like regular controller/action
        // and use those search paths. This is what we did in 1.0.0 without giving much thought to it.
        return _options.ViewLocationFormats;
    }
}

從此處可以看出,它是通過判斷 區域名稱和控制器名稱 是否都不為空,以此來判斷客戶端訪問的到底是區域還是非區域。

文章最後我們通過調試來看下AreaViewLocationFormats 和ViewLocationFormats 的初始值:

至此本文就全部介紹完瞭,如果覺得對您有所啟發請記得點個贊哦!!!

Demo源碼:

鏈接: https://pan.baidu.com/s/1gn4JQTzn7hQLgfAtaUPDLg

提取碼: mjgr

到此這篇關於ASP.NET Core MVC 修改視圖的默認路徑及其實現原理的文章就介紹到這瞭,更多相關ASP.NET Core MVC 視圖路徑內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: