.NETアプリケーション向けにApplicationStartupManager起動フレームワークを導入

.NETアプリケーション向けにApplicationStartupManager起動フレームワークを導入

ユーザーがデスクトップアイコンをダブルクリックしてからアプリケーションが起動するまで数分待たされたら、次にアンインストールをクリックするのではないでしょうか。大規模なアプリケーションソフトウェアの起動プロセスにおいて、複雑な起動ロジックを実行しつつ起動パフォーマンスにも注意を払う必要があるため、このプロセスのためのフレームワークを構築することは完全に合理的です。

最終更新 2022/04/10 8:38
林德熙
読了目安 18 分
カテゴリ
.NET
タグ
.NET C#

大規模なアプリケーションソフトウェア、特にクライアントアプリケーションソフトウェアでは、アプリケーションの起動プロセス中に、各モジュールの初期化や登録など、多数のロジックを実行する必要があります。大規模なアプリケーションソフトウェアの起動プロセスは非常に複雑であり、クライアントアプリケーションソフトウェアは、サーバーアプリケーションとは異なり、アプリケーションの起動パフォーマンスに要件があります。ユーザーがデスクトップアイコンをダブルクリックした後に、アプリケーションが起動するまでに数分待たされる場合、次のステップはアンインストールをクリックすることになるでしょう。大規模なアプリケーションの起動プロセスにおいて、複雑な起動ロジックの実行と起動パフォーマンスの両方を両立させるために、このプロセス用のフレームワークを作成することは完全に合理的なことです。私のチームが起動プロセス用に作成したライブラリ、それが本記事で紹介する dotnetCampus.ApplicationStartupManager 起動フレームワークライブラリです。

背景

このライブラリの起源は、かつて VisualStudio チームの発表を聞いたことにあります。そのとき、先輩方から、VisualStudio の起動パフォーマンスを最適化するために、チームがアプリケーション起動時にCPU、メモリ、ディスクをフル活用するという面白い方向性を打ち出したと教えていただきました。もちろんこれは冗談で、本来の意味は、VisualStudio アプリケーションの起動時に、コンピュータの性能を徹底的に引き出すべきだということです。ちょうど私のチームにも多数の大規模なアプリケーションがあり、コードの MergeRequest 数が1万を超えるものもあります。これらのアプリケーションのロジックの複雑さは非常に高く、従来はモジュール間の依存関係の複雑さによる落とし穴を減らすために、単一スレッドで実行するしかありませんでした。しかし、その後アプリケーションの起動パフォーマンスを最適化するために、マシン性能を搾り取る戦略を検討し、その中にはマルチスレッド方式も含まれていました。

しかし、マルチスレッドを導入すると、必然的にスレッドに関連する多くの問題に遭遇します。最大の問題は、各起動モジュール間の依存関係をどのように処理するかです。適切なフレームワークがなく、開発者の個人的な能力だけに頼ってこのリファクタリングを行うのは完全に非現実的であり、長続きしません。もしかしたらこのバージョンでは最適化できても、次のバージョンではどうでしょうか?

もう一つ非常に重要なのは、各起動項目の消費時間を分析するなど、起動パフォーマンスの監視をどのように行うかです。起動の各ビジネスモジュールのパフォーマンス最適化を個別に行う前に、起動モジュールのパフォーマンス測定を行うことは非常に重要です。しかし面白いことに、起動モジュールは非常に特殊なユーザー環境に関係しており、実験室での測定結果と実際のユーザー使用時の結果には大きな誤差があります。これにより、起動フレームワークには、各起動モジュールのパフォーマンス測定・監視を容易にサポートするという重要な要件が生じます。

複数のプロジェクトで起動フレームワークの導入が期待されているため、起動フレームワークは十分に抽象化され、単一プロジェクトの機能に結合しないことが望まれます。

約1年の開発期間を経て、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の両方の起動タスク項目の完了に依存。

上記の起動タスク項目は、有向非巡回起動フローチャートを構成できます。各起動タスク項目は、自身の前置または後置を持つことができます。では、なぜ非巡回である必要があるのでしょうか?もし2つの起動タスク項目が互いに待機依存している場合、当然ながら正常に起動できません。下図のように、3つの起動タスク項目が互いに依存している場合、どの起動タスク項目が先に起動しても期待通りではなく、先に起動したタスク項目の前置条件が満たされておらず、論理的に実行されていない前置依存が存在することになります。

起動フローチャートをより良く構築するために、論理的には起動点と終了点という2つの仮想ノードも追加されています。どの起動タスク項目も、仮想的な起動点に依存し、またすべてが終了点に従います。

また、具体的なビジネス側は、自身に関連する起動プロセスを定義することもできます。つまり、事前定義された起動ノードであり、重要な起動プロセス点は各起動項目によって依存されます。これにより、人為的に起動プロセスを複数のフェーズに分割できます。

例えば、起動プロセスを以下のフェーズに分割できます。

  • 起動点: 仮想ノード。アプリケーション起動を表し、起動フローチャート構築用。
  • 基盤インフラ: この前に基本サービスの起動ロジック(ログ初期化、コンテナ初期化など)を実行すべきであることを示す。他の起動タスク項目は基盤インフラに依存することで、基盤インフラ後に実行される起動タスク項目では基盤インフラが準備完了しているとみなせる。
  • ウィンドウ起動: クライアントプログラムのウィンドウ初期化前に、UIの準備ロジック(スタイルリソースや必要なデータ準備、ViewModelの注入など)を完了する必要がある。ウィンドウ起動後は、UI要素に対するロジックの実行や、UIに強く関連するロジックの登録が可能。あるいは、メインインターフェース表示前に実行する必要がない起動タスク項目をウィンドウ起動後に実行することで、メインインターフェース表示パフォーマンスを向上させる。
  • アプリケーション起動: 起動ロジックが完了。アプリケーション起動後の起動タスク項目は、ゆっくり実行できるロジック(アプリケーションの自動更新のトリガー、ログファイルのクリーンアップなど)に属する。
  • 終了点: 仮想ノード。アプリケーション起動プロセスが完全に完了したことを表し、起動フローチャート構築用。

図のように、各起動タスク項目は、特定の起動タスク項目に依存することも、主要な起動プロセス点に依存することも選択できます。

このロジックにより、今後の最適化に備えられ、上位のビジネス開発者がビジネス層の起動タスク項目を開発しやすくなります。上位のビジネス開発者は、自分が新しく作成した起動タスク項目をどこに配置すべきかを比較的明確に理解でき、また各モジュールの起動タスク項目の依存関係をデバッグし、循環依存ロジックが存在しないかを確認できます。

高性能な非同期マルチスレッドによる起動タスク項目の実行

マシン性能をより良く活用するためには、マルチスレッド起動が必要です。起動フローチャートの構築が完了すると、起動タスク項目をツリー形式に描くことができ、マルチスレッドスケジューリングが容易になります。.NET の Task 方式によるスケジューリングを基盤とすることで、マルチスレッド非同期待機を実現し、マルチスレッド環境における複数の起動タスク項目の依存関係に起因するスレッド安全性の問題を解決できます。

例えば、スレッドプールの Task スケジューリングを使用すると、論理的に異なる起動タスク項目の起動タスクチェーンを異なるスレッドに割り当てることができます。実際に実行されるスレッドはスレッドプールに依存し、実際にはスレッドプールが2つの実スレッドだけで実行することもあります。

アプリケーション起動プロセスにおいて、.NET スレッドプールのスケジューリングメカニズムを理解していない場合、マルチスレッド使用について若干の議論が生じることがあります。中心的な論点は、アプリケーション起動中にCPUリソースを占有すると、ユーザーのコンピュータが操作不能になるのではないか、という点です。実はこの質問には答えにくいところがあります。もしこの疑問をお持ちでしたら、少々詳しく説明させてください。まず第一に、問題そのものに質問を投げかけます。もしスレッドを1つだけ起動した場合でも、ユーザーのコンピュータは操作不能になるのでしょうか?答えは「はい」で、それは完全にユーザーのコンピュータ次第であり、コンピュータの構成や特殊な環境(例えば、低スペックなデバイスに国産の複数のウイルス対策ソフトが同時に動作している場合など)に依存します。アプリケーション起動の瞬間に大量のウイルススキャンが実行され、当然操作不能になります。さらに、コンピュータが操作不能になるのは、CPUがフルに占有されることと必然的な関係があるのでしょうか?答えは「まったくそうではない」です。アプリケーション起動プロセスでは、必ずDLLの読み込みが発生します。特にアプリケーションのコールドスタート時には大量のファイル読み書きが発生し、機械式ディスクの場合、ディスクの読み書きが占有され、当然コンピュータは操作不能になります。このプロセスはマルチスレッドの有無とはほとんど関係がありません。機械式ディスクとCPUの性能差が明らかだからです。第二に、操作不能になる時間が重要かどうか。例えば、アプリケーションがマルチスレッドを使用したために500ミリ秒間操作不能になり、シングルスレッドでは4×500ms = 2秒かかる場合、マルチスレッドを使用する価値はあるでしょうか?これはトレードオフであり、アプリケーションのロジックによって異なります。例えば生産性ツールの場合、もともとこのツールを使うためにパソコンを起動しているのです。VisualStudio のようなコードを書くためのツールを起動する場合、その過程で他の同時使用のニーズはなく、操作不能になっても構わないでしょう。最後に、.NET のマルチスレッドはCPUリソースを完全に占有するわけではない(IO非同期を忘れないでください)という点も重要です。

もちろん、アプリケーションフローフレームワークを導入する開発者は初心者ではなく、スレッドに関する知識は既に理解しており、自身に適した方法で起動タスク項目を実行できると信じています。これは間接的に、この起動フレームワークライブラリの導入には一定の敷居があることを示しています。

UIスレッドの自動スケジューリングロジックのサポート

クライアントアプリケーションには、UIスレッドという特別なスレッドが存在します。起動プロセスでは、UIスレッドで実行する必要があるロジックが多数あります。.NET系の各アプリケーションフレームワークのUIスレッドスケジューリングはそれぞれ異なるため、起動フレームワークにある程度の適応が必要です。

特定の起動タスク項目に、その起動タスク項目がUIスレッドで実行される必要があることをマークするだけで、フレームワークが自動的にその起動タスク項目をUIスレッドで実行するようにスケジューリングします。

設計上、デフォルトでは起動タスク項目はUIスレッド以外で実行されるようにスケジューリングされます。

起動タスクリソースの動的割り当て

ユーザー環境における各起動タスク項目の消費時間は、実験室でのテスト結果(開発機やテスト機のいずれであっても)とはほとんどの場合大きな差があります。固定された順序で起動タスク項目を実行すると、多くの起動時間が無駄な待機に費やされます。この起動フレームワークライブラリは、起動プロセス中に各起動タスク項目の消費時間に基づいて自動的に動的スケジューリングを行うことをサポートします。

中心的な方法は、構築された起動フローチャートが各タスクの待機ロジックをサポートし、Task 待機メカニズムに基づいて動的待機ロジックをスケジューリングすることで、起動タスク項目を動的に編成し、コンパクトな時間内に複数のスレッドを起動タスクの実行で満たすことです。対応する上位ビジネス開発者が Task メカニズム(非同期待機など)を正しく使用できれば、起動プロセスの多くを隠蔽できます。

プリコンパイルフレームワークへの対応

起動プロセスはパフォーマンスに敏感な部分であり、各モジュールの起動タスク項目をどのように収集するかは大きな問題です。起動部分はパフォーマンスに敏感であるため、リフレクションメカニズムは適していません。幸いなことに、dotnet campus には技術的な蓄積があり、2018年には SourceFusion プリコンパイルフレームワークをオープンソース化し、その後2020年に、元の SourceFusion の経験を活かして、dotnetCampus.Telescope プリコンパイルフレームワークを再公開しました。新たに公開された dotnetCampus.TelescopeSourceFusion リポジトリに置かれています。

ApplicationStartupManager 起動フレームワークの開発初期から、プリコンパイルフレームワークとの連携を考慮していました。プリコンパイルを通じて、リフレクションを使わずに起動タスク項目を収集する機能を提供し、起動プロセスにおけるリフレクションによるアセンブリ列挙のパフォーマンス低下を大幅に削減できます。

プリコンパイルフレームワークと連携することは、本来ユーザー環境で実行する必要があったロジックの時間を、開発者のコンパイル時に移行することを意味します。開発者のコンパイル時に、本来ユーザー環境で実行する必要があったロジックが実行されます。これにより、ユーザー環境におけるロジック実行時間を削減できます。

プリコンパイルフレームワークを導入することで、開発者のコンパイル時に、すべてのプロジェクトの起動タスク項目(起動タスク項目の型、デリゲートで作成する起動タスク項目、起動タスク項目の Attribute 属性)を収集できます。

起動プロセス消費時間の監視

大規模アプリケーションにとって、ユーザー環境での動作を監視することは非常に重要です。起動プロセスにおいて、監視は極めて重要です。監視の最大の意義は以下の通りです。

第一に、ユーザーデバイス上での各起動タスク項目の実際の実行消費時間を把握でき、それにより後続のバージョンでパフォーマンス最適化を行う際にデータの裏付けが得られます。そうでなければ、開発やテスト環境の限られたデバイスでは、真のパフォーマンスボトルネックを特定することは困難です。例えば、ユーザーデバイス上の95パーセンタイルの起動分布(95%のユーザーの起動消費時間分布)に注目するだけでなく、95パーセンタイルから99パーセンタイルの間のユーザーの起動分布にも注目し、特別なデバイス環境を理解して特別な最適化を行うことができます。

第二に、バージョン比較やアラート発報が可能になります。大規模アプリケーションでは、通常グレイリリースや事前リリースのメカニズムがあり、グレイリリース中に起動消費時間を監視することで、特定の起動タスク項目の消費時間が増加した場合に開発者に通知するアラートメカニズムと連携できます。これにより、プロジェクトの長期的な開発に役立ちます。

最後に、起動が遅い場合、どのステップが遅いのかをユーザーに伝えることができます。このメカニズムは開放性に重点を置いており、例えば VisualStudio は起動が遅い原因となっているプラグインを常に通知します。

使用方法

各プロジェクトのカスタマイズ要件を抽出した結果、起動フレームワークライブラリはコアロジックのみを持つことになりました。これは、使用時には具体的なビジネス側が自ら初期化ロジックを追加し、ビジネスの具体的なロジックに適応させる必要があることを意味します。言い換えれば、起動フレームワークの導入は単にライブラリをインストールして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 にあるサンプルコードを例として使用します。

新しいプロジェクトを3つ作成します。それぞれ次のようになります。

  • WPFDemo.Lib1: 基盤となる各コンポーネントライブラリを表します。特にビジネスコンポーネント。
  • WPFDemo.Api: アプリケーションの API 層のアセンブリ。ここで起動フローのフレームワークロジックをデプロイします。
  • WPFDemo.App: アプリケーションのトップレベル、つまり Main 関数があるアセンブリ。ここで起動ロジックをトリガーします。

大まかに抽象化した後のアプリケーションのモデルアーキテクチャは次のとおりですが、デモの便宜上、Business 層と App 層を統合し、多数の Lib コンポーネントを1つの 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 の2つのプロパティは、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 の main 関数で起動フレームワークを実行できます。WPFDemo.App プロジェクトの Program 型で、main 関数内でまずコマンドラインを解析し、次に 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 プリコンパイルフレームワークはプロジェクトコード自体を処理するため、プリコンパイルを使用するすべてのプロジェクトにこのライブラリをインストールする必要があります。そのため、上記3つのプロジェクトすべてにインストールする必要があり、参照依存による自動インストールは機能しません。

インストールが完了したら、プロジェクトに 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 を追加することで、アセンブリのプリコンパイルされた型のエクスポートをマークします。2つのパラメータは、それぞれエクスポートする型の基本型と継承する属性です。

上記コードは、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()
                ),
            };
        }
    }
}

つまり、アセンブリ内の起動項目が自動的に収集され、収集コードが生成されます。

起動フレームワークモジュール内で、AttributedTypesExport.g.cs から収集された型を取得するための AssemblyMetadataExporter という型を新規作成できます。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 の後で実行する必要があり、メインスレッドで実行するようスケジューリングすることを示しています。メインウィンドウの表示には当然、ViewModel の登録やスタイル辞書の初期化など、他のUI関連ロジックが完了した後に表示する必要があります。そして、メインウィンドウの準備が完了して初めて 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 のサンプルプロジェクトにあります。

さらに探索

関連読書

その他の記事
同じカテゴリ / 同じタグ 2026/04/22

各OSバージョンの.NETサポート状況(250707更新)

仮想マシンとテストマシンを使用して、各OSバージョンの.NETサポート状況を確認します。OSインストール後、対応するランタイムをインストールし、Stardustエージェントを実行できることを確認します(合格条件)。

続きを読む
同じカテゴリ / 同じタグ 2026/02/07

AOTの使用経験のまとめ

プロジェクト作成当初から、新機能を追加したり新しい構文を使用したりした場合には、すぐにAOT公開テストを実施するという良い習慣を身につけるべきです。

続きを読む