在 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
类型真是再自然不过的了。