ZKWeb網站框架是一個自主開發的網頁框架,實現了動態插件和自動編譯功能。
ZKWeb把一個文件夾當成是一個插件,無需使用csproj或xproj等形式的項目文件管理,並且支持修改插件代碼后自動重新編譯加載。
下面將說明ZKWeb如何實現這個功能,您也可以參考下面的代碼和流程在自己的項目中實現。
ZKWeb的開源協議是MIT,有需要的代碼可以直接搬,不需要擔心協議問題。
實現動態編譯依賴的主要技術
編譯: Roslyn Compiler
Roslyn是微軟提供的開源的c# 6.0編譯工具,可以通過Roslyn來支持自宿主編譯功能。
要使用Roslyn可以安裝nuget包Microsoft.CodeAnalysis.CSharp
。
微軟還提供了更簡單的Microsoft.CodeAnalysis.CSharp.Scripting
包,這個包只需簡單幾行就能實現c#的動態腳本。
加載dll: System.Runtime.Loader
在.Net Framework中動態加載一個dll程序集可以使用Assembly.LoadFile
,但是在.Net Core中這個函數被移除了。
微軟為.Net Core提供了一套全新的程序集管理機制,要求使用AssemblyLoadContext
來加載程序集。
遺憾的是我還沒有找到微軟官方關於這方面的說明。
生成pdb: Microsoft.DiaSymReader.Native, Microsoft.DiaSymReader.PortablePdb
為了支持調試編譯出來的程序集,還需要生成pdb調試文件。
在.Net Core中,Roslyn並不包含生成pdb的功能,還需要安裝Microsoft.DiaSymReader.Native
和Microsoft.DiaSymReader.PortablePdb
才能支持生成pdb文件。
安裝了這個包以后Roslyn會自動識別並使用。
實現動態編譯插件系統的流程
在ZKWeb框架中,插件是一個文件夾,網站的配置文件中的插件列表就是文件夾的列表。
在網站啟動時,會查找每個文件夾下的*.cs
文件對比文件列表和修改時間是否與上次編譯的不同,如果不同則重新編譯該文件夾下的代碼。
網站啟動后,會監視*.cs
和*.dll
文件是否有變化,如果有變化則重新啟動網站以重新編譯。
ZKWeb的插件文件夾結構如下
- 插件文件夾
- bin:程序集文件夾
- net: .Net Framework編譯的程序集
- 插件名稱.dll: 編譯出來的程序集
- 插件名稱.pdb: 調試文件
- CompileInfo.txt: 儲存了文件列表和修改時間
- netstandard: .Net Core編譯的程序集
- 同net文件夾下的內容
- net: .Net Framework編譯的程序集
- src 源代碼文件夾
- static 靜態文件的文件夾
- 其他文件夾……
- bin:程序集文件夾
通過Roslyn編譯代碼文件到程序集dll
在網站啟動時,插件管理器在得到插件文件夾列表后會使用Directory.EnumerateFiles
遞歸查找該文件夾下的所有*.cs
文件。
在得到這些代碼文件路徑后,我們就可以傳給Roslyn讓它編譯出dll程序集。
ZKWeb調用Roslyn編譯的完整代碼可以查看這里,下面說明編譯的流程:
首先調用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
程序集。
// 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詢問,