ZKWeb网站框架是一个自主开发的网页框架,实现了动态插件和自动编译功能。
ZKWeb把一个文件夹当成是一个插件,无需使用csproj或xproj等形式的项目文件管理,而且支持修改插件代码后自动从新编译加载。linux
下面将说明ZKWeb如何实现这个功能,您也能够参考下面的代码和流程在本身的项目中实现。
ZKWeb的开源协议是MIT,有须要的代码能够直接搬,不须要担忧协议问题。git
编译: Roslyn Compiler
Roslyn是微软提供的开源的c# 6.0编译工具,能够经过Roslyn来支持自宿主编译功能。
要使用Roslyn能够安装nuget包Microsoft.CodeAnalysis.CSharp
。
微软还提供了更简单的Microsoft.CodeAnalysis.CSharp.Scripting
包,这个包只需简单几行就能实现c#的动态脚本。github
加载dll: System.Runtime.Loader
在.Net Framework中动态加载一个dll程序集可使用Assembly.LoadFile
,可是在.Net Core中这个函数被移除了。
微软为.Net Core提供了一套全新的程序集管理机制,要求使用AssemblyLoadContext
来加载程序集。
遗憾的是我尚未找到微软官方关于这方面的说明。web
生成pdb: Microsoft.DiaSymReader.Native, Microsoft.DiaSymReader.PortablePdb
为了支持调试编译出来的程序集,还须要生成pdb调试文件。
在.Net Core中,Roslyn并不包含生成pdb的功能,还须要安装Microsoft.DiaSymReader.Native
和Microsoft.DiaSymReader.PortablePdb
才能支持生成pdb文件。
安装了这个包之后Roslyn会自动识别并使用。json
在ZKWeb框架中,插件是一个文件夹,网站的配置文件中的插件列表就是文件夹的列表。
在网站启动时,会查找每一个文件夹下的*.cs
文件对比文件列表和修改时间是否与上次编译的不一样,若是不一样则从新编译该文件夹下的代码。
网站启动后,会监视*.cs
和*.dll
文件是否有变化,若是有变化则从新启动网站以从新编译。
ZKWeb的插件文件夹结构以下c#
在网站启动时,插件管理器在获得插件文件夹列表后会使用Directory.EnumerateFiles
递归查找该文件夹下的全部*.cs
文件。
在获得这些代码文件路径后,咱们就能够传给Roslyn让它编译出dll程序集。
ZKWeb调用Roslyn编译的完整代码能够查看这里,下面说明编译的流程:windows
首先调用CSharpSyntaxTree.ParseText
来解析代码列表到语法树列表,咱们能够从源代码列表得出List<SyntaxTree>
。
parseOptions
是解析选项,ZKWeb会在.Net Core编译时定义NETCORE
标记,这样插件代码中可使用#if NETCORE
来定义.Net Core专用的处理。
path
是文件路径,必须传入文件路径才能调试生成出来的程序集,不然即便生成了pdb也不能捕捉断点。框架
// Parse source files into syntax trees // Also define NETCORE for .Net Core var parseOptions = CSharpParseOptions.Default; #if NETCORE parseOptions = parseOptions.WithPreprocessorSymbols("NETCORE"); #endif var syntaxTrees = sourceFiles .Select(path => CSharpSyntaxTree.ParseText( File.ReadAllText(path), parseOptions, path, Encoding.UTF8)) .ToList();
接下来须要分析代码中的using
来找出代码依赖了哪些程序集,并逐一载入这些程序集。
例如遇到using System.Threading;
会尝试载入System
和System.Threading
程序集。ide
// Find all using directive and load the namespace as assembly // It's for resolve assembly dependencies of plugin LoadAssembliesFromUsings(syntaxTrees);
LoadAssembliesFromUsings
的代码以下,虽然比较长可是逻辑并不复杂。
关于IAssemblyLoader
将在后面阐述,这里只须要知道它能够按名称载入程序集。模块化
/// <summary> /// Find all using directive /// And try to load the namespace as assembly /// </summary> /// <param name="syntaxTrees">Syntax trees</param> protected void LoadAssembliesFromUsings(IList<SyntaxTree> syntaxTrees) { // Find all using directive var assemblyLoader = Application.Ioc.Resolve<IAssemblyLoader>(); foreach (var tree in syntaxTrees) { foreach (var usingSyntax in ((CompilationUnitSyntax)tree.GetRoot()).Usings) { var name = usingSyntax.Name; var names = new List<string>(); while (name != null) { // The type is "IdentifierNameSyntax" if it's single identifier // eg: System // The type is "QualifiedNameSyntax" if it's contains more than one identifier // eg: System.Threading if (name is QualifiedNameSyntax) { var qualifiedName = (QualifiedNameSyntax)name; var identifierName = (IdentifierNameSyntax)qualifiedName.Right; names.Add(identifierName.Identifier.Text); name = qualifiedName.Left; } else if (name is IdentifierNameSyntax) { var identifierName = (IdentifierNameSyntax)name; names.Add(identifierName.Identifier.Text); name = null; } } if (names.Contains("src")) { // Ignore if it looks like a namespace from plugin continue; } names.Reverse(); for (int c = 1; c <= names.Count; ++c) { // Try to load the namespace as assembly // eg: will try "System" and "System.Threading" from "System.Threading" var usingName = string.Join(".", names.Take(c)); if (LoadedNamespaces.Contains(usingName)) { continue; } try { assemblyLoader.Load(usingName); } catch { } LoadedNamespaces.Add(usingName); } } } }
通过上面这一步后,代码依赖的全部程序集应该都载入到当前进程中了,
咱们须要找出这些程序集而且传给Roslyn,在编译代码时引用这些程序集文件。
下面的代码生成了一个List<PortableExecutableReference>
对象。
// Add loaded assemblies to compile references var assemblyLoader = Application.Ioc.Resolve<IAssemblyLoader>(); var references = assemblyLoader.GetLoadedAssemblies() .Select(assembly => assembly.Location) .Select(path => MetadataReference.CreateFromFile(path)) .ToList();
构建编译选项
这里须要调用微软非公开的函数WithTopLevelBinderFlags来设置IgnoreCorLibraryDuplicatedTypes。
这个标志让Roslyn能够忽略System.Runtime.Extensions和System.Private.CoreLib中重复的类型。
若是须要让Roslyn正常工做在windows和linux上,必须设置这个标志,具体能够看https://github.com/dotnet/roslyn/issues/13267。
Roslyn Scripting默认会使用这个标志,操蛋的微软
// Create compilation options and set IgnoreCorLibraryDuplicatedTypes flag // To avoid error like The type 'Path' exists in both // 'System.Runtime.Extensions, Version=4.1.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a' // and // 'System.Private.CoreLib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=7cec85d7bea7798e var compilationOptions = new CSharpCompilationOptions( OutputKind.DynamicallyLinkedLibrary, optimizationLevel: optimizationLevel); var withTopLevelBinderFlagsMethod = compilationOptions.GetType() .FastGetMethod("WithTopLevelBinderFlags", BindingFlags.Instance | BindingFlags.NonPublic); var binderFlagsType = withTopLevelBinderFlagsMethod.GetParameters()[0].ParameterType; compilationOptions = (CSharpCompilationOptions)withTopLevelBinderFlagsMethod.FastInvoke( compilationOptions, binderFlagsType.GetField("IgnoreCorLibraryDuplicatedTypes").GetValue(binderFlagsType));
最后调用Roslyn编译,传入语法树列表和引用程序集列表能够获得目标程序集。
使用Emit
函数编译后会返回一个EmitResult
对象,里面保存了编译中出现的错误和警告信息。
注意编译出错时Emit
不会抛出例外,须要手动检查EmitResult
中的Success
属性。
// Compile to assembly, throw exception if error occurred var compilation = CSharpCompilation.Create(assemblyName) .WithOptions(compilationOptions) .AddReferences(references) .AddSyntaxTrees(syntaxTrees); var emitResult = compilation.Emit(assemblyPath, pdbPath); if (!emitResult.Success) { throw new CompilationException(string.Join("\r\n", emitResult.Diagnostics.Where(d => d.WarningLevel == 0))); }
到此已经完成了代码文件(cs)到程序集(dll)的编译,下面来看如何载入这个程序集。
在.Net Framework中,载入程序集文件很是简单,只须要调用Assembly.LoadFile
。
在.Net Core中,载入程序集文件须要定义AssemblyLoadContext
,而且全部相关的程序集都须要经过同一个Context
来载入。
须要注意的是AssemblyLoadContext
不能用在.Net Framework中,ZKWeb为了消除这个差别定义了IAssemblyLoader
接口。
完整的代码能够查看
IAssemblyLoader
CoreAssemblyLoader
NetAssemblyLoader
.Net Framework的载入只是调用了Assembly
中原来的函数,这里就再也不说明了。
.Net Core使用的载入器定义了AssemblyLoadContext
,代码以下:
代码中的plugin.ReferenceAssemblyPath
指的是插件自带的第三方dll文件,用于载入插件依赖可是主项目中没有引用的dll文件。
/// <summary> /// The context for loading assembly /// </summary> private class LoadContext : AssemblyLoadContext { protected override Assembly Load(AssemblyName assemblyName) { try { // Try load directly return Assembly.Load(assemblyName); } catch { // If failed, try to load it from plugin's reference directory var pluginManager = Application.Ioc.Resolve<PluginManager>(); foreach (var plugin in pluginManager.Plugins) { var path = plugin.ReferenceAssemblyPath(assemblyName.Name); if (path != null) { return LoadFromAssemblyPath(path); } } throw; } } }
定义了LoadContext
之后须要把这个类设为单例,载入时都经过这个Context
来载入。
由于.Net Core目前没法获取到全部已载入的程序集,只能获取程序自己依赖的程序集列表,
这里还添加了一个ISet<Assembly> LoadedAssemblies
用于记录历史载入的全部程序集。
/// <summary> /// Load assembly by name /// </summary> public Assembly Load(string name) { // Replace name if replacement exists name = ReplacementAssemblies.GetOrDefault(name, name); var assembly = Context.LoadFromAssemblyName(new AssemblyName(name)); LoadedAssemblies.Add(assembly); return assembly; } /// <summary> /// Load assembly by name object /// </summary> public Assembly Load(AssemblyName assemblyName) { var assembly = Context.LoadFromAssemblyName(assemblyName); LoadedAssemblies.Add(assembly); return assembly; } /// <summary> /// Load assembly from it's binary contents /// </summary> public Assembly Load(byte[] rawAssembly) { using (var stream = new MemoryStream(rawAssembly)) { var assembly = Context.LoadFromStream(stream); LoadedAssemblies.Add(assembly); return assembly; } } /// <summary> /// Load assembly from file path /// </summary> public Assembly LoadFile(string path) { var assembly = Context.LoadFromAssemblyPath(path); LoadedAssemblies.Add(assembly); return assembly; }
到这里已经能够载入编译的程序集(dll)文件了,下面来看如何实现修改代码后自动从新编译。
ZKWeb使用了FileSystemWatcher
来检测代码文件的变化,完整代码能够查看这里。
主要的代码以下
// Function use to stop website Action stopWebsite = () => { var stoppers = Application.Ioc.ResolveMany<IWebsiteStopper>(); stoppers.ForEach(s => s.StopWebsite()); }; // Function use to handle file changed Action<string> onFileChanged = (path) => { var ext = Path.GetExtension(path).ToLower(); if (ext == ".cs" || ext == ".json" || ext == ".dll") { stopWebsite(); } }; // Function use to start file system watcher Action<FileSystemWatcher> startWatcher = (watcher) => { watcher.NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName; watcher.Changed += (sender, e) => onFileChanged(e.FullPath); watcher.Created += (sender, e) => onFileChanged(e.FullPath); watcher.Deleted += (sender, e) => onFileChanged(e.FullPath); watcher.Renamed += (sender, e) => { onFileChanged(e.FullPath); onFileChanged(e.OldFullPath); }; watcher.EnableRaisingEvents = true; }; // Monitor plugin directory var pathManager = Application.Ioc.Resolve<PathManager>(); pathManager.GetPluginDirectories().Where(p => Directory.Exists(p)).ForEach(p => { var pluginFilesWatcher = new FileSystemWatcher(); pluginFilesWatcher.Path = p; pluginFilesWatcher.IncludeSubdirectories = true; startWatcher(pluginFilesWatcher); });
这段代码监视了插件文件夹下的cs, json, dll
文件,
一旦发生变化就调用IWebsiteStopper
来中止网站,网站下次打开时将会从新编译和载入插件。
IWebsiteStopper
是一个抽象的接口,在Asp.Net中中止网站调用了HttpRuntime.UnloadAppDomain
,而在Asp.Net Core中中止网站调用了IApplicationLifetime.StopApplication
。
Asp.Net中止网站会卸载当前的AppDomain
,下次刷新网页时会自动从新启动。
而Asp.Net Core中止网站会终止当前的进程,使用IIS托管时IIS会在自动重启进程,但使用自宿主时则须要依赖外部工具来重启。
ZKWeb实现的动态编译技术大幅度的减小了开发时的等待时间,
主要节省在不须要每次都按快捷键编译和不须要像其余模块化开发同样须要从子项目复制dll文件到主项目,若是dll文件较多并且用了机械硬盘,复制时间可能会比编译时间还要漫长。
我将会在这个博客继续分享ZKWeb框架中使用的技术。 若是有不明白的部分,欢迎加入ZKWeb交流群522083886询问,