.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. 2 の【追加情報 ページ】が開きます。
ここでは fig. 2 のようにターゲットフレームワークのみ設定します。.NET Core 以降はクロスプラットフォームに対応しているので Docker イメージも同時に作成することができますが、管理人はあまり理解できていないので、ここでは有効にしません。
プロジェクトが作成されると fig. 3 のようなファイルが生成されます。
プロジェクト作成直後でも実行可能なコードが生成されているので、【デバッグの開始】で起動すると、fig. 4 のような実行画面(画像クリックで再生・停止を切り替え)が表示されます。
fig. 4 の通り(クリックして再生開始)1 秒ごとにコンソールへログが出力されている事が確認できると思います。つまり、一定期間ごとに処理を繰り返して実行するタイマーのような動作をするアプリを作成するのであれば、アプリの基本部分は既に作成済みの状態になっています。
以降はテンプレートから生成されたソースを紹介していきます。
テンプレートから生成されたソースコード
src. 1 は Program.cs に生成されたコードです。
1 2 3 4 5 6 7 8 9 10 | using RecentWatcher; IHost host = Host.CreateDefaultBuilder(args) .ConfigureServices(services => { services.AddHostedService<Worker>(); }) .Build(); await host.RunAsync(); |
.NET6 ではトップレベルステートメントを使用した暗黙のエントリポイント(C#9.0 以降)が生成されるので、今までの記述に慣れていると見にくいかもしれませんが、【Host.CreateDefaultBuilder
】で生成した IHost
(汎用ホスト)を非同期で実行するコードが生成されています。
src. 1 の IHost.RunAsync
が呼び出されると 6 行目に DI されている Worker
クラスの StartAsync
が呼び出されます。src. 2 は Worker
クラスに生成されるコードです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | namespace RecentWatcher; public class Worker : BackgroundService { private readonly ILogger<Worker> _logger; public Worker(ILogger<Worker> logger) { _logger = logger; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { _logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now); await Task.Delay(1000, stoppingToken); } } } |
ワーカーサービステンプレートから生成したプロジェクトは、アプリのエントリポイント ⇒ IHost.RunAsync
⇒ BackgroundService.StartAsync
(override 可能)⇒ BackgroundService.ExecuteAsync
の順に呼び出されるので、アプリの機能は src. 2 の 12 ~ 19行目の ExecuteAsync
内に実装することになります。
プロジェクト生成直後の ExecuteAsync
には src. 2 の 14 ~ 18行目のようにコンソールへログを出力するコードが生成されているので、実行すると fig. 4 のように Task.Delay
の第 1 パラメータに指定したミリ秒ごとにログが出力されます。
今回このエントリで紹介するサンプルアプリは一定期間ごとに処理を実行するタイプではなく、フォルダを監視するタイプのアプリなので、クラス名と ExecuteAsync
内の処理を src. 3 のように変更しておきます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | namespace RecentWatcher; public class RecentWatcherWorker : BackgroundService { private readonly ILogger<RecentWatcherWorker> _logger; public RecentWatcherWorker(ILogger<RecentWatcherWorker> logger) { _logger = logger; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { // ここにはアプリで実行する処理を実装 var tcs = new TaskCompletionSource<bool>(); stoppingToken.Register(s => (s as TaskCompletionSource<bool>)?.SetResult(true), tcs); await tcs.Task; _logger.LogInformation($"{nameof(RecentWatcherWorker)} Finished!"); } } |
src. 3 の 16 ~ 18 行目の終了処理は【Creating a Windows Service with C#/.NET5 | #ifdef Windows】で紹介されているサンプルコードをそのまま流用しています。
但し、上記ブログサンプルのままだと fig. 5 の【Null 許容 設定値(プロジェクトのプロパティ)】次第で CS8602
警告が表示されてしまうので、17 行目のみ若干修正しています。
fig. 5 のように【Null 許容】が【有効化】に設定されていると、元のサンプルコードのままでは『CS8602
警告』が表示されます。
src. 3 の 16 ~ 18 行目を追加すると、14 行目の処理実行後にアプリ自体が待機状態になり、コマンドプロンプトの『×ボタン』や『Ctrl + c』等で実行が中断されるまで待機し続けます。
以降は、14 行目に追加すべきサンプルアプリ本来の処理について紹介します。
FileSystemWatcher でフォルダを監視する
最初に紹介した通り、最近使ったファイルフォルダを監視するため、内部で FileSystemWatcher
を使用する RecentFileWatcher
クラスを新たに追加して src. 4 のように実装します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 | using elf.Windows.Libraries; namespace RecentWatcher; /// <summary>最近使ったファイルフォルダを監視します。</summary> public class RecentFileWatcher : IDisposable { private FileSystemWatcher? watcher = null; /// <summary>最近使ったファイルフォルダの監視を開始します。</summary> public void StartAsync() { // FileSystemWatcherを初期化 this.watcher = new FileSystemWatcher(Environment.GetFolderPath(Environment.SpecialFolder.Recent)); this.watcher.Created += (object sender, FileSystemEventArgs e) => { using (var shellLink = new ShellLink()) { Console.WriteLine(shellLink.GetLinkSourceFilePath(e.FullPath)); } }; this.watcher.EnableRaisingEvents = true; } private bool disposedValue; protected virtual void Dispose(bool disposing) { if (!disposedValue) { if (disposing) this.watcher?.Dispose(); disposedValue = true; } } ~ 略 ~ } |
C#10 からは namespace
を【{}】でくくらなくても良くなったので省略しています。19 行目の shellLink.GetLinkSourceFilePath
は Windows
の IShellLink
インタフェースを利用してショートカットファイルからリンク先のファイルパスを取得していますが、ここでは詳しく紹介しません。ショートカットファイルを取り扱う方法については別エントリに書いたので、【Windows Script Host で情報が取得できないショートカットファイル】を見てください。
src. 4 のコード量は多くないので、src. 3 の RecentWatcherWorker
の中に書いても構わないと思いますが、テストファーストで進めるなら別クラスに分けた方がテストし易いと管理人個人的には考えているので、別クラスに分けています。
この時点では『最近使ったファイル フォルダ』を監視して、追加されたショートカットファイルのリンク先のフルパスをコンソールに表示しているだけですが、サンプルアプリのコア機能は実装完了です。以降は、src. 4 の RecentFileWatcher
を DI コンテナから DI する方法を紹介します。
Generic Host の DI サポート
src. 5 は Generic Host の DI コンテナへクラスを登録するコードで、7 行目のように登録するクラスを IServiceCollection
へ Add
する事で登録できます。
1 2 3 4 5 6 7 8 9 10 11 | using RecentWatcher; IHost host = Host.CreateDefaultBuilder(args) .ConfigureServices(services => { services.AddHostedService<RecentWatcherWorker>() .AddSingleton<RecentFileWatcher>(); }) .Build(); await host.RunAsync(); |
src. 1 で紹介した際は触れませんでしたが、少し詳しく紹介します。
CreateDefaultBuilder
まず、3 行目に見える【CreateDefaultBuilder
】。これはテンプレートから自動生成されたコードですが、このメソッドを呼び出すと、上で紹介した Generic Host が持つ 4 つの機能が全て組み込まれます。つまり、DI コンテナやロガーの初期化、アプリの構成やライフサイクル管理等のどのアプリでも共通的に使用される機能が初期化されるので、開発者はアプリ固有の機能を追加するだけで済むようになっています。
基本的には CreateDefaultBuilder
で作成されたままでも問題なく動作しますが、個別にカスタマイズしたい場合でも変更は可能になっています。ここで全てを紹介すると長くなってしまいますし、管理人も完全解説できる程精通している訳ではないので、別エントリに分けて紹介していく予定はあります。
ConfigureServices
src. 5 の 4 行目、ConfigureServices
は本エントリのメインテーマとして位置付けした DI コンテナの設定を行うメソッドで主に、アプリ固有の機能を実装したクラスを DI コンテナへ登録する場合にコードを追加する箇所です。ConfigureServices
内で以下のような IServiceCollection
の拡張メソッドを呼び出してクラスを登録します。
拡張メソッド | 内容 |
---|---|
IServiceCollection.AddHostedService | src. 1 から既出のメソッドで、主に Worker クラスの登録に使用します。
アプリ起動時に自動起動したいクラスはこのメソッドを使用して登録します。 |
IServiceCollection.AddSingleton | src. 5 で RecentFileWatcher を登録する際に使用しているメソッドで、名前の通りシングルトンなクラスを登録する場合に使用します。つまり、最初に作成されたインスタンスが使い回されるので、どこに DI されても同じインスタンスを使用したいクラスを登録する際に使用します。 |
IServiceCollection.AddTransient | 一時的な生存期間を持つクラスを登録するためのメソッドで、DI される度に新しいインスタンスを作成したいクラスを登録する際に使用します。 |
IServiceCollection.AddScoped | 上の AddTransient と同じく一時的な生存期間を持つクラスを登録するためのメソッドですが、このメソッドで登録したクラスはコンストラクタに DI できません。(後述します)このメソッドで登録したクラスを |
IServiceCollection.AddOptions | appsettings.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 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | namespace RecentWatcher; public class RecentWatcherWorker : BackgroundService { protected override async Task ExecuteAsync(CancellationToken stoppingToken) { this.watcher.StartAsync(); var tcs = new TaskCompletionSource<bool>(); stoppingToken.Register(s => (s as TaskCompletionSource<bool>)?.SetResult(true), tcs); await tcs.Task; logger.LogInformation($"{nameof(RecentWatcherWorker)} Finished!"); } private readonly ILogger<RecentWatcherWorker> logger; private readonly RecentFileWatcher watcher; public RecentWatcherWorker(ILogger<RecentWatcherWorker> workerLogger, RecentFileWatcher recentFileWatcher) => (this.logger, this.watcher) = (workerLogger, 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(クリックすると再生を開始します)のように再生した動画ファイルと動画ファイルの保存先フォルダが表示されます。
※ 上で再生した動画は 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 のようなテーブルを作成しています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | CREATE TABLE Extensions ( Extension TEXT, PRIMARY KEY ( Extension ) ); CREATE TABLE RecentHistories ( AccessTime TEXT, FilePath TEXT, PRIMARY KEY ( AccessTime ) ); |
テーブル名の通り、登録対象ファイルの拡張子(Extensions
テーブル)と『最近使ったファイル』から取得したファイル情報を保存(RecentHistories
テーブル)するテーブルを作成しています。
SQLite とアプリ内オブジェクトをマッピングする Dapper
DB とアプリ内オブジェクトのマッピングには【Dapper】を使用します。Dapper の使用方法については別のエントリに書いたので、良ければ『Micro-ORM Dapper の使い方』を見てください。
Dapper を使用して SQLite にアクセスするために src. 8 のようなインタフェースを作成して Dapper を呼び出しています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | using System.Data.Common; namespace elf.DataAccesses.Interfaces; /// <summary>DapperでDBへアクセスするためのインタフェースを表します。</summary> public interface IDapperConnectionFactory { /// <summary>DbConnectionを取得します。</summary> /// <returns>取得したDbConnection。</returns> public ValueTask<DbConnection> GetConnectionAsync(); /// <summary>DBへの接続文字列を取得します。</summary> public string DbConnectString { get; } } |
Dapper を使用する場合、DbConnection
さえあれば後は Dapper がカバーしてくれるので、src. 8 のようなシンプルなインタフェースで事足りると考えました。
そして、src. 8 の IDapperConnectionFactory
の実装クラスとして src. 9 の DapperSqLiteConnectionFactory
も作成しています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | using elf.DataAccesses.Interfaces; using System.Data.Common; using System.Data.SQLite; namespace elf.DataAccess.SqLite; /// <summary>DapperからSQLiteに接続するDbConnectionのファクトリを表します。</summary> public class DapperSqLiteConnectionFactory : IDapperConnectionFactory { /// <summary>DBへの接続文字列を取得します。</summary> public string DbConnectString => this.connectString; /// <summary>DbConnectionを取得します。</summary> /// <returns>取得したDbConnection。</returns> public async ValueTask<DbConnection> GetConnectionAsync() { var connection = new SQLiteConnection(this.connectString); await connection.OpenAsync(); return connection; } private readonly string connectString; /// <summary>コンストラクタ。</summary> /// <param name="filePath">SQLiteデータベースファイルへのフルパスを表す文字列。</param> public DapperSqLiteConnectionFactory(string filePath) => this.connectString = new SQLiteConnectionStringBuilder() { DataSource = filePath }.ToString(); } |
一応、他のプロジェクトに使い回せるように別プロジェクトに分けていますが、サンプルとしては少し冗長な構成かもしれません…
src. 8、9 の IDapperConnectionFactory
は名前の通り Factory
パターンを利用するためのインタフェースなので、本来はリポジトリパターンとの共用も可能ですが、テーブルが 2 つしかないアプリでは煩雑過ぎると思った(と言うよりぶっちゃけ面倒)ので Factory
パターンのみで作成しました。
SQLite と Dapper を使用したデータの読み書き
前項で紹介した SQLite DB ファイルと IDapperConnectionFactory
を使用してデータの取得・書き込みを行うためのインタフェースと実装クラスが src. 10 です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 | using Dapper; using elf.DataAccesses.Interfaces; using System.Text; namespace RecentWatcher; /// <summary>最近使ったファイルのDB読み書き処理を表します。</summary> public interface IRecentFileEditor { /// <summary>初回書き込み設定を取得します。</summary> /// <returns>初回書き込み設定を表すInitialWriteSettings。</returns> public ValueTask<InitialWriteSettings> GetInitialWriteSettingsAsync(); /// <summary>最近使ったファイルをDBに追加します。</summary> /// <param name="targetFile">登録対象のファイルを表すRegistTargetFile。</param> /// <returns>最近使ったファイルをDBに追加するTask。</returns> public ValueTask AddTargetFileAsync(RegistTargetFile targetFile); } /// <summary>最近使ったファイルDBの読み書きを表します。</summary> public class RecentFileEditor : IRecentFileEditor { /// <summary>最近使ったファイルをDBに追加します。</summary> /// <param name="targetFile">登録対象のファイルを表すRegistTargetFile。</param> /// <returns>最近使ったファイルをDBに追加するTask。</returns> public async ValueTask AddTargetFileAsync(RegistTargetFile targetFile) { await using (var connection = await this.connectionFactory.GetConnectionAsync()) { var tran = await connection.BeginTransactionAsync(); try { // 存在する場合はInsertしない var sql = this.getSameAccessTimeRecord(); var accessTime = await connection.ExecuteScalarAsync<DateTime?>(sql, new { AccessTime = targetFile.AccessTime }); if (!accessTime.HasValue) await connection.ExecuteAsync(this.getAddTargetFileSql(), targetFile); await tran.CommitAsync(); } catch (Exception) { await tran.RollbackAsync(); throw; } } } ~ 略 ~ /// <summary>初回書き込み設定を取得します。</summary> /// <returns>初回書き込み設定を取得するTask。</returns> public async ValueTask<InitialWriteSettings> GetInitialWriteSettingsAsync() { await using (var connection = await this.connectionFactory.GetConnectionAsync()) { var ret = await connection.QueryFirstAsync<InitialWriteSettings>(this.getLatestRecentFileAccessDateTime()); ret.Extensions.AddRange(await connection.QueryAsync<string>(this.getExtensionSelectSql())); return ret; } } ~ 略 ~ private readonly IDapperConnectionFactory connectionFactory; /// <summary>コンストラクタ。</summary> /// <param name="dapperConnectionFactory">Dapperを使用するためのDB接続を取得するIDapperConnectionFactory。</param> public RecentFileEditor(IDapperConnectionFactory dapperConnectionFactory) => this.connectionFactory = dapperConnectionFactory; } |
GitHub リポジトリ では 2 つのファイルに分かれていますが、src. 10 では 1 つにまとめて紹介しています。又、実際は内部に SQL 文も書いていますが、src. 10 では省略している(単純な Select
と Insert
です)ので、必要があれば 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 ファイル名を追加します。
1 2 3 4 5 6 7 8 9 | { "SqliteFileName": "recentFiles.db", "Logging": { "LogLevel": { "Default": "Information", "Microsoft.Hosting.Lifetime": "Information" } } } |
RecentWatcher
プロジェクト配下には【appsettings.json
】の他に【appsettings.Development.json
】も存在しますが、2 行目の設定値【SqliteFileName
】は両方のファイルに追加しておきます。
DB の接続文字列は開発環境と本番環境で違うことがほとんどですが、Generic Host では開発時の実行には【appsettings.Development.json
】リリース時の実行には【appsettings.json
】が読み込まれるので、開発環境と本番環境で別の値を設定することができます。
そして追加した設定値(SqliteFileName
)は src. 12 のように利用する事ができます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | using elf.DataAccess.SqLite; using elf.DataAccesses.Interfaces; using RecentWatcher; IHost host = Host.CreateDefaultBuilder(args) .ConfigureServices((hostContext, services) => { var dbPath = Path.Combine(AppContext.BaseDirectory, hostContext.Configuration.GetSection("SqliteFileName").Value); services.AddHostedService<RecentWatcherWorker>() .AddSingleton<RecentFileWatcher>() .AddSingleton<IDapperConnectionFactory>(new DapperSqLiteConnectionFactory(dbPath)) .AddTransient<IRecentFileEditor, RecentFileEditor>(); }) .Build(); await host.RunAsync(); |
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 のように実装します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 | using elf.Windows.Libraries; namespace RecentWatcher; /// <summary>最近使ったファイルフォルダを監視します。</summary> public class RecentFileWatcher : IDisposable { private FileSystemWatcher? watcher = null; private InitialWriteSettings? settings = null; /// <summary>最近使ったファイルフォルダの監視を開始します。</summary> public async ValueTask StartAsync() { this.settings = await this.editor.GetInitialWriteSettingsAsync(); var recent = Environment.GetFolderPath(Environment.SpecialFolder.Recent); // 未登録のファイルがあれば登録してFileSystemWatcherを初期化 await this.writeUnregistedFilesAsync(recent); this.initializedFileSystemWatcher(recent); } /// <summary>未登録の最近使ったファイルを保存します。</summary> /// <param name="recentPath">最近使ったファイルフォルダのパスを表す文字列。</param> /// <returns>未登録の最近使ったファイルを保存するValueTask。</returns> private async ValueTask writeUnregistedFilesAsync(string recentPath) { var recentDir = new DirectoryInfo(recentPath); using (var shellLink = new ShellLink()) { foreach (var item in recentDir.EnumerateFiles("*.lnk")) await this.writeRecentFile(item.FullName, this.settings!.LatestRecentDateTime, shellLink); } } /// <summary>最近使ったファイルを保存します。</summary> /// <param name="linkFilePath">ショートカットファイルのパスを表す文字列。</param> /// <param name="minDateTime"> /// <para>このパラメータに指定した最小日時をショートカットファイルの最終更新日時が /// 超えたファイルのみ保存します。</para> /// <para>nullを指定した場合は常にlinkFilePathパラメータに指定したショートカットファイルの /// リンク先ファイルが保存されます</para> /// </param> /// <param name="shellLink">ショートカットファイルの情報を取得するShellLink。</param> /// <returns>最近使ったファイルを保存するValueTask。</returns> private async ValueTask writeRecentFile(string linkFilePath, DateTime? minDateTime, ShellLink shellLink) { var linkFile = new FileInfo(linkFilePath); if (minDateTime.HasValue) { if (linkFile.LastWriteTime <= minDateTime) return; } var realPath = shellLink.GetLinkSourceFilePath(linkFilePath); if (this.settings!.Extensions.Any(e => e == Path.GetExtension(realPath))) { this.logger.LogInformation(realPath); await this.editor.AddTargetFileAsync(new RegistTargetFile(linkFile.LastWriteTime, realPath)); } } /// <summary>FileSystemWatcherを初期化します。</summary> /// <param name="recentFolderPath">最近使ったファイルフォルダのフルパスを表す文字列。</param> private void initializedFileSystemWatcher(string recentFolderPath) { this.watcher = new FileSystemWatcher(recentFolderPath); this.watcher.Created += async (object sender, FileSystemEventArgs e) => { using (var shellLink = new ShellLink()) { await this.writeRecentFile(e.FullPath, null, shellLink).ConfigureAwait(false); } }; this.watcher.EnableRaisingEvents = true; } private readonly IRecentFileEditor editor; private readonly ILogger<RecentFileWatcher> logger; /// <summary>コンストラクタ。</summary> /// <param name="recentFileEditor">最近使ったファイルを読み書きするIRecentFileEditor。(DIコンテナからインジェクション)</param> /// <param name="watcherLogger">ログを出力するILogger<RecentFileWatcher>。(DIコンテナからインジェクション)</param> public RecentFileWatcher(IRecentFileEditor recentFileEditor, ILogger<RecentFileWatcher> watcherLogger) => (this.editor, this.logger) = (recentFileEditor, watcherLogger); private bool disposedValue; protected virtual void Dispose(bool disposing) { if (!disposedValue) { if (disposing) this.watcher?.Dispose(); disposedValue = true; } } ~ 略 ~ } |
src. 13 では ロガーと IRecentFileEditor
を DI して、最近使ったファイルを DB に追加するようにしています。14 行目で DB から登録対象ファイルの条件(拡張子と登録済みファイルの最終更新日時)を取得して、18 行目には DB へ未登録のファイルが存在した場合は登録する処理も追加しています。ロガーはとりあえず登録対象ファイルのフルパスを出力しています。
後は、src. 14 のように RecentWatcherWorker
に RecentFileWatcher
を呼び出すコードを追加すると DI コンテナに登録したクラス達が連携して動作するようになります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | namespace RecentWatcher; /// <summary>最近使ったファイルフォルダを監視するワーカクラスを表します。</summary> public class RecentWatcherWorker : BackgroundService { /// <summary>処理を実行します。</summary> /// <param name="stoppingToken">処理の継続・中止を表すCancellationToken。</param> /// <returns>処理を実行するTask。</returns> protected override async Task ExecuteAsync(CancellationToken stoppingToken) { await this.watcher.StartAsync().ConfigureAwait(false); var tcs = new TaskCompletionSource<bool>(); stoppingToken.Register(s => (s as TaskCompletionSource<bool>)?.SetResult(true), tcs); await tcs.Task; logger.LogInformation($"{nameof(RecentWatcherWorker)} Finished!"); } private readonly ILogger<RecentWatcherWorker> logger; private readonly RecentFileWatcher watcher; /// <summary>コンストラクタ。</summary> /// <param name="workerLogger">ログを出力するILogger<RecentWatcherWorker>。(DIコンテナからインジェクション)</param> /// <param name="recentFileWatcher">最近使ったファイルフォルダを監視するRecentFileWatcher。(DIコンテナからインジェクション)</param> public RecentWatcherWorker(ILogger<RecentWatcherWorker> workerLogger, RecentFileWatcher recentFileWatcher) => (this.logger, this.watcher) = (workerLogger, recentFileWatcher); } |
実際は RegistTargetFile
や InitialWriteSettings
のようなモデル系クラスも出てきていますが、全文はリポジトリ を見てください。
実行すると、fig. 7 のように最近使ったファイルフォルダに存在する登録対象のファイルが追加され、mp4
等の動画ファイルを再生すると、追加もされています。
ちなみに、テストで再生した動画は Pixabay で配布されている可愛い子猫の動画です。
以上がアプリのコア機能の実装です。
DI コンテナに登録されたクラスの破棄(Dispose
)
ここまでは DI コンテナに登録されたクラスの生成について紹介してきましたが、クラスの破棄(Dispose
)についても紹介します。
src. 12 で紹介した通り、これまで作成したクラスは AddSingleton
、AddTransient
で 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 の RecentFileEditor
に IDisposable
の継承を追加して確認するとちゃんとブレークポイントで止まったので、DI コンテナが破棄してくれているようです。
本来、シングルトンクラスのコンストラクタに DI するクラスはシングルトンであるはずだと考えていますが、今回のサンプルアプリでは、紹介の都合上、RecentFileEditor
をあえて AddTransient
で登録しています。
AddScoped
で登録したクラスの使い方
ここまでは、Generic Host の DI コンテナが持つ 3 つの登録方法の内、2 つだけを取り上げてきましたが、本章では残りの AddScoped
で登録するクラスについて紹介します。
ここまで読んでいただいた方の中には気が付かれている方も居るかもしれませんが、RecentFileWatcher
には src. 15 で網掛けした 2 箇所にまだ依存関係が残っています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 | using elf.Windows.Libraries; namespace RecentWatcher; /// <summary>最近使ったファイルフォルダを監視します。</summary> public class RecentFileWatcher : IDisposable { ~ 略 ~ /// <summary>未登録の最近使ったファイルを保存します。</summary> /// <param name="recentPath">最近使ったファイルフォルダのパスを表す文字列。</param> /// <returns>未登録の最近使ったファイルを保存するValueTask。</returns> private async ValueTask writeUnregistedFilesAsync(string recentPath) { var recentDir = new DirectoryInfo(recentPath); using (var shellLink = new ShellLink()) { foreach (var item in recentDir.EnumerateFiles("*.lnk")) await this.writeRecentFile(item.FullName, this.settings!.LatestRecentDateTime, shellLink); } } ~ 略 ~ /// <summary>FileSystemWatcherを初期化します。</summary> /// <param name="recentFolderPath">最近使ったファイルフォルダのフルパスを表す文字列。</param> private void initializedFileSystemWatcher(string recentFolderPath) { this.watcher = new FileSystemWatcher(recentFolderPath); this.watcher.Created += async (object sender, FileSystemEventArgs e) => { using (var shellLink = new ShellLink()) { await this.writeRecentFile(e.FullPath, null, shellLink).ConfigureAwait(false); } }; this.watcher.EnableRaisingEvents = true; } ~ 略 ~ } |
src. 15 で 2 箇所 new している ShellLink
クラスを DI する(AddScoped
)には src. 16 のように登録処理を追加します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | using elf.DataAccess.SqLite; using elf.DataAccesses.Interfaces; using elf.Windows.Libraries; using RecentWatcher; IHost host = Host.CreateDefaultBuilder(args) .ConfigureServices((hostContext, services) => { var dbPath = Path.Combine(AppContext.BaseDirectory, hostContext.Configuration.GetSection("SqliteFileName").Value); services.AddHostedService<RecentWatcherWorker>() .AddSingleton<RecentFileWatcher>() .AddSingleton<IDapperConnectionFactory>(new DapperSqLiteConnectionFactory(dbPath)) .AddTransient<IRecentFileEditor, RecentFileEditor>() .AddScoped<ShellLink>(); }) .Build(); await host.RunAsync(); |
src. 16 の通り、限定的なスコープを持つクラスの登録は AddScoped
を使用します。src. 16 で AddScoped
している ShellLink
はインタフェースを作成していないので実装クラスを登録していますが、インタフェースを作成していれば、上の RecentFileEditor
の場合と同様に AddScoped<IShellLink, ShellLink>
として登録することも可能です。
DI コンテナへの登録は他の Add~
の場合と同じですが、ConfigureServices
の章で紹介した通り、AddScoped
で登録したクラスをコンストラクタに DI すると AggregateException
が Throw
されるため、DI する場合は src. 17 のように DI する型に IServiceScopeFactory
を指定します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | using elf.Windows.Libraries; namespace RecentWatcher; /// <summary>最近使ったファイルフォルダを監視します。</summary> public class RecentFileWatcher : IDisposable { ~ 略 ~ /// <summary>未登録の最近使ったファイルを保存します。</summary> /// <param name="recentPath">最近使ったファイルフォルダのパスを表す文字列。</param> /// <returns>未登録の最近使ったファイルを保存するValueTask。</returns> private async ValueTask writeUnregistedFilesAsync(string recentPath) { var recentDir = new DirectoryInfo(recentPath); await using (var scope = this.scopeFactory.CreateAsyncScope()) { var shellLink = scope.ServiceProvider.GetRequiredService<ShellLink>(); foreach (var item in recentDir.EnumerateFiles("*.lnk")) await this.writeRecentFile(item.FullName, this.settings!.LatestRecentDateTime, shellLink); } } ~ 略 ~ private readonly IRecentFileEditor editor; private readonly ILogger<RecentFileWatcher> logger; private readonly IServiceScopeFactory scopeFactory; /// <summary>コンストラクタ。</summary> /// <param name="recentFileEditor">最近使ったファイルを読み書きするIRecentFileEditor。(DIコンテナからインジェクション)</param> /// <param name="watcherLogger">ログを出力するILogger<RecentFileWatcher>。(DIコンテナからインジェクション)</param> public RecentFileWatcher(IRecentFileEditor recentFileEditor, ILogger<RecentFileWatcher> watcherLogger, IServiceScopeFactory factory) => (this.editor, this.logger, this.scopeFactory) = (recentFileEditor, watcherLogger, factory); ~ 略 ~ } |
AddScoped
で登録したクラスのインスタンスを取得する場合、コンストラクタに直接 DI するのではなく src. 17 の 33 行目のように IServiceScopeFactory
を DI して、16 行目のように IServiceScopeFactory
から IServiceScope
を取得して、ServiceProvider.GetRequiredService
で DI コンテナに登録したクラスを取得する必要があります。
ShellLink
は IDisposable
を継承したクラスですが、using
で囲まなくても、IServiceScope
の破棄と同時に IServiceScope
が Dispose
を呼び出します。
AddScoped
で登録したクラスは DI パターンと言うよりサービスロケーターパターンだと思いますが、暫定的な生存期間を持つクラスを DI コンテナで扱えるのは便利だと思います。以前書いた【Prism の DI コンテナらは Ioc 上に歌う【step: 4 .NET Core WPF Prism MVVM 入門 2020】】では Unity や DryIoc 等で生存期間が暫定的なクラスを扱う方法が調べても分からず、結局 DI コンテナ自体を DI する方法を紹介しましたが、こう言う方法の方が分かりやすいと管理人個人的には思いました。
作成したアプリの発行
以上でアプリの実装は完了したので、デプロイ手順もついでに紹介します。
ソリューションエクスプローラーでスタートアッププロジェクトを右クリックして fig. 8 の【発行】を選択します。
今回のサンプルアプリは自分で使用するためのものなので、【フォルダー】を選択して、【次へ】をクリックすると、fig. 10 の【場所】を指定する View に切り替わります。
アプリのデプロイ
fig. 10 でデプロイ先のフォルダを選択して【完了】をクリックすると fig. 11 の【公開プロファイル View】が開きます。
とりあえず、デフォルト設定のまま【発行ボタン】をクリックすると、fig. 12 のように指定したフォルダにデプロイされます。
特に設定している訳ではないので pdb ファイル等も出力されていますし、複数プロジェクトが含まれているので多少ファイル数が多くなるのはしょうがないとしても、実行に 43 個ものファイルが必要なのは少し多過ぎる気がします。
シングルバイナリ(単一実行ファイル)の作成
デプロイされるファイル数が 40 個を超えても問題があるわけではありませんが、fig. 13 の【公開プロファイル View】で設定すれば .NET Core 3.0 から導入されたシングルバイナリを作成することもできます。
fig. 13 の赤枠で囲んだ箇所はどこをクリックしても fig. 14 の【プロファイル設定】が表示されます。
fig. 14 のプロファイル設定ダイアログで、配置モードを【自己完結】に設定して、ファイルの公開オプションの中の【単一ファイルの作成】をチェックして保存すると fig. 15 のようにシングルバイナリ(単一実行ファイル)がデプロイ先に生成されます。
デプロイ先を変更しない場合、元々存在するファイルは削除されないので、fig. 12 で出力されていた 43 個のファイルは手作業で削除して、fig. 13 の【公開プロファイル View】から再度発行します。相変わらず pdb ファイルも出力されていますが、配布に必要なファイルは 4 つまでに減りました。ですが、RecentWatcher.exe
は約 63 MB のサイズで作成されています。
これは fig. 12 で出力されていた DLL をそのまま 1 つのファイルに押し込むのでサイズが大きくなるのはしょうがないかもしれませんが、当然、DLL に含まれている機能を全て使用している訳ではありません。そこで再度、fig. 16 のプロファイル設定ダイアログを開きます。
fig. 16 のファイルの公開オプション内にある【未使用コードのトリミング】をチェックして、再度発行すると、fig. 15 で約 63MB もあった RecentWatcher.exe
のサイズは fig. 17 のサイズまで減りました。
未使用コードをトリミングしても 19MB もありますが、まあ許容範囲ではないでしょうか。自分で実装した機能は大して多くないとしても、設定ファイルの読み込みやログ出力等、src. 12 等に出てきた【CreateDefaultBuilder
】の裏では結構な処理が書かれているのでしょうがないとは思います。
※ 【未使用コードのトリミング】をチェックして作成した exe を実行すると実行時エラーが発生しました
エントリを書き上げた後、このエントリで紹介した通りの設定でデプロイした exe
を実行すると NotSupportedException
が Throw
されました。
例外の発生個所は、ここでは紹介していないショートカットファイルからリンク先ファイルのフルパスを取得するクラス(ShellLink
)を new
している部分でした。ショートカットファイルの情報は Windows
の IShellLink
から COM
経由で取得していますが、【未使用コードのトリミング】を設定すると IShellLink
の呼び出しに必要なコードまでトリミングされるのではないかと予想しています。(VS 2022 でデバッグ実行した場合は正常に動作します)
現状では回避策や対応方法等も分からないため、とりあえず管理人の PC では【未使用コードのトリミング】のチェックを外してデプロイした約 63MB の exe
を使用しています。もし、対応方法等が見つかればこのエントリを更新しようとは思っていますが、望みは薄そうな気はします。
COM
を使用している場合は【未使用コードのトリミング】をチェックしてはいけないと言う訳ではないと思いますが、トリミングするコードを詳細に指定できる訳でもなさそうなので、【未使用コードのトリミング】をチェックしてデプロイしたファイルでの動作確認は必須と言えますし、下に書いたコマンドプロンプト画面を表示しない設定でデプロイすると、例外内容も確認できないため、最低でも例外エラーが確認できるログを仕込むことも必須だと思います。
後、プロファイル設定には紹介していない fig. 18 の【Ready ToRun
コンパイルを有効にする】もあります。
fig. 18 の【Ready ToRun
コンパイルを有効にする】をチェックしてデプロイすると、起動時間が速くなる実行ファイルが生成されるようですが、ファイルサイズも 2 ~ 3 倍? になるそうです。管理人は内容がイマイチ理解できていないので試していないので、興味がある方は Microsoft 公式の【ReadyToRun
展開の概要 – .NET | Microsoft Docs】や【.NET Core 3.0 で有効化される Tiered Compilation
と ReadyToRun
について – しばやん雑記】等を見てください。
コマンドプロンプト画面の非表示
以上で、ユーザ環境でも実行可能なアプリのデプロイまで完了しましたが、このまま実行するとコマンドプロンプト画面が表示されてしまいます。コマンドプロンプト画面が表示されても構わない場合、以下の設定は必要ありませんが、実行時にコマンドプロンプト画面を表示したくない場合は、fig. 19 の【プロジェクトのプロパティ】を開いて【出力の種類】を【Windows アプリケーション】に変更すると、実行時にコマンドプロンプトが表示されなくなります。
fig. 19 の設定でデプロイすると実行時にコマンドプロンプトは表示されませんが、ちょっとだけ表示したいと思っても表示できないはずです…表示する方法を知っている人が居れば教えてくださいw
おまけ:Windows サービスとして実行
このサンプルアプリは最初に書いた通り、Windows
サービスとして実行しても想定した動作にはなりませんが、最初に書いた制限に当てはまらない機能を作成する場合は、Windows
サービスとして実行することもできます。
上で紹介した手順でデプロイしても、バックグラウンドプロセスとして実行されますが、サービスとしては実行されません。作成したアプリを Windows
サービスとして実行する場合は、別途追加パッケージが必要になるので、fig. 20 の【ソリューションの NuGet
パッケージの管理 View】を開いて【windows.service
】を検索します。
検索結果の中から【Microsoft.Extensions.Hosting.WindowsServices
】を選択してスタートアッププロジェクト(ここでは RecentWatcher
プロジェクト)にインストールします。
インストールが完了したら、src. 18 のようにエントリポイント内の IHost
(汎用ホスト)作成箇所に 1 行追加します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | using elf.DataAccess.SqLite; using elf.DataAccesses.Interfaces; using elf.Windows.Libraries; using Microsoft.Extensions.Logging.EventLog; using RecentWatcher; IHost host = Host.CreateDefaultBuilder(args) .ConfigureLogging(logging => { logging.AddEventLog(config => { config.SourceName = "Recent File Watcher Source"; config.LogName = "Recent File Watcher"; }) .AddFilter<EventLogLoggerProvider>(level => LogLevel.Information <= level); }) .ConfigureServices((hostContext, services) => { var dbPath = Path.Combine(AppContext.BaseDirectory, hostContext.Configuration.GetSection("SqliteFileName").Value); services.AddHostedService<RecentWatcherWorker>() .AddSingleton<RecentFileWatcher>() .AddSingleton<IDapperConnectionFactory>(new DapperSqLiteConnectionFactory(dbPath)) .AddTransient<IRecentFileEditor, RecentFileEditor>() .AddScoped<ShellLink>(); }) .UseWindowsService() .Build(); await host.RunAsync(); |
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.Metro や Material Design In XAML Toolkit では .NET6 や WinUI、MAUI への対応方針が不明確なので、とりあえずは明確になっている Generic Host を軸に単体テスト(ユニットテスト)や CI について調べた事をまとめようと思っています。
ただ、アニメの一覧や生活環境の変化などもあって以前ほどのペースでは書けないかもしれません… 待っている人が居るかは分かりませんが、今後も技術系エントリは書いていこうと思っているのでよろしくお願いします。
今回もいつもの通りサンプルコードは GitHub リポジトリ に上げています。