当使用自定义位置时,如何指定ASP.NET Core MVC中的视图位置?

55

假设我有一个控制器,使用基于属性的路由处理请求的URL为/admin/product,如下:

[Route("admin/[controller]")]        
public class ProductController: Controller {

    // GET: /admin/product
    [Route("")]
    public IActionResult Index() {

        return View();
    }
}

现在,假设我想将我的视图组织在一个文件夹结构中,大致反映它们所相关联的URL路径。因此,我希望该控制器的视图位于此处:

/Views/Admin/Product.cshtml

进一步来说,如果我有这样一个控制器:

[Route("admin/marketing/[controller]")]        
public class PromoCodeListController: Controller {

    // GET: /admin/marketing/promocodelist
    [Route("")]
    public IActionResult Index() {

        return View();
    }
}

我希望这个框架自动在这里查找视图:

Views/Admin/Marketing/PromoCodeList.cshtml
理想情况下,通知框架视图位置的方法应该是基于基于属性的路由信息的通用方式,无论涉及多少个URL段(即嵌套有多深),均可工作。
我如何指示Core MVC框架(我目前正在使用RC1版本)在这种位置查找控制器的视图?
10个回答

96

好消息是,从ASP.NET Core2开始,您不再需要自定义ViewEngine或者扩展视图位置了。

使用OdeToCode.AddFeatureFolders包

这是最简单的方法...K. Scott Allen为您提供了一个NuGet包:OdeToCode.AddFeatureFolders,这个包很干净,并且包括对区域的可选支持。Github链接:https://github.com/OdeToCode/AddFeatureFolders

安装包后,只需按照以下步骤:

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddMvc()
                .AddFeatureFolders();

        ...
    }

    ...
}  

自行搭建

如果您需要对文件夹结构进行极其精细的控制,或由于某些原因不允许/不想使用依赖项,则可以选择此方法。这种方法也很简单,但可能比上面的NuGet包更加冗杂:

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
         ...

         services.Configure<RazorViewEngineOptions>(o =>
         {
             // {2} is area, {1} is controller,{0} is the action    
             o.ViewLocationFormats.Clear(); 
             o.ViewLocationFormats.Add("/Controllers/{1}/Views/{0}" + RazorViewEngine.ViewExtension);
             o.ViewLocationFormats.Add("/Controllers/Shared/Views/{0}" + RazorViewEngine.ViewExtension);

             // Untested. You could remove this if you don't care about areas.
             o.AreaViewLocationFormats.Clear();
             o.AreaViewLocationFormats.Add("/Areas/{2}/Controllers/{1}/Views/{0}" + RazorViewEngine.ViewExtension);
             o.AreaViewLocationFormats.Add("/Areas/{2}/Controllers/Shared/Views/{0}" + RazorViewEngine.ViewExtension);
             o.AreaViewLocationFormats.Add("/Areas/Shared/Views/{0}" + RazorViewEngine.ViewExtension);
        });

        ...         
    }

...
}

就是这样!不需要特殊的类。

处理Resharper/Rider

额外提示:如果您正在使用ReSharper,您可能会注意到在某些地方,ReSharper找不到您的视图并给出令人讨厌的警告。为了解决这个问题,请引入Resharper.Annotations包,并在startup.cs(或任何其他位置)中为每个视图位置添加以下属性之一:

[assembly: AspMvcViewLocationFormat("/Controllers/{1}/Views/{0}.cshtml")]
[assembly: AspMvcViewLocationFormat("/Controllers/Shared/Views/{0}.cshtml")]

[assembly: AspMvcViewLocationFormat("/Areas/{2}/Controllers/{1}/Views/{0}.cshtml")]
[assembly: AspMvcViewLocationFormat("/Controllers/Shared/Views/{0}.cshtml")]

希望这篇文章能够帮助一些人节省掉我刚经历的数小时的挫败感。 :)

3
你是我的英雄。我来到这个问题是因为我想要实现基于特征的文件夹结构,而你已经做到了。太棒了! - John Hargrove
2
@JohnHargrove 谢谢你的赞美之词,你让我度过了难熬的一天 :) - Brian MacKay
4
我喜欢有关ReSharper的“奖励提示”!我一直在将我的对ViewPartialView的调用标记为红色。 - t3chb0t
1
老兄,这是个好消息。 - Ronnie Overby
1
@IvanMontilla 如果你想分享一个视图,你必须将它放在Shared目录下(例如/Controllers/Shared/Views)。然后它将在所有控制器中都可用,但这是我所知道的唯一方法。除了使用Areas,那将是过度杀伤力的做法。 - Brian MacKay
显示剩余2条评论

52

您可以通过实现视图位置扩展器来扩展视图引擎查找视图的位置。以下是一些示例代码以演示该方法:

public class ViewLocationExpander: IViewLocationExpander {

    /// <summary>
    /// Used to specify the locations that the view engine should search to 
    /// locate views.
    /// </summary>
    /// <param name="context"></param>
    /// <param name="viewLocations"></param>
    /// <returns></returns>
    public IEnumerable<string> ExpandViewLocations(ViewLocationExpanderContext context, IEnumerable<string> viewLocations) {
        //{2} is area, {1} is controller,{0} is the action
        string[] locations = new string[] { "/Views/{2}/{1}/{0}.cshtml"};
        return locations.Union(viewLocations);          //Add mvc default locations after ours
    }


    public void PopulateValues(ViewLocationExpanderContext context) {
        context.Values["customviewlocation"] = nameof(ViewLocationExpander);
    }
}

接下来在startup.cs文件中的ConfigureServices(IServiceCollection services)方法中,添加以下代码以将其注册到IoC容器中。 在services.AddMvc();之后立即执行此操作。

services.Configure<RazorViewEngineOptions>(options => {
        options.ViewLocationExpanders.Add(new ViewLocationExpander());
    });

现在您可以通过将自定义目录结构添加到locations string[]中的视图引擎查找视图和局部视图的位置列表。此外,您可以在同一目录或任何父目录中放置一个_ViewImports.cshtml文件,它将被发现并与位于此新目录结构中的视图合并。

更新:
这种方法的一个好处是提供了比 ASP.NET Core 2 中后引入的方法更加灵活的方式(感谢 @BrianMacKay 记录新方法)。例如,这种 ViewLocationExpander 方法不仅允许指定要搜索视图和区域的路径层次结构,还允许指定布局和视图组件。同时,您可以访问完整的ActionContext来确定适当的路由可能是什么。这提供了很多灵活性和实力。例如,如果您想通过评估当前请求的路径来确定适当的视图位置,则可以通过context.ActionContext.HttpContext.Request.Path获取当前请求的路径。


这是一个不错的解决方案,但它并不能解决查找具有路由属性的操作或控制器视图的问题。View方法仍然似乎使用控制器的名称而不是路由名称来定位视图。 - Xipooo
@Xipooo,说得好。我提供的示例是一个很好的开始,但要使用路由,您可以将“locations”数组设置为包括/Views + context.ActionContext.HttpContext.Request.Path + index.cshtml.cshtml - RonC
这让我能够使用通过 .NET Standard 1.6 添加到 bin 文件夹的视图。非常感谢,解决方案很棒。 - DeadlyChambers
你能解释一下在 PopulateValues 方法中为什么要这样做 context.Values["customviewlocation"] = nameof(ViewLocationExpander) 吗?这是必要的吗?还是它可以优化性能或者有其他目的? - t3chb0t
我在最初处理这个问题时也有同样的疑问,花了很长时间才得到好的答案。请参见此处:https://dev59.com/oZbfa4cB1Zd3GeqPsl0z 。简而言之,它提供了额外的数据并将其合并到缓存键中,用于位置值。 - RonC

34

.Net Core中,您可以指定视图的完整路径。

return View("~/Views/booking/checkout.cshtml", checkoutRequest);


7
完全正确,但我正在寻找一种解决方案,可以让框架自动查找自定义位置的视图,而不必像这样指定它。对于想要手动指定视图位置的人来说,这是一个很好的解决方案。 - RonC
3
是的,我更喜欢你的答案,比被接受的那个好多了。我本来想用它,直到意识到对于我需要的东西来说有点过头了。我发帖是因为我能够解决我的问题,而不必添加任何自定义代码。也许会有人觉得这很有用。谢谢你的答案!祝好! - Brian Rizo

8
我正在使用core 3.1,并只需在Startup.cs的ConfigureServices方法中执行此操作。
 services.AddControllersWithViews().AddRazorOptions(
     options => {// Add custom location to view search location
         options.ViewLocationFormats.Add("/Views/Shared/YourLocation/{0}.cshtml");                    
     });

{0} 是视图名称的占位符,非常简单。


4

你需要一个定制的RazorviewEngine

首先,需要创建这个引擎:

public class CustomEngine : RazorViewEngine
{
    private readonly string[] _customAreaFormats = new string[]
    {
        "/Views/{2}/{1}/{0}.cshtml"
    };

    public CustomEngine(
        IRazorPageFactory pageFactory,
        IRazorViewFactory viewFactory,
        IOptions<RazorViewEngineOptions> optionsAccessor,
        IViewLocationCache viewLocationCache)
        : base(pageFactory, viewFactory, optionsAccessor, viewLocationCache)
    {
    }

    public override IEnumerable<string> AreaViewLocationFormats =>
        _customAreaFormats.Concat(base.AreaViewLocationFormats);
}

这将创建一个额外的区域格式,与{areaName}/{controller}/{view}的使用情况相匹配。

其次,在Startup.cs类的ConfigureServices方法中注册引擎:

public void ConfigureServices(IServiceCollection services)
{
    // Add custom engine (must be BEFORE services.AddMvc() call)
    services.AddSingleton<IRazorViewEngine, CustomEngine>();

    // Add framework services.
    services.AddMvc();
}

第三步,在 Configure 方法中将区域路由添加到您的MVC路由中:
app.UseMvc(routes =>
{
    // add area routes
    routes.MapRoute(name: "areaRoute",
        template: "{area:exists}/{controller}/{action}",
        defaults: new { controller = "Home", action = "Index" });

    routes.MapRoute(
        name: "default",
        template: "{controller=Home}/{action=Index}/{id?}");
});

最后,将您的ProductController类更改为使用AreaAttribute
[Area("admin")]
public class ProductController : Controller
{
    public IActionResult Index()
    {
        return View();
    }
}

现在,你的应用程序结构可以像这样:

示例项目结构


Will - 谢谢你提供的解决方案。我可以确认它是有效的。在进一步的研究后,我找到了另一种更简单的方法,通过注入ExpandViewLocations类来实现。我会把你的解决方案视作答案,并且你对于详尽的回复应该得到赞赏。 - RonC
5
对于当前的ASP.Net Core项目,这个答案已经不再适用了,因为AreaViewLocationFormats已经不存在。我认为现在这个设置已经被移动到了RazorViewEngineOptions中。 - James Wilkins

2
尽管其他答案可能是正确的,我想补充一些更基础的内容:
- 在MVC .NET中有很多隐式路由行为。 - 您也可以使所有内容都变得显式。
那么,这对于.NET MVC来说如何工作呢?
默认情况下:
- 默认“路由”是protocol://server:port/ ,例如http://localhost:607888/ - 如果您没有任何具有显式路由的控制器,并且没有定义任何启动默认值,则无法正常工作。 - 下面的代码可以解决这个问题:
app.UseMvc(routes => { routes.MapRoute( name: "default", template: "{controller=Special}/{action=Index}"); });
控制器路由:
如果您添加一个名为SpecialController的类和一个Index()方法,则您的http://localhost:.../将转到该页面。注意:NameController =>后缀Controller被省略,这是隐式命名约定。
如果您更喜欢在控制器上显式定义路由,请使用以下代码:
[Route("Special")]//explicit route
public class SpecialController : Controller
{ ....

=> http://localhost:<port>/Special will end up on this controller

为将HTTP请求映射到控制器方法,您还可以向方法中添加显式的[Route(...)]信息:

// GET: explicit route page
[HttpGet("MySpecialIndex")]
public ActionResult Index(){...}

=> http://localhost:<port>/Special/MySpecialIndex will end up on SpecialController.Index()

查看路由

现在假设您的Views文件夹像这样:

Views\
   Special1\
          Index1.cshtml
   Special\
          Index.cshtml

控制器如何“找到”其视图? 这里的示例是:
[Route("Special")]//explicit route
public class Special1Controller : Controller
{
    // GET: Default route page
    [HttpGet]
    public ActionResult Index()
    {
        //
        // Implicit path, implicit view name: Special1<Controller> -> View  = Views/Special/Index.cshtml
        //
        //return View();

        //
        // Implicit path, explicit view name, implicit extention 
        // Special <Controller> -> View  = Views/Special/Index.cshtml
        //
         //return View("Index");

        //
        // Everything explcit
        //
        return View("Views/Special1/Index1.cshtml");
    }

所以,我们有: return View(); => 一切都是隐式的,将方法名称作为视图,将控制器路径作为视图路径等。 http://<>:<>/Special => Method = Index(), View = /Views/Special/Index.cshtml return View("Index"); //显式视图名称,隐式路径和扩展名 => Method = Special1Controller.Index(), View = /Views/Special/Index.cshtml return View("Views/Special1/Index1.cshtml"); // 隐式方法,显式视图 => http://<>:<>/Special, Method = Special1Controller.Index(), View = /Views/Special1/Index1.cshtml
如果您将显式映射到方法和视图组合在一起: => http://<>:<>/Special/MySpecialIndex, Method = Special1Controller.Index(), View = /Views/Special1/Index1.cshtml
最后,为什么要使一切隐式? 优点是较少的易错管理,并且您可以强制执行命名和文件夹设置的干净管理。 缺点是有很多魔法正在进行,每个人都需要理解。
那么为什么要使一切显式? 优点:这对“所有人”来说更可读。不需要知道所有隐含规则。并且更灵活地显式更改路由和映射。控制器和路由路径之间的冲突机会也稍微减少了一些。
最后:当然,您可以混合显式和隐式路由。
我的偏好是一切都显式。为什么?我喜欢显式映射和关注点分离。类名和方法名可以具有命名约定,而不会干扰您的请求命名约定。 例如,假设我的类/方法是camelCase,我的查询是小写,则会很好地工作:http://..:../whatever/something and ControllerX.someThing(请记住,Windows有点不区分大小写,Linux绝对不是!并且现代.netcore Docker组件可能最终在Linux平台上运行!) 我也不喜欢具有X000行代码的“大型单体”类。通过将它们显式地赋予相同的HTTP查询路由,拆分控制器但不拆分查询完美地起作用。 底线:了解它的工作原理,并明智地选择一种策略!

1

所以经过搜索,我在另一个stackoverflow上找到了问题所在。 我遇到了同样的问题,将ViewImports文件从非区域部分复制过来后,链接开始按预期工作。
如下所示:Asp.Net core 2.0 MVC anchor tag helper not working
另一个解决方案是在视图级别复制:
@addTagHelper*,Microsoft.AspNetCore.Mvc.TagHelpers


1

根据问题,我认为值得提到如何在路由中使用区域。

我将大部分答案归功于@Mike的回答。

在我的情况下,我有一个与区域名称匹配的控制器名称。我使用自定义约定来更改控制器的名称为“Home”,以便我可以在MapControllerRoute中创建默认路由{area}/{controller=Home}/{action=Index}/{id?}

我之所以陷入了这个SO问题是因为现在Razor没有搜索我的原始控制器名称视图文件夹,因此找不到我的视图。

我只需将这段代码添加到 ConfigureServices (这里的区别在于使用了 AreaViewLocationFormats):

services.AddMvc().AddRazorOptions(options => 
    options.AreaViewLocationFormats.Add("/Areas/{2}/Views/{2}/{0}" + RazorViewEngine.ViewExtension));
// as already noted, {0} = action name, {1} = controller name, {2} = area name

0

我希望它可能会按照控制器上定义的方式工作。

无论如何,截至2023年中期,现在的答案都很简单,只需使用新的默认Program.cs即可。

builder.Services.confiure<RazorViewEngineOptipns>(o => 
{
    o.ViewLocationFormats.Add( "/your/custom/path/{1}/{0}" + RazorViewEngine.ViewExtension);
});

0

你可以将两个操作结合起来,以保持DRY。

  1. 使用视图路径而不是名称作为参数1调用Controller.View(string, object?)方法。(参见Brian Rizo的答案)
  2. 在控制器类中覆盖上述方法,从视图名称构造路径,然后调用基本方法。

在所有控制器操作方法中,您现在可以继续使用视图名称返回视图。

    [Route("Wizard/UploadStuff/[action]")]
    public class UploadStuffController : Controller
    {
        //overwrites base method to incorporate common path 
        public new ViewResult View(string name, object? model)
        {
            var path = $"Views/Wizard/UploadStuff/{name}.cshtml";
            return base.View(path, model);
        }

        public async Task<IActionResult> Step1(string param1)
        {
            UploadStuffModel model;
            
            //... more code            
            
            return View("Step1", model); 
        }

        //... more action methods
  }

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接