次の方法で共有


インターセプター

Entity Framework Core (EF Core) インターセプターを使用すると、EF Core 操作のインターセプト、変更、抑制が可能になります。 これには、コマンドの実行などの低レベルのデータベース操作や、SaveChanges の呼び出しなどの上位レベルの操作が含まれます。

インターセプターは、インターセプトされる操作の変更または抑制を許可するという点で、ログ記録や診断とは異なります。 ログ記録には、単純なログ記録 または Microsoft.Extensions.Logging の方が適しています。

インターセプターは、コンテキストの構成時に DbContext インスタンスごとに登録されます。 診断リスナーを使用して、プロセス内のすべての DbContext インスタンスに対して同じ情報を取得します。

使用可能なインターセプター

次の表に、使用可能なインターセプター インターフェイスを示します。

インターセプター インターセプトされたオペレーション シングルトン
IDbCommandInterceptor コマンドの作成
コマンドの実行
コマンドの失敗
コマンドの DbDataReader の指定
いいえ
IDbConnectionInterceptor 接続の開始と終了
接続の作成
接続エラー
いいえ
IDbTransactionInterceptor トランザクションの作成
既存のトランザクションの使用
トランザクションのコミット
トランザクションのロールバック
セーブポイントの作成と使用
トランザクションの失敗
いいえ
ISaveChangesInterceptor SavingChanges/SavedChanges
SaveChangesFailed
最適化コンカレンシー処理
いいえ
IMaterializationInterceptor クエリ結果からのエンティティ インスタンスの作成、初期化、および最終処理 イエス
IQueryExpressionInterceptor クエリがコンパイルされる前の LINQ 式ツリーの変更 イエス
IIdentityResolutionInterceptor エンティティを追跡するときの識別子の競合の解決 イエス

インターセプターの登録

インターセプターは、AddInterceptorsするときにを使用して登録されます。 これは一般的に、 DbContext.OnConfiguringのオーバーライドで行われます。 例えば次が挙げられます。

public class ExampleContext : BlogsContext
{
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        => optionsBuilder.AddInterceptors(new TaggedQueryCommandInterceptor());
}

または、 AddInterceptors は、 AddDbContext の一部として、または dbContext コンストラクターに渡す DbContextOptions インスタンスを作成するときに呼び出すことができます。

ヒント

AddDbContext が使用されている場合、または DbContextOptions インスタンスが DbContext コンストラクターに渡された場合、OnConfiguring は引き続き呼び出されます。 これにより、DbContext の構築方法に関係なく、コンテキスト構成を適用するのに最適な場所になります。

インターセプターはステートレスであることがよくあります。つまり、単一のインターセプター インスタンスをすべての DbContext インスタンスに使用できます。 例えば次が挙げられます。

public class TaggedQueryCommandInterceptorContext : BlogsContext
{
    private static readonly TaggedQueryCommandInterceptor _interceptor
        = new TaggedQueryCommandInterceptor();

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        => optionsBuilder.AddInterceptors(_interceptor);
}

各インターセプター インスタンスは、 IInterceptorから派生した 1 つ以上のインターフェイスを実装する必要があります。 各インスタンスは、複数のインターセプト インターフェイスを実装している場合でも、1 回だけ登録する必要があります。EF Core は、必要に応じて各インターフェイスのイベントをルーティングします。

シングルトン インターセプター

一部のインターセプターは ISingletonInterceptor を実装します (上の表を参照)。これらのインターセプターは EF Core の内部サービス プロバイダーでシングルトン サービスとして登録されます。つまり、同じサービス プロバイダーを使用するすべての DbContext インスタンス間で 1 つのインスタンスが共有されます。

シングルトン インターセプターは EF Core の内部サービス構成の一部になるため、個別のインターセプター インスタンスごとに新しい内部サービス プロバイダーが構築されます。 たとえば、DbContextが構成されるたびにシングルトン インターセプターのAddDbContextを渡すと、最終的にManyServiceProvidersCreatedWarningがトリガーされ、パフォーマンスが低下します。

Warnung

すべての DbContext インスタンスに対して同じシングルトン インターセプター インスタンスを常に再利用します。 コンテキストが構成されるたびに新しいインスタンスを作成しないでください。

たとえば、コンテキスト構成ごとに新しいインターセプター インスタンスが作成されるため、次は 正しくありません

// Don't do this! A new instance each time causes a new internal service provider to be built.
services.AddDbContext<CustomerContext>(
    b => b.UseSqlServer(connectionString)
          .AddInterceptors(new MyMaterializationInterceptor()));

代わりに、同じインスタンスを再利用します。

// Correct: reuse a single interceptor instance
var interceptor = new MyMaterializationInterceptor();
services.AddDbContext<CustomerContext>(
    b => b.UseSqlServer(connectionString)
          .AddInterceptors(interceptor));

または、静的フィールドを使用します。

public class CustomerContext : DbContext
{
    private static readonly MyMaterializationInterceptor _interceptor = new();

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        => optionsBuilder.AddInterceptors(_interceptor);
}

これらのインターセプターはシングルトンであるため、スレッド セーフである必要があります。 一般に、変更可能な状態を保持しないでください。 スコープ指定されたサービス (現在の DbContextなど) にアクセスする必要がある場合は、各インターセプター メソッドに渡されるイベント データに対して Context または同様のプロパティを使用します。

データベースの傍受

データベースのインターセプトは、リレーショナル データベース プロバイダーでのみ使用できます。

低レベルのデータベース インターセプトは、次の表に示す 3 つのインターフェイスに分割されます。

インターセプター インターセプトされたデータベース操作
IDbCommandInterceptor コマンドの作成
コマンドの実行
コマンドの失敗
コマンドの DbDataReader の指定
IDbConnectionInterceptor 接続の開始と終了
接続の作成
接続エラー
IDbTransactionInterceptor トランザクションの作成
既存のトランザクションの使用
トランザクションのコミット
トランザクションのロールバック
セーブポイントの作成と使用
トランザクションの失敗

基底クラス DbCommandInterceptorDbConnectionInterceptor、および DbTransactionInterceptor には、対応するインターフェイス内の各メソッドの no-op 実装が含まれています。 使用されていないインターセプト メソッドを実装する必要を回避するには、基底クラスを使用します。

各インターセプター型のメソッドはペアになっています。最初のメソッドはデータベース操作が開始される前に呼び出され、2 つ目は操作が完了した後に呼び出されます。 たとえば、 DbCommandInterceptor.ReaderExecuting はクエリが実行される前に呼び出され、クエリがデータベースに送信された後に DbCommandInterceptor.ReaderExecuted が呼び出されます。

メソッドの各ペアには、同期と非同期の両方のバリエーションがあります。 これにより、非同期データベース操作のインターセプトの一環として、アクセス トークンの要求などの非同期 I/O を実行できます。

例: クエリ ヒントを追加するコマンド インターセプト

ヒント

コマンド インターセプターのサンプルは GitHub からダウンロードできます。

IDbCommandInterceptorは、データベースに送信される前に SQL を変更するために使用できます。 この例では、クエリ ヒントを含むように SQL を変更する方法を示します。

多くの場合、インターセプトの最も複雑な部分は、コマンドが変更する必要があるクエリに対応するタイミングを決定することです。 SQL の解析は 1 つのオプションですが、脆弱になる傾向があります。 もう 1 つのオプションは、 EF Core クエリ タグ を使用して、変更する必要がある各クエリにタグを付ける方法です。 例えば次が挙げられます。

var blogs1 = await context.Blogs.TagWith("Use hint: robust plan").ToListAsync();

このタグは、コマンド テキストの最初の行に常にコメントとして含まれるため、インターセプターで検出できます。 タグを検出すると、クエリ SQL が変更され、適切なヒントが追加されます。

public class TaggedQueryCommandInterceptor : DbCommandInterceptor
{
    public override InterceptionResult<DbDataReader> ReaderExecuting(
        DbCommand command,
        CommandEventData eventData,
        InterceptionResult<DbDataReader> result)
    {
        ManipulateCommand(command);

        return result;
    }

    public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
        DbCommand command,
        CommandEventData eventData,
        InterceptionResult<DbDataReader> result,
        CancellationToken cancellationToken = default)
    {
        ManipulateCommand(command);

        return new ValueTask<InterceptionResult<DbDataReader>>(result);
    }

    private static void ManipulateCommand(DbCommand command)
    {
        if (command.CommandText.StartsWith("-- Use hint: robust plan", StringComparison.Ordinal))
        {
            command.CommandText += " OPTION (ROBUST PLAN)";
        }
    }
}

注意:

  • インターセプターは、インターセプター インターフェイス内のすべてのメソッドを実装する必要がないように、 DbCommandInterceptor から継承します。
  • インターセプターは、同期メソッドと非同期メソッドの両方を実装します。 これにより、同期クエリと非同期クエリに同じクエリ ヒントが適用されます。
  • インターセプターは、生成された SQL を使用して EF Core によって呼び出された Executing メソッドを、データベースに送信する に実装します。 これは、データベース呼び出しが返された後に呼び出される Executed メソッドと対照的です。

この例のコードを実行すると、クエリにタグが付くと、次のコードが生成されます。

-- Use hint: robust plan

SELECT [b].[Id], [b].[Name]
FROM [Blogs] AS [b] OPTION (ROBUST PLAN)

一方、クエリがタグ付けされていない場合は、変更されずにデータベースに送信されます。

SELECT [b].[Id], [b].[Name]
FROM [Blogs] AS [b]

例: AAD を使用した SQL Azure 認証の接続インターセプト

ヒント

接続インターセプターのサンプルは、GitHub からダウンロードできます。

データベースへの接続に使用する前に、 IDbConnectionInterceptor を使用して DbConnection を操作できます。 これを使用して、Azure Active Directory (AAD) アクセス トークンを取得できます。 例えば次が挙げられます。

public class AadAuthenticationInterceptor : DbConnectionInterceptor
{
    public override InterceptionResult ConnectionOpening(
        DbConnection connection,
        ConnectionEventData eventData,
        InterceptionResult result)
        => throw new InvalidOperationException("Open connections asynchronously when using AAD authentication.");

    public override async ValueTask<InterceptionResult> ConnectionOpeningAsync(
        DbConnection connection,
        ConnectionEventData eventData,
        InterceptionResult result,
        CancellationToken cancellationToken = default)
    {
        var sqlConnection = (SqlConnection)connection;

        var provider = new AzureServiceTokenProvider();
        // Note: in some situations the access token may not be cached automatically the Azure Token Provider.
        // Depending on the kind of token requested, you may need to implement your own caching here.
        sqlConnection.AccessToken = await provider.GetAccessTokenAsync("https://database.windows.net/", null, cancellationToken);

        return result;
    }
}

ヒント

Microsoft.Data.SqlClient では、接続文字列を使用した AAD 認証がサポートされるようになりました。 詳細については、SqlAuthenticationMethod を参照してください。

Warnung

接続を開くために同期呼び出しが行われた場合、インターセプターがスローされます。 これは、アクセス トークンを取得する非同期以外のメソッド がなく、デッドロックを危険にさらすことなく非非同期コンテキストから非同期メソッドを呼び出すユニバーサルで簡単な方法がないためです。

Warnung

場合によっては、アクセス トークンが Azure トークン プロバイダーに自動的にキャッシュされない場合があります。 要求されたトークンの種類によっては、ここで独自のキャッシュを実装することが必要になる場合があります。

例: 接続文字列の遅延初期化

接続文字列は、多くの場合、構成ファイルから読み取られる静的資産です。 これらの要素は、UseSqlServer を構成するときに、DbContext などに簡単に渡すことができます。 ただし、コンテキスト インスタンスごとに接続文字列が変わる場合があります。 たとえば、マルチテナント システムでは、テナントごとに接続文字列が異なることがあります。

IDbConnectionInterceptorを使用して、動的な接続と接続文字列を処理できます。 その最初のものは、接続文字列なしで DbContext を構成する機能です。 例えば次が挙げられます。

services.AddDbContext<CustomerContext>(
    b => b.UseSqlServer());

次に、IDbConnectionInterceptor メソッドの 1 つを実装して、使う前に接続を構成できます。 接続文字列の取得やアクセス トークンの検索などの非同期操作を実行できるため、ConnectionOpeningAsync が適切な選択肢です。 たとえば、現在のリクエストにスコープされたサービスを想像してください。このサービスは、現在のテナントを理解しています。

services.AddScoped<ITenantConnectionStringFactory, TestTenantConnectionStringFactory>();

Warnung

接続文字列やアクセス トークンなどの非同期検索を必要なたびに実行すると、非常に時間がかかる場合があります。 これらの項目をキャッシュし、キャッシュされた文字列またはトークンのみを定期的に更新することを検討してください。 たとえば、多くの場合、アクセス トークンは、更新が必要になる前に、かなりの期間使用できます。

これは、コンストラクターの挿入を使って、各 DbContext インスタンスに挿入できます。

public class CustomerContext : DbContext
{
    private readonly ITenantConnectionStringFactory _connectionStringFactory;

    public CustomerContext(
        DbContextOptions<CustomerContext> options,
        ITenantConnectionStringFactory connectionStringFactory)
        : base(options)
    {
        _connectionStringFactory = connectionStringFactory;
    }

    // ...
}

このサービスは、コンテキスト用にインターセプター実装を構築するときに使われます。

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    => optionsBuilder.AddInterceptors(
        new ConnectionStringInitializationInterceptor(_connectionStringFactory));

最後に、インターセプターは、このサービスを使って接続文字列を非同期的に取得し、接続が初めて使われるときに設定します。

public class ConnectionStringInitializationInterceptor : DbConnectionInterceptor
{
    private readonly ITenantConnectionStringFactory _connectionStringFactory;

    public ConnectionStringInitializationInterceptor(ITenantConnectionStringFactory connectionStringFactory)
    {
        _connectionStringFactory = connectionStringFactory;
    }

    public override InterceptionResult ConnectionOpening(
        DbConnection connection,
        ConnectionEventData eventData,
        InterceptionResult result)
        => throw new NotSupportedException("Synchronous connections not supported.");

    public override async ValueTask<InterceptionResult> ConnectionOpeningAsync(
        DbConnection connection, ConnectionEventData eventData, InterceptionResult result,
        CancellationToken cancellationToken = new())
    {
        if (string.IsNullOrEmpty(connection.ConnectionString))
        {
            connection.ConnectionString = (await _connectionStringFactory.GetConnectionStringAsync(cancellationToken));
        }

        return result;
    }
}

接続文字列は、接続が初めて使われるときにのみ取得されます。 その後は、DbConnection に格納されている接続文字列が使われ、新しい接続文字列の検索は行われません。

ヒント

接続文字列を取得するサービスは非同期コード パスから呼び出す必要があるため、このインターセプターは非同期ではない ConnectionOpening メソッドをオーバーライドしてスローします。

例: キャッシュ用の高度なコマンド インターセプト

EF Core インターセプターは次のことができます。

  • インターセプトされる操作の実行を抑制するように EF Core に指示する
  • EF Core に報告された操作の結果を変更する

この例では、これらの機能を使用してプリミティブ第 2 レベルのキャッシュのように動作するインターセプターを示します。 キャッシュされたクエリ結果は、特定のクエリに対して返され、データベースラウンドトリップを回避します。

Warnung

この方法で EF Core の既定の動作を変更するときは注意してください。 EF Core は、正常に処理できない異常な結果が発生した場合、予期しない方法で動作する可能性があります。 また、この例ではインターセプターの概念を示します。これは、堅牢な第 2 レベルのキャッシュ実装のテンプレートとして意図されていません。

この例では、アプリケーションはクエリを頻繁に実行して、最新の "毎日のメッセージ" を取得します。

async Task<string> GetDailyMessage(DailyMessageContext context)
    => (await context.DailyMessages.TagWith("Get_Daily_Message").OrderBy(e => e.Id).LastAsync()).Message;

このクエリは、インターセプターで簡単に検出できるように タグ付けされます 。 データベースに対してクエリを実行して、毎日 1 回だけ新しいメッセージを検索するという考え方です。 それ以外の場合、アプリケーションはキャッシュされた結果を使用します。 (このサンプルでは、サンプルで 10 秒の遅延を使用して新しい日をシミュレートします)。

インターセプターの状態

このインターセプターはステートフルです。クエリが実行された最新の毎日のメッセージの ID とメッセージ テキストと、そのクエリが実行された時刻が格納されます。 この状態のため、キャッシュでは複数のコンテキスト インスタンスで同じインターセプターを使用する必要があるため、 ロック も必要です。

private readonly object _lock = new object();
private int _id;
private string _message;
private DateTime _queriedAt;

実行前

Executing メソッド (つまり、データベース呼び出しを行う前) では、インターセプターはタグ付けされたクエリを検出し、キャッシュされた結果があるかどうかを確認します。 このような結果が見つかった場合、クエリは抑制され、キャッシュされた結果が代わりに使用されます。

public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
    DbCommand command,
    CommandEventData eventData,
    InterceptionResult<DbDataReader> result,
    CancellationToken cancellationToken = default)
{
    if (command.CommandText.StartsWith("-- Get_Daily_Message", StringComparison.Ordinal))
    {
        lock (_lock)
        {
            if (_message != null
                && DateTime.UtcNow < _queriedAt + new TimeSpan(0, 0, 10))
            {
                command.CommandText = "-- Get_Daily_Message: Skipping DB call; using cache.";
                result = InterceptionResult<DbDataReader>.SuppressWithResult(new CachedDailyMessageDataReader(_id, _message));
            }
        }
    }

    return new ValueTask<InterceptionResult<DbDataReader>>(result);
}

コードが InterceptionResult<TResult>.SuppressWithResult を呼び出し、キャッシュされたデータを含む代替 DbDataReader を渡す方法に注目してください。 この InterceptionResult が返され、クエリ実行が抑制されます。 代わりに、置換リーダーがクエリの結果として EF Core によって使用されます。

このインターセプターは、コマンド テキストも操作します。 この操作は必要ありませんが、ログ メッセージのわかりやすさが向上します。 クエリは実行されないため、コマンド テキストは有効な SQL である必要はありません。

実行後

キャッシュされたメッセージが使用できない場合、または有効期限が切れている場合、上記のコードは結果を抑制しません。 そのため、EF Core は通常どおりクエリを実行します。 その後、実行後にインターセプターの Executed メソッドに戻ります。 この時点で、結果がまだキャッシュリーダーでない場合は、新しいメッセージ ID と文字列が実際のリーダーから抽出され、このクエリを次に使用する準備が整います。

public override async ValueTask<DbDataReader> ReaderExecutedAsync(
    DbCommand command,
    CommandExecutedEventData eventData,
    DbDataReader result,
    CancellationToken cancellationToken = default)
{
    if (command.CommandText.StartsWith("-- Get_Daily_Message", StringComparison.Ordinal)
        && !(result is CachedDailyMessageDataReader))
    {
        try
        {
            await result.ReadAsync(cancellationToken);

            lock (_lock)
            {
                _id = result.GetInt32(0);
                _message = result.GetString(1);
                _queriedAt = DateTime.UtcNow;
                return new CachedDailyMessageDataReader(_id, _message);
            }
        }
        finally
        {
            await result.DisposeAsync();
        }
    }

    return result;
}

デモ

キャッシュ インターセプターのサンプルには、キャッシュをテストするために毎日のメッセージを照会する単純なコンソール アプリケーションが含まれています。

// 1. Initialize the database with some daily messages.
using (var context = new DailyMessageContext())
{
    await context.Database.EnsureDeletedAsync();
    await context.Database.EnsureCreatedAsync();

    context.AddRange(
        new DailyMessage { Message = "Remember: All builds are GA; no builds are RTM." },
        new DailyMessage { Message = "Keep calm and drink tea" });

    await context.SaveChangesAsync();
}

// 2. Query for the most recent daily message. It will be cached for 10 seconds.
using (var context = new DailyMessageContext())
{
    Console.WriteLine(await GetDailyMessage(context));
}

// 3. Insert a new daily message.
using (var context = new DailyMessageContext())
{
    context.Add(new DailyMessage { Message = "Free beer for unicorns" });

    await context.SaveChangesAsync();
}

// 4. Cached message is used until cache expires.
using (var context = new DailyMessageContext())
{
    Console.WriteLine(await GetDailyMessage(context));
}

// 5. Pretend it's the next day.
Thread.Sleep(10000);

// 6. Cache is expired, so the last message will not be queried again.
using (var context = new DailyMessageContext())
{
    Console.WriteLine(await GetDailyMessage(context));
}

async Task<string> GetDailyMessage(DailyMessageContext context)
    => (await context.DailyMessages.TagWith("Get_Daily_Message").OrderBy(e => e.Id).LastAsync()).Message;

次の出力が生成されます。

info: 10/15/2020 12:32:11.801 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      -- Get_Daily_Message

      SELECT "d"."Id", "d"."Message"
      FROM "DailyMessages" AS "d"
      ORDER BY "d"."Id" DESC
      LIMIT 1

Keep calm and drink tea

info: 10/15/2020 12:32:11.821 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (0ms) [Parameters=[@p0='Free beer for unicorns' (Size = 22)], CommandType='Text', CommandTimeout='30']
      INSERT INTO "DailyMessages" ("Message")
      VALUES (@p0);
      SELECT "Id"
      FROM "DailyMessages"
      WHERE changes() = 1 AND "rowid" = last_insert_rowid();

info: 10/15/2020 12:32:11.826 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      -- Get_Daily_Message: Skipping DB call; using cache.

Keep calm and drink tea

info: 10/15/2020 12:32:21.833 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      -- Get_Daily_Message

      SELECT "d"."Id", "d"."Message"
      FROM "DailyMessages" AS "d"
      ORDER BY "d"."Id" DESC
      LIMIT 1

Free beer for unicorns

ログ出力から、タイムアウトが切れるまでアプリケーションがキャッシュされたメッセージを引き続き使用していることに注意してください。その時点で、データベースに新しいメッセージが再度照会されます。

例: SQL Server クエリ統計のログ記録

この例では、SQL Server クエリ統計をアプリケーション ログに送信するために連携する 2 つのインターセプターを示します。 統計情報を生成するには、IDbCommandInterceptor で 2 つのことを行う必要があります。

1 つ目として、インターセプターはコマンドに SET STATISTICS IO ON というプレフィックスを付けます。これは、結果セットが使用された後でクライアントに統計を送信するよう SQL Server に指示します。

public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
    DbCommand command,
    CommandEventData eventData,
    InterceptionResult<DbDataReader> result,
    CancellationToken cancellationToken = default)
{
    command.CommandText = "SET STATISTICS IO ON;" + Environment.NewLine + command.CommandText;

    return new(result);
}

2 つ目に、インターセプターは DataReaderClosingAsync メソッドを実装します。これは、 DbDataReader が結果を使用し終えた後、閉じる前に呼び出されます。 SQL Server は、統計を送信するとき、リーダーの 2 番目の結果にそれを配置するため、この時点でインターセプターは、接続に統計を設定する NextResultAsync を呼び出して、その結果を読み取ります。

public override async ValueTask<InterceptionResult> DataReaderClosingAsync(
    DbCommand command,
    DataReaderClosingEventData eventData,
    InterceptionResult result)
{
    await eventData.DataReader.NextResultAsync();

    return result;
}

2 番目のインターセプターは、接続から統計を取得し、アプリケーションのロガーに書き込むために必要です。 このためには、 IDbConnectionInterceptorを使用して、 ConnectionCreated メソッドを実装します。 ConnectionCreated は、EF Core が接続を作成した直後に呼び出されるため、その接続の追加構成を行うために使用できます。 この場合、インターセプターは、ILogger を取得してから、SqlConnection.InfoMessage イベントにフックしてメッセージをログします。

public override DbConnection ConnectionCreated(ConnectionCreatedEventData eventData, DbConnection result)
{
    var logger = eventData.Context!.GetService<ILoggerFactory>().CreateLogger("InfoMessageLogger");
    ((SqlConnection)eventData.Connection).InfoMessage += (_, args) =>
    {
        logger.LogInformation(1, args.Message);
    };
    return result;
}

Important

ConnectionCreatingConnectionCreated メソッドは、EF Core が DbConnection を作成するときにのみ呼び出されます。 アプリケーションで DbConnection を作成して EF Core に渡す場合、これらは呼び出されません。

コマンド ソースによるフィルター処理

診断ソースとインターセプターに提供される CommandEventData には、EF のどの部分がコマンドの作成を担当していたかを示す CommandSource プロパティが含まれています。 これはインターセプターのフィルターとして使用できます。 たとえば、 SaveChangesからのコマンドにのみ適用されるインターセプターが必要な場合があります。

public class CommandSourceInterceptor : DbCommandInterceptor
{
    public override InterceptionResult<DbDataReader> ReaderExecuting(
        DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result)
    {
        if (eventData.CommandSource == CommandSource.SaveChanges)
        {
            Console.WriteLine($"Saving changes for {eventData.Context!.GetType().Name}:");
            Console.WriteLine();
            Console.WriteLine(command.CommandText);
        }

        return result;
    }
}

SaveChanges インターセプト

ヒント

SaveChanges インターセプター サンプルは GitHub からダウンロードできます。

SaveChanges インターセプト ポイントと SaveChangesAsync インターセプト ポイントは、 ISaveChangesInterceptor インターフェイスによって定義されます。 他のインターセプターに関しては、便利な方法として、no-op メソッドを持つ SaveChangesInterceptor 基底クラスが提供されます。

ヒント

インターセプターは強力です。 ただし、多くの場合、SaveChanges メソッドをオーバーライドしたり、DbContext で公開されている SaveChanges の .NET イベント を使用したりする方が簡単な場合があります。

例: 監査のための SaveChanges インターセプト

SaveChanges をインターセプトして、加えられた変更の独立した監査レコードを作成できます。

これは、堅牢な監査ソリューションを意図したものではありません。 むしろ、インターセプトの特徴を示すために使用される単純な例です。

アプリケーション コンテキスト

監査用のサンプルでは、ブログと投稿を含む単純な DbContext を使用します。

public class BlogsContext : DbContext
{
    private readonly AuditingInterceptor _auditingInterceptor = new AuditingInterceptor("DataSource=audit.db");

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        => optionsBuilder
            .AddInterceptors(_auditingInterceptor)
            .UseSqlite("DataSource=blogs.db");

    public DbSet<Blog> Blogs { get; set; }
}

public class Blog
{
    public int Id { get; set; }
    public string Name { get; set; }

    public ICollection<Post> Posts { get; } = new List<Post>();
}

public class Post
{
    public int Id { get; set; }
    public string Title { get; set; }

    public Blog Blog { get; set; }
}

インターセプターの新しいインスタンスが DbContext インスタンスごとに登録されていることに注意してください。 これは、監査インターセプターに現在のコンテキスト インスタンスにリンクされた状態が含まれているためです。

監査コンテキスト

このサンプルには、監査データベースに使用される 2 つ目の DbContext とモデルも含まれています。

public class AuditContext : DbContext
{
    private readonly string _connectionString;

    public AuditContext(string connectionString)
    {
        _connectionString = connectionString;
    }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        => optionsBuilder.UseSqlite(_connectionString);

    public DbSet<SaveChangesAudit> SaveChangesAudits { get; set; }
}

public class SaveChangesAudit
{
    public int Id { get; set; }
    public Guid AuditId { get; set; }
    public DateTime StartTime { get; set; }
    public DateTime EndTime { get; set; }
    public bool Succeeded { get; set; }
    public string ErrorMessage { get; set; }

    public ICollection<EntityAudit> Entities { get; } = new List<EntityAudit>();
}

public class EntityAudit
{
    public int Id { get; set; }
    public EntityState State { get; set; }
    public string AuditMessage { get; set; }

    public SaveChangesAudit SaveChangesAudit { get; set; }
}

インターセプター

インターセプターを使用した監査の一般的な考え方は次のとおりです。

  • 監査メッセージは SaveChanges の先頭に作成され、監査データベースに書き込まれます。
  • SaveChanges は続行できます
  • SaveChanges が成功した場合、成功を示す監査メッセージが更新されます
  • SaveChanges が失敗した場合、監査メッセージが更新され、エラーが示されます

最初のステージは、 ISaveChangesInterceptor.SavingChangesISaveChangesInterceptor.SavingChangesAsyncのオーバーライドを使用してデータベースに変更が送信される前に処理されます。

public async ValueTask<InterceptionResult<int>> SavingChangesAsync(
    DbContextEventData eventData,
    InterceptionResult<int> result,
    CancellationToken cancellationToken = default)
{
    _audit = CreateAudit(eventData.Context);

    using var auditContext = new AuditContext(_connectionString);

    auditContext.Add(_audit);
    await auditContext.SaveChangesAsync();

    return result;
}

public InterceptionResult<int> SavingChanges(
    DbContextEventData eventData,
    InterceptionResult<int> result)
{
    _audit = CreateAudit(eventData.Context);

    using var auditContext = new AuditContext(_connectionString);
    auditContext.Add(_audit);
    auditContext.SaveChanges();

    return result;
}

同期メソッドと非同期メソッドの両方をオーバーライドすると、 SaveChanges または SaveChangesAsync が呼び出されるかどうかにかかわらず、監査が確実に行われます。 また、非同期オーバーロード自体が非ブロッキング非同期 I/O を監査データベースに対して実行できることにも注意してください。 同期SavingChangesメソッドから例外を発生させることで、すべてのデータベースI/Oが非同期になるようにすることができます。 そのためには、アプリケーションが常に SaveChangesAsync を呼び出し、 SaveChangesしない必要があります。

監査メッセージ

すべてのインターセプター メソッドには、インターセプトされるイベントに関するコンテキスト情報を提供する eventData パラメーターがあります。 この場合、現在のアプリケーション DbContext がイベント データに含まれ、監査メッセージを作成するために使用されます。

private static SaveChangesAudit CreateAudit(DbContext context)
{
    context.ChangeTracker.DetectChanges();

    var audit = new SaveChangesAudit { AuditId = Guid.NewGuid(), StartTime = DateTime.UtcNow };

    foreach (var entry in context.ChangeTracker.Entries())
    {
        var auditMessage = entry.State switch
        {
            EntityState.Deleted => CreateDeletedMessage(entry),
            EntityState.Modified => CreateModifiedMessage(entry),
            EntityState.Added => CreateAddedMessage(entry),
            _ => null
        };

        if (auditMessage != null)
        {
            audit.Entities.Add(new EntityAudit { State = entry.State, AuditMessage = auditMessage });
        }
    }

    return audit;

    string CreateAddedMessage(EntityEntry entry)
        => entry.Properties.Aggregate(
            $"Inserting {entry.Metadata.DisplayName()} with ",
            (auditString, property) => auditString + $"{property.Metadata.Name}: '{property.CurrentValue}' ");

    string CreateModifiedMessage(EntityEntry entry)
        => entry.Properties.Where(property => property.IsModified || property.Metadata.IsPrimaryKey()).Aggregate(
            $"Updating {entry.Metadata.DisplayName()} with ",
            (auditString, property) => auditString + $"{property.Metadata.Name}: '{property.CurrentValue}' ");

    string CreateDeletedMessage(EntityEntry entry)
        => entry.Properties.Where(property => property.Metadata.IsPrimaryKey()).Aggregate(
            $"Deleting {entry.Metadata.DisplayName()} with ",
            (auditString, property) => auditString + $"{property.Metadata.Name}: '{property.CurrentValue}' ");
}

結果は、挿入、更新、または削除ごとに 1 つずつ、SaveChangesAudit エンティティのコレクションを持つEntityAudit エンティティです。 その後、インターセプターは、これらのエンティティを監査データベースに挿入します。

ヒント

ToString は、イベントの同等のログ メッセージを生成するために、すべての EF Core イベント データ クラスでオーバーライドされます。 たとえば、ContextInitializedEventData.ToString を呼び出すと、"Entity Framework Core 5.0.0 が 'BlogsContext' を初期化し、プロバイダー 'Microsoft.EntityFrameworkCore.Sqlite' を使用し、オプションは None" というメッセージが生成されます。

成功の検出

監査エンティティはインターセプターに格納されるため、SaveChanges が成功または失敗した場合に再度アクセスできます。 成功するには、 ISaveChangesInterceptor.SavedChanges または ISaveChangesInterceptor.SavedChangesAsync が呼び出されます。

public int SavedChanges(SaveChangesCompletedEventData eventData, int result)
{
    using var auditContext = new AuditContext(_connectionString);

    auditContext.Attach(_audit);
    _audit.Succeeded = true;
    _audit.EndTime = DateTime.UtcNow;

    auditContext.SaveChanges();

    return result;
}

public async ValueTask<int> SavedChangesAsync(
    SaveChangesCompletedEventData eventData,
    int result,
    CancellationToken cancellationToken = default)
{
    using var auditContext = new AuditContext(_connectionString);

    auditContext.Attach(_audit);
    _audit.Succeeded = true;
    _audit.EndTime = DateTime.UtcNow;

    await auditContext.SaveChangesAsync(cancellationToken);

    return result;
}

監査エンティティは、データベースに既に存在し、更新する必要があるため、監査コンテキストにアタッチされます。 次に、 SucceededEndTimeを設定します。これにより、これらのプロパティが変更済みとしてマークされ、SaveChanges によって監査データベースに更新が送信されます。

エラーの検出

失敗は成功とほぼ同じ方法で処理されますが、 ISaveChangesInterceptor.SaveChangesFailed または ISaveChangesInterceptor.SaveChangesFailedAsync の方法で処理されます。 イベント データには、スローされた例外が含まれています。

public void SaveChangesFailed(DbContextErrorEventData eventData)
{
    using var auditContext = new AuditContext(_connectionString);

    auditContext.Attach(_audit);
    _audit.Succeeded = false;
    _audit.EndTime = DateTime.UtcNow;
    _audit.ErrorMessage = eventData.Exception.Message;

    auditContext.SaveChanges();
}

public async Task SaveChangesFailedAsync(
    DbContextErrorEventData eventData,
    CancellationToken cancellationToken = default)
{
    using var auditContext = new AuditContext(_connectionString);

    auditContext.Attach(_audit);
    _audit.Succeeded = false;
    _audit.EndTime = DateTime.UtcNow;
    _audit.ErrorMessage = eventData.Exception.InnerException?.Message;

    await auditContext.SaveChangesAsync(cancellationToken);
}

デモ

監査サンプルには、ログ データベースに変更を加え、作成された監査を示す単純なコンソール アプリケーションが含まれています。

// Insert, update, and delete some entities

using (var context = new BlogsContext())
{
    context.Add(
        new Blog { Name = "EF Blog", Posts = { new Post { Title = "EF Core 3.1!" }, new Post { Title = "EF Core 5.0!" } } });

    await context.SaveChangesAsync();
}

using (var context = new BlogsContext())
{
    var blog = await context.Blogs.Include(e => e.Posts).SingleAsync();

    blog.Name = "EF Core Blog";
    context.Remove(blog.Posts.First());
    blog.Posts.Add(new Post { Title = "EF Core 6.0!" });

    await context.SaveChangesAsync();
}

// Do an insert that will fail

using (var context = new BlogsContext())
{
    try
    {
        context.Add(new Post { Id = 3, Title = "EF Core 3.1!" });

        await context.SaveChangesAsync();
    }
    catch (DbUpdateException)
    {
    }
}

// Look at the audit trail

using (var context = new AuditContext("DataSource=audit.db"))
{
    foreach (var audit in await context.SaveChangesAudits.Include(e => e.Entities).ToListAsync())
    {
        Console.WriteLine(
            $"Audit {audit.AuditId} from {audit.StartTime} to {audit.EndTime} was{(audit.Succeeded ? "" : " not")} successful.");

        foreach (var entity in audit.Entities)
        {
            Console.WriteLine($"  {entity.AuditMessage}");
        }

        if (!audit.Succeeded)
        {
            Console.WriteLine($"  Error: {audit.ErrorMessage}");
        }
    }
}

結果には、監査データベースの内容が表示されます。

Audit 52e94327-1767-4046-a3ca-4c6b1eecbca6 from 10/14/2020 9:10:17 PM to 10/14/2020 9:10:17 PM was successful.
  Inserting Blog with Id: '-2147482647' Name: 'EF Blog'
  Inserting Post with Id: '-2147482647' BlogId: '-2147482647' Title: 'EF Core 3.1!'
  Inserting Post with Id: '-2147482646' BlogId: '-2147482647' Title: 'EF Core 5.0!'
Audit 8450f57a-5030-4211-a534-eb66b8da7040 from 10/14/2020 9:10:17 PM to 10/14/2020 9:10:17 PM was successful.
  Inserting Post with Id: '-2147482645' BlogId: '1' Title: 'EF Core 6.0!'
  Updating Blog with Id: '1' Name: 'EF Core Blog'
  Deleting Post with Id: '1'
Audit 201fef4d-66a7-43ad-b9b6-b57e9d3f37b3 from 10/14/2020 9:10:17 PM to 10/14/2020 9:10:17 PM was not successful.
  Inserting Post with Id: '3' BlogId: '' Title: 'EF Core 3.1!'
  Error: SQLite Error 19: 'UNIQUE constraint failed: Post.Id'.

例: オプティミスティック コンカレンシーインターセプト

EF Core では、更新または削除によって実際に影響を受けた行数が、影響を受けると予想された行数と同じであることを調べることにより、オプティミスティック同時実行制御パターンがサポートされています。 多くの場合、これはコンカレンシー トークンと組み合わせて使われます。つまり、予想された値が読み取られた後で行が更新されていない場合にのみ、予想された値と一致する列の値です。

EF は、DbUpdateConcurrencyException をスローすることで、オプティミスティック同時実行制御の違反を通知します。 ISaveChangesInterceptorには、ThrowingConcurrencyExceptionがスローされる前に呼び出されるメソッドThrowingConcurrencyExceptionAsyncDbUpdateConcurrencyExceptionがあります。 これらのインターセプト ポイントを使うと、例外を抑制でき、場合によってはデータベースの非同期変更と組み合わせて違反を解決できます。

たとえば、2 つの要求が同じエンティティをほぼ同時に削除しようとした場合、データベースに行が存在しなくなるため、2 番目の削除は失敗する可能性があります。 これは問題ないかもしれません。いずれにしても最終的には、エンティティが削除されています。 次のインターセプターは、これを行う方法を示しています。

public class SuppressDeleteConcurrencyInterceptor : ISaveChangesInterceptor
{
    public InterceptionResult ThrowingConcurrencyException(
        ConcurrencyExceptionEventData eventData,
        InterceptionResult result)
    {
        if (eventData.Entries.All(e => e.State == EntityState.Deleted))
        {
            Console.WriteLine("Suppressing Concurrency violation for command:");
            Console.WriteLine(((RelationalConcurrencyExceptionEventData)eventData).Command.CommandText);

            return InterceptionResult.Suppress();
        }

        return result;
    }

    public ValueTask<InterceptionResult> ThrowingConcurrencyExceptionAsync(
        ConcurrencyExceptionEventData eventData,
        InterceptionResult result,
        CancellationToken cancellationToken = default)
        => new(ThrowingConcurrencyException(eventData, result));
}

このインターセプターについて注目すべき点がいくつかあります。

  • 同期と非同期両方のインターセプト メソッドが実装されています。 これは、アプリケーションが SaveChanges または SaveChangesAsync を呼び出す可能性がある場合に重要です。 ただし、すべてのアプリケーション コードが非同期の場合は、ThrowingConcurrencyExceptionAsync のみを実装する必要があります。 同様に、アプリケーションが非同期データベース メソッドを使わない場合は、ThrowingConcurrencyException のみを実装する必要があります。 これは、一般に、同期と非同期のメソッドがあるすべてのインターセプターに当てはまります。
  • インターセプターは、保存されるエンティティの EntityEntry オブジェクトにアクセスできます。 この場合、これは、削除操作でコンカレンシー違反が発生しているかどうかを調べるために使われます。
  • アプリケーションでリレーショナル データベース プロバイダーを使っている場合は、ConcurrencyExceptionEventData オブジェクトを RelationalConcurrencyExceptionEventData オブジェクトにキャストできます。 これにより、実行されているデータベース操作に関するリレーショナル固有の追加情報が提供されます。 この場合は、リレーショナル コマンドのテキストがコンソールに出力されます。
  • InterceptionResult.Suppress() が返された場合は、EF Core に、実行しようとしていたアクション (この場合は DbUpdateConcurrencyException をスローすること) を抑制するように指示します。 EF Core の動作を単に観察するのではなく、"EF Core の動作を変更する" この機能は、インターセプターの最も強力な機能の 1 つです。

具体化のインターセプション

IMaterializationInterceptor では、エンティティ インスタンスが作成される前後、およびそのインスタンスのプロパティが初期化される前後のインターセプトがサポートされます。 インターセプターでは、各ポイントでエンティティ インスタンスを変更するか置き換えることができます。 これにより、次のことができます。

  • マップされていないプロパティの設定、または検証、計算値、フラグに必要なメソッドの呼び出し。
  • ファクトリを使用したインスタンスの作成。
  • EF で通常作成されるものとは異なるエンティティ インスタンスの作成 (キャッシュからのインスタンスや、プロキシ型のインスタンスなど)。
  • エンティティ インスタンスへのサービスの挿入。

IMaterializationInterceptor はシングルトン インターセプターです。つまり、1 つのインスタンスがすべての DbContext インスタンス間で共有されます。

例: エンティティの作成に対する単純なアクション

データを編集しているユーザーに表示できるように、エンティティがデータベースから取得された時間を追跡したいとします。 これを実現するには、最初にインターフェイスを定義します。

public interface IHasRetrieved
{
    DateTime Retrieved { get; set; }
}

インターフェイスを使うことは、多くの異なるエンティティ型で同じインターセプターを使用できるため、インターセプターでは一般的です。 例えば次が挙げられます。

public class Customer : IHasRetrieved
{
    public int Id { get; set; }
    public string Name { get; set; } = null!;
    public string? PhoneNumber { get; set; }

    [NotMapped]
    public DateTime Retrieved { get; set; }
}

このプロパティはエンティティの使用中にのみ使われ、データベースに永続化してはならないことを示すため、[NotMapped] 属性が使われていることに注意してください。

その後、インターセプターは、IMaterializationInterceptor から適切なメソッドを実装し、取得した時刻を設定する必要があります。

public class SetRetrievedInterceptor : IMaterializationInterceptor
{
    public object InitializedInstance(MaterializationInterceptionData materializationData, object instance)
    {
        if (instance is IHasRetrieved hasRetrieved)
        {
            hasRetrieved.Retrieved = DateTime.UtcNow;
        }
        
        return instance;
    }
}

このインターセプターのインスタンスは、DbContext を構成するときに登録されます。

public class CustomerContext : DbContext
{
    private static readonly SetRetrievedInterceptor _setRetrievedInterceptor = new();
    
    public DbSet<Customer> Customers => Set<Customer>();

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) 
        => optionsBuilder
            .AddInterceptors(_setRetrievedInterceptor)
            .UseSqlite("Data Source = customers.db");
}

ヒント

このインターセプターはステートレスなので (一般的です)、1 つのインスタンスが作成されて、すべての DbContext インスタンス間で共有されます。

これで、データベースで Customer のクエリが実行されるたびに、Retrieved プロパティが自動的に設定されます。 例えば次が挙げられます。

await using (var context = new CustomerContext())
{
    var customer = await context.Customers.SingleAsync(e => e.Name == "Alice");
    Console.WriteLine($"Customer '{customer.Name}' was retrieved at '{customer.Retrieved.ToLocalTime()}'");
}

出力を生成します。

Customer 'Alice' was retrieved at '9/22/2022 5:25:54 PM'

例: エンティティへのサービスの挿入

EF Core には、コンテキスト インスタンスに特別なサービスを挿入するためのサポートが既に組み込まれています。たとえば、「プロキシを使用しない遅延読み込み」には ILazyLoader サービスを挿入することで機能します。

IMaterializationInterceptor を使って、これを任意のサービスに一般化できます。 次の例では、独自のログを実行できるようにエンティティに ILogger を挿入する方法を示します。

エンティティにサービスを挿入すると、それらのエンティティ型が挿入されたサービスに結合されるため、一部のユーザーはそれをアンチパターンと見なします。

前と同様に、インターフェイスを使って、何ができるかを定義します。

public interface IHasLogger
{
    ILogger? Logger { get; set; }
}

また、ログするエンティティ型で、このインターフェイスを実装する必要があります。 例えば次が挙げられます。

public class Customer : IHasLogger
{
    private string? _phoneNumber;

    public int Id { get; set; }
    public string Name { get; set; } = null!;

    public string? PhoneNumber
    {
        get => _phoneNumber;
        set
        {
            Logger?.LogInformation(1, $"Updating phone number for '{Name}' from '{_phoneNumber}' to '{value}'.");

            _phoneNumber = value;
        }
    }

    [NotMapped]
    public ILogger? Logger { get; set; }
}

ここでは、インターセプターで IMaterializationInterceptor.InitializedInstance を実装する必要があります。これは、すべてのエンティティ インスタンスが作成され、そのプロパティ値が初期化された後に呼び出されます。 インターセプターはコンテキストから ILogger を取得し、それを使って IHasLogger.Logger を初期化します。

public class LoggerInjectionInterceptor : IMaterializationInterceptor
{
    private ILogger? _logger;

    public object InitializedInstance(MaterializationInterceptionData materializationData, object instance)
    {
        if (instance is IHasLogger hasLogger)
        {
            _logger ??= materializationData.Context.GetService<ILoggerFactory>().CreateLogger("CustomersLogger");
            hasLogger.Logger = _logger;
        }

        return instance;
    }
}

今度は、取得される DbContextILogger インスタンスごとに変わる可能性があり、DbContext がインターセプターにキャッシュされているため、ILogger インスタンスごとにインターセプターの新しいインスタンスが使われます。

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    => optionsBuilder.AddInterceptors(new LoggerInjectionInterceptor());

これで、Customer.PhoneNumber が変更されるたびに、この変更がアプリケーションのログに記録されます。 例えば次が挙げられます。

info: CustomersLogger[1]
      Updating phone number for 'Alice' from '+1 515 555 0123' to '+1 515 555 0125'.

クエリ式のインターセプション

IQueryExpressionInterceptor では、コンパイル前に クエリの LINQ 式ツリー をインターセプトできます。 これを使用すると、アプリケーション全体に適用される方法でクエリを動的に変更できます。

IQueryExpressionInterceptor はシングルトン インターセプターです。つまり、通常、1 つのインスタンスがすべての DbContext インスタンス間で共有されます。

Warnung

インターセプターは強力ですが、式ツリーを操作するときに問題が発生するのは簡単です。 クエリを直接変更するなど、目的を達成する簡単な方法があるかどうかを常に検討してください。

例: 安定した並べ替えを行うクエリに順序を挿入する

顧客のページを返すメソッドについて考えてみましょう。

Task<List<Customer>> GetPageOfCustomers(string sortProperty, int page)
{
    using var context = new CustomerContext();

    return context.Customers
        .OrderBy(e => EF.Property<object>(e, sortProperty))
        .Skip(page * 20).Take(20).ToListAsync();
}

ヒント

このクエリでは、EF.Property メソッドを使って、並べ替えの基準となるプロパティを指定します。 これにより、アプリケーションはプロパティ名を動的に渡すことができ、エンティティ型の任意のプロパティによる並べ替えが可能になります。 インデックスのない列で並べ替えを行うと、遅くなる場合があることに注意してください。

並べ替えに使われるプロパティが常に安定した順序を返す限り、これは問題なく動作します。 ただし、必ずそうであるとは限りません。 たとえば、上記の LINQ クエリでは、Customer.City で並べ替えると、SQLite で次のものが生成されます。

SELECT "c"."Id", "c"."City", "c"."Name", "c"."PhoneNumber"
FROM "Customers" AS "c"
ORDER BY "c"."City"
LIMIT @__p_1 OFFSET @__p_0

複数の顧客が同じ City を持っている場合、このクエリの順序は安定しません。 これにより、複数のページにまたがるデータをユーザーが見たとき、結果の欠落や重複が発生する可能性があります。

この問題を解決する一般的な方法は、主キーで第二の並べ替えを実行することです。 ただし、インターセプターは、これをすべてのクエリに手動で追加するのではなく、セカンダリ順序を動的に追加できます。 これを容易にするために、整数主キーを持つエンティティのインターフェイスを定義します。

public interface IHasIntKey
{
    int Id { get; }
}

このインターフェイスは、対象のエンティティ型で実装されます。

public class Customer : IHasIntKey
{
    public int Id { get; set; }
    public string Name { get; set; } = null!;
    public string? City { get; set; }
    public string? PhoneNumber { get; set; }
}

次に、 IQueryExpressionInterceptorを実装するインターセプターが必要です。

public class KeyOrderingExpressionInterceptor : IQueryExpressionInterceptor
{
    public Expression QueryCompilationStarting(Expression queryExpression, QueryExpressionEventData eventData)
        => new KeyOrderingExpressionVisitor().Visit(queryExpression);

    private class KeyOrderingExpressionVisitor : ExpressionVisitor
    {
        private static readonly MethodInfo ThenByMethod
            = typeof(Queryable).GetMethods()
                .Single(m => m.Name == nameof(Queryable.ThenBy) && m.GetParameters().Length == 2);

        protected override Expression VisitMethodCall(MethodCallExpression? methodCallExpression)
        {
            var methodInfo = methodCallExpression!.Method;
            if (methodInfo.DeclaringType == typeof(Queryable)
                && methodInfo.Name == nameof(Queryable.OrderBy)
                && methodInfo.GetParameters().Length == 2)
            {
                var sourceType = methodCallExpression.Type.GetGenericArguments()[0];
                if (typeof(IHasIntKey).IsAssignableFrom(sourceType))
                {
                    var lambdaExpression = (LambdaExpression)((UnaryExpression)methodCallExpression.Arguments[1]).Operand;
                    var entityParameterExpression = lambdaExpression.Parameters[0];

                    return Expression.Call(
                        ThenByMethod.MakeGenericMethod(
                            sourceType,
                            typeof(int)),
                        methodCallExpression,
                        Expression.Lambda(
                            typeof(Func<,>).MakeGenericType(entityParameterExpression.Type, typeof(int)),
                            Expression.Property(entityParameterExpression, nameof(IHasIntKey.Id)),
                            entityParameterExpression));
                }
            }

            return base.VisitMethodCall(methodCallExpression);
        }
    }
}

これはおそらくかなり複雑に見えます。そのとおりです。 通常、式ツリーの操作は簡単ではありません。 何が行われているのかを見てみましょう。

  • 基本的に、インターセプターは ExpressionVisitor をカプセル化します。 ビジターは VisitMethodCall をオーバーライドし、クエリ式ツリー内のメソッドが呼び出されるたびに、これが呼び出されます。

  • ビジターは、これが目的の OrderBy メソッドの呼び出しであるかどうかをチェックします。

  • そうである場合、ビジターは、ジェネリック メソッドの呼び出しが、IHasIntKey インターフェイスを実装する型に対するものかどうかをさらにチェックします。

  • この時点で、メソッドの呼び出しが OrderBy(e => ...) という形式であることがわかります。 この呼び出しからラムダ式を抽出し、その式で使われているパラメーター (つまり、e) を取得します。

  • 次に、MethodCallExpression ビルダー メソッドを使って、新しい Expression.Call を構築します。 この場合、呼び出されるメソッドは ThenBy(e => e.Id) です。 これを構築するには、上で抽出したパラメーターと、Id インターフェイスの IHasIntKey プロパティへのプロパティ アクセスを使います。

  • この呼び出しへの入力は元の OrderBy(e => ...) であるため、最終的な結果は OrderBy(e => ...).ThenBy(e => e.Id) の式になります。

  • この変更された式がビジターから返されます。つまり、LINQ クエリが適切に変更され、ThenBy の呼び出しが含まれるようになっています。

  • 引き続き EF Core は、このクエリ式を、使われているデータベースに適した SQL にコンパイルします。

このインターセプターを登録し、 GetPageOfCustomers を実行すると、次の SQL が生成されるようになりました。

SELECT "c"."Id", "c"."City", "c"."Name", "c"."PhoneNumber"
FROM "Customers" AS "c"
ORDER BY "c"."City", "c"."Id"
LIMIT @__p_1 OFFSET @__p_0

これにより、同じ City を持つ複数の顧客が存在する場合でも、常に安定した順序が生成されます。

多くの場合、クエリを直接変更するだけで、同じことをより簡単に実現できます。 例えば次が挙げられます。

Task<List<Customer>> GetPageOfCustomers2(string sortProperty, int page)
{
    using var context = new CustomerContext();

    return context.Customers
        .OrderBy(e => EF.Property<object>(e, sortProperty))
        .ThenBy(e => e.Id)
        .Skip(page * 20).Take(20).ToListAsync();
}

この場合は、ThenBy が単にクエリに追加されます。 そうです。すべてのクエリに個別に行う必要があるかもしれませんが、単純でわかりやすいため、常に機能します。

アイデンティティ解決策のインターセプト

IIdentityResolutionInterceptor では、 DbContext が新しいエンティティ インスタンスの追跡を開始したときに ID 解決の競合を傍受できます。

このインターセプターは現在、 DbContext.UpdateDbContext.Attach、および同様のメソッドを使用して、同じキーで既に追跡されているエンティティを追跡する場合にのみ呼び出されます。 クエリから返されるエンティティに対しては呼び出されません。 これは将来のリリースで変更される可能性があります。 この問題を参照してください

DbContext では、指定された主キー値を持つエンティティ インスタンスを 1 つだけ追跡できます。 これは、同じキー値を持つエンティティの複数のインスタンスを 1 つのインスタンスに解決する必要があることを意味します。 この型のインターセプターは、既存の追跡対象インスタンスと新しいインスタンスで呼び出され、新しいインスタンスから既存のインスタンスにプロパティ値とリレーションシップの変更を適用する必要があります。 その後、新しいインスタンスは破棄されます。

EF Core には、既存の追跡対象エンティティを新しいインスタンスの値で更新する、 UpdatingIdentityResolutionInterceptor組み込みの実装が用意されています。 これは、コンテキストを構成するときに登録できます。

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    => optionsBuilder
        .AddInterceptors(new UpdatingIdentityResolutionInterceptor());

カスタム ID 解決ロジックを実装するには、 IIdentityResolutionInterceptor を実装するクラスを作成し、 UpdateTrackedInstance メソッドをオーバーライドします。

public class CustomIdentityResolutionInterceptor : IIdentityResolutionInterceptor
{
    public void UpdateTrackedInstance(
        IdentityResolutionInterceptionData interceptionData,
        EntityEntry existingEntry,
        object newEntity)
    {
        // Custom logic to merge property values from newEntity into the existing tracked entity
        existingEntry.CurrentValues.SetValues(newEntity);
    }
}