.NET Core MVC跨程序集视图加载异常及解决方案咨询
问题背景
我有一个ASP.NET Core MVC Web应用,包含多个独立程序集(模块),每个模块对应专属URL前缀。我通过module路由参数为每个模块添加URL前缀,现有AccessControl和TimeAttendance两个模块,它们都包含同名的AreaController:
AccessControl模块控制器
namespace Modules.AccessControl.UI.Controllers { [Route("[module]/[controller]")] public class AreaController : Controller { [HttpGet] public IActionResult Index() { return View(); } } }
TimeAttendance模块控制器
namespace Modules.TimeAttendance.UI.Controllers { [Route("[module]/[controller]")] public class AreaController : Controller { [HttpGet] public IActionResult Index() { return View(); } } }
两个模块各自拥有Views/Area/Index.cshtml视图文件,但访问https://localhost:44375/TimeAttendance/Area时,虽然能正确执行TimeAttendance模块的Index动作,却错误加载AccessControl模块的视图。
已知信息:
- 手动指定视图完整路径可以解决问题,但希望通过配置实现自动匹配
- 因为
AccessControl是第一个加载的程序集,视图引擎会优先匹配它的视图文件
需求:让return View()默认根据当前控制器所在程序集定位视图,类似Swagger中通过程序集名称区分资源的方式。
尝试过程中的问题
我尝试通过自定义IViewLocationExpander修改视图搜索路径,但出现视图未找到错误,直接指定/Modules.TimeAttendance.UI/Views/Area/Index.cshtml路径也无效。
已实现的路由代码
public class Module(string routePrefix, IModuleStartup startup) { /// <summary> /// 获取模块中所有控制器和端点的路由前缀 /// </summary> public string RoutePrefix { get; } = routePrefix; /// <summary> /// 获取模块的启动类 /// </summary> public IModuleStartup Startup { get; } = startup; /// <summary> /// 获取模块的程序集 /// </summary> public Assembly Assembly => Startup.GetType().Assembly; } public class ModuleRoutingConvention(IEnumerable<Module> modules) : IActionModelConvention { private readonly IEnumerable<Module> _modules = modules; public void Apply(ActionModel action) { var module = _modules.FirstOrDefault(m => action.Controller.ControllerType.Assembly.FullName?.StartsWith($"Modules.{m.RoutePrefix}") ?? false); if (module == null) { return; } action.RouteValues.Add("module", module.RoutePrefix); } } public class ModuleRoutingMvcOptionsPostConfigure : IPostConfigureOptions<MvcOptions> { private readonly IEnumerable<Module> _modules; public ModuleRoutingMvcOptionsPostConfigure(IEnumerable<Module> modules) { _modules = modules; } public void PostConfigure(string? name, MvcOptions options) { options.Conventions.Add(new ModuleRoutingConvention(_modules)); } } /// <summary> /// 添加模块的扩展方法 /// </summary> public static class ModuleServiceCollection { /// <summary> /// 添加模块 /// </summary> /// <param name="services">服务集合</param> /// <param name="routePrefix">模块的路由前缀</param> /// <typeparam name="TStartup">模块启动类的类型</typeparam> /// <returns>服务集合</returns> public static IServiceCollection AddModule<TStartup>(this IServiceCollection services, string routePrefix, IConfiguration configuration) where TStartup : IModuleStartup, new() { // 注册程序集到MVC,以便找到模块中的控制器 services.AddControllers().ConfigureApplicationPartManager(manager => manager.ApplicationParts.Add(new AssemblyPart(typeof(TStartup).Assembly))); var startup = new TStartup(); startup.ConfigureServices(services, configuration); services.AddSingleton(new Module(routePrefix, startup)); return services; } }
自定义IViewLocationExpander代码(初始版本)
public class ModuleViewLocationMapper : IViewLocationExpander { public IEnumerable<string> ExpandViewLocations(ViewLocationExpanderContext context, IEnumerable<string> viewLocations) { List<string> locations = new List<string>(viewLocations); var controllerActionDescriptor = (ControllerActionDescriptor)context.ActionContext.ActionDescriptor; if (controllerActionDescriptor != null) locations.Insert(0, string.Concat("~/", controllerActionDescriptor.ControllerTypeInfo.Assembly.GetName().Name, "/Views/{1}/{0}", RazorViewEngine.ViewExtension)); locations.RemoveAt(1); return locations; } public void PopulateValues(ViewLocationExpanderContext context) { } }
解决方案
要解决这个问题,需要让视图引擎优先搜索当前控制器所在程序集的嵌入式视图,具体步骤如下:
1. 将模块视图设置为嵌入式资源
对于每个模块类库中的视图文件(如Index.cshtml),右键选择属性,将生成操作改为嵌入式资源。
2. 修正自定义IViewLocationExpander
初始版本的问题在于未正确处理嵌入式资源路径,且缺少缓存键导致视图路径被错误缓存。修正后的代码:
public class ModuleViewLocationExpander : IViewLocationExpander { private const string AssemblyKey = "module-assembly"; public IEnumerable<string> ExpandViewLocations(ViewLocationExpanderContext context, IEnumerable<string> viewLocations) { if (context.Values.TryGetValue(AssemblyKey, out var assemblyName) && !string.IsNullOrEmpty(assemblyName)) { // 优先搜索当前模块的视图路径 var moduleViewPaths = new[] { $"/{assemblyName}/Views/{{1}}/{{0}}{RazorViewEngine.ViewExtension}", $"/{assemblyName}/Views/Shared/{{0}}{RazorViewEngine.ViewExtension}" }; // 先返回模块专属路径,再返回默认路径 return moduleViewPaths.Concat(viewLocations); } return viewLocations; } public void PopulateValues(ViewLocationExpanderContext context) { var descriptor = context.ActionContext.ActionDescriptor as ControllerActionDescriptor; if (descriptor != null) { // 存储控制器所在程序集名称,用于缓存和路径生成 context.Values[AssemblyKey] = descriptor.ControllerTypeInfo.Assembly.GetName().Name; } } }
3. 注册嵌入式视图文件提供程序
在每个模块的IModuleStartup实现中,注册嵌入式视图的文件提供程序,让视图引擎能找到程序集内的视图资源:
public class TimeAttendanceStartup : IModuleStartup { public void ConfigureServices(IServiceCollection services, IConfiguration configuration) { services.Configure<RazorViewEngineOptions>(options => { options.FileProviders.Add(new EmbeddedFileProvider( typeof(TimeAttendanceStartup).Assembly, $"{typeof(TimeAttendanceStartup).Namespace}.Views")); }); } public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { // 按需实现中间件配置 } }
4. 注册自定义视图定位扩展器
在主项目的Program.cs中注册自定义的IViewLocationExpander:
builder.Services.Configure<RazorViewEngineOptions>(options => { options.ViewLocationExpanders.Add(new ModuleViewLocationExpander()); });
完成以上配置后,return View()会自动优先搜索当前控制器所在模块程序集的视图,彻底解决跨模块视图加载错误的问题。
内容的提问来源于stack exchange,提问作者jstuardo

