在 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();
}
}
}
好了我們的演示項目已經搭好了架子。
我們的目標
在我們的示例項目中,我們希望我們的目錄組織方式是按照功能模塊組織的,即同一個功能模塊的所有 Controller 和 View 都放在同一個目錄下。對於多個功能模塊共享、通用的內容,比如 _Layout
, _Footer
, _ViewStart
和 _ViewImports
則單獨放在根目錄下的一個叫 Shared 的子目錄中。
最簡單的方式: ViewLocationFormats
假設我們現在有2個功能模塊 Home 和 About,分別需要 HomeController
和它的 Index
view,以及 AboutMeController
和它的 Index
view. 因為一個 Controller 可能會包含多個 view,因此我選擇為每一個功能模塊目錄下再增加一個 Views
目錄,集中這個功能模塊下的所有 View. 整個目錄結構看起來是這樣的:
從目錄結構中我們可以發現我們的視圖目錄為 /{controller}/Views/{viewName}.cshtml
, 比如 HomeController
的 Index
視圖所在的位置就是 /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個缺點。
- 所有的功能模塊目錄必須在根目錄下創建,無法建立層級目錄關系。且看下面的目錄結構截圖:
注意 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 文件不在視圖的目錄層次結構中呢?
在這個 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 采用相同的邏輯 —— 使用 RazorProject
的 FindHierarchicalItems
方法 —— 來構建應用 _ViewImports.cshtml 和 _ViewStart.cshtml 的目錄層次結構。所以最終,我們只需要一個類型來解決問題 —— ModuleBasedRazorProject
。
回顧這整個思考和嘗試的過程,很有意思,最終解決方案是自定義一個 RazorProject
。是啊,畢竟我們的需求只是一個不同目錄結構的 Razor Project,所以去實現一個我們自己的 RazorProject
類型真是再自然不過的了。
文本中的示例代碼在這里