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.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
檔案不在檢視的目錄層次結構中呢?
_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
採用相同的邏輯 —— 使用 RazorProject
的 FindHierarchicalItems
方法 —— 來構建應用 _ViewImports.cshtml
和 _ViewStart.cshtml
的目錄層次結構。所以最終,我們只需要一個型別來解決問題 —— ModuleBasedRazorProject
。
回顧這整個思考和嘗試的過程,很有意思,最終解決方案是自定義一個 RazorProject
。是啊,畢竟我們的需求只是一個不同目錄結構的 Razor Project
,所以去實現一個我們自己的 RazorProject
型別真是再自然不過的了。
相關文章
- 自定義目錄
- 【ASP.NET Core】自定義的配置源ASP.NET
- 【asp.net core 系列】15 自定義IdentityASP.NETIDE
- ASP.NET Core - 自定義中介軟體ASP.NET
- ASP.NET Core Identity自定義資料庫結構和完全使用Dapper而非EntityFramework CoreASP.NETIDE資料庫APPFramework
- 【asp.net core】自定義模型繫結及其驗證ASP.NET模型
- ASP.NET Core - 實現自定義WebApi模型驗證ASP.NETWebAPI模型
- Asp.Net Core入門之自定義中介軟體ASP.NET
- asp.net core 實現支援自定義 Content-TypeASP.NET
- ASP.NET Core - 實現Http自定義請求頭策略ASP.NETHTTP
- Asp.Net Core入門之自定義服務註冊ASP.NET
- asp.net core 自定義中介軟體【以dapper為例】ASP.NETAPP
- ASP.NET Core - 配置系統之自定義配置提供程式ASP.NET
- Apache 新增自定義vhost 目錄,等其他配置Apache
- 在ASP.NET Core中建立自定義端點視覺化圖ASP.NET視覺化
- Asp.net core自定義依賴注入容器,替換自帶容器ASP.NET依賴注入
- Laravel 自定義函式存放位置Laravel函式
- java使用sshd 實現sftp 自定義顯示目錄JavaFTP
- Confluence6修改Home目錄的位置
- 【ASP.NET Core】MVC控制器的各種自定義:特性化的路由規則ASP.NETMVC路由
- 【asp.net core 系列】9 實戰之 UnitOfWork以及自定義程式碼生成ASP.NET
- 【ASP.NET Core】MVC模型繫結:自定義InputFormatter讀取CSV內容ASP.NETMVC模型ORM
- ASP.NET Core 2.0網址重定向方法ASP.NET
- 用ASP.NET Core 2.0 建立規範的 REST API -- DELETE, UPDATE, PATCH 和 LogASP.NETRESTAPIdelete
- sqlite中存放自定義表結構的位置SQLite
- 【.NET Core】 C#目錄C#
- 如何在ASP.NET Core自定義中介軟體中讀取Request.Body和Response.Body的內容?ASP.NET
- ASP.Net Core5.0 EF Core使用記錄ASP.NET
- (精華)2020年7月1日 ASP.NET Core 使用靜態檔案和目錄瀏覽ASP.NET
- .NET Core - 自定義專案模板
- 記錄一次vue2.0(history模式)下微信自定義分享的坑Vue模式
- springboot -- 2.0版本自定義ReidsCacheManager的改變Spring Boot
- .NET Core 自定義中介軟體 Middleware
- Linux CentOS更改MySQL資料庫目錄位置LinuxCentOSMySql資料庫
- 網站遷移紀實:從Web Form 到 Asp.Net Core (Abp vNext 自定義開發)網站WebORMASP.NET
- .Net Core AutoMapper自定義擴充套件方法的使用APP套件
- VSCode修改擴充套件和使用者資料夾目錄位置(Windows)VSCode套件Windows
- 用ASP.NET Core 2.0 建立規範的 REST API -- 預備知識ASP.NETRESTAPI