标题:从零开始实现ASP.NET Core MVC的插件式开发(九) - 如何启用预编译视图

作者:Lamond Lu

地址:https://www.cnblogs.com/lwqlun/p/13992077.html

源代码:https://github.com/lamondlu/Mystique

适用版本:.NET Core 3.1, .NET 5

前景回顾

简介

在这个项目创建的时候,项目的初衷是使用预编译视图来呈现界面,但是由于多次尝试失败,最后改用了运行时编译视图,这种方式在第一次加载的时候非常的慢,所有的插件视图都要在运行时编译。这个问题困扰我很久。近日,在几位同道的共同努力下,终于实现了这种加载方式。


此篇要鸣谢网友 j4587698yang-er 对针对当前项目的支持,你们的思路帮我解决了当前项目针对不能启用预编译视图的2个主要的问题

  • 在当前项目目录结构下,启动时加载组件,组件预编译视图不能正常使用
  • 运行时加载组件之后,组件中的预编译视图不能正常使用

升级.NET 5

随着.NET 5的发布,当前项目也升级到了.NET 5版本。

整个升级的过程比我预想的简单的多,只是修改了一下项目使用的Target fremework。重新编译打包了一下插件程序,项目就可以正常运行了,整个过程中没有产生任何因为版本升级导致的编译问题。

预编译视图不能使用的问题

在升级了.NET 5之后,我重新尝试在启动时关闭了运行时编译,加载预编译视图View, 借此测试.NET 5对预编译视图的支持情况。

    public static void MystiqueSetup(this IServiceCollection services, IConfiguration configuration)
{
... IMvcBuilder mvcBuilder = services.AddMvc(); ServiceProvider provider = services.BuildServiceProvider();
using (IServiceScope scope = provider.CreateScope())
{
... foreach (ViewModels.PluginListItemViewModel plugin in allEnabledPlugins)
{
CollectibleAssemblyLoadContext context = new CollectibleAssemblyLoadContext(plugin.Name);
string moduleName = plugin.Name; string filePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Modules", moduleName, $"{moduleName}.dll");
string viewFilePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Modules", moduleName, $"{moduleName}.Views.dll");
string referenceFolderPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Modules", moduleName); _presets.Add(filePath);
using (FileStream fs = new FileStream(filePath, FileMode.Open))
{
Assembly assembly = context.LoadFromStream(fs);
context.SetEntryPoint(assembly); loader.LoadStreamsIntoContext(context, referenceFolderPath, assembly); MystiqueAssemblyPart controllerAssemblyPart = new MystiqueAssemblyPart(assembly);
mvcBuilder.PartManager.ApplicationParts.Add(controllerAssemblyPart);
PluginsLoadContexts.Add(plugin.Name, context); BuildNotificationProvider(assembly, scope);
} using (FileStream fsView = new FileStream(viewFilePath, FileMode.Open))
{
Assembly viewAssembly = context.LoadFromStream(fsView);
loader.LoadStreamsIntoContext(context, referenceFolderPath, viewAssembly); CompiledRazorAssemblyPart moduleView = new CompiledRazorAssemblyPart(viewAssembly);
mvcBuilder.PartManager.ApplicationParts.Add(moduleView);
} context.Enable();
}
}
} AssemblyLoadContextResoving(); ...
}

运行项目之后,你会发现项目竟然会得到一个无法找到视图的错误。

这里的结果很奇怪,因为参考第一章的场景,ASP.NET Core默认是支持启动时加载预编译视图的。在第一章的时候,我们创建了1个组件,在启动时,直接加载到主AssemblyLoadContext中,启动之后,我们是可以正常访问到视图的。

在仔细思考之后,我想到的两种可能性。

  • 一种可能是因为我们的组件加载在独立的AssemblyLoadContext中,而非主AssemblyLoadContext中,所以可能导致加载失败
  • 插件的目录结构与第一章不符合,导致加载失败

但是苦于不能调试ASP.NET Core的源码,所以这一部分就暂时搁置了。直到前几天,网友j4587698 在项目Issue中针对运行时编译提出的方案给我的调试思路。

在ASP.NET Core中,默认的视图的编译和加载使用了2个内部类DefaultViewCompilerProviderDefaultViewCompiler。但是由于这2个类是内部类,所以没有办法继承并重写,更谈不上调试了。

j4587698的思路和我不同,他的做法是,在当前主项目中,直接复制DefaultViewCompilerProviderDefaultViewCompiler2个类的代码,并将其定义为公开类,在程序启动时,替换默认依赖注入容器中的类实现,使用公开的DefaultViewCompilerProvider DefaultViewCompiler类,替换ASP.NET Core默认指定的内部类。

根据他的思路,我新增了一个基于IServiceCollection的扩展类,追加了Replace方法来替换注入容器中的实现。

    public static class ServiceCollectionExtensions
{
public static IServiceCollection Replace<TService, TImplementation>(this IServiceCollection services)
where TImplementation : TService
{
return services.Replace<TService>(typeof(TImplementation));
} public static IServiceCollection Replace<TService>(this IServiceCollection services, Type implementationType)
{
return services.Replace(typeof(TService), implementationType);
} public static IServiceCollection Replace(this IServiceCollection services, Type serviceType, Type implementationType)
{
if (services == null)
{
throw new ArgumentNullException(nameof(services));
} if (serviceType == null)
{
throw new ArgumentNullException(nameof(serviceType));
} if (implementationType == null)
{
throw new ArgumentNullException(nameof(implementationType));
} if (!services.TryGetDescriptors(serviceType, out var descriptors))
{
throw new ArgumentException($"No services found for {serviceType.FullName}.", nameof(serviceType));
} foreach (var descriptor in descriptors)
{
var index = services.IndexOf(descriptor); services.Insert(index, descriptor.WithImplementationType(implementationType)); services.Remove(descriptor);
} return services;
} private static bool TryGetDescriptors(this IServiceCollection services, Type serviceType, out ICollection<ServiceDescriptor> descriptors)
{
return (descriptors = services.Where(service => service.ServiceType == serviceType).ToArray()).Any();
} private static ServiceDescriptor WithImplementationType(this ServiceDescriptor descriptor, Type implementationType)
{
return new ServiceDescriptor(descriptor.ServiceType, implementationType, descriptor.Lifetime);
}
}

并在程序启动时,使用公开的MyViewCompilerProvider类,替换了原始注入类DefaultViewCompilerProvider

    public static void MystiqueSetup(this IServiceCollection services, IConfiguration configuration)
{
_serviceCollection = services; services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();
services.AddSingleton<IMvcModuleSetup, MvcModuleSetup>();
services.AddScoped<IPluginManager, PluginManager>();
services.AddScoped<ISystemManager, SystemManager>();
services.AddScoped<IUnitOfWork, Repository.MySql.UnitOfWork>();
services.AddSingleton<INotificationRegister, NotificationRegister>();
services.AddSingleton<IActionDescriptorChangeProvider>(MystiqueActionDescriptorChangeProvider.Instance);
services.AddSingleton<IReferenceContainer, DefaultReferenceContainer>();
services.AddSingleton<IReferenceLoader, DefaultReferenceLoader>();
services.AddSingleton(MystiqueActionDescriptorChangeProvider.Instance); ... services.Replace<IViewCompilerProvider, MyViewCompilerProvider>();
}

MyViewCompilerProvider中, 直接返回了新定义的MyViewCompiler

    public class MyViewCompilerProvider : IViewCompilerProvider
{
private readonly MyViewCompiler _compiler; public MyViewCompilerProvider(
ApplicationPartManager applicationPartManager,
ILoggerFactory loggerFactory)
{
var feature = new ViewsFeature();
applicationPartManager.PopulateFeature(feature); _compiler = new MyViewCompiler(feature.ViewDescriptors, loggerFactory.CreateLogger<MyViewCompiler>());
} public IViewCompiler GetCompiler() => _compiler;
}

PS: 此处只是直接复制了ASP.NET Core源码中DefaultViewCompilerProvider DefaultViewCompiler2个类的代码,稍作修改,保证编译通过。

    public class MyViewCompiler : IViewCompiler
{
private readonly Dictionary<string, Task<CompiledViewDescriptor>> _compiledViews;
private readonly ConcurrentDictionary<string, string> _normalizedPathCache;
private readonly ILogger _logger; public MyViewCompiler(
IList<CompiledViewDescriptor> compiledViews,
ILogger logger)
{
...
} /// <inheritdoc />
public Task<CompiledViewDescriptor> CompileAsync(string relativePath)
{
if (relativePath == null)
{
throw new ArgumentNullException(nameof(relativePath));
} // Attempt to lookup the cache entry using the passed in path. This will succeed if the path is already
// normalized and a cache entry exists.
if (_compiledViews.TryGetValue(relativePath, out var cachedResult))
{ return cachedResult;
} var normalizedPath = GetNormalizedPath(relativePath);
if (_compiledViews.TryGetValue(normalizedPath, out cachedResult))
{ return cachedResult;
} // Entry does not exist. Attempt to create one. return Task.FromResult(new CompiledViewDescriptor
{
RelativePath = normalizedPath,
ExpirationTokens = Array.Empty<IChangeToken>(),
});
} private string GetNormalizedPath(string relativePath)
{
...
}
}

针对DefaultViewCompiler,这里的重点是CompileAsync方法,它会根据传入的相对路径,在加载的编译视图集合中加载视图。下面我们在此处打上断点,并模拟进入DemoPlugin1的主页。

看完这个调试过程,你是不是发现了点什么,当我们访问DemoPlugin1的主页路由/Modules/DemoPlugin/Plugin1/HelloWorld的时候,ASP.NET Core尝试查找的视图相对路径是·

  • /Areas/DemoPlugin1/Views/Plugin1/HelloWorld.cshtml
  • /Areas/DemoPlugin1/Views/Shared/HelloWorld.cshtml
  • /Views/Shared/HelloWorld.cshtml
  • /Pages/Shared/HelloWorld.cshtml
  • /Modules/DemoPlugin1/Views/Plugin1/HelloWorld.cshtml
  • /Views/Shared/HelloWorld.cshtml

而当我们查看现在已有的编译视图映射是,你会发现注册的对应视图路径确是/Views/Plugin1/HelloWorld.cshtml

下面我们再回过头来看看DemoPlugin1的目录结构

由此我们推断出,预编译视图在生成的时候,会记录当前视图的相对路径,而在主程序加载的插件的过程中,由于我们使用了Area来区分模块,多出的一级目录,所以导致目录映射失败了。因此如果我们将DemoPlugin1的插件视图目录结构改为以上提示的6个地址之一,问题应该就解决了。

那么这里有没有办法,在不改变路径的情况下,让视图正常加载呢,答案是有的。参照之前的代码,在加载视图组件的时候,我们使用了内置类CompiledRazorAssemblyPart, 那么让我们来看看它的源码。

    public class CompiledRazorAssemblyPart : ApplicationPart, IRazorCompiledItemProvider
{
/// <summary>
/// Initializes a new instance of <see cref="CompiledRazorAssemblyPart"/>.
/// </summary>
/// <param name="assembly">The <see cref="System.Reflection.Assembly"/></param>
public CompiledRazorAssemblyPart(Assembly assembly)
{
Assembly = assembly ?? throw new ArgumentNullException(nameof(assembly));
} /// <summary>
/// Gets the <see cref="System.Reflection.Assembly"/>.
/// </summary>
public Assembly Assembly { get; } /// <inheritdoc />
public override string Name => Assembly.GetName().Name; IEnumerable<RazorCompiledItem> IRazorCompiledItemProvider.CompiledItems
{
get
{
var loader = new RazorCompiledItemLoader();
return loader.LoadItems(Assembly);
}
}
}

这个类非常的简单,它通过RazorCompiledItemLoader类对象从程序集中加载的视图, 并将最终的编译视图都存放在一个RazorCompiledItem类的集合里。

    public class RazorCompiledItemLoader
{
public virtual IReadOnlyList<RazorCompiledItem> LoadItems(Assembly assembly)
{
if (assembly == null)
{
throw new ArgumentNullException(nameof(assembly));
} var items = new List<RazorCompiledItem>();
foreach (var attribute in LoadAttributes(assembly))
{
items.Add(CreateItem(attribute));
} return items;
} protected virtual RazorCompiledItem CreateItem(RazorCompiledItemAttribute attribute)
{
if (attribute == null)
{
throw new ArgumentNullException(nameof(attribute));
} return new DefaultRazorCompiledItem(attribute.Type, attribute.Kind, attribute.Identifier);
} protected IEnumerable<RazorCompiledItemAttribute> LoadAttributes(Assembly assembly)
{
if (assembly == null)
{
throw new ArgumentNullException(nameof(assembly));
} return assembly.GetCustomAttributes<RazorCompiledItemAttribute>();
}
}

这里我们可以参考前面的调试方式,创建出一套自己的视图加载类,代码和当前的实现一模一样

MystiqueModuleViewCompiledItemLoader

    public class MystiqueModuleViewCompiledItemLoader : RazorCompiledItemLoader
{
public MystiqueModuleViewCompiledItemLoader()
{
} protected override RazorCompiledItem CreateItem(RazorCompiledItemAttribute attribute)
{
if (attribute == null)
{
throw new ArgumentNullException(nameof(attribute));
} return new MystiqueModuleViewCompiledItem(attribute);
} }

MystiqueRazorAssemblyPart

    public class MystiqueRazorAssemblyPart : ApplicationPart, IRazorCompiledItemProvider
{
public MystiqueRazorAssemblyPart(Assembly assembly)
{
Assembly = assembly ?? throw new ArgumentNullException(nameof(assembly));
AreaName = areaName;
} public Assembly Assembly { get; } public override string Name => Assembly.GetName().Name; IEnumerable<RazorCompiledItem> IRazorCompiledItemProvider.CompiledItems
{
get
{
var loader = new MystiqueModuleViewCompiledItemLoader();
return loader.LoadItems(Assembly);
}
}
}

MystiqueModuleViewCompiledItem

    public class MystiqueModuleViewCompiledItem : RazorCompiledItem
{
public override string Identifier { get; } public override string Kind { get; } public override IReadOnlyList<object> Metadata { get; } public override Type Type { get; } public MystiqueModuleViewCompiledItem(RazorCompiledItemAttribute attr, string moduleName)
{
Type = attr.Type;
Kind = attr.Kind;
Identifier = attr.Identifier; Metadata = Type.GetCustomAttributes(inherit: true).ToList();
}
}

这里我们在MystiqueModuleViewCompiledItem类的构造函数部分打上断点。

重新启动项目之后,你会发现当加载DemoPlugin1的视图时,这里的Identifier属性其实就是当前编译试图项的映射目录。这样我们很容易就想到在此处动态修改映射目录,为此我们需要将模块名称通过构造函数传入,以上3个类的更新代码如下:

MystiqueModuleViewCompiledItemLoader

    public class MystiqueModuleViewCompiledItemLoader : RazorCompiledItemLoader
{
public string ModuleName { get; } public MystiqueModuleViewCompiledItemLoader(string moduleName)
{
ModuleName = moduleName;
} protected override RazorCompiledItem CreateItem(RazorCompiledItemAttribute attribute)
{
if (attribute == null)
{
throw new ArgumentNullException(nameof(attribute));
} return new MystiqueModuleViewCompiledItem(attribute, ModuleName);
}
}

MystiqueRazorAssemblyPart

    public class MystiqueRazorAssemblyPart : ApplicationPart, IRazorCompiledItemProvider
{
public MystiqueRazorAssemblyPart(Assembly assembly, string moduleName)
{
Assembly = assembly ?? throw new ArgumentNullException(nameof(assembly));
ModuleName = moduleName;
} public string ModuleName { get; } public Assembly Assembly { get; } public override string Name => Assembly.GetName().Name; IEnumerable<RazorCompiledItem> IRazorCompiledItemProvider.CompiledItems
{
get
{
var loader = new MystiqueModuleViewCompiledItemLoader(ModuleName);
return loader.LoadItems(Assembly);
}
}
}

MystiqueModuleViewCompiledItem

    public class MystiqueModuleViewCompiledItem : RazorCompiledItem
{
public override string Identifier { get; } public override string Kind { get; } public override IReadOnlyList<object> Metadata { get; } public override Type Type { get; } public MystiqueModuleViewCompiledItem(RazorCompiledItemAttribute attr, string moduleName)
{
Type = attr.Type;
Kind = attr.Kind;
Identifier = "/Modules/" + moduleName + attr.Identifier; Metadata = Type.GetCustomAttributes(inherit: true).Select(o =>
o is RazorSourceChecksumAttribute rsca
? new RazorSourceChecksumAttribute(rsca.ChecksumAlgorithm, rsca.Checksum, "/Modules/" + moduleName + rsca.Identifier)
: o).ToList();
}
}

PS: 这里有个容易疏漏的点,就是MystiqueModuleViewCompiledItem中的MetaData, 它使用了Identifier属性的值,所以一旦Identifier属性的值被动态修改,此处的值也要修改,否则调试会不成功。

修改完成之后,我们重启项目,来测试一下。

编译视图的映射路径动态修改成功,页面成功被打开了,至此启动时的预编译视图加载完成。

运行时加载编译视图

最后我们来到了运行加载编译视图的问题,有了之前的调试方案,现在调试起来就轻车熟路。

为了测试,我们再运行时加载编译视图,我们首先禁用掉DemoPlugin1, 然后重启项目,并启用DemoPlugin1

通过调试,很明显问题出在预编译视图的加载上,在启用组件之后,编译视图映射集合没有更新,所以导致加载失败。这也证明了我们之前第三章时候的推断。当使用IActionDescriptorChangeProvider重置Controller/Action映射的时候,ASP.NET Core不会更新视图映射集合,从而导致视图加载失败。

    MystiqueActionDescriptorChangeProvider.Instance.HasChanged = true;
MystiqueActionDescriptorChangeProvider.Instance.TokenSource.Cancel();

那么解决问题的方式也就很清楚了,我们需要在使用IActionDescriptorChangeProvider重置Controller/Action映射之后,刷新视图映射集合。为此,我们需要修改之前定义的MyViewCompilerProvider, 添加Refresh方法来刷新映射。

    public class MyViewCompilerProvider : IViewCompilerProvider
{
private MyViewCompiler _compiler;
private ApplicationPartManager _applicationPartManager;
private ILoggerFactory _loggerFactory; public MyViewCompilerProvider(
ApplicationPartManager applicationPartManager,
ILoggerFactory loggerFactory)
{
_applicationPartManager = applicationPartManager;
_loggerFactory = loggerFactory;
Refresh();
} public void Refresh()
{
var feature = new ViewsFeature();
_applicationPartManager.PopulateFeature(feature); _compiler = new MyViewCompiler(feature.ViewDescriptors, _loggerFactory.CreateLogger<MyViewCompiler>());
} public IViewCompiler GetCompiler() => _compiler;
}

Refresh方法是借助ViewsFeature来重新创建了一个新的IViewCompiler, 并填充了最新的视图映射。

PS: 这里的实现方式参考了DefaultViewCompilerProvider的实现,该类是在构造中填充的视图映射。

根据以上修改,在使用IActionDescriptorChangeProvider重置Controller/Action映射之后, 我们使用Refresh方法来刷新映射。

    private void ResetControllActions()
{
MystiqueActionDescriptorChangeProvider.Instance.HasChanged = true;
MystiqueActionDescriptorChangeProvider.Instance.TokenSource.Cancel(); var provider = _context.HttpContext
.RequestServices
.GetService(typeof(IViewCompilerProvider)) as MyViewCompilerProvider;
provider.Refresh();
}

最后,我们重新启动项目,再次在运行时启用DemoPlugin1,进入插件主页面,页面正常显示了。

至此运行时加载与编译视图的场景也顺利解决了。

最新文章

  1. Perl碎碎念
  2. html5 drag
  3. [Leetcode][JAVA] Recover Binary Search Tree (Morris Inorder Traversal)
  4. Windows Azure Web Site (17) 设置Web App TimeOut时间
  5. [工具] 分布式系统下批量创建用户及分发公钥打通ssh通道的脚本
  6. MatLab GUI Change Size 改变界面大小
  7. Tiffany
  8. php里ezpdo orm框架初探
  9. 优化HTTP前端请求构建高性能ASP.NET站点
  10. boost
  11. XML读写
  12. Data Base mongodb高版本与低版本的区别
  13. controller层中,参数的获取方式以及作用域的问题
  14. VS2017 WinFrom打包设置与教程
  15. 基于配置文件的方式配置AOP
  16. 软工网络15个人作业4——alpha阶段个人总结
  17. Android 实现卡片翻转的动画(翻牌动画)
  18. FileOutputSream文件字节输出流
  19. java第三章笔记
  20. jQuery -- 光阴似箭(二):jQuery效果的使用

热门文章

  1. docker的run操作
  2. (转载)Quartus II中FPGA的管脚分配保存方法(Quartus II)
  3. C#实现迭代器
  4. 用Pycharm创建指定的Django版本
  5. day31 Pyhton 面向对象的基础 三大特性
  6. LeCun自曝使用C语言23年之久,2年前才上手Python,还曾短暂尝试Lua!
  7. centos8上添加sudoer用户
  8. php休眠微秒
  9. laravel或者lumen门面和服务提供者使用
  10. tamcat7.0(安装文件下载)