【ASP.NET Core】MVC中自定义视图的查找位置
.NET Core 的内容处处可见,刷爆全球各大社区,所以,老周相信各位大伙伴已经看得不少了,故而,老周不考虑一个个知识点地去写,那样会成为年度最大的屁话,何况官方文档也很详尽。老周主要扯一下大伙伴们在入门的时候可能会疑惑的内容。
ASP.NET Core 可以在一个项目中混合使用 Web Pages 和 MVC ,这是老周最希望的,因为这样会变得更灵活。Web Pages 类似于我们过去的 Web 开发方式,以页面为单位,此模型侧重于功能划分。而 MVC 侧重于数据,有什么样的数据模型就有什么样的 Controller,有什么样的 Controller 就会对应什么样的 Action ,而 Action 又会有对应的 UI,即 View。所以说 MVC 是以数据为核心的。
如果两者可以同时使用,那在我的项目中,可能有些内容以功能为重点,而另一些内容是以数据为中心的,这样可以灵活地交替使用,因此,老周向来最喜欢空项目模板,因为空的什么都没有,什么都没有才能做到什么都有。大概,老庄所说的“无”,与佛家所说的“空”,就是这样的。
Web Pages 和 MVC 可以一起用,是因为它们的配置方法是一样的,在 Startup 类中,有两个约定的方法。
ConfigureServices 方法是告诉应用程序我要用到哪些功能,Service 是用来扩展的,你自己也可以编写各种功能,然后添加到 services 集合中就好了。不管是 W eb Pages 还是 MVC ,都是添加这一行代码
public void ConfigureServices(IServiceCollection services) { services.AddMvc(); }
估计大家会发现,除了 AddMvc 方法外,还有一个 AddMvcCore 方法,你一定会有疑问,这两个家伙一家吗?于是,你会尝试一下把 AddMvc 换成 AddMvcCore ,然后运行时你会发现找不到视图。
带 Core 结尾的方法,只添加核心的功能,并非 MVC 所需的必备功能,此方法也许更适合 Web API,但即便我们写的是 API 项目,我们也极少用这个方法,所以,在实际开发中,你可以直接无视 AddMvcCore 方法。
那么,这哥儿俩到底有啥不同呢。咱们不妨看看源代码。AddMvcCore 主要添加了以下功能。
// // Options // services.TryAddEnumerable( ServiceDescriptor.Transient<IConfigureOptions<MvcOptions>, MvcCoreMvcOptionsSetup>()); services.TryAddEnumerable( ServiceDescriptor.Transient<IPostConfigureOptions<MvcOptions>, MvcOptionsConfigureCompatibilityOptions>()); services.TryAddEnumerable( ServiceDescriptor.Transient<IConfigureOptions<ApiBehaviorOptions>, ApiBehaviorOptionsSetup>()); services.TryAddEnumerable( ServiceDescriptor.Transient<IConfigureOptions<RouteOptions>, MvcCoreRouteOptionsSetup>()); // // Action Discovery // // These are consumed only when creating action descriptors, then they can be deallocated services.TryAddEnumerable( ServiceDescriptor.Transient<IApplicationModelProvider, DefaultApplicationModelProvider>()); services.TryAddEnumerable( ServiceDescriptor.Transient<IApplicationModelProvider, ApiBehaviorApplicationModelProvider>()); services.TryAddEnumerable( ServiceDescriptor.Transient<IActionDescriptorProvider, ControllerActionDescriptorProvider>()); services.TryAddSingleton<IActionDescriptorCollectionProvider, ActionDescriptorCollectionProvider>(); // // Action Selection // services.TryAddSingleton<IActionSelector, ActionSelector>(); services.TryAddSingleton<ActionConstraintCache>(); // Will be cached by the DefaultActionSelector services.TryAddEnumerable( ServiceDescriptor.Transient<IActionConstraintProvider, DefaultActionConstraintProvider>()); // // Controller Factory // // This has a cache, so it needs to be a singleton services.TryAddSingleton<IControllerFactory, DefaultControllerFactory>(); // Will be cached by the DefaultControllerFactory services.TryAddTransient<IControllerActivator, DefaultControllerActivator>(); services.TryAddSingleton<IControllerFactoryProvider, ControllerFactoryProvider>(); services.TryAddSingleton<IControllerActivatorProvider, ControllerActivatorProvider>(); services.TryAddEnumerable( ServiceDescriptor.Transient<IControllerPropertyActivator, DefaultControllerPropertyActivator>()); // // Action Invoker // // The IActionInvokerFactory is cachable services.TryAddSingleton<IActionInvokerFactory, ActionInvokerFactory>(); services.TryAddEnumerable( ServiceDescriptor.Transient<IActionInvokerProvider, ControllerActionInvokerProvider>()); // These are stateless services.TryAddSingleton<ControllerActionInvokerCache>(); services.TryAddEnumerable( ServiceDescriptor.Singleton<IFilterProvider, DefaultFilterProvider>()); // // Request body limit filters // services.TryAddTransient<RequestSizeLimitFilter>(); services.TryAddTransient<DisableRequestSizeLimitFilter>(); services.TryAddTransient<RequestFormLimitsFilter>(); // Error description services.TryAddSingleton<IErrorDescriptionFactory, DefaultErrorDescriptorFactory>(); // // ModelBinding, Validation // // The DefaultModelMetadataProvider does significant caching and should be a singleton. services.TryAddSingleton<IModelMetadataProvider, DefaultModelMetadataProvider>(); services.TryAdd(ServiceDescriptor.Transient<ICompositeMetadataDetailsProvider>(s => { var options = s.GetRequiredService<IOptions<MvcOptions>>().Value; return new DefaultCompositeMetadataDetailsProvider(options.ModelMetadataDetailsProviders); })); services.TryAddSingleton<IModelBinderFactory, ModelBinderFactory>(); services.TryAddSingleton<IObjectModelValidator>(s => { var options = s.GetRequiredService<IOptions<MvcOptions>>().Value; var metadataProvider = s.GetRequiredService<IModelMetadataProvider>(); return new DefaultObjectValidator(metadataProvider, options.ModelValidatorProviders); }); services.TryAddSingleton<ClientValidatorCache>(); services.TryAddSingleton<ParameterBinder>(s => { var options = s.GetRequiredService<IOptions<MvcOptions>>().Value; var loggerFactory = s.GetRequiredService<ILoggerFactory>(); var metadataProvider = s.GetRequiredService<IModelMetadataProvider>(); var modelBinderFactory = s.GetRequiredService<IModelBinderFactory>(); var modelValidatorProvider = new CompositeModelValidatorProvider(options.ModelValidatorProviders); return new ParameterBinder(metadataProvider, modelBinderFactory, modelValidatorProvider, loggerFactory); }); // // Random Infrastructure // services.TryAddSingleton<MvcMarkerService, MvcMarkerService>(); services.TryAddSingleton<ITypeActivatorCache, TypeActivatorCache>(); services.TryAddSingleton<IUrlHelperFactory, UrlHelperFactory>(); services.TryAddSingleton<IHttpRequestStreamReaderFactory, MemoryPoolHttpRequestStreamReaderFactory>(); services.TryAddSingleton<IHttpResponseStreamWriterFactory, MemoryPoolHttpResponseStreamWriterFactory>(); services.TryAddSingleton(ArrayPool<byte>.Shared); services.TryAddSingleton(ArrayPool<char>.Shared); services.TryAddSingleton<OutputFormatterSelector, DefaultOutputFormatterSelector>(); services.TryAddSingleton<IActionResultExecutor<ObjectResult>, ObjectResultExecutor>(); services.TryAddSingleton<IActionResultExecutor<PhysicalFileResult>, PhysicalFileResultExecutor>(); services.TryAddSingleton<IActionResultExecutor<VirtualFileResult>, VirtualFileResultExecutor>(); services.TryAddSingleton<IActionResultExecutor<FileStreamResult>, FileStreamResultExecutor>(); services.TryAddSingleton<IActionResultExecutor<FileContentResult>, FileContentResultExecutor>(); services.TryAddSingleton<IActionResultExecutor<RedirectResult>, RedirectResultExecutor>(); services.TryAddSingleton<IActionResultExecutor<LocalRedirectResult>, LocalRedirectResultExecutor>(); services.TryAddSingleton<IActionResultExecutor<RedirectToActionResult>, RedirectToActionResultExecutor>(); services.TryAddSingleton<IActionResultExecutor<RedirectToRouteResult>, RedirectToRouteResultExecutor>(); services.TryAddSingleton<IActionResultExecutor<RedirectToPageResult>, RedirectToPageResultExecutor>(); services.TryAddSingleton<IActionResultExecutor<ContentResult>, ContentResultExecutor>(); // // Route Handlers // services.TryAddSingleton<MvcRouteHandler>(); // Only one per app services.TryAddTransient<MvcAttributeRouteHandler>(); // Many per app // // Middleware pipeline filter related // services.TryAddSingleton<MiddlewareFilterConfigurationProvider>(); // This maintains a cache of middleware pipelines, so it needs to be a singleton services.TryAddSingleton<MiddlewareFilterBuilder>();
代码很长,看不懂也没关系,反正你知道它添加这么一堆核心功能。
我们再来看看 AddMvc 方法。
var builder = services.AddMvcCore(); builder.AddApiExplorer(); builder.AddAuthorization(); AddDefaultFrameworkParts(builder.PartManager); // Order added affects options setup order // Default framework order builder.AddFormatterMappings(); builder.AddViews(); builder.AddRazorViewEngine(); builder.AddRazorPages(); builder.AddCacheTagHelper(); // +1 order builder.AddDataAnnotations(); // +1 order // +10 order builder.AddJsonFormatters(); builder.AddCors();
注意这句:
var builder = services.AddMvcCore();
这说明,运行时是先调用 AddMvcCore 方法添加核心的功能后,再添加 MVC 所必备的其他功能。尤其是下面这几行,很重要。
builder.AddViews();
builder.AddRazorViewEngine();
builder.AddRazorPages();
现在你明白为什么调用 AddMvcCore 方法后会找不到视图的原因了吧。
扯远了,咱们还是回到 Startup 类来,弄完 ConfigureServices 方法后,还要在 Configure 方法中 use 一下。
public void Configure(IApplicationBuilder app, IHostingEnvironment env) { if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); } app.UseMvc(); }
别以为在 services 上面 add 完后就能用,那是两回事,services 集合仅仅说明添加功能,并不代表启用功能,UseMvc 是告诉应用程序在接收到 HTTP 请求后用 MVC 方式进行处理,些时相关的功能才会以中间件的形式插入到 HTTP 处理管道中。
你可以把 HTTP 处理管道看作一个生产线,而 services 集合中添加的内容相当于采购,我生产过程用到锄头,你帮我买,我用到馒头,你帮我买,我用到铁钳,你帮我买。至于说你买来后怎么用,用多少,那是生产线上的事情了。
你可以把 ConfigureServices 方法看作是买菜,把 Configure 方法看作是下厨。
这里顺便废话一下,Startup 类你是可以改为其他名字的,比如叫 MyStart,然后在 Main 入口处改一下 UseStartup 就行了。
WebHost.CreateDefaultBuilder(args) .UseStartup<MyStart>() .Build();
运行的时候,程序会优先查找 Startup 这个名字,如果找不到再找其他的,所以,这个类名没必要改,这样还能减少程序查找的成本,反正你改了名字也没什么实际意义的,还是按照约定来吧。ConfigureServices 方法和 Configure 方法你是不能改的,因为程序会通过反射来找这两个方法。
说了那么多,下面进入咱们主题,我们知道,默认的约会是把视图页面放到 /Views 目录下的,并且按照 Controller 的名字建立子目录,以 Action 的名字来命名页面文件。
比如,有个 Controller 叫 Home ,里面有个 Action 叫 Test,那么默认的视图应该是这样的。
/Views |--- /Home |--- /Test.cshtml
注意文件与目录名是严格区分大小写的,如果 Controller 是 Demo,你的目录是 demo ,是找不到视图,尤其是在 Linux 等系统上运行时,更加要严格遵守大小写的规则。
有时候,老周会觉得这样的路径不爽,目录层次套得多,老周喜欢对页面文件这样命名:Controller-Action.cshtml。例如,Controller 叫 Home,其中一个 Action 叫 Index ,那么视图页的名字就是 Home-Index.cshtml。
那么,我们该怎么修改默认的视图查找位置呢。不急,先来看看人家默认的视图查找位置。在 Configure 方法中加入以下代码。
//app.UseMvc(); app.Run(async context => { // 取出选项实例 IOptions<RazorViewEngineOptions> razoropt = app.ApplicationServices.GetService<IOptions<RazorViewEngineOptions>>(); var locations = razoropt.Value.ViewLocationFormats; StringBuilder strbd = new StringBuilder(); foreach (var item in locations) { strbd.AppendLine(item); } // 这一行不要少,少了会乱码 context.Response.ContentType = "text/plain;charset=utf-8"; await context.Response.WriteAsync($"视图的默认查找位置:\n{strbd}"); });
这里要注意一个代码约定,services 集合添加功能时,经常会附带各种选项类,而为了便于识别,选项类通常是以 Options 结尾,比如,上面代码中的 RazorViewEngineOptions。
还记得上面老周贴的源代码吗,在 AddMvc 方法中有这一句:
builder.AddRazorViewEngine();
这会使得 RazorViewEngineOptions 类的实例被加入到依赖注入的列表中,而 services 集合所添加的各种东东会合并到 app.ApplicationServices 属性上,所以,我们通过这个属性可以取出 RazorViewEngineOptions 实例,但是,你要记得:凡是选项配置类都是用 IOptions<TOptions> 泛型对象来包装,虽然它是个接口,其实现类型也许在这里。
依赖注入类型在注册时往往是以接口类型为 key ,这样一来我们无需考虑它有哪些实现类型,只要统一用 IOptions 接口就能获取对应的选项类实例。
所以你要记住这个约定,选项类都用 IOptiions<TOptions> 类型来包装,并且其 Value 属性中获取选项类的实例,这种约定也是为了区分类型的用途,因为所有类型都可以加入依赖注入列表中的,只有带 IOption 包装的才是选项类。
要自定义视图的查找方法,你不必要实现 IViewLocationExpander 接口,你只需要修改 RazorViewEngineOptions 类的以下三个属性即可:
1、PageViewLocationFormats:专用于 Web Pages 模型,定义查找 Razor 页面的查找位置。
2、AreaViewLocationFormats:定义带 area 的 MVC 模型的 View 页面位置。这个也许你有些陌生,一般 MVC 应用我们少加 area,它的作用可以将 MVC 模型进行分组,比如 admin 组中有 MVC,users 组中也有 MVC,只是前者不能随便访问。
3、ViewLocationFormats:这是咱们今天的重点,也是最常用的。用于定义视图的查找位置。
这些属性都是字符列表,可以动态增减。现在我们运行应用,看看上面的代码所输出的内容。
我们看到,默认主要查找两个目录,Views 和它的子目录 Shared。
这时候,你注意到,路径中有参数,{1} 表示 Controller 名称,{0} 表示 Action 名称。如果 Controller = Home, Action = Index,那么,查找的视图页就是 /Views/Home/Index.cshtml。
可能你又要问了,为什么参数 0 是 Action名,参数 1 是 Controller名呢,这顺序怎么是反过来的?对的,如果有 Area 的话,路径就可以变成 /{2}/Views/{1}/{0}.cshtml。
因为 Action 名是必须的,Controller 次之,Area 许多时候可以忽略,所以,Action 名字的参数位置是 0。有的视图页是不需要限定 Controller 名称的,比如以下这几个特殊页面:_Layout.cshtml、_ViewStart.cshtml、_ViewImports.cshtml。在查找这几个视图时,Action 名称直接就叫 ”_Layout“、”_ViewStart“、”_ViewImports“,不需要指定 Controller 的名字。
好了,知道上面这些原理,相信你也懂得怎么动手了,接下来,老周就以改为 /视图/Controller-Action.cshtml 为例。
在项目中新建两个目录,咱们来个中文名,就叫”控制器“和”视图“。
其实,Controller 类放哪儿都行,因为它们是代码,最终会参与编译的,我们要处理的主要是View。
我们写一个 DemoController 控制器,按照约定,就叫 DemoController,其实类名叫 Demo 也行的。
然后里面写一个简单的 Test 方法,作为 Action,直接返回与该 Action 关联的视图页。
public class DemoController : Controller { public IActionResult Test() { return View(); } }
接着,在 视图 目录下,加一个叫 Demo-Test.cshtml 的文件。注意大小写。
最后,很重要一步,就是在 Startup.ConfigureServices 方法中加入自定义的视图搜索路径。
public void ConfigureServices(IServiceCollection services) { services.AddMvc().AddRazorOptions(opt => { opt.ViewLocationFormats.Clear();// 清空默认的列表 opt.ViewLocationFormats.Add("/视图/{0}" + RazorViewEngine.ViewExtension); opt.ViewLocationFormats.Add("/视图/{1}-{0}" + RazorViewEngine.ViewExtension); }); }
这里为什么要加一条 /视图/{0}.cshtml 呢,前面说过了,有的特殊页面是只有 Action 的,如 _Layout.cshtml。RazorViewEngine.ViewExtension 是个静态字段,表示视图页的扩展名,其实就是 .cshtml,所以这里你完全可以直接写.cshtml。
这时候,运行程序,从 http://<your host>:<your port>/Demo/Test 访问,就能找到视图 Demo-Test.cshtml 了。输入 URL 时是不分大小写的,但是,在代码中查找视图时是区分大小写的。
但为了方便测试,我们在 UseMvc 时加个带默认值的路由。
public void Configure(IApplicationBuilder app, IHostingEnvironment env) { if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); } app.UseMvc(r => { r.MapRoute("hehe", "{controller=Demo}/{action=Test}"); }); }
路由规则需要一个名字,这个名字有啥用,以后再告诉你。
此时,运行应用就很方便了,直接根 URL 上去就能看到视图了。
再补充一下问题,在 Program.cs 文件中,如果你调用的是默认的 CreateDefaultBuilder 方法是很好办的,因为它会为我们配置好一切。
public static void Main(string[] args) { BuildWebHost(args).Run(); } public static IWebHost BuildWebHost(string[] args) => WebHost.CreateDefaultBuilder(args) .UseStartup<Startup>() .Build();
但是,如果你自己改写了代码,比如这样。
public static void Main(string[] args) { var host = new WebHostBuilder() .UseKestrel() .UseStartup<Startup>() .UseUrls("http://localhost:9999") .Build(); host.Run(); }
ASP.NET Core 应用可以独立运行,Kestrel 是传说中的神兽,有了这只神兽,你可以跨平台独立运行。如果你只在 Windows 上独立,除了神兽外,你还可以用 HttpSys。这里我顺便指定了 URL ,端口是 9999。
运行后,把这个 URL 复制到浏览器可以进行访问。
但,你再也找不到视图了。
为什么呢?因为少了一句代码。你到 \bin 目录下看看,编译只生成了.dll,并没有复制页面和其他资源,而上面的代码执行后,默认是在这个 bin 下面找资源的,所以找不到了。
解决方法是加上这一句代码。
var host = new WebHostBuilder() .UseKestrel() .UseContentRoot(Directory.GetCurrentDirectory()) .UseStartup<Startup>() .UseUrls("http://localhost:9999") .Build();
加上这一句后,应用会自动处理当前目录的路径,调试阶段,它查找的是 VS 项目所在的目录,所以能找到视图。而在网站发布后,当前目录会自动变为 .dll 所在的目录,发布时会自动复制项目的资源。
好了,本文说到这里了,88。