.NET 6のトップレベルステートメントを使ったコンソールプロジェクトで、EFCoreのマイグレーションはなぜ動くのか

# はじめに

.NET 6 ではコンソールアプリケーションのテンプレートが大きく変わり、下記のようにとてもシンプルなプロジェクトが作成されるようになりました。ただ、とてもシンプルな反面、なにから手を付けてよいのか少し迷ってしまいます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/36535/b8be7426-67e8-9b8b-2973-1fde1f2471ab.png)

EFCore 6 では EFCore 5 まであった「特定のメソッド名が無いとマイグレーションは動かせない」という制限をから解放されています。今回はこれまでのマイグレーションの実行方法を振り返りながら、.NET 6 + EFCore 6 のコンソールプロジェクトでトップレベルステートメントを使ったアプリがなぜデータベースマイグレーションを実行できるのかを確認していきます。

# dotnet ef サブコマンドでデータベースを管理する

`dotnet ef` サブコマンドでマイグレーションを行うためには次の前提条件があります。

- 環境に .NET SDK 及び `dotnet ef` サブコマンドがインストールされている
- `Microsoft.EntityFrameworkCore.Design` パッケージが追加されている
- デザイン時のデータコンテキストクラスが存在する（IHostBuilder を利用しない場合）
- 特定の名前を持つ初期化コードが存在する（EFCore 6 以前）
- `IHostBuilder` の `Run` メソッドを呼び出すコードが存在する（EFCore 6 以降）

## 環境に .NET SDK 及び dotnet ef サブコマンドがインストールされている

`dotnet ef` サブコマンドは [.NET SDK](https://docs.microsoft.com/ja-jp/dotnet/core/sdk) を必要とします。また、.NET Core 3.1 からは `dotnet ef` サブコマンドは [.NET SDK](https://dotnet.microsoft.com/download) とは別にインストールする必要があります。 次のコマンドを実行して `dotnet ef` サブコマンドをインストールするか、アップデートしましょう。

https://docs.microsoft.com/ja-jp/ef/core/cli/dotnet

```sh:サブコマンドのインストール
❯ dotnet tool install --global dotnet-ef
次のコマンドを使用してツールを呼び出せます。dotnet-ef
ツール 'dotnet-ef' (バージョン '6.0.0') が正常にインストールされました。
```

```sh:サブコマンドのアップデート
❯ dotnet tool update --global dotnet-ef
ツール 'dotnet-ef' がバージョン '5.0.0' からバージョン '6.0.0' に正常に更新されました。
```

## `Microsoft.EntityFrameworkCore.Design` パッケージが追加されている

マイグレーションの実行には対象のプロジェクトに `Microsoft.EntityFrameworkCore.Design` パッケージが必要です。

例えば、MySQL のマイグレーションを含むコンソールプロジェクト作る場合、スタートアッププロジェクトには最低限次のパッケージを追加しておく必要があります。

```sh
❯ dotnet new console -o Sample
❯ cd Sample
❯ dotnet add package Microsoft.EntityFrameworkCore.Design
❯ dotnet add package Microsoft.Extensions.Hosting
❯ dotnet add package Pomelo.EntityFrameworkCore.MySql
```

`Microsoft.EntityFrameworkCore.Design` がスタートアッププロジェクトに含まれない場合、マイグレーションを登録したり、実行したりする場合に次のようなエラーメッセージが表示されます。

```sh
❯ dotnet ef migrations add first
Build started...
Build succeeded.
Your startup project 'Sample' doesn't reference Microsoft.EntityFrameworkCore.Design. This package is required for the Entity Framework Core Tools to work. Ensure your startup project
is correct, install the package, and try again.
```

## デザイン時のデータコンテキストクラスが存在する（IHostBuilder を利用しない場合）

EFCore を利用した最も単純なプログラムは次のようなコードでしょう。

```cs:Program.cs
var options = new DbContextOptionsBuilder<BlogDbContext>()
    .UseMySql(connectionString, ServerVersion.Parse("8.0"))
    .Options;
using var dbContext = new BlogDbContext(options);
foreach (var blog in await dbContext.Blogs.ToListAsync())
{
    Console.WriteLine($"{blog.Id}, {blog.Name}");
}
```

上記のコードはプログラム自体は問題なく実行されますが、dotnet ef コマンドなどでマイグレーションを登録したり、実行したりしたりすると次のようなエラーメッセージが表示されます。

```sh
❯ dotnet ef migrations add first
Build started...
Build succeeded.
An error occurred while accessing the Microsoft.Extensions.Hosting services. Continuing without the application service provider. Error: Unable to build IHost
Unable to create an object of type 'BlogDbContext'. For the different patterns supported at design time, see https://go.microsoft.com/fwlink/?linkid=851728
```

これは EFCore がマイグレーションの実行に必要なデータコンテキストの情報を取得できないために発生します。
このため、EFCore 6 以前ではマイグレーションを実行するために次のような特徴のメソッドやクラスが必要でした。

- `IDesignTimeDbContextFactory<TContext>` を実装したクラス
- データコンテキストを含む `static IHostBuilder CreateHostBuilder(string[] args)` もしくは `static IWebHostBuilder CreateWebHostBuilder(string[] args)` というシグネチャを持つメソッド

最も単純な解決方法は、`IDesignTimeDbContextFactory<TContext>` を実装する次のようなクラスをプロジェクトに含めることです。EFCore はこのクラスをもとにマイグレーションを実行します。

```cs:BlogDesingTimeDbContext.cs
public class BlogDesingTimeDbContext : IDesignTimeDbContextFactory<BlogDbContext>
{
    public BlogDbContext CreateDbContext(string[] args)
    {
        const string connectionString = "Server=localhost;Port=3306;Uid=root;Pwd=root;Database=mydb";
        var options = new DbContextOptionsBuilder<BlogDbContext>()
            .UseMySql(connectionString, ServerVersion.Parse("8.0"))
            .Options;
        return new BlogDbContext(options);
    }
}
```

## 特定の名前を持つ初期化コードが存在する（EFCore 6 以前）

`IHostBuilder` を利用して DI コンテナにデータコンテキストを登録するようなアプリの場合、EF Core 6 までは `CreateHostBuilder` や `CreateWebHostBuilder` といったメソッド名を頼りにデータコンテキストを取得していました。
これらの名前が利用できない場合は前述の `IDesignTimeDbContextFactory<TContext>` を実装したクラスを用意する必要がありました。

```cs:Program.csでCreateHostBuilderを利用する例
public static int Main(string[] args)
{
    await CreateHostBuilder(args)
        .Build()
        .Services
        .GetRequiredService<Runner>()
        .Run();
}

static IHostBuilder CreateHostBuilder(string[] args) =>
    Host.CreateDefaultBuilder(args)
        .ConfigureServices((context, services) =>
        {
            services.AddScoped<Runner>();
            services.AddDbContext<BlogDbContext>(options =>
            {
                options.UseMySql(connectionString, ServerVersion.Parse("8.0"));
            });
        });

class Runner
{
    private readonly BlogDbContext _dbContext;
    public Runner(BlogDbContext dbContext)
    {
        _dbContext = dbContext;
    }
    public async Task RunAsync()
    {
        foreach (var blog in await _dbContext.Blogs.ToListAsync())
        {
            Console.WriteLine($"{blog.Id}, {blog.Name}");
        }
    }
}
```

## `IHostBuilder` の `Run` メソッドを呼び出すコードが存在する（EFCore 6 以降）

EFCore 6 のマイグレーションでは `IHostBuilder` の `Build` メソッドの実行が完了するのを監視しデータコンテキストを取得するようになったため、`CreateHostBuilder` というメソッドを含んでいなくてもマイグレーションを実行できるようになりました。
.NET 6 のトップレベルステートメントと自然に融合しているため、初期化コードが分離されず上から下に読み下せるようになったのでこれまでよりもわかりやすくなりましたね。

```cs:Program.cs
var builder = Host.CreateDefaultBuilder(args);
builder.ConfigureServices((context, services) =>
{
    services.AddScoped<Worker>();
    services.AddDbContext<BlogDbContext>(options =>
    {
        options.UseMySql(
                context.Configuration.GetConnectionString("blogdbcontext"),
                ServerVersion.Parse("8.0"),
                mysqlOption => { mysqlOption.EnableRetryOnFailure(10); })
            .EnableSensitiveDataLogging();
    });
});
await builder.Build().Services.GetRequiredService<Worker>().RunAsync();
```

# .NET 6 の HostBuilder と EFCore のマイグレーション

EFCore はマイグレーション起動時に [HostFactoryResolver.ResolveServiceProviderFactory](https://github.com/dotnet/runtime/blob/main/src/libraries/Microsoft.Extensions.HostFactoryResolver/src/HostFactoryResolver.cs#L124) を呼び出してデータコンテキストを含むサービスコンテナを探します。

繰り返しになりますが、EFCore 5 までは、マイグレーションの初期化時に `CreateHostBuilder` や `CreateWebHostBuilder` という[メソッドの名前をもとに解決](https://github.com/dotnet/runtime/blob/v5.0.12/src/libraries/Microsoft.Extensions.HostFactoryResolver/src/HostFactoryResolver.cs#L30)していました。

EFCore 6 からは、`HostBuilder` の `Build` メソッドがビルド開始前と完了時に `DiagnosticListener` を通じて発行する [HostBuilding](https://github.com/dotnet/runtime/blob/main/src/libraries/Microsoft.Extensions.Hosting/src/HostBuilder.cs#L140) イベントと [HostBuilt](https://github.com/dotnet/runtime/blob/main/src/libraries/Microsoft.Extensions.Hosting/src/HostBuilder.cs#L152) イベントを [HostingListener](https://github.com/dotnet/runtime/blob/main/src/libraries/Microsoft.Extensions.HostFactoryResolver/src/HostFactoryResolver.cs#L197) が[監視](https://github.com/dotnet/runtime/blob/main/src/libraries/Microsoft.Extensions.HostFactoryResolver/src/HostFactoryResolver.cs#L324)し、[ServiceProviderを取得](https://github.com/dotnet/runtime/blob/main/src/libraries/Microsoft.Extensions.HostFactoryResolver/src/HostFactoryResolver.cs#L186)することで解決しています。これによって単なる文字列ではなく、意味のある初期化コードをもとにマイグレーションが実行できるようになりました。

詳細については下記の投稿が詳しいです。

https://andrewlock.net/exploring-dotnet-6-part-5-supporting-ef-core-tools-with-webapplicationbuilder/#updates-to-the-hostfactoryresolver

# おわりに

コンソールプロジェクトはプロジェクトテンプレートにほとんど何も書かれていないので、初めて見たときは自由度が高すぎてどうしたらよいかわからなくなくなりますが、IHostBuilder を使ったアプリケーションを作成するのであれば ASP.NET Core と変わりがありません。