对于大型的应用软件,特别是客户端应用软件,应用启动过程中,需要执行大量的逻辑,包括各个模块的初始化和注册等等逻辑。大型应用软件的启动过程都是非常复杂的,而客户端应用软件是对应用的启动性能有所要求的,不同于服务端的应用软件。设想,用户双击了桌面图标,然而等待几分钟,应用才启动完毕,那用户下一步会不会就是点击卸载了。为了权衡大型应用软件在启动过程,既需要执行复杂的启动逻辑,又需要关注启动性能,为此过程造一个框架是一个完全合理的事情。我所在的团队为启动过程造的库,就是本文将要和大家介绍我所在团队开源的 dotnetCampus.ApplicationStartupManager 启动流程框架的库
背景
這個庫的起源是一次聽 visualstudio 團隊的分享,當時大佬們告訴我,為了優化 visualstudio 的啟動性能,他的團隊制定了一個有趣的方向,那就是在應用啟動的時候將 cpu 和內存和磁碟跑滿。當然,這是一個玩笑的話,本來的意思是,在 visualstudio 應用啟動的時候,應該充分壓榨計算機的性能。剛好,我所在的團隊也有很多個大型的應用,代碼的 mergerequest 數都破萬的應用。這些應用的邏輯複雜度都是非常高的,原本只能是採用單個線程執行,從而減少模塊之間的依賴複雜度導致的坑。但在後續為了優化應用軟體的啟動性能,考慮到進行機器性能的壓榨策略,其中就包括了多線程的方式
然而在開多線程的時候,自然就會遇到很多線程相關的問題,最大的問題就是如何處理各個啟動模塊之間的依賴關係。如果沒有一個較好的框架來進行處理,只靠開發者的個人能力來處理,做此重構是完全不靠譜的,或者說這個事情是做不遠的,也許這個版本能優化,但下個版本呢
還有一點非常重要的是如何做啟動性能的監控,如分析各個啟動項的耗時情況。在進行逐個啟動業務模塊的性能優化之前,十分有必要進行啟動模塊的性能測量。而有趣的是,啟動模塊是非常和妖魔的用戶環境相關的,也就是在實驗室里測量的結果,和實際的用戶使用的結果是有很大的誤差的。這也就給啟動流程框架提了一個重要的需求,那就是能支持方便的對各個啟動模塊進行性能測量監控
由於有多個項目都期望接入啟動流程框架,因此啟動流程框架應該做到足夠的抽象,最好不能有耦合單一項目的功能
經過了大概一年的開發時間,在 2019 年正式將啟動流程框架投入使用。當前在近千萬台設備上跑著啟動流程框架的邏輯
当前此启动流程框架的库在 GitHub 上,基于最友好的 MIT 协议,也就是大家可以随便用的协议进行开源,开源地址: https://github.com/dotnet-campus/dotnetCampus.ApplicationStartupManager
功能
我所在的团队开源的 ApplicationStartupManager 启动流程框架的库提供了如下的卖点
- 自動構建啟動流程圖
- 支持高性能異步多線程的啟動任務項執行
- 支持 ui 線程自動調度邏輯
- 動態分配啟動任務資源
- 支持接入預編譯框架
- 支持所有的 .net 應用
- 啟動流程耗時監控
啟動流程圖
各個啟動任務項之間,必然存在顯式或隱式依賴,如依賴某個邏輯或模塊初始化,或者依賴某個服務的註冊,或者有執行時機的依賴。在開發者梳理完成依賴之後,給各個啟動任務項確定相互之間的依賴關係,即可根據此依賴關係構建出啟動流程圖
假設有以下幾個啟動任務項,啟動任務項之間有相互的依賴關係,如下圖,使用箭頭表示依賴關係

- 啟動任務項 a : 最先啟動的啟動任務項,如日誌或容器的初始化啟動任務項
- 啟動任務項 b : 一些基礎服務,但是需要依賴 a 啟動任務項完成才能執行
- 啟動任務項 c : 依賴 b 啟動任務項的執行完成
- 啟動任務項 d : 另一個獨立的模塊,和 b c e 啟動任務項沒有聯繫,但是也依賴 a 啟動任務項的完成
- 啟動任務項 e : 同時依賴 b c 啟動任務項的完成
- 啟動任務項 f : 同時依賴 a d 啟動任務項的完成
以上的啟動任務項可以構成一個有向無環啟動流程圖,每個啟動任務項都可以有自己的前置或後置。那為什麼需要是無環呢?要是有兩個啟動任務項是相互等待依賴的,那就自然就無法成功啟動了,如下圖,有三個啟動任務項都在相互依賴,那也就是說無論哪個啟動任務項先啟動,都是不符合預期的,因為先啟動的啟動任務項的前置沒有被滿足,啟動過程中邏輯上是存在有前置依賴沒有執行

為了更好的構建啟動流程圖,在邏輯上也加上了兩個虛擬的節點,那就是啟動點和結束點,無論是哪個啟動任務項,都會依賴虛擬的啟動點,以及都會跟隨著結束點
另外,具體業務方也會定義自己的關聯啟動過程,也就是預設的啟動節點,關鍵啟動過程點將被各個啟動項所依賴,如此即可人為將啟動過程分為多個階段
例如可以將啟動過程分為如下階段
- 啟動點: 虛擬的節點,表示應用啟動,用於構建啟動流程圖
- 基礎設施: 表示在此之前應該做啟動基礎服務的邏輯,例如初始化日誌,初始化容器等等。其他啟動任務項可以依賴基礎設施,從而認為在基礎設施之後執行的啟動任務項,基礎設施已準備完成
- 窗口啟動: 在客戶端程式的窗口初始化之前,需要完成 ui 的準備邏輯,例如樣式資源和必要的數據準備,或者 viewmodel 的注入等。在窗口啟動之後,即可對 ui 元素執行邏輯,或者註冊 ui 強相關邏輯。或者是在窗口啟動之後,執行那些不需要在主界面顯示之前執行的啟動任務項,從而提升主界面顯示性能
- 應用啟動: 完成了啟動的邏輯,在應用啟動之後的啟動任務項都是屬於可以慢慢執行的邏輯,例如觸發應用的自動更新,例如執行一下日誌文件清理等等
- 結束點: 虛擬的節點,表示應用啟動過程完全完成,用於構建啟動流程圖

如圖,每個啟動任務項可以選擇依賴的是具體的某個啟動任務項,也可以選擇依賴的是關鍵啟動過程點
通過此邏輯,可以為後續的優化做準備,也方便上層業務開發者開發業務層的啟動任務項。讓上層業務開發者可以比較清晰了解自己新寫的啟動任務項應該放在哪個地方,也可以提供了調試各個模塊的啟動任務項的依賴情況,了解是否存在循環的依賴邏輯
高性能異步多線程的啟動任務項執行
為了更好的壓榨機器性能,進行多線程啟動是必要的。在完成了啟動流程圖的構建之後,即可將啟動任務項畫成樹形,自然也就方便進行多線程調度。基於 .net 的 task 方式調度,可以實現多線程異步等待,解決多個啟動任務項的依賴在多線程情況下的線程安全問題
如使用線程池的 task 調度,可以從邏輯上,將不同的啟動任務項的啟動任務鏈劃分為給不同的線程執行。實際執行的線程是依靠線程池調度,甚至實際執行上,線程池只是用了兩個實際線程在執行

對應用的啟動過程中,在不明白 .net 線程池調度機制的情況下,將在開啟多線程問題上稍微有一點爭議。核心爭議的就是如果一個應用啟動過程中,占滿了 cpu 資源,是否就讓用戶電腦卡的不能動了。其實上面這個問題不好回答,如果大家有此疑惑,那就請聽我細細分析一下。首先一點就是問題本身,先問 問題 本身一個問題,如果只是開一個線程啟動,會不會也讓用戶的電腦卡的不能動了?答案是 是的,完全取決於用戶電腦,包括電腦配置以及電腦的妖魔環境,例如一個渣配的設備配合國產的好幾個殺毒軟體一起,那麼在應用啟動的瞬間,就有大量的殺毒工作在執行,自然就卡的不能動了。而且,電腦卡的不能動了,是不是和 cpu 被占滿是必然關係?答案是 完全不是,應用啟動過程中,一定會存在 dll 加載的過程,特別是應用的冷啟動過程,大量的文件讀寫,對於一些機械盤來說,將會占滿磁碟的讀寫,自然也就能讓電腦卡的不能動了,這個過程和是否開啟多線程,其實關係很小,畢竟機械盤和 cpu 之間的性能擺在這。第二個是卡的時間是否重要,例如應用開了多線程就卡了 500 毫秒,而如果應用啟動只用單線程則需要 4 x 500ms = 2s 的耗時,那是否此時開多線程划得來呢?這個是需要權衡的,不同的應用邏輯自然不同,例如生產力工具,我本來開機就是為了用此工具,例如寫代碼用的 visualstudio 工具,我打開了這個應用,過程中自然沒有其他同步使用的需求,卡了就卡了咯。最後一個問題就是,開啟 .net 的多線程完全不等於占滿了 cpu 資源,別忘了 io 異步哦
當然了,會接入應用流程的開發者肯定不屬於新手,相信對於線程方面知識已有所了解,會自己選擇合適的方式執行啟動任務項。這也側面告訴大家,本啟動流程框架的庫接入是有一定的門檻的
支持 ui 線程自動調度邏輯
對於客戶端應用,自然有一個特殊的線程是 ui 線程,啟動過程,有很多邏輯是需要在 ui 線程執行的。由於 .net 系的各個應用框架的 ui 線程調度都不咋相同,因此需要啟動流程框架執行一定量的適配
在具體的啟動任務項上標記當前的啟動任務項需要在 ui 線程執行即可,框架層將會自動調度啟動任務項到 ui 線程執行
設計上,默認將會調度啟動任務項到非 ui 線程執行
動態分配啟動任務資源
在用戶端的各個啟動任務項的耗時和在實驗室里測試的結果,無論是開發機還是測試機,大多數時候都是有很大的差值的。如果按照固定的順序去執行啟動任務項,自然有很多啟動時間都在空白的等待上。本啟動流程框架庫支持在啟動過程中,自動根據各個啟動任務項的耗時,動態進行調度
核心方法就是構建出來的啟動流程圖,支持各個任務的等待邏輯,基於 task 等待機制,即可進行動態調度等待邏輯,從而實現動態編排啟動任務項,在緊湊的時間內讓多條線程排滿啟動任務的執行。如果對應的上層業務開發者能正確使用 task 機制,例如正確使用異步等待,可以實現在啟動過程中極大隱藏
支持接入預編譯框架
启动过程是属于性能敏感的部分,各个模块的启动任务项如何收集是一个很大的问题。启动部分属于性能敏感部分,不合适采用反射的机制。好在 dotnet campus 里面有技术储备,在 2018 年的时候就开源了 SourceFusion 预编译框架,后面在 2020 年时吸取了原有 SourceFusion 的挖坑经验,重新开源了 dotnetCampus.Telescope 预编译框架,新开源的 dotnetCampus.Telescope 也放在 SourceFusion 仓库中
在 ApplicationStartupManager 启动流程框架开发之初就考虑了对接预编译框架,通过预编译提供了无须反射即可完成启动任务项收集的能力,可以极大减少因为启动过程中反射程序集的性能损耗
對接了預編譯框架,相當於原本需要在用戶端執行的邏輯的時間,搬到開發者編譯時,在開發者編譯時執行了原本需要在用戶端執行的邏輯。如此可以減少用戶端的執行邏輯的時間
接入了預編譯框架,可以實現在開發者編譯時,將所有項目的啟動任務項收集起來,包括啟動任務項類型和委託創建啟動任務項,以及啟動任務項的 attribute 特性
啟動流程耗時監控
對於大型應用來說,很重要的一點就是關注在用戶端的運行效果。啟動過程中,監控是十分重要的。監控最大的意義在於:
第一,可以了解到在用戶設備上,各個啟動任務項的實際執行耗時情況,從而在後續版本進行性能優化的時候,有數據支撐。否則憑藉在開發或測試端有限的設備上,很難跑出真正的性能瓶頸。如不僅關注在用戶設備上的 95 線啟動分布,所謂 95 線就是在百分之九十五的用戶上的啟動耗時分布,也可以關注關注 95 線到 99 線中間的用戶的啟動分布,了解一些比較特殊的設備的環境,從而做特別的優化
第二,可以做版本對比,做預警。對於大型應用,基本都有灰發和預發機制,通過在灰發過程中監控啟動耗時,可以對接預警機制,在某個啟動任務項耗時上升時告訴開發者。如此可以有利項目的長遠開發
最後一點,是可以告訴用戶,啟動的慢,是慢在哪一步。這個機制集中在提供了開放性上,例如 visual studio 將會不斷告訴你,啟動慢是哪個插件導致的
使用方法
在抽離了各個項目的定製化需求之後,啟動流程框架的庫只有核心的邏輯,這也就意味著在使用的時候,還需要具體的業務方自己加入初始化邏輯和適配業務的具體邏輯。換句話說是,接入啟動流程框架不是簡單安裝一下庫,然後調用 api 即可,而是需要根據應用的業務需求,進行一部分對接的工作。好在啟動流程框架只有在大型項目或者預期能做到大型的項目才適用,相比於大型應用的其他邏輯,對接啟動流程框架的代碼量基本可以忽略。對於小型項目或非多人協作的項目,自然是不合適的
整个 ApplicationStartupManager 启动流程框架设计上是高性能的,减少各个部分的性能内损。但是在上启动流程框架本身就存在一定的框架性能损耗,如果对应的只是小项目或非多人协作的项目,假设可以自己编排启动任务项,那自然自己编排启动任务项如此做是能达到性能最高的
应用 ApplicationStartupManager 启动流程框架能解决的矛盾点在于项目的复杂度加上多人协作的沟通,与启动性能之间的矛盾。接入启动流程框架可以让上层业务开发者屏蔽对启动过程细节的干扰,方便上层业务开发者根据业务需求加入启动任务项,方便启动模块维护者定位和处理启动任务项的性能
按照慣例,在使用 .net 的某個庫的第一步就是通過 nuget 安裝庫
第一步使用 nuget 安裝 applicationstartupmanager 庫。如果項目使用 sdk 風格的項目文件格式,可以在 csproj 項目文件上添加如下的代碼進行安裝
<ItemGroup>
<PackageReference Include="dotnetCampus.ApplicationStartupManager" Version="0.0.1-alpha01" />
</ItemGroup>
为了方便让大家看到 ApplicationStartupManager 启动流程框架库的效果,我采用了放在 https://github.com/dotnet-campus/dotnetCampus.ApplicationStartupManager 里的例子代码来作为例子
新建三個項目,分別如下
- wpfdemo.lib1: 代表底層的各個組件庫,特別指業務組件
- wpfdemo.api: 應用的 api 層的程式集,將在這裡部署啟動流程的框架邏輯
- wpfdemo.app: 應用的頂層,也就是 main 函數所在的程式集,在這裡觸發啟動的邏輯
大概的抽象之後的應用的模型架構如下,不過為了演示方便,就將 business 層和 app 層合一,將眾多的 lib 組件合為一個 lib1 項目

新建完成項目,也安裝完成 nuget 包,現在就是開始在 api 層搭建應用相關聯的啟動框架邏輯。為什麼在安裝完成了 nuget 包之後,還需要 api 做額外的邏輯?每個應用都有自己獨特的邏輯,每個應用的啟動任務項所需的參數是不相同的,每個應用的日誌記錄方式也可以是不相同的,不同類型的應用的啟動節點也是不相同的,如此這些都是需要做應用相關的定製的
先定義應用相關的預設的啟動節點
/// <summary>
/// 包含预设的启动节点。
/// </summary>
public class StartupNodes
{
/// <summary>
/// 基础服务(日志、异常处理、容器、生命周期管理等)请在此节点之前启动,其他业务请在此之后启动。
/// </summary>
public const string Foundation = "Foundation";
/// <summary>
/// 需要在任何一个 Window 创建之前启动的任务请在此节点之前。
/// 此节点之后将开始启动 UI。
/// </summary>
public const string CoreUI = "CoreUI";
/// <summary>
/// 需要在主 <see cref="Window"/> 创建之后启动的任务请在此节点之后。
/// 此节点完成则代表主要 UI 已经初始化完毕(但不一定已显示)。
/// </summary>
public const string UI = "UI";
/// <summary>
/// 应用程序已完成启动。如果应该显示一个窗口,则此窗口已布局、渲染完毕,对用户完全可见,可开始交互。
/// 不被其他业务依赖的模块可在此节点之后启动。
/// </summary>
public const string AppReady = "AppReady";
/// <summary>
/// 任何不关心何时启动的启动任务应该设定为在此节点之前完成。
/// </summary>
public const string StartupCompleted = "StartupCompleted";
}
定義完成之後,即可通過此將啟動過程分為如下階段

再定義一個和應用業務方相關的日誌類型,不同的應用記錄日誌的方式大部分都是不相同的,所使用的底層日誌記錄也都是不相同的
/// <summary>
/// 和项目关联的日志
/// </summary>
public class StartupLogger : StartupLoggerBase
{
public void LogInfo(string message)
{
Debug.WriteLine(message);
}
public override void ReportResult(IReadOnlyList<IStartupTaskWrapper> wrappers)
{
var stringBuilder = new StringBuilder();
foreach (var keyValuePair in MilestoneDictionary)
{
stringBuilder.AppendLine($"{keyValuePair.Key} - [{keyValuePair.Value.threadName}] Start:{keyValuePair.Value.start} Elapsed:{keyValuePair.Value.elapsed}");
}
Debug.WriteLine(stringBuilder.ToString());
}
}
如例子上的日志就是记录到 Debug.WriteLine 输出,同时日志里也添加了 LogInfo 方法
继续定制应用业务相关的启动任务项的参数,如例子代码的项目就用到了 dotnetCampus.CommandLine 提供的命令行参数解析,各个启动任务项也许会用到命令行参数,因此也就需要带入到启动任务项的参数里面,作为一个属性。例子代码的项目也用到了 dotnetCampus.Configurations 高性能配置文件库 提供的应用软件配置功能,也是各个启动任务项所需要的,放入到启动任务项的参数
加上和應用業務相關的屬性之後的啟動任務項的參數定義如下
public class StartupContext : IStartupContext
{
public StartupContext(IStartupContext startupContext, CommandLine commandLine, StartupLogger logger, FileConfigurationRepo configuration, IAppConfigurator configs)
{
_startupContext = startupContext;
Logger = logger;
Configuration = configuration;
Configs = configs;
CommandLine = commandLine;
CommandLineOptions = CommandLine.As<Options>();
}
public StartupLogger Logger { get; }
public CommandLine CommandLine { get; }
public Options CommandLineOptions { get; }
public FileConfigurationRepo Configuration { get; }
public IAppConfigurator Configs { get; }
public Task<string> ReadCacheAsync(string key, string @default = "")
{
return Configuration.TryReadAsync(key, @default);
}
private readonly IStartupContext _startupContext;
public Task WaitStartupTaskAsync(string startupKey)
{
return _startupContext.WaitStartupTaskAsync(startupKey);
}
}
为了继续承接 WaitStartupTaskAsync 的功能,于是构造函数依然带上 IStartupContext 用于获取框架里默认提供的启动任务项的参数。上面代码的 Configuration 和 Configs 两个属性都是 dotnetCampus.Configurations 高性能配置文件库 提供的功能,可以使用 COIN 格式进行配置文件的读写
完成了啟動任務項的參數的定義,就可以來定製具體應用的啟動任務項的基類型了。因為啟動任務項的基類型一定是和啟動任務項的參數相關,而啟動任務項的參數每個應用都有所不同,因此啟動任務項的基類型也就不同。即使不同的程度只有啟動任務項的參數,代碼層面可以使用泛形來解決,但也會因為泛形的將會讓業務層的代碼量較多,不如在應用上再定義
/// <summary>
/// 表示一个和当前业务强相关的启动任务
/// </summary>
public class StartupTask : StartupTaskBase
{
protected sealed override Task RunAsync(IStartupContext context)
{
return RunAsync((StartupContext) context);
}
protected virtual Task RunAsync(StartupContext context)
{
return CompletedTask;
}
}
如上代碼,所有的應用的業務端都應該繼承 startuptask 作為啟動任務項的基類。繼承之後,依然是重寫 runasync 方法,在此方法裡面執行業務邏輯
這裡設計上讓 runasync 作為一個虛方法而不是一個抽象方法是因為有一些應用業務上需要一點占坑用的啟動任務項,這些啟動任務項沒有實際邏輯功能,只是為了優化啟動流程的編排而添加。另外重要的一點在於可以讓上層業務開發者在編寫到一些只有同步的邏輯時,解決不知道如何返回 runasync 的 task 的問題,可以讓上層業務開發者自然返回 base.runasync 方法的結果,從而減少了各個詭異的返回 task 的方法
在完成了定製啟動任務基類型之後,就需要編寫基於 startupmanagerbase 的和應用業務相關的 startupmanager 類型,在這裡的邏輯需要包含如何啟動具體的啟動任務項的邏輯,代碼如下
/// <summary>
/// 和项目关联的启动管理器,用来注入业务相关的逻辑
/// </summary>
public class StartupManager : StartupManagerBase
{
public StartupManager(CommandLine commandLine, FileConfigurationRepo configuration, Func<Exception, Task> fastFailAction, IMainThreadDispatcher mainThreadDispatcher) : base(new StartupLogger(), fastFailAction, mainThreadDispatcher)
{
var appConfigurator = configuration.CreateAppConfigurator();
Context = new StartupContext(StartupContext, commandLine, (StartupLogger) Logger, configuration, appConfigurator);
}
private StartupContext Context { get; }
protected override Task<string> ExecuteStartupTaskAsync(StartupTaskBase startupTask, IStartupContext context, bool uiOnly)
{
return base.ExecuteStartupTaskAsync(startupTask, Context, uiOnly);
}
}
以上代碼通過重寫 executestartuptaskasync 方法實現在調用具體的啟動任務項傳入業務相關的 startupcontext 參數
如果應用有更多的需求,可以重寫 startupmanagerbase 更多方法,包括導出所有的啟動項的 exportstartuptasks 方法,重寫此方法可以讓應用定義如何導出所有的啟動任務項。重寫 addstartuptaskmetadatacollector 方法可以讓應用定義如何加入被管理的程式集中的啟動信息等
以上幾步完成之後,還有一項需要完成的是,剛才新建的 wpfdemo.api 項目其實沒有加上 wpf 的依賴,而在應用裡面,是有啟動任務項需要依賴在 ui 線程執行,於是就在加上 wpf 的依賴的 wpfdemo.app 上完成定義
class MainThreadDispatcher : IMainThreadDispatcher
{
public async Task InvokeAsync(Action action)
{
await Application.Current.Dispatcher.InvokeAsync(action);
}
}
以上的基礎完成之後,就可以在 program.cs 的主函數將啟動框架跑起來,進入到 wpfdemo.app 項目的 program 類型,在主函數裡面先解析命令行,然後再創建 app 再跑起啟動框架
[STAThread]
static void Main(string[] args)
{
var commandLine = CommandLine.Parse(args);
var app = new App();
//开始启动任务
StartStartupTasks(commandLine);
app.Run();
}
在 startstartuptasks 方法裡面使用 task.run 的方式在後台線程跑起來啟動框架,如此可以讓主線程也就是此應用的 ui 線程開始跑起來界面相關邏輯
private static void StartStartupTasks(CommandLine commandLine)
{
Task.Run(() =>
{
// 1. 读取应用配置
// 应用将会根据配置决定启动的行为
var configFilePath = "App.coin";
var repo = ConfigurationFactory.FromFile(configFilePath);
// 2. 对接预编译模块,获取启动任务项
var assemblyMetadataExporter = new AssemblyMetadataExporter(BuildStartupAssemblies());
// 3. 创建启动框架和跑起来
var startupManager = new StartupManager(commandLine, repo, HandleShutdownError, new MainThreadDispatcher())
// 3.1 导入预设的应用启动节点,这是必要的步骤,业务方的各个启动任务项将会根据此决定启动顺序
.UseCriticalNodes
(
StartupNodes.Foundation,
StartupNodes.CoreUI,
StartupNodes.UI,
StartupNodes.AppReady,
StartupNodes.StartupCompleted
)
// 3.2 导出程序集的启动项
.AddStartupTaskMetadataCollector(() =>
// 这是预编译模块收集的应用的所有的启动任务项
assemblyMetadataExporter.ExportStartupTasks());
startupManager.Run();
});
}
以上的例子应用里面,有业务是需要根据配置决定启动过程,因此需要先读取应用配置。应用配置选取 dotnetCampus.Configurations 高性能配置文件库 可以极大减少因为读取配置而占用太多启动时间。以上的例子里,还对接了预编译模块。预编译模块的功能是收集应用里的所有启动任务项,如此可以极大提升收集启动任务项的耗时,也不需要让上层业务开发者需要手工注册启动任务项
以上代碼即可實現在 main 函數啟動之後,跑起來啟動框架。不過上面代碼編譯還不能通過,因為還沒有完成 assemblymetadataexporter 的邏輯,這個預編譯模塊相關邏輯
這不等價於這套啟動框架強依賴於預編譯模塊,而是說可選接入預編譯模塊。只需要有任何的邏輯,能對接 addstartuptaskmetadatacollector 方法,在此方法裡面能傳入獲取應用所需的啟動任務項即可。無論使用任何的方式,包括反射等都是可以的。接入預編譯模塊只是為了優化性能,減少收集啟動任務項的耗時
接下來就是預編譯模塊的接入邏輯,本文不涉及 telescope 預編譯模塊的原理部分,只包含如何接入的方法
和 .NET 的其他库一样,为了接入预编译模块,就需要先安装 NuGet 库。通过 NuGet 安装 dotnetCampus.Telescope 库,如果是新 SDK 风格的项目文件,可以编辑 csproj 项目文件,添加如下代码安装
<ItemGroup>
<PackageReference Include="dotnetCampus.TelescopeSource" Version="1.0.0-alpha02" />
</ItemGroup>
不同于其他的库,由于 dotnetCampus.Telescope 预编译框架是对项目代码本身进行处理的,需要每个用到预编译都安装此库,因此需要为以上三个项目都安装,而不能靠引用依赖自动安装
安裝完成之後,在項目上新建一個 assemblyinfo.cs 的文件,給程式集添加特性。按照約定,需要將 assemblyinfo.cs 文件放入到 properties 文件夾裡面。這個 properties 文件夾算是一個特別的文件夾,在 visual studio 里新建就可以看到此文件夾的圖標和其他文件夾不相同
在 assemblyinfo.cs 文件裡面添加如下代碼
[assembly: dotnetCampus.Telescope.MarkExport(typeof(WPFDemo.Api.StartupTaskFramework.StartupTask), typeof(dotnetCampus.ApplicationStartupManager.StartupTaskAttribute))]
以上就是对接预编译框架的代码,十分简单。通过给程序集加上 dotnetCampus.Telescope.MarkExportAttribute 可以标记程序集的导出预编译的类型,传入的两个参数分别是导出的类型的基类型以及所继承的特性
以上代码表示导出所有继承 WPFDemo.Api.StartupTaskFramework.StartupTask 类型,且标记了 dotnetCampus.ApplicationStartupManager.StartupTaskAttribute 特性的类型
標記之後,重新構建代碼,將會在 obj 文件夾找到 attributedtypesexport.g.cs 生成文件,如在本文的例子項目裡面,生成文件的路徑如下
C:\lindexi\Code\ApplicationStartupManager\demo\WPFDemo\WPFDemo.Api\obj\Debug\net6.0\TelescopeSource.GeneratedCodes\AttributedTypesExport.g.cs
假設有一個叫 foo1startup 的啟動任務項定義如下
[StartupTask(BeforeTasks = StartupNodes.CoreUI, AfterTasks = StartupNodes.Foundation)]
public class Foo1Startup : StartupTask
{
protected override Task RunAsync(StartupContext context)
{
context.Logger.LogInfo("Foo1 Startup");
return base.RunAsync(context);
}
}
那麼生成的 attributedtypesexport.g.cs 將包含以下代碼
using dotnetCampus.ApplicationStartupManager;
using dotnetCampus.Telescope;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using WPFDemo.Api.StartupTaskFramework;
namespace dotnetCampus.Telescope
{
public partial class __AttributedTypesExport__ : ICompileTimeAttributedTypesExporter<StartupTask, StartupTaskAttribute>
{
AttributedTypeMetadata<StartupTask, StartupTaskAttribute>[] ICompileTimeAttributedTypesExporter<StartupTask, StartupTaskAttribute>.ExportAttributeTypes()
{
return new AttributedTypeMetadata<StartupTask, StartupTaskAttribute>[]
{
new AttributedTypeMetadata<StartupTask, StartupTaskAttribute>(
typeof(WPFDemo.Api.Startup.Foo1Startup),
new StartupTaskAttribute()
{
BeforeTasks = StartupNodes.CoreUI,
AfterTasks = StartupNodes.Foundation
},
() => new WPFDemo.Api.Startup.Foo1Startup()
),
};
}
}
}
也就是自動收集了程式集裡面的啟動項,生成收集的代碼
可以在启动框架模块里面,新建一个叫 AssemblyMetadataExporter 的类型来从 AttributedTypesExport.g.cs 拿到收集的类型。从 Telescope 拿到 __AttributedTypesExport__ 生成类型的方法是调用 AttributedTypes 的 FromAssembly 方法,代码如下
IEnumerable<AttributedTypeMetadata<StartupTask, StartupTaskAttribute>> collection = AttributedTypes.FromAssembly<StartupTask, StartupTaskAttribute>(_assemblies);
以上代码传入的 _assemblies 参数就是需要获取收集的启动任务项程序集列表,调用以上代码,将会从传入的各个程序集里获取预编译收集的类型
將此收集的返回值封裝為 startuptaskmetadata 即可返回給啟動框架
using System.Reflection;
using dotnetCampus.ApplicationStartupManager;
using dotnetCampus.Telescope;
namespace WPFDemo.Api.StartupTaskFramework
{
public class AssemblyMetadataExporter
{
public AssemblyMetadataExporter(Assembly[] assemblies)
{
_assemblies = assemblies;
}
public IEnumerable<StartupTaskMetadata> ExportStartupTasks()
{
var collection = Export<StartupTask, StartupTaskAttribute>();
return collection.Select(x => new StartupTaskMetadata(x.RealType.Name.Replace("Startup", ""), x.CreateInstance)
{
Scheduler = x.Attribute.Scheduler,
BeforeTasks = x.Attribute.BeforeTasks,
AfterTasks = x.Attribute.AfterTasks,
//Categories = x.Attribute.Categories,
CriticalLevel = x.Attribute.CriticalLevel,
});
}
public IEnumerable<AttributedTypeMetadata<TBaseClassOrInterface, TAttribute>> Export<TBaseClassOrInterface, TAttribute>() where TAttribute : Attribute
{
return AttributedTypes.FromAssembly<TBaseClassOrInterface, TAttribute>(_assemblies);
}
private readonly Assembly[] _assemblies;
}
}
回到 program.cs 裡面,新建一個 buildstartupassemblies 方法,此方法裡面,寫明需要收集啟動任務項的程式集列表,交給 assemblymetadataexporter 去獲取
class Program
{
private static void StartStartupTasks(CommandLine commandLine)
{
Task.Run(() =>
{
var assemblyMetadataExporter = new AssemblyMetadataExporter(BuildStartupAssemblies());
// 忽略其他逻辑
});
}
private static Assembly[] BuildStartupAssemblies()
{
// 初始化预编译收集的所有模块。
return new Assembly[]
{
// WPFDemo.App
typeof(Program).Assembly,
// WPFDemo.Lib1
typeof(Foo2Startup).Assembly,
// WPFDemo.Api
typeof(Foo1Startup).Assembly,
};
}
}
通過 startupmanager 的 addstartuptaskmetadatacollector 即可將導出的啟動任務項加入到啟動框架
var assemblyMetadataExporter = new AssemblyMetadataExporter(BuildStartupAssemblies());
var startupManager = new StartupManager(/*忽略代码*/)
// 导出程序集的启动项
.AddStartupTaskMetadataCollector(() => assemblyMetadataExporter.ExportStartupTasks());
startupManager.Run();
如此即可完成所有的應用的啟動框架配置邏輯,接下來就是各個業務模塊編寫啟動邏輯
通過添加各個業務模塊的啟動任務項演示啟動框架的使用方法
在 wpfdemo.app 添加 mainwindowstartup 用來做主窗口的啟動,代碼如下
using System.Threading.Tasks;
using dotnetCampus.ApplicationStartupManager;
using WPFDemo.Api.StartupTaskFramework;
namespace WPFDemo.App.Startup
{
[StartupTask(BeforeTasks = StartupNodes.AppReady, AfterTasks = StartupNodes.UI, Scheduler = StartupScheduler.UIOnly)]
internal class MainWindowStartup : StartupTask
{
protected override Task RunAsync(StartupContext context)
{
var mainWindow = new MainWindow();
mainWindow.Show();
return CompletedTask;
}
}
}
以上代碼通過 startuptask 特性標記了啟動任務項需要在 appready 之前執行完成,需要在 ui 之後執行,要求調度到主線程執行。對於主窗口顯示,自然是需要等待其他的 ui 相關邏輯執行完成,如 viewmodel 註冊和樣式字典初始化等才能顯示的。而只有在主窗口準備完成之後,才能算 appready 應用完成,因此可以如此編排啟動任務項
接下來再添加一個和業務相關的啟動任務項,添加 businessstartup 實現業務,業務要求在主界面添加一個按鈕。因此如需求,需要讓 businessstartup 在 mainwindowstartup 執行完成之後才能啟動,代碼如下
[StartupTask(BeforeTasks = StartupNodes.AppReady, AfterTasks = "MainWindowStartup", Scheduler = StartupScheduler.UIOnly)]
internal class BusinessStartup : StartupTask
{
protected override Task RunAsync(StartupContext context)
{
if (Application.Current.MainWindow.Content is Grid grid)
{
grid.Children.Add(new Button()
{
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Bottom,
Margin = new Thickness(10, 10, 10, 10),
Content = "Click"
});
}
return CompletedTask;
}
}
可以看到,在 BusinessStartup 里,通过 AfterTasks 设置了 MainWindowStartup 字符串,也就表示了需要在 MainWindowStartup 执行完成之后才能执行
此外,依賴關係是可以跨多個項目的,例如在基礎設施裡面有 wpfdemo.lib1 程式集的 libstartup 表示某個組件的初始化,這個組件屬於基礎設施,通過 beforetasks 指定要在 foundation 預設啟動節點啟動
[StartupTask(BeforeTasks = StartupNodes.Foundation)]
class LibStartup : StartupTask
{
protected override Task RunAsync(StartupContext context)
{
context.Logger.LogInfo("Lib Startup");
return base.RunAsync(context);
}
}
如上可以看到,在此框架設計上,給了 startuptask 類型的 runasync 作為虛方法,方便業務對接時,做同步邏輯,可以通過調用基類方法返回 task 對象
以上代碼只是標記了 beforetasks 而沒有標記 aftertasks 那麼將會默認給 aftertasks 賦值為虛擬的啟動點,也就是不需要等待其他啟動項
在 wpfdemo.api 程式集裡面有一個 optionstartup 表示根據命令行決定執行的邏輯,這個也屬於基礎設施,但是依賴於 libstartup 的執行完成,代碼如下
[StartupTask(BeforeTasks = StartupNodes.Foundation, AfterTasks = "LibStartup")]
class OptionStartup : StartupTask
{
protected override Task RunAsync(StartupContext context)
{
context.Logger.LogInfo("Command " + context.CommandLineOptions.Name);
return CompletedTask;
}
}
如此即可實現讓 optionstartup 在 libstartup 之後執行,且在 foundation 之前執行
以上的代碼的啟動圖如下,其中 libstartup 和 optionstartup 沒有要求一定要在 ui 線程,默認是調度到線程池裡執行

在 beforetasks 和 aftertasks 都是可以傳入多個不同的啟動項列表,多個之間使用分號分割。也可以換成使用 beforetasklist 和 aftertasklist 使用數組的方式,例如有 wpfdemo.api 程式集的 foo1startup 和在 wpfdemo.lib1 的 foo2startup 和 foo3startup 啟動任務項,其中 foo3startup 需要依賴 foo1startup 和 foo2startup 的執行完成,可以使用如下代碼
[StartupTask(BeforeTasks = StartupNodes.CoreUI, AfterTaskList = new[] { nameof(WPFDemo.Lib1.Startup.Foo2Startup), "Foo1Startup" })]
public class Foo3Startup : StartupTask
{
protected override Task RunAsync(StartupContext context)
{
context.Logger.LogInfo("Foo3 Startup");
return base.RunAsync(context);
}
}
以上就是应用接入 ApplicationStartupManager 启动流程框架的方法,以及业务方编写启动任务项的例子。以上的代码放在 https://github.com/dotnet-campus/dotnetCampus.ApplicationStartupManager 的例子项目