😺

.NET9におけるコンソールアプリの実装方法

2025/01/19に公開1

はじめに

この記事では.NET9にてコンソールアプリを作成するハウツーを記載したいと思います。

全体のサンプルコードは以下に配置してあります。

https://github.com/neko3cs/neko3cs-lab/tree/main/src/dotnet-9-console-app

本稿で説明すること

  • 標準のライブラリのみを使った.NET9でのモダンなコンソールアプリの作成方法
    • AOTコンパイルを用いたコンソールアプリの実装
    • 汎用ホストを用いたコンソールアプリの実装

本稿で説明しないこと

  • サードパーティのライブラリを用いたコンソールアプリの作成方法

作成方法

.NET9のSDKがインストールされていることを前提としてます。

プロジェクトの作成

プロジェクトを作成します。フォルダー階層など気になる点があれば各々で対応してください。

mkdir GenericHostConsoleApp &&
cd GenericHostConsoleApp &&
dotnet new sln -o . -n GenericHostConsoleApp &&
dotnet new console -o . -n GenericHostConsoleApp

最初にハローワールドプログラムができるのでこの段階で実行して試してみましょう。

AOTコンパイル設定

.NET7からAOTコンパイルという機能が実装されています。これは機械語に直接翻訳された実行可能ファイルを生成する機能です。これにより、実行ファイルは.NETに依存せずに実行できるようになります。(ビルド時は必要だが)

メリットとして、環境に.NETをインストールしたり、ランタイム同梱でコンパイルしてファイルサイズが大きいみたいなことに悩まなくて良くなる点です。

ではそのやり方ですが、以下の設定をGenericHostConsoleApp.csprojに追記するだけです。

<PublishAot>true</PublishAot>
<RuntimeIdentifier>win-x64</RuntimeIdentifier> 
<IncludeNativeLibrariesForSelfExtract>false</IncludeNativeLibrariesForSelfExtract>
<StripSymbols>true</StripSymbols>
<EnableTrimAnalyzer>true</EnableTrimAnalyzer>

それぞれの意味は次のような無いようになります。

PublishAot

AOTコンパイルを有効化します。これにより、.NETのランタイムを必要としない、機械語の実行可能ファイルにコンパイルされます。

RuntimeIdentifier

コンパイル対象の環境を指定します。今回はIntel CPUのWindowsを対象にしているので、 win-x64 とつけます。

その他の設定は以下をご覧ください。

https://learn.microsoft.com/ja-jp/dotnet/core/rid-catalog

IncludeNativeLibrariesForSelfExtract

自己展開式の実行可能ファイルに関する設定です。自己展開式というのはイメージとしてzipファイルのようにexeやdllファイルが束なってコンパイルされ、実行時にWindowsの一時フォルダー領域に展開されて実行される形態です。

一時フォルダー領域にゴミが溜まってしまうため、こちらは false を明示して、直接実行可能な形式にします。

StripSymbols

デバック情報を実行可能ファイルを削除するかどうかの設定になります。 false にすると実行可能ファイルの軽量化に繋げられます。

一般的には実行可能ファイルをVisual Studioに接続してデバッグするより、ログファイルを出力したりしてエラーを解析することのが多いかと思うので、消してしまっていいと私は思います。

EnableTrimAnalyzer

トリム時の問題を分析してくれます。トリムとは PublishAot 設定にて同梱される標準ライブラリのうち、使用していない領域を削除する機能になります。これにより、実行ファイルの軽量化がされます。

ですが、場合によっては予期せぬエラーにつながることがあります。これをビルド時に警告してくれるがこちらの機能になります。あった方がいいので true にします。

汎用ホストを用いた実装

見るべきコードは以下の3つです。

  • Program.cs
  • Worker.cs
  • appsettings.json

次は Program.csです。

using GenericHostConsoleApp;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

var host = Host.CreateDefaultBuilder(args)
  .ConfigureAppConfiguration((hostContext, config) =>
  {
    config.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true);
  })
  .ConfigureServices((hostContext, services) =>
  {
    services.AddHostedService<Worker>();
  })
  .ConfigureLogging((hostContext, logging) =>
  {
    logging.AddConsole();
  })
  .Build();

await host.RunAsync();

Program.csは一般的な汎用ホストの基本形みたいな感じのコードです。

ConfigureAppConfigurationメソッドで設定ファイルを読み込めます。

ConfigureServicesメソッドでDIができます。DIについてはあまり説明しませんが、簡単にいうとテストしやすい感じでいい感じにクラスを扱えるようにする技法です。公式ライブラリでサポートしてくれてるので、今や.NETではDIするのが普通です。今回はバッチのメイン処理を記述しているWorkerクラスをDIしています。

ConfigureLoggingメソッドでロギングの設定ができます。今回はコンソール上にのみ表示させていますが、データベースやイベントログなど、さまざまな場所に同時にログを書き込ませることが可能になります。実際にログを書く処理は後述のWorkerクラスで説明します。

細かい説明は以下を参考にされると良いです。

https://learn.microsoft.com/ja-jp/dotnet/core/extensions/dependency-injection

次はWorker.csです。

using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

namespace GenericHostConsoleApp;

public class Worker(
    ILogger<Worker> logger,
    IConfiguration configuration,
    IHostApplicationLifetime applicationLifetime
) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken cancellationToken)
    {
        logger.LogInformation("Worker starting at: {time}", DateTimeOffset.Now);

        var appName = configuration["Settings:ApplicationName"];
        var awaitAtMilliSeconds = int.Parse(configuration["Settings:AwaitAtMilliSeconds"] ?? "0");

        logger.LogInformation($"Application Name: {appName}");
        await Task.Delay(awaitAtMilliSeconds, cancellationToken);

        logger.LogInformation("Worker stopping at: {time}", DateTimeOffset.Now);

        applicationLifetime.StopApplication();
    }
}

Worker.csは実際に処理したいバッチのメイン処理を記述しています。BackgroundServiceというクラスを継承することでいい感じに実装できます。クラス名のすぐ後についてる引数はプライマリコンストラクターという機能です。プライベートフィールド扱いになるのでコードがスッキリします。

Program.csでおこなったConfigureLoggingメソッドの呼び出しによりILoggerの引数が、ConfigureAppConfigurationメソッドの呼び出しによりIConfigurationの引数が受け取れるようになります。それぞれはログを書き込むインスタンスと設定ファイルの値を読み取るインスタンスになります。

バッチのメイン処理はExecuteAsyncメソッドに記載します。これはBackgroundServiceに実装されているメソッドをオーバーライドして利用します。基本的にはこのメソッドに処理を書けば良いです。

コンストラクターにてIHostApplicationLifetimeという型のインスタンスも受け取っています。これはアプリケーションのライフサイクルをコントロールするためのインスタンスになります。基本的にアプリケーションの停止に利用します。

次はappsettings.jsonです。

{
  "Settings": {
    "ApplicationName": "Generic Host Console App",
    "AwaitAtMilliSeconds": 5000
  }
}

とくに説明することはないです。ただのJsonファイルです。

まとめ

今回は.NET9を用いたコンソールアプリの実装方法を説明してきました。ほとんどの機能は.NET9以前の機能でしたが、.NET9ではAOTコンパイルの対象OSが増え、インライン展開の強化がされており、実行速度も高速で軽量化も図られています。

.NET向けのコンソールアプリ用のフレームワークもサードパーティで用意されていたりしますが、エンタープライズ向けの業務バッチなどには引数の名前付き指定やフラグ機能などは不要だったりして、できるだけ依存ライブラリを減らしたいものかと思います。今回は標準ライブラリのみで実装できるため、運用システムからスケジュール実行されるなど、ツールなどの日常で利用するアプリではない場合に有用かと思いました。

フレームワークやライブラリは無検討に導入するのではなく、用途に応じて導入することが重要です。みなさんのご参考になればと思います。

Discussion

neko3csneko3cs

自分用メモ

引数を取り扱いたいときは以下のようなパースクラスを作っておくと便利。

ConfigureServicesでパースしてそのままDIしちゃえば各クラスで扱える。

internal record CommandLineArgs
{
    public string InputFilePath { get; private set; }
    public string OutputDirPath { get; private set; }

    private CommandLineArgs(string[] args)
    {
        InputFilePath = args[0];
        OutputDirPath = args[1];
    }

    public static bool TryParse(string[] args, out string errorMessage, out CommandLineArgs? commandLineArgs)
    {
        errorMessage = string.Empty;
        commandLineArgs = null;

        if (args.Length is not 2)
        {
            errorMessage = "Usage: GenericHostConsoleApp.exe <input_file_path> <output_file_path>";
            return false;
        }
        if (!File.Exists(args[0]))
        {
            errorMessage = $"入力ファイル '{args[0]}' が見つかりません。";
            return false;
        }
        if (!Directory.Exists(args[1]))
        {
            errorMessage = $"出力フォルダ '{args[1]}' が見つかりません。";
            return false;
        }

        commandLineArgs = new CommandLineArgs(args);
        return true;
    }
}