dotnet 為大型應用接入 ApplicationStartupManager 啟動流程框架

2022-09-21 09:00:25

對於大型的應用軟體,特別是使用者端應用軟體,應用啟動過程中,需要執行大量的邏輯,包括各個模組的初始化和註冊等等邏輯。大型應用軟體的啟動過程都是非常複雜的,而使用者端應用軟體是對應用的啟動效能有所要求的,不同於伺服器端的應用軟體。設想,使用者雙擊了桌面圖示,然而等待幾分鐘,應用才啟動完畢,那使用者下一步會不會就是點選解除安裝了。為了權衡大型應用軟體在啟動過程,既需要執行復雜的啟動邏輯,又需要關注啟動效能,為此過程造一個框架是一個完全合理的事情。我所在的團隊為啟動過程造的庫,就是本文將要和大家介紹我所在團隊開源的 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 用於獲取框架裡預設提供的啟動任務項的引數。上面程式碼的 ConfigurationConfigs 兩個屬性都是 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 的例子專案