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

風靈使發表於2019-02-27

ASP.NET Core 裡擴充套件 Razor 查詢檢視目錄不是什麼新鮮和困難的事情,但 _ViewStart_ViewImports 這2個檢視比較特殊,如果想讓 Razor 在我們指定的目錄中查詢它們,則需要耗費一點額外的精力。本文將提供一種方法做到這一點。注意,文字僅適用於 ASP.NET Core 2.0+, 因為 Razor 在 2.0 版本里的內部實現有較大重構,因此這裡提供的方法並不適用於 ASP.NET Core 1.x

為了全面描述 ASP.NET Core 2.0 中擴充套件 Razor 查詢檢視目錄的能力,我們還是由淺入深,從最簡單的擴充套件方式著手吧。

準備工作

首先,我們可以建立一個新的 ASP.NET Core 專案用於演示。

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

接下來稍微調整下 Startup.cs 檔案的內容,引入 MVC

// 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();
        }
    }
}

好了我們的演示專案已經搭好了架子。

我們的目標

在我們的示例專案中,我們希望我們的目錄組織方式是按照功能模組組織的,即同一個功能模組的所有 ControllerView 都放在同一個目錄下。對於多個功能模組共享、通用的內容,比如 _Layout, _Footer, _ViewStart_ViewImports 則單獨放在根目錄下的一個叫 Shared 的子目錄中。

最簡單的方式: ViewLocationFormats

假設我們現在有2個功能模組 HomeAbout,分別需要 HomeController 和它的 Index view,以及 AboutMeController 和它的 Index view. 因為一個 Controller 可能會包含多個 view,因此我選擇為每一個功能模組目錄下再增加一個 Views 目錄,集中這個功能模組下的所有 View. 整個目錄結構看起來是這樣的:

Home & About Folders
從目錄結構中我們可以發現我們的檢視目錄為 /{controller}/Views/{viewName}.cshtml, 比如 HomeControllerIndex 檢視所在的位置就是 /Home/Views/Index.cshtml,這跟 MVC 預設的檢視位置 /Views/{Controller}/{viewName}.cshtml 很相似(/Views/Home/Index.cshtml),共同的特點是路徑中的 Controller 部分和 View 部分是動態的,其它的都是固定不變的。其實 MVC 預設的尋找檢視位置的方式一點都不高階,類似於這樣:

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,程式碼如下:

// Startup.cs

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

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

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

這種做法有2個缺點。

所有的功能模組目錄必須在根目錄下建立,無法建立層級目錄關係。且看下面的目錄結構截圖:

Home, About & Reports Folders

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

因為所有的 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 型別真是再自然不過的了。

相關文章