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. 整个目录结构看起来是这样的:

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个缺点。

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

文本中的示例代码在这里

posted on 2017-09-06 10:23  零度的火  阅读(4922)  评论(5编辑  收藏  举报

导航