.NET6 で Generic Host を使った常駐アプリ

久々の技術系エントリは .NET Core 以降のパッケージに含まれている Generic Host(汎用ホスト)を使用してコンソールタイプの常駐アプリを作成する方法を紹介します。

尚、このエントリは Visual Studio 2022 Community Edition で .NET 6 以降 と C# を使用するエントリなので、C# での基本的なコーディング知識を持っている人が対象です。

作成するアプリの機能

このエントリで紹介するサンプルアプリは『最近使ったファイル』フォルダを監視して、特定のファイル(拡張子で判別)が追加されるとアプリ内の DB へ登録するだけの単純なアプリです。

常駐アプリ?

上に書いた特徴を見て、Windows サービスアプリとして作成すれば良いのでは?と思った人も居るかもしれません。管理人も元々 Windows サービスを作成するエントリにしようと思って、試しに作成してみましたが、以下のような理由で Windows サービスとして動かすことを断念しました。

  • Windows サービスとして起動はできたが、想定した動作をしていない
  • 簡易的なデバッグログを仕込んで確認すると『最近使ったファイル フォルダ』が取得できていないことが発覚
  • 『最近使ったファイル フォルダ』はユーザ別フォルダである事に気付いたので、ログオンユーザを確認すると『ローカル System アカウント』でサービスが作成されていた。そこから Environment.GetFolderPath(Environment.SpecialFolder.Recent) は『ローカル System アカウント』のユーザ別フォルダパスを取得しようとしていると予想。
    ※ ローカル System アカウントのユーザフォルダは用意されていないため、空文字が返ってきていると思われます。
  • 『最近使ったファイル フォルダ』のパスを予め設定ファイルに書いておく方法も試したが結局、別ユーザ(ログインしているユーザ)のユーザ別フォルダにはアクセス権が無いため『最近使ったファイル フォルダ』の中身が読み取れない
  • Windows サービスの起動ユーザに自分(ログインユーザ)のアカウントとパスワードを設定してみたが、サービスの起動に失敗(なぜ起動に失敗しているのか原因は不明)
  • 他の手段も試してみることも考えたが、元々想定していたエントリの内容から離れそうな気がしたため、Windows サービスでの実行を断念。
  • サービスとしての動作確認中も exe をダブルクリック等で起動すれば正常に動作することは確認できていたため、バックグラウンドで動作する常駐アプリとしてエントリを書くことに方針転換。

Windows サービスの実行ユーザやフォルダのアクセス権限等の認識不足が原因で、Windows サービスとして作成することは断念しましたが、フォルダを監視するアプリは Windows サービスとして作成できないと言う意味ではありません

あくまでもサービスを開始するユーザ(ローカル System アカウント)と情報を取得したいユーザ(ログインユーザ)が違う場合は注意が必要と言う事です。サービスを開始するユーザにログインユーザのアカウントを設定して起動できれば問題ないような気がしますが、サービスをインストールするために用意しようと考えていたバッチファイルを一般ユーザに編集させるのはハードルが高過ぎる気がして断念したと言う一面もあります。
(# 現状、一般に配布する予定がある訳ではありませんが…)

今回のサンプルアプリのようにユーザ別フォルダへのアクセスではなく、通常のフォルダを監視するようなアプリであれば問題なく動作すると思うので、要求仕様次第だと思います。

以上のような経緯で今回のエントリは Windows サービスではなく常駐アプリとして作成することにしましたが、ここで紹介するサンプルアプリも Windows サービスとして作成するアプリもほとんど差異が無いので、このエントリで紹介している内容はほとんど適用できます
※ サンプルアプリを Windows サービスとして実行するための実装もエントリ末尾の『おまけ』で紹介しています。

Generic Host(汎用ホスト)を利用して常駐アプリを作成する

Windows に常駐するアプリを作成する場合、よく見かけるのは Windows Form や WPF 等のフォーム系アプリで、通知領域にアイコンを表示するパターンだと思いますが、画面が不要な場合もあると思います。通常、画面が不要な常駐アプリを作成する場合はコンソールアプリテンプレートから作成すると思いますが、.NET Core から導入された Generic Host(汎用ホスト)を利用すると、どんなアプリでも使うと思われる機能が予め導入されるため作成が楽になります。

Generic Host(汎用ホスト)とは

Generic Host(汎用ホスト)は以下のような機能をカプセル化したオブジェクトです。

  • 依存関係の挿入
    DI コンテナ
  • ログの記録
    ファイル、コンソール、EventLog 等への出力
  • 構成
    設定ファイルや環境変数などからの設定の読み込み
  • IHostedService の実装
    アプリ開始時の初期化や終了前のクリーンアップ処理の実行等のアプリケーションのライフサイクル管理

Generic Host は元々 ASP.NET Core で Web Host と呼ばれていたものから Web 固有の機能を分離したもので、今回紹介するコンソールアプリだけでなく WPF や Windows Forms 等にも組み込みが可能です。

WPF の場合は以前このサイトで公開した『WPF に対応した Windows Template Studio』に書いた通り Prism と Generic Host を共用するプロジェクトを作成することができます。ただ、エントリ公開時から WTS 自体がバージョンアップしているので最新版と内容が違う個所もあると思いますが…

このエントリでは上で紹介した Generic Host 4 つの特徴の内、1 番目の【依存関係の挿入 (DI コンテナ)】をメインに紹介します。

Generic Host を組み込んだプロジェクト

コンソールアプリを作成する場合、通常は【コンソールアプリ テンプレート】を選択すると思いますが、Generic Host を組み込んだコンソールアプリを作成する場合は、fig. 1 【ワーカーサービス プロジェクトテンプレート】を選択するとほぼそのまま使用できるコードが生成されます。
※ .NET Framework には【ワーカーサービス テンプレート】は存在しません。

fig.1 ワーカーサービス プロジェクトテンプレート

テンプレート選択後、プロジェクト名等の設定後に fig. 2 の【追加情報 ページ】が開きます。

fig.2 追加情報

ここでは fig. 2 のようにターゲットフレームワークのみ設定します。.NET Core 以降はクロスプラットフォームに対応しているので Docker イメージも同時に作成することができますが、管理人はあまり理解できていないので、ここでは有効にしません。

プロジェクトが作成されると fig. 3 のようなファイルが生成されます。

fig. 3 テンプレートから生成されたファイル

プロジェクト作成直後でも実行可能なコードが生成されているので、【デバッグの開始】で起動すると、fig. 4 のような実行画面(画像クリックで再生・停止を切り替え)が表示されます。

fig.4 プロジェクト作成直後の実行画面

fig. 4 の通り(クリックして再生開始)1 秒ごとにコンソールへログが出力されている事が確認できると思います。つまり、一定期間ごとに処理を繰り返して実行するタイマーのような動作をするアプリを作成するのであれば、アプリの基本部分は既に作成済みの状態になっています。

以降はテンプレートから生成されたソースを紹介していきます。

テンプレートから生成されたソースコード

src. 1 は Program.cs に生成されたコードです。

.NET6 ではトップレベルステートメントを使用した暗黙のエントリポイント(C#9.0 以降)が生成されるので、今までの記述に慣れていると見にくいかもしれませんが、【Host.CreateDefaultBuilder】で生成した IHost(汎用ホスト)を非同期で実行するコードが生成されています。

src. 1 の IHost.RunAsync が呼び出されると 6 行目に DI されている Worker クラスの StartAsync が呼び出されます。src. 2 は Worker クラスに生成されるコードです。

ワーカーサービステンプレートから生成したプロジェクトは、アプリのエントリポイント ⇒ IHost.RunAsyncBackgroundService.StartAsync(override 可能)⇒ BackgroundService.ExecuteAsync の順に呼び出されるので、アプリの機能は src. 2 の 12 ~ 19行目の ExecuteAsync 内に実装することになります。

プロジェクト生成直後の ExecuteAsync には src. 2 の 14 ~ 18行目のようにコンソールへログを出力するコードが生成されているので、実行すると fig. 4 のように Task.Delay の第 1 パラメータに指定したミリ秒ごとにログが出力されます。

今回このエントリで紹介するサンプルアプリは一定期間ごとに処理を実行するタイプではなく、フォルダを監視するタイプのアプリなので、クラス名と ExecuteAsync 内の処理を src. 3 のように変更しておきます。

src. 3 の 16 ~ 18 行目の終了処理は【Creating a Windows Service with C#/.NET5 | #ifdef Windows】で紹介されているサンプルコードをそのまま流用しています。

但し、上記ブログサンプルのままだと fig. 5 の【Null 許容 設定値(プロジェクトのプロパティ)】次第で CS8602 警告が表示されてしまうので、17 行目のみ若干修正しています。

fig.5 Null 許容設定

fig. 5 のように【Null 許容】が【有効化】に設定されていると、元のサンプルコードのままでは『CS8602 警告』が表示されます。

src. 3 の 16 ~ 18 行目を追加すると、14 行目の処理実行後にアプリ自体が待機状態になり、コマンドプロンプトの『×ボタン』や『Ctrl + c』等で実行が中断されるまで待機し続けます。

以降は、14 行目に追加すべきサンプルアプリ本来の処理について紹介します。

FileSystemWatcher でフォルダを監視する

最初に紹介した通り、最近使ったファイルフォルダを監視するため、内部で FileSystemWatcher を使用する RecentFileWatcher クラスを新たに追加して src. 4 のように実装します。

C#10 からは namespace を【{}】でくくらなくても良くなったので省略しています。19 行目の shellLink.GetLinkSourceFilePathWindowsIShellLink インタフェースを利用してショートカットファイルからリンク先のファイルパスを取得していますが、ここでは詳しく紹介しません。ショートカットファイルを取り扱う方法については別エントリに書いたので、【Windows Script Host で情報が取得できないショートカットファイル】を見てください。

src. 4 のコード量は多くないので、src. 3 の RecentWatcherWorker の中に書いても構わないと思いますが、テストファーストで進めるなら別クラスに分けた方がテストし易いと管理人個人的には考えているので、別クラスに分けています。

この時点では『最近使ったファイル フォルダ』を監視して、追加されたショートカットファイルのリンク先のフルパスをコンソールに表示しているだけですが、サンプルアプリのコア機能は実装完了です。以降は、src. 4 の RecentFileWatcher を DI コンテナから DI する方法を紹介します。

Generic Host の DI サポート

src. 5 は Generic Host の DI コンテナへクラスを登録するコードで、7 行目のように登録するクラスを IServiceCollectionAdd する事で登録できます。

src. 1 で紹介した際は触れませんでしたが、少し詳しく紹介します。

CreateDefaultBuilder

まず、3 行目に見える【CreateDefaultBuilder】。これはテンプレートから自動生成されたコードですが、このメソッドを呼び出すと、上で紹介した Generic Host が持つ 4 つの機能が全て組み込まれます。つまり、DI コンテナやロガーの初期化、アプリの構成やライフサイクル管理等のどのアプリでも共通的に使用される機能が初期化されるので、開発者はアプリ固有の機能を追加するだけで済むようになっています。

基本的には CreateDefaultBuilder で作成されたままでも問題なく動作しますが、個別にカスタマイズしたい場合でも変更は可能になっています。ここで全てを紹介すると長くなってしまいますし、管理人も完全解説できる程精通している訳ではないので、別エントリに分けて紹介していく予定はあります。

ConfigureServices

src. 5 の 4 行目、ConfigureServices は本エントリのメインテーマとして位置付けした DI コンテナの設定を行うメソッドで主に、アプリ固有の機能を実装したクラスを DI コンテナへ登録する場合にコードを追加する箇所です。ConfigureServices 内で以下のような IServiceCollection の拡張メソッドを呼び出してクラスを登録します。

拡張メソッド内容
IServiceCollection.AddHostedServicesrc. 1 から既出のメソッドで、主に Worker クラスの登録に使用します。

AddHostedService で登録されたクラスは下で紹介する AddTransient で登録したクラスと同じ生存期間を持ちます。AddTransient との違いはアプリ起動時に IHostedService.StartAsync メソッド、アプリ終了時に IHostedService.StopAsync メソッドが呼び出される所です。

アプリ起動時に自動起動したいクラスはこのメソッドを使用して登録します。

IServiceCollection.AddSingletonsrc. 5 で RecentFileWatcher を登録する際に使用しているメソッドで、名前の通りシングルトンなクラスを登録する場合に使用します。

つまり、最初に作成されたインスタンスが使い回されるので、どこに DI されても同じインスタンスを使用したいクラスを登録する際に使用します。

IServiceCollection.AddTransient一時的な生存期間を持つクラスを登録するためのメソッドで、DI される度に新しいインスタンスを作成したいクラスを登録する際に使用します。
IServiceCollection.AddScoped上の AddTransient と同じく一時的な生存期間を持つクラスを登録するためのメソッドですが、このメソッドで登録したクラスはコンストラクタに DI できません。(後述します)

このメソッドで登録したクラスを AddSingleton 等で登録したクラスのコンストラクタに DI すると実行時に AggregateExceptionThrow されます。

IServiceCollection.AddOptionsappsettings.json 等に記述された設定値を DI したい場合に使用するメソッドのようですが、アプリ自体が小規模だったり、設定値は DB で管理したいような場合だと使用機会は無いかもしれません。

管理人は使用例を思い付いていないので、このエントリでは名前の紹介に留めます。

又、上記に加えて TryAdd~ メソッド(例:TryAddSingleton 等)も用意されていて(要 using Microsoft.Extensions.DependencyInjection.Extensions;)同一の型が複数回登録されることを防止することもできます。

IServiceCollection(DI コンテナ)へクラスを登録するためのメソッドは他にも用意されていますが、AddSingleton】【AddTransient】【AddScoped】の 3 つを押さえておけば、大体の事は賄えると思います。そして、これらクラスを登録するメソッドは戻り値が IServiceCollection になっているので、src. 5 のようにメソッドチェーンで記述することもできます。

Generic Host の標準 DI コンテナである Microsoft.Extensions.DependencyInjection.Extensions について、別記事の【Generic Host の DI たちが依存性を救うようです】で、もう少し詳しく紹介しているので、そちらも読んでもらえると、イメージが掴みやすいと思います。(2022/11/7 追記)

DI についての基本的な考え方については【Prism の DI コンテナらは Ioc 上に歌う【step: 4 .NET Core WPF Prism MVVM 入門 2020】】で詳しく紹介しているので、良ければ読んでください。

上で紹介したエントリは、Prism に同梱される DI コンテナについて紹介していますが、DI の考え方自体は同じなので参考になると思います(WPF アプリの作成に興味がない人にはキツイかもしれませんが…)

DI を利用する場合、DI コンテナへ登録する型はインタフェースを指定するのが基本だと思いますが、RecentFileWatcher はアプリのコア機能であり、RecentFileWatcher が存在しないと始まらない中心的なクラス(インタフェースを定義する意味がイマイチ感じられない)なので、インタフェースは作成せず実装型を DI コンテナへ登録しています。

何事にも例外はあるので、敢えてここではインタフェースの作成は必須ではなく、実装型も DI コンテナへ登録可能な事が分かるようなサンプルにしています。

DI コンテナに登録されたクラスの DI

前項で登録したクラスを使用するには src. 6 の 19 行目のようにコンストラクタインジェクションを利用して第 2 パラメータに RecentFileWatcher を追加します。

コンストラクタの第 1 パラメータに指定している ILogger<RecentWatcherWorker> はテンプレートから生成されたパラメータですが、この ILogger も DI されるクラスです。20 行目の代入には何となく Tuple を利用していますが、this.logger = workerLogger; this.watcher = recentFileWatcher; のように分けて書いても何の問題もありません。

後は DI されたインスタンスを 7 行目のように呼び出して RecentFileWatcher を実行します。【Prism の DI コンテナらは Ioc 上に歌う【step: 4 .NET Core WPF Prism MVVM 入門 2020】】でも書きましたが、src. 5 の 6 ~ 7 行目のように DI するクラスと DI されるクラスの両方を DI コンテナに登録することで DI が実現されます。

ここまでの状態で実行すると fig. 6(クリックすると再生を開始します)のように再生した動画ファイルと動画ファイルの保存先フォルダが表示されます。

fig.6 最近使ったファイルに追加されたファイルの表示

※ 上で再生した動画は Pixabay の cat-feline-whiskers-animal-66004 で配布されているファイルです。

次は取得したファイルの情報を保存するための DB と DB の読み書きについて簡単に紹介します。

取得したファイル情報を保存する SQLite と Dapper

今回のサンプルアプリで取得したファイル情報を保存する DB は SQLite を使用します。SQLite でなければならない事はありませんが、コピーするだけでインストールの必要もなく単一のファイルとして扱えますし、管理人が使い慣れているので SQLite を選択しました。今時なら Azure や AWS 等のクラウド DB を使用する手もあると思いますが、管理人は未経験である事と、いろんな端末からアクセスできる方が良い!と感じる要素も無いので、ファイルベースの SQLite を選択しました。

SQLite については新規エントリの『SQLite とメンテナンスツール』で紹介しているので、そちらを見てください。

又、SQLite を使用するためにインストールする NuGet パッケージについては『SQLite の NuGet パッケージ』で紹介しています。

実際の SQLite データベースファイルは【recentFiles.db】と言う名前でプロジェクトに追加しているので、必要があればリポジトリ から落として見てください。

参考までに、DB には src. 7 のようなテーブルを作成しています。

テーブル名の通り、登録対象ファイルの拡張子(Extensions テーブル)と『最近使ったファイル』から取得したファイル情報を保存(RecentHistories テーブル)するテーブルを作成しています。

SQLite とアプリ内オブジェクトをマッピングする Dapper

DB とアプリ内オブジェクトのマッピングには【Dapper】を使用します。Dapper の使用方法については別のエントリに書いたので、良ければ『Micro-ORM Dapper の使い方』を見てください。

Dapper を使用して SQLite にアクセスするために src. 8 のようなインタフェースを作成して Dapper を呼び出しています。

Dapper を使用する場合、DbConnection さえあれば後は Dapper がカバーしてくれるので、src. 8 のようなシンプルなインタフェースで事足りると考えました。

そして、src. 8 の IDapperConnectionFactory の実装クラスとして src. 9 の DapperSqLiteConnectionFactory も作成しています。

一応、他のプロジェクトに使い回せるように別プロジェクトに分けていますが、サンプルとしては少し冗長な構成かもしれません…

src. 8、9 の IDapperConnectionFactory は名前の通り Factory パターンを利用するためのインタフェースなので、本来はリポジトリパターンとの共用も可能ですが、テーブルが 2 つしかないアプリでは煩雑過ぎると思った(と言うよりぶっちゃけ面倒)ので Factory パターンのみで作成しました。

SQLite と Dapper を使用したデータの読み書き

前項で紹介した SQLite DB ファイルと IDapperConnectionFactory を使用してデータの取得・書き込みを行うためのインタフェースと実装クラスが src. 10 です。

GitHub リポジトリ では 2 つのファイルに分かれていますが、src. 10 では 1 つにまとめて紹介しています。又、実際は内部に SQL 文も書いていますが、src. 10 では省略している(単純な SelectInsert です)ので、必要があれば GitHub リポジトリ を見てください。

DB へのアクセスに Dapper を使用しているため、知らない人には見にくいかもしれませんが、DB から取得したデータをオブジェクトのプロパティにマッピングして返しているだけです。ここで重要なのは 62 行目に宣言している IDapperConnectionFactory の使い方です。

ここでは DI の基本にならってインタフェースを使用して登録しているため、src. 10 を書く時には src. 9 の DapperSqLiteConnectionFactory は必須ではなく src. 8 の IDapperConnectionFactory さえ作成済みであれば src. 10 は実装が可能です。但し、DBへの読み書きが主な機能なので、単体テストを実行するためには DapperSqLiteConnectionFactory の作成が必要になります。Moq 等でエミュレートコードを書いても構わないと思いますが、単純なインタフェースなので、DapperSqLiteConnectionFactory を作成した方が早いとも思います…

appsettings.json に設定した SQLite のファイル名

データベース(SQLite)に接続するための接続文字列は、外部ファイルに保存することが一般的だと思いますが、SQLite の接続文字列に必要なのは DB ファイルのフルパスだけなので、ファイル名を定数として定義する方法もアリだと思います。ですが、せっかくなので Generic Host の設定ファイル(appsettings.json)を利用する方法を紹介します。

appsettings.json から設定値を取得して DI する方法について別途【Generic Host の DI が appsettings.json を統べる】を書きました!

ここでは appsettings.json から設定値を取得する方法しか紹介していませんが、【Generic Host の DI が appsettings.json を統べる】では設定値をクラスにマッピングして DI する方法を紹介しています。

appsettings.json へ src. 11 のように SQLite DB ファイル名を追加します。

RecentWatcher プロジェクト配下には【appsettings.json】の他に【appsettings.Development.json】も存在しますが、2 行目の設定値【SqliteFileName】は両方のファイルに追加しておきます。

DB の接続文字列は開発環境と本番環境で違うことがほとんどですが、Generic Host では開発時の実行には【appsettings.Development.json】リリース時の実行には【appsettings.json】が読み込まれるので、開発環境と本番環境で別の値を設定することができます。

そして追加した設定値(SqliteFileName)は src. 12 のように利用する事ができます。

6 行目の ConfigureServices はテンプレートから生成差された直後はパラメータが 1 つだけ(IServiceCollection)のメソッドでしたが、設定値を取得するための HostBuilderContext も渡されるオーバーロードに変更しています。そして、HostBuilderContext を経由して取得した appsettings.json の設定値を使って実行時の SQLite DB ファイルパスを生成しています。

そして src. 12 の 12 行目では IDapperConnectionFactory インタフェースを DI コンテナへ登録しています。AddSingleton に限った話ですが、12 行目のように DI コンテナへ追加する際にインスタンスを渡すと DI コンテナではインスタンスを生成せず、渡したインスタンスを DI してくれます。(シングルトンなので当たり前の話ですが…)
この方法が最善手だとは思っていませんが、管理人はよく使う手法です。

appsettings.json から設定値を読み込む方法は GetSection 以外にも色々ありますが、それだけでもう 1 つ別のエントリが書けるくらいのボリュームがありそうなので、ここでは GetSection で単一項目を読み込む方法を紹介するだけに留めます。

尚、Generic Host では appsettings.json からの読み込みはサポートしていますが、書き込みは検索しても見つからなかったので、現時点ではサポートされていないと思います。ただ、JSON フォーマットなので自前で書き込み処理を書けばできそうだと思っていますが、試していません。

RecentFileWatcher の最終形態

以上で、最近使ったファイルを監視して追加されたファイルを DB(SQLite)へ登録する準備が整ったので、RecentFileWatcher を src. 13 のように実装します。

src. 13 では ロガーと IRecentFileEditor を DI して、最近使ったファイルを DB に追加するようにしています。14 行目で DB から登録対象ファイルの条件(拡張子と登録済みファイルの最終更新日時)を取得して、18 行目には DB へ未登録のファイルが存在した場合は登録する処理も追加しています。ロガーはとりあえず登録対象ファイルのフルパスを出力しています。

後は、src. 14 のように RecentWatcherWorkerRecentFileWatcher を呼び出すコードを追加すると DI コンテナに登録したクラス達が連携して動作するようになります。

実際は RegistTargetFileInitialWriteSettings のようなモデル系クラスも出てきていますが、全文はリポジトリ を見てください。

実行すると、fig. 7 のように最近使ったファイルフォルダに存在する登録対象のファイルが追加され、mp4 等の動画ファイルを再生すると、追加もされています。

fig.7 A5M2 で開いた RecentHistories テーブル

ちなみに、テストで再生した動画は Pixabay で配布されている可愛い子猫の動画です。

以上がアプリのコア機能の実装です。

DI コンテナに登録されたクラスの破棄(Dispose

ここまでは DI コンテナに登録されたクラスの生成について紹介してきましたが、クラスの破棄(Dispose)についても紹介します。

src. 12 で紹介した通り、これまで作成したクラスは AddSingletonAddTransient で DI コンテナに登録していますが、この 2 つのメソッドで登録したクラスは DI コンテナが破棄されるタイミング(アプリ終了)で DI コンテナから Dispose が呼び出されるので、IDisposable インタフェースさえ継承していれば Dispose が呼ばれます。

確認するには Dispose メソッド内にブレークポイントを張ればアプリ終了時にインスタンスが破棄される事が確認できます。管理人は『× ボタン』でも終了処理が呼ばれると思っていましたが、Ctrl + c 等で終了しないと呼ばれないようです。

本来は IDisposable を継承したクラスを内部に保持するクラスも IDisposable を継承して、Dispose メソッド内で保持したクラスを破棄するのが当然のお作法だと思っていますが、【サービスの有効期間 | .NET での依存関係の挿入 | Microsoft Docs】には DI コンテナが破棄されるタイミングで登録したクラスの Dispose が呼ばれる事が書いてあるので確認してみると、RecentFileWatcher.Dispose 内のブレークポイントで停止する事が確認できました。

AddSingleton】したクラスの破棄は DI コンテナの役割だと思いますが、管理人的に、AddTransient したクラスの破棄は DI された側のクラスの役割だと考えています。試しに src. 13 の RecentFileEditorIDisposable の継承を追加して確認するとちゃんとブレークポイントで止まったので、DI コンテナが破棄してくれているようです。

本来、シングルトンクラスのコンストラクタに DI するクラスはシングルトンであるはずだと考えていますが、今回のサンプルアプリでは、紹介の都合上、RecentFileEditor をあえて AddTransient で登録しています。

AddScoped で登録したクラスの使い方

ここまでは、Generic Host の DI コンテナが持つ 3 つの登録方法の内、2 つだけを取り上げてきましたが、本章では残りの AddScoped で登録するクラスについて紹介します。

ここまで読んでいただいた方の中には気が付かれている方も居るかもしれませんが、RecentFileWatcher には src. 15 で網掛けした 2 箇所にまだ依存関係が残っています。

src. 15 で 2 箇所 new している ShellLink クラスを DI する(AddScoped)には src. 16 のように登録処理を追加します。

src. 16 の通り、限定的なスコープを持つクラスの登録は AddScoped を使用します。src. 16 で AddScoped している ShellLink はインタフェースを作成していないので実装クラスを登録していますが、インタフェースを作成していれば、上の RecentFileEditor の場合と同様に AddScoped<IShellLink, ShellLink> として登録することも可能です。

DI コンテナへの登録は他の Add~ の場合と同じですが、ConfigureServices の章で紹介した通り、AddScoped で登録したクラスをコンストラクタに DI すると AggregateExceptionThrow されるため、DI する場合は src. 17 のように DI する型に IServiceScopeFactory を指定します。

AddScoped で登録したクラスのインスタンスを取得する場合、コンストラクタに直接 DI するのではなく src. 17 の 33 行目のように IServiceScopeFactory を DI して、16 行目のように IServiceScopeFactory から IServiceScope を取得して、ServiceProvider.GetRequiredService で DI コンテナに登録したクラスを取得する必要があります。

ShellLinkIDisposable を継承したクラスですが、using で囲まなくても、IServiceScope の破棄と同時に IServiceScopeDispose を呼び出します

AddScoped で登録したクラスは DI パターンと言うよりサービスロケーターパターンだと思いますが、暫定的な生存期間を持つクラスを DI コンテナで扱えるのは便利だと思います。以前書いた【Prism の DI コンテナらは Ioc 上に歌う【step: 4 .NET Core WPF Prism MVVM 入門 2020】】では Unity や DryIoc 等で生存期間が暫定的なクラスを扱う方法が調べても分からず、結局 DI コンテナ自体を DI する方法を紹介しましたが、こう言う方法の方が分かりやすいと管理人個人的には思いました。

作成したアプリの発行

以上でアプリの実装は完了したので、デプロイ手順もついでに紹介します。
ソリューションエクスプローラーでスタートアッププロジェクトを右クリックして fig. 8 の【発行】を選択します。

fig.8 [コンテキストメニュー] – [発行]

[発行] を選択すると fig. 9 の【公開ダイアログ】が開きます。

fig.9 公開ダイアログ

今回のサンプルアプリは自分で使用するためのものなので、【フォルダー】を選択して、【次へ】をクリックすると、fig. 10 の【場所】を指定する View に切り替わります。

fig.10 場所の指定 View

アプリのデプロイ

fig. 10 でデプロイ先のフォルダを選択して【完了】をクリックすると fig. 11 の【公開プロファイル View】が開きます。

fig.11 公開プロファイル

とりあえず、デフォルト設定のまま【発行ボタン】をクリックすると、fig. 12 のように指定したフォルダにデプロイされます。

fig.12 デプロイ先フォルダ

特に設定している訳ではないので pdb ファイル等も出力されていますし、複数プロジェクトが含まれているので多少ファイル数が多くなるのはしょうがないとしても、実行に 43 個ものファイルが必要なのは少し多過ぎる気がします。

シングルバイナリ(単一実行ファイル)の作成

デプロイされるファイル数が 40 個を超えても問題があるわけではありませんが、fig. 13 の【公開プロファイル View】で設定すれば .NET Core 3.0 から導入されたシングルバイナリを作成することもできます。

fig.13 公開プロファイル View

fig. 13 の赤枠で囲んだ箇所はどこをクリックしても fig. 14 の【プロファイル設定】が表示されます。

fig.14 プロファイル設定

fig. 14 のプロファイル設定ダイアログで、配置モードを【自己完結】に設定して、ファイルの公開オプションの中の【単一ファイルの作成】をチェックして保存すると fig. 15 のようにシングルバイナリ(単一実行ファイル)がデプロイ先に生成されます。

fig.15 シングルバイナリでデプロイ

デプロイ先を変更しない場合、元々存在するファイルは削除されないので、fig. 12 で出力されていた 43 個のファイルは手作業で削除して、fig. 13 の【公開プロファイル View】から再度発行します。相変わらず pdb ファイルも出力されていますが、配布に必要なファイルは 4 つまでに減りました。ですが、RecentWatcher.exe約 63 MB のサイズで作成されています

これは fig. 12 で出力されていた DLL をそのまま 1 つのファイルに押し込むのでサイズが大きくなるのはしょうがないかもしれませんが、当然、DLL に含まれている機能を全て使用している訳ではありません。そこで再度、fig. 16 のプロファイル設定ダイアログを開きます。

fig.16 プロファイル設定ダイアログ – 未使用コードのトリミング

fig. 16 のファイルの公開オプション内にある【未使用コードのトリミング】をチェックして、再度発行すると、fig. 15 で約 63MB もあった RecentWatcher.exe のサイズは fig. 17 のサイズまで減りました。

fig.17 未使用コードトリミング後のデプロイサイズ

未使用コードをトリミングしても 19MB もありますが、まあ許容範囲ではないでしょうか。自分で実装した機能は大して多くないとしても、設定ファイルの読み込みやログ出力等、src. 12 等に出てきた【CreateDefaultBuilder】の裏では結構な処理が書かれているのでしょうがないとは思います。

※ 【未使用コードのトリミング】をチェックして作成した exe を実行すると実行時エラーが発生しました

エントリを書き上げた後、このエントリで紹介した通りの設定でデプロイした exe を実行すると NotSupportedExceptionThrow されました

例外の発生個所は、ここでは紹介していないショートカットファイルからリンク先ファイルのフルパスを取得するクラス(ShellLink)を new している部分でした。ショートカットファイルの情報は WindowsIShellLink から COM 経由で取得していますが、【未使用コードのトリミング】を設定すると IShellLink の呼び出しに必要なコードまでトリミングされるのではないかと予想しています。(VS 2022 でデバッグ実行した場合は正常に動作します)

現状では回避策や対応方法等も分からないため、とりあえず管理人の PC では【未使用コードのトリミング】のチェックを外してデプロイした約 63MB の exe を使用しています。もし、対応方法等が見つかればこのエントリを更新しようとは思っていますが、望みは薄そうな気はします。

COM を使用している場合は【未使用コードのトリミング】をチェックしてはいけないと言う訳ではないと思いますが、トリミングするコードを詳細に指定できる訳でもなさそうなので、【未使用コードのトリミング】をチェックしてデプロイしたファイルでの動作確認は必須と言えますし、下に書いたコマンドプロンプト画面を表示しない設定でデプロイすると、例外内容も確認できないため、最低でも例外エラーが確認できるログを仕込むことも必須だと思います。

後、プロファイル設定には紹介していない fig. 18 の【Ready ToRun コンパイルを有効にする】もあります。

fig.18 プロファイル設定 – Ready ToRun コンパイルを有効にする

fig. 18 の【Ready ToRun コンパイルを有効にする】をチェックしてデプロイすると、起動時間が速くなる実行ファイルが生成されるようですが、ファイルサイズも 2 ~ 3 倍? になるそうです。管理人は内容がイマイチ理解できていないので試していないので、興味がある方は Microsoft 公式の【ReadyToRun 展開の概要 – .NET | Microsoft Docs】や【.NET Core 3.0 で有効化される Tiered CompilationReadyToRun について – しばやん雑記】等を見てください。

コマンドプロンプト画面の非表示

以上で、ユーザ環境でも実行可能なアプリのデプロイまで完了しましたが、このまま実行するとコマンドプロンプト画面が表示されてしまいます。コマンドプロンプト画面が表示されても構わない場合、以下の設定は必要ありませんが、実行時にコマンドプロンプト画面を表示したくない場合は、fig. 19 の【プロジェクトのプロパティ】を開いて【出力の種類】を【Windows アプリケーション】に変更すると、実行時にコマンドプロンプトが表示されなくなります。

fig.19 プロジェクトのプロパティ – 出力の種類

fig. 19 の設定でデプロイすると実行時にコマンドプロンプトは表示されませんが、ちょっとだけ表示したいと思っても表示できないはずです…表示する方法を知っている人が居れば教えてくださいw

おまけ:Windows サービスとして実行

このサンプルアプリは最初に書いた通り、Windows サービスとして実行しても想定した動作にはなりませんが、最初に書いた制限に当てはまらない機能を作成する場合は、Windows サービスとして実行することもできます。

上で紹介した手順でデプロイしても、バックグラウンドプロセスとして実行されますが、サービスとしては実行されません。作成したアプリを Windows サービスとして実行する場合は、別途追加パッケージが必要になるので、fig. 20 の【ソリューションの NuGet パッケージの管理 View】を開いて【windows.service】を検索します。

fig.20 ソリューションの NuGet パッケージの管理

検索結果の中から【Microsoft.Extensions.Hosting.WindowsServices】を選択してスタートアッププロジェクト(ここでは RecentWatcher プロジェクト)にインストールします。

インストールが完了したら、src. 18 のようにエントリポイント内の IHost(汎用ホスト)作成箇所に 1 行追加します。

27 行目の【UseWindowsService】を追加するだけで Windows サービスとして実行できる exe が生成されるようになります。実装の変更は src. 18 の 1 行だけなので、後はサービスコンソールのコマンド等でサービスとして登録すれば起動・終了ができるようになります。

サービスコンソールに登録するコマンド等はここでは紹介しないので、必要があれば【Creating a Windows Service with C#/.NET5 – #ifdef Windows】等を見てください。

おわりに

久々の技術系エントリでしたが、前に続きを書くと言った WPF UI Gallery ではなく Generic Host のエントリになってしまいました。メインテーマが WPF だった路線を変更するつもりではなく今後、.NET6 でデスクトップアプリを作成するには必要な内容になりそうなので、Generic Host を選択しました。

2021/11/8 に .NET6 が正式リリースされましたが、WPF UI Gallery で取り上げている MahApps.MetroMaterial Design In XAML Toolkit では .NET6 や WinUI、MAUI への対応方針が不明確なので、とりあえずは明確になっている Generic Host を軸に単体テスト(ユニットテスト)や CI について調べた事をまとめようと思っています。

ただ、アニメの一覧や生活環境の変化などもあって以前ほどのペースでは書けないかもしれません… 待っている人が居るかは分かりませんが、今後も技術系エントリは書いていこうと思っているのでよろしくお願いします。

今回もいつもの通りサンプルコードは GitHub リポジトリ に上げています。

 

 

 

おすすめ

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください

  翻译: