對于大型的應用軟體,特別是客戶端應用軟體,應用啟動程序中,需要執行大量的邏輯,包括各個模塊的初始化和注冊等等邏輯,大型應用軟體的啟動程序都是非常復雜的,而客戶端應用軟體是對應用的啟動性能有所要求的,不同于服務端的應用軟體,設想,用戶雙擊了桌面圖示,然而等待幾分鐘,應用才啟動完畢,那用戶下一步會不會就是點擊卸載了,為了權衡大型應用軟體在啟動程序,既需要執行復雜的啟動邏輯,又需要關注啟動性能,為此程序造一個框架是一個完全合理的事情,我所在的團隊為啟動程序造的庫,就是本文將要和大家介紹我所在團隊開源的 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 的例子專案
博客園博客只做備份,博客發布就不再更新,如果想看最新博客,請到 https://blog.lindexi.com/

本作品采用知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議進行許可,歡迎轉載、使用、重新發布,但務必保留文章署名[林德熙](http://blog.csdn.net/lindexi_gd)(包含鏈接:http://blog.csdn.net/lindexi_gd ),不得用于商業目的,基于本文修改后的作品務必以相同的許可發布,如有任何疑問,請與我[聯系](mailto:[email protected]),
轉載請註明出處,本文鏈接:https://www.uj5u.com/net/509221.html
標籤:.NET Core
上一篇:如何撰寫一個函式來查找字串陣列中的最長公共前綴,說明:所有輸入只包含小寫字母a~z ,如果不存在公共前綴,回傳空字串
