ZKWeb網站框架的動態編譯的實現原理

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.NativeMicrosoft.DiaSymReader.PortablePdb才能支持生成pdb文件。
安裝了這個包之後Roslyn會自動識別並使用。json

實現動態編譯插件系統的流程

在ZKWeb框架中,插件是一個文件夾,網站的配置文件中的插件列表就是文件夾的列表。
在網站啓動時,會查找每一個文件夾下的*.cs文件對比文件列表和修改時間是否與上次編譯的不一樣,若是不一樣則從新編譯該文件夾下的代碼。
網站啓動後,會監視*.cs*.dll文件是否有變化,若是有變化則從新啓動網站以從新編譯。
ZKWeb的插件文件夾結構以下c#

  • 插件文件夾
    • bin:程序集文件夾
      • net: .Net Framework編譯的程序集
        • 插件名稱.dll: 編譯出來的程序集
        • 插件名稱.pdb: 調試文件
        • CompileInfo.txt: 儲存了文件列表和修改時間
      • netstandard: .Net Core編譯的程序集
        • 同net文件夾下的內容
    • src 源代碼文件夾
    • static 靜態文件的文件夾
    • 其餘文件夾……

經過Roslyn編譯代碼文件到程序集dll

在網站啓動時,插件管理器在獲得插件文件夾列表後會使用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;會嘗試載入SystemSystem.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詢問,

相關文章
相關標籤/搜索