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.xhtml

为了全面描述 ASP.NET Core 2.0 中扩展 Razor 查找视图目录的能力,咱们仍是由浅入深,从最简单的扩展方式着手吧。git

准备工做

首先,咱们能够建立一个新的 ASP.NET Core 项目用于演示。github

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

接下来稍微调整下 Startup.cs 文件的内容,引入 MVC:web

// 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 的子目录中。bash

最简单的方式: ViewLocationFormats

假设咱们如今有2个功能模块 Home 和 About,分别须要 HomeController 和它的 Index view,以及 AboutMeController 和它的 Index view. 由于一个 Controller 可能会包含多个 view,所以我选择为每个功能模块目录下再增长一个 Views 目录,集中这个功能模块下的全部 View. 整个目录结构看起来是这样的:mvc

Home & About Folders

从目录结构中咱们能够发现咱们的视图目录为 /{controller}/Views/{viewName}.cshtml, 好比 HomeControllerIndex 视图所在的位置就是 /Home/Views/Index.cshtml,这跟 MVC 默认的视图位置 /Views/{Controller}/{viewName}.cshtml 很类似(/Views/Home/Index.cshtml),共同的特色是路径中的 Controller 部分和 View 部分是动态的,其它的都是固定不变的。其实 MVC 默认的寻找视图位置的方式一点都不高端,相似于这样:app

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,代码以下:iview

// Startup.cs

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

收工,就这么简单。顺便说一句,还有一个参数 {2},表明 Area 名称。ide

这种作法是否是已经很完美了呢?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 类型真是再天然不过的了。

文本中的示例代码在这里

相关文章
相关标签/搜索