ASP.NET Core 2.0 自定義 _ViewStart 和 _ViewImports 的目錄位置

在 ASP.NET Core 裏擴展 Razor 查找視圖目錄不是什麼新鮮和困難的事情,但 _ViewStart_ViewImports 這2個視圖比較特殊,若是想讓 Razor 在咱們指定的目錄中查找它們,則須要耗費一點額外的精力。本文將提供一種方法作到這一點。注意,文本僅適用於 ASP.NET Core 2.0+, 由於 Razor 在 2.0 版本里的內部實現有較大重構,所以這裏提供的方法並不適用於 ASP.NET Core 1.xhtml

爲了全面描述 ASP.NET Core 2.0 中擴展 Razor 查找視圖目錄的能力,咱們仍是由淺入深,從最簡單的擴展方式着手吧。git

準備工做

首先,咱們能夠建立一個新的 ASP.NET Core 項目用於演示。github

mkdir CustomizedViewLocation
cd CustomizedViewLocation
dotnet new web # 建立一個空的 ASP.NET Core 應用

接下來稍微調整下 Startup.cs 文件的內容,引入 MVC:web

// Startup.cs

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;

namespace CustomizedViewLocation
{
    public class Startup
    {
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddMvc();
        }

        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            app.UseMvcWithDefaultRoute();
        }
    }
}

好了咱們的演示項目已經搭好了架子。緩存

咱們的目標

在咱們的示例項目中,咱們但願咱們的目錄組織方式是按照功能模塊組織的,即同一個功能模塊的全部 Controller 和 View 都放在同一個目錄下。對於多個功能模塊共享、通用的內容,好比 _Layout, _Footer, _ViewStart_ViewImports 則單獨放在根目錄下的一個叫 Shared 的子目錄中。bash

最簡單的方式: ViewLocationFormats

假設咱們如今有2個功能模塊 Home 和 About,分別須要 HomeController 和它的 Index view,以及 AboutMeController 和它的 Index view. 由於一個 Controller 可能會包含多個 view,所以我選擇爲每個功能模塊目錄下再增長一個 Views 目錄,集中這個功能模塊下的全部 View. 整個目錄結構看起來是這樣的:mvc

Home & About Folders

從目錄結構中咱們能夠發現咱們的視圖目錄爲 /{controller}/Views/{viewName}.cshtml, 好比 HomeControllerIndex 視圖所在的位置就是 /Home/Views/Index.cshtml,這跟 MVC 默認的視圖位置 /Views/{Controller}/{viewName}.cshtml 很類似(/Views/Home/Index.cshtml),共同的特色是路徑中的 Controller 部分和 View 部分是動態的,其它的都是固定不變的。其實 MVC 默認的尋找視圖位置的方式一點都不高端,相似於這樣:app

string controllerName = "Home"; // 「我」知道當前 Controller 是 Home
string viewName = "Index"; // "我「知道當前須要解析的 View 的名字

// 把 viewName 和 controllerName 帶入一個表明視圖路徑的格式化字符串獲得最終的視圖路徑。
string viewPath = string.Format("/Views/{1}/{0}.cshtml", viewName, controllerName);

// 根據 viewPath 找到視圖文件作後續處理

若是咱們能夠構建另外一個格式字符串,其中 {0} 表明 View 名稱, {1} 表明 Controller 名稱,而後替換掉默認的 /Views/{1}/{0}.cshtml,那咱們就可讓 Razor 到咱們設定的路徑去檢索視圖。而要作到這點很是容易,利用 ViewLocationFormats,代碼以下:iview

// Startup.cs

public void ConfigureServices(IServiceCollection services)
{
    IMvcBuilder mvcBuilder = services.AddMvc();
    mvcBuilder.AddRazorOptions(options => options.ViewLocationFormats.Add("/{1}/Views/{0}.cshtml"));
}

收工,就這麼簡單。順便說一句,還有一個參數 {2},表明 Area 名稱。ide

這種作法是否是已經很完美了呢?No, No, No. 誰能看出來這種作法有什麼缺點?

這種作法有2個缺點。

  1. 全部的功能模塊目錄必須在根目錄下建立,沒法創建層級目錄關係。且看下面的目錄結構截圖:

Home, About & Reports Folders

注意 Reports 目錄,由於咱們有種類繁多的報表,所以咱們但願能夠把各類報表分門別類放入各自的目錄。可是這麼作以後,咱們以前設置的 ViewLocationFormats 就無效了。例如咱們訪問 URL /EmployeeReport/Index, Razor 會試圖尋找 /EmployeeReport/Views/Index.cshtml,但其真正的位置是 /Reports/AdHocReports/EmployeeReport/Views/Index.cshtml。前面還有好幾層目錄呢~

  1. 由於全部的 View 文件再也不位於同一個父級目錄之下,所以 _ViewStart.cshtml_ViewImports.cshtml 的做用將受到極大限制。緣由後面細表。

下面咱們來分別解決這2個問題。

最靈活的方式: IViewLocationExpander

有時候,咱們的視圖目錄除了 controller 名稱 和 view 名稱2個變量外,還涉及到別的動態部分,好比上面的 Reports 相關 Controller,視圖路徑有更深的目錄結構,而 controller 名稱僅表明末級的目錄。此時,咱們須要一種更靈活的方式來處理: IViewLocationExpander,經過實現 IViewLocationExpander,咱們能夠獲得一個 ViewLocationExpanderContext,而後據此更靈活地建立 view location formats。

對於咱們要解決的目錄層次問題,咱們首先須要觀察,而後會發現目錄層次結構和 Controller 類型的命名空間是有對應關係的。例如以下定義:

using Microsoft.AspNetCore.Mvc;

namespace CustomizedViewLocation.Reports.AdHocReports.EmployeeReport
{
    public class EmployeeReportController : Controller
    {
        public IActionResult Index() => View();
    }
}

觀察 EmployeeReportController 的命名空間 CustomizedViewLocation.Reports.AdHocReports.EmployeeReport以及 Index 視圖對應的目錄 /Reports/AdHocReports/EmployeeReport/Views/Index.cshtml 能夠發現以下對應關係:

命名空間 視圖路徑 ViewLocationFormat
CustomizedViewLocation 項目根路徑 /
Reports.AdHocReports Reports/AdHocReports 把整個命名空間以「.」爲分割點掐頭去尾,而後把「.」替換爲「/」
EmployeeReport EmployeeReport Controller 名稱
Views 固定目錄
Index.cshtml 視圖名稱.cshtml

因此咱們 IViewLocationExpander 的實現類型主要是獲取和處理 Controller 的命名空間。且看下面的代碼。

// NamespaceViewLocationExpander.cs

using System;
using System.Collections.Generic;
using System.Linq;
using System.IO;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Controllers;

namespace CustomizedViewLocation
{
    public class NamespaceViewLocationExpander : IViewLocationExpander
    {
        private const string VIEWS_FOLDER_NAME = "Views";

        public IEnumerable<string> ExpandViewLocations(ViewLocationExpanderContext context, IEnumerable<string> viewLocations)
        {
            ControllerActionDescriptor cad = context.ActionContext.ActionDescriptor as ControllerActionDescriptor;
            string controllerNamespace = cad.ControllerTypeInfo.Namespace;
            int firstDotIndex = controllerNamespace.IndexOf('.');
            int lastDotIndex = controllerNamespace.LastIndexOf('.');
            if (firstDotIndex < 0)
                return viewLocations;

            string viewLocation;
            if (firstDotIndex == lastDotIndex)
            {
                // controller folder is the first level sub folder of root folder
                viewLocation = "/{1}/Views/{0}.cshtml";
            }
            else
            {
                string viewPath = controllerNamespace.Substring(firstDotIndex + 1, lastDotIndex - firstDotIndex - 1).Replace(".", "/");
                viewLocation = $"/{viewPath}/{{1}}/Views/{{0}}.cshtml";
            }

            if (viewLocations.Any(l => l.Equals(viewLocation, StringComparison.InvariantCultureIgnoreCase)))
                return viewLocations;

            if (viewLocations is List<string> locations)
            {
                locations.Add(viewLocation);
                return locations;
            }

            // it turns out the viewLocations from ASP.NET Core is List<string>, so the code path should not go here.
            List<string> newViewLocations = viewLocations.ToList();
            newViewLocations.Add(viewLocation);
            return newViewLocations;
        }

        public void PopulateValues(ViewLocationExpanderContext context)
        {

        }
    }
}

上面對命名空間的處理略顯繁瑣。其實你能夠不用管,重點是咱們能夠獲得 ViewLocationExpanderContext,並據此構建新的 view location format 而後與現有的 viewLocations 合併並返回給 ASP.NET Core。

細心的同窗可能還注意到一個空的方法 PopulateValues,這玩意兒有什麼用?具體做用能夠參照這個 StackOverflow 的問題,基本上來講,一旦某個 Controller 及其某個 View 找到視圖位置以後,這個對應關係就會緩存下來,之後就不會再調用 ExpandViewLocations方法了。可是,若是你有這種狀況,就是同一個 Controller, 同一個視圖名稱可是還應該依據某些特別條件去找不一樣的視圖位置,那麼就能夠利用 PopulateValues 方法填充一些特定的 Value, 這些 Value 會參與到緩存鍵的建立, 從而控制到視圖位置緩存的建立。

下一步,把咱們的 NamespaceViewLocationExpander 註冊一下:

// Startup.cs

public void ConfigureServices(IServiceCollection services)
{
    IMvcBuilder mvcBuilder = services.AddMvc();
    mvcBuilder.AddRazorOptions(options => 
    {
        // options.ViewLocationFormats.Add("/{1}/Views/{0}.cshtml"); we don't need this any more if we make use of NamespaceViewLocationExpander
        options.ViewLocationExpanders.Add(new NamespaceViewLocationExpander());
    });
}

另外,有了 NamespaceViewLocationExpander, 咱們就不須要前面對 ViewLocationFormats 的追加了,由於那種狀況做爲一種特例已經在 NamespaceViewLocationExpander 中處理了。
至此,目錄分層的問題解決了。

_ViewStart.cshtml 和 _ViewImports 的起效機制與調整

對這2個特別的視圖,咱們並不陌生,一般在 _ViewStart.cshtml 裏面設置 Layout 視圖,而後每一個視圖就自動地啓用了那個 Layout 視圖,在 _ViewImports.cshtml 裏引入的命名空間和 TagHelper 也會自動包含在全部視圖裏。它們爲何會起做用呢?

_ViewImports 的祕密藏在 RazorTemplateEngine 類MvcRazorTemplateEngine 類中。

MvcRazorTemplateEngine 類指明瞭 "_ViewImports.cshtml" 做爲默認的名字。

// MvcRazorTemplateEngine.cs 部分代碼
// 完整代碼: https://github.com/aspnet/Razor/blob/rel/2.0.0/src/Microsoft.AspNetCore.Mvc.Razor.Extensions/MvcRazorTemplateEngine.cs

public class MvcRazorTemplateEngine : RazorTemplateEngine
{
    public MvcRazorTemplateEngine(RazorEngine engine, RazorProject project)
        : base(engine, project)
    {
        Options.ImportsFileName = "_ViewImports.cshtml";
        Options.DefaultImports = GetDefaultImports();
    }
}

RazorTemplateEngine 類則代表了 Razor 是如何去尋找 _ViewImports.cshtml 文件的。

// RazorTemplateEngine.cs 部分代碼
// 完整代碼:https://github.com/aspnet/Razor/blob/rel/2.0.0/src/Microsoft.AspNetCore.Razor.Language/RazorTemplateEngine.cs

public class RazorTemplateEngine
{
    public virtual IEnumerable<RazorProjectItem> GetImportItems(RazorProjectItem projectItem)
    {
        var importsFileName = Options.ImportsFileName;
        if (!string.IsNullOrEmpty(importsFileName))
        {
            return Project.FindHierarchicalItems(projectItem.FilePath, importsFileName);
        }

        return Enumerable.Empty<RazorProjectItem>();
    }
}

FindHierarchicalItems 方法會返回一個路徑集合,其中包括從視圖當前目錄一路到根目錄的每一級目錄下的 _ViewImports.cshtml 路徑。換句話說,若是從根目錄開始,到視圖所在目錄的每一層目錄都有 _ViewImports.cshtml 文件的話,那麼它們都會起做用。這也是爲何一般咱們在 根目錄下的 Views 目錄裏放一個 _ViewImports.cshtml 文件就會被全部視圖文件所引用,由於 Views 目錄是是全部視圖文件的父/祖父目錄。那麼若是咱們的 _ViewImports.cshtml 文件不在視圖的目錄層次結構中呢?

_ViewImports 文件的位置

在這個 DI 爲王的 ASP.NET Core 世界裏,RazorTemplateEngine 也被註冊爲 DI 裏的服務,所以我目前的作法繼承 MvcRazorTemplateEngine 類,微調 GetImportItems 方法的邏輯,加入咱們的特定路徑,而後註冊到 DI 取代原來的實現類型。代碼以下:

// ModuleRazorTemplateEngine.cs

using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Mvc.Razor.Extensions;
using Microsoft.AspNetCore.Razor.Language;

namespace CustomizedViewLocation
{
    public class ModuleRazorTemplateEngine : MvcRazorTemplateEngine
    {
        public ModuleRazorTemplateEngine(RazorEngine engine, RazorProject project) : base(engine, project)
        {
        }

        public override IEnumerable<RazorProjectItem> GetImportItems(RazorProjectItem projectItem)
        {
            IEnumerable<RazorProjectItem> importItems = base.GetImportItems(projectItem);
            return importItems.Append(Project.GetItem($"/Shared/Views/{Options.ImportsFileName}"));
        }
    }
}

而後在 Startup 類裏把它註冊到 DI 取代默認的實現類型。

// Startup.cs

// using Microsoft.AspNetCore.Razor.Language;

public void ConfigureServices(IServiceCollection services)
{
    services.AddSingleton<RazorTemplateEngine, ModuleRazorTemplateEngine>();

    IMvcBuilder mvcBuilder = services.AddMvc();
    
    // 其它代碼省略
}

下面是 _ViewStart.cshtml 的問題了。不幸的是,Razor 對 _ViewStart.cshtml 的處理並無那麼「靈活」,看代碼就知道了。

// RazorViewEngine.cs 部分代碼
// 完整代碼:https://github.com/aspnet/Mvc/blob/rel/2.0.0/src/Microsoft.AspNetCore.Mvc.Razor/RazorViewEngine.cs

public class RazorViewEngine : IRazorViewEngine
{
    private const string ViewStartFileName = "_ViewStart.cshtml";

    internal ViewLocationCacheResult CreateCacheResult(
        HashSet<IChangeToken> expirationTokens,
        string relativePath,
        bool isMainPage)
    {
        var factoryResult = _pageFactory.CreateFactory(relativePath);
        var viewDescriptor = factoryResult.ViewDescriptor;
        if (viewDescriptor?.ExpirationTokens != null)
        {
            for (var i = 0; i < viewDescriptor.ExpirationTokens.Count; i++)
            {
                expirationTokens.Add(viewDescriptor.ExpirationTokens[i]);
            }
        }

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

            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 viewStartProjectItem in _razorProject.FindHierarchicalItems(path, ViewStartFileName))
        {
            var result = _pageFactory.CreateFactory(viewStartProjectItem.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, viewStartProjectItem.FilePath));
            }
        }

        return viewStartPages;
    }
}

上面的代碼裏 GetViewStartPages 方法是個 private,沒有什麼機會讓咱們加入本身的邏輯。看了又看,好像只能從 _razorProject.FindHierarchicalItems(path, ViewStartFileName) 這裏着手。這個方法一樣在處理 _ViewImports.cshtml時用到過,所以和 _ViewImports.cshtml 同樣,從根目錄到視圖當前目錄之間的每一層目錄的 _ViewStarts.cshtml 都會被引入。若是咱們能夠調整一下 FindHierarchicalItems 方法,除了完成它本來的邏輯以外,再加入咱們對咱們 /Shared/Views 目錄的引用就行了。而 FindHierarchicalItems 這個方法是在 Microsoft.AspNetCore.Razor.Language.RazorProject 類型裏定義的,並且是個 virtual 方法,並且它是註冊在 DI 裏的,不過在 DI 中的實現類型是 Microsoft.AspNetCore.Mvc.Razor.Internal.FileProviderRazorProject。咱們所要作的就是建立一個繼承自 FileProviderRazorProject 的類型,而後調整 FindHierarchicalItems 方法。

using System.Linq;
using System.Collections.Generic;
using Microsoft.AspNetCore.Mvc.Razor.Internal;
using Microsoft.AspNetCore.Razor.Language;

namespace CustomizedViewLocation
{
    public class ModuleBasedRazorProject : FileProviderRazorProject
    {
        public ModuleBasedRazorProject(IRazorViewEngineFileProviderAccessor accessor)
            : base(accessor)
        {

        }

        public override IEnumerable<RazorProjectItem> FindHierarchicalItems(string basePath, string path, string fileName)
        {
            IEnumerable<RazorProjectItem> items = base.FindHierarchicalItems(basePath, path, fileName);

            // the items are in the order of closest first, furthest last, therefore we append our item to be the last item.
            return items.Append(GetItem("/Shared/Views/" + fileName));
        }
    }
}

完成以後再註冊到 DI。

// Startup.cs

// using Microsoft.AspNetCore.Razor.Language;

public void ConfigureServices(IServiceCollection services)
{
    // services.AddSingleton<RazorTemplateEngine, ModuleRazorTemplateEngine>(); // we don't need this any more if we make use of ModuleBasedRazorProject
    services.AddSingleton<RazorProject, ModuleBasedRazorProject>();

    IMvcBuilder mvcBuilder = services.AddMvc();
    
    // 其它代碼省略
}

有了 ModuleBasedRazorProject 咱們甚至能夠去掉以前咱們寫的 ModuleRazorTemplateEngine 類型了,由於 Razor 採用相同的邏輯 —— 使用 RazorProjectFindHierarchicalItems 方法 —— 來構建應用 _ViewImports.cshtml 和 _ViewStart.cshtml 的目錄層次結構。因此最終,咱們只須要一個類型來解決問題 —— ModuleBasedRazorProject

回顧這整個思考和嘗試的過程,頗有意思,最終解決方案是自定義一個 RazorProject。是啊,畢竟咱們的需求只是一個不一樣目錄結構的 Razor Project,因此去實現一個咱們本身的 RazorProject 類型真是再天然不過的了。

文本中的示例代碼在這裏

相關文章
相關標籤/搜索