Interceptors

Entity Framework Core (EF Core)-Abfangfunktionen ermöglichen das Abfangen, Ändern und/oder Unterdrücken von EF Core-Vorgängen. Dies umfasst Datenbankvorgänge auf niedriger Ebene, z. B. das Ausführen eines Befehls, sowie Vorgänge höherer Ebene, z. B. Aufrufe von SaveChanges.

Abfangfunktionen unterscheiden sich von Protokollierung und Diagnose insofern, als sie eine Änderung oder Unterdrückung des abzufangenden Vorgangs ermöglichen. Einfache Protokollierung oder Microsoft.Extensions.Logging sind bessere Optionen für Protokollierung.

Abfangfunktionen werden pro DbContext-Instanz registriert, wenn der Kontext konfiguriert wird. Verwenden Sie einen Diagnoselistener, um dieselben Informationen abzurufen, jedoch für alle DbContext-Instanzen im Prozess.

Registrieren von Abfangfunktionen

Abfangfunktionen werden mittels AddInterceptors beim Konfigurieren einer DbContext-Instanz registriert. Dies geschieht in der Regel in einer Außerkraftsetzung von DbContext.OnConfiguring. Beispiel:

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

Alternativ kann AddInterceptors als Teil von AddDbContext aufgerufen werden oder beim Erstellen einer DbContextOptions-Instanz, die an den DbContext-Konstruktor übergeben werden soll.

Tipp

OnConfiguring wird weiterhin aufgerufen, wenn AddDbContext verwendet wird oder eine DbContextOptions-Instanz an den DbContext-Konstruktor übergeben wird. Dadurch ist es der ideale Ort, um die Kontextkonfiguration anzuwenden, unabhängig davon, wie der DbContext konstruiert wird.

Abfangfunktionen sind häufig zustandslos, was bedeutet, dass eine einzelne Instanz einer Abfangfunktion für alle DbContext-Instanzen verwendet werden kann. Beispiel:

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

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

Jede Instanz einer Abfangfunktion muss eine oder mehrere Schnittstellen implementieren, die von IInterceptor abgeleitet werden. Jede Instanz sollte nur einmal registriert werden, auch wenn sie mehrere Abfangfunktionsschnittstellen implementiert. EF Core wird Ereignisse für jede Schnittstelle entsprechend weiterleiten.

Abfangen von Datenbanken

Hinweis

Das Abfangen von Datenbanken ist nur für relationale Datenbankanbieter verfügbar.

Das Abfangen von Datenbanken auf niedriger Ebene ist auf die drei Schnittstellen aufgeteilt, die in der folgenden Tabelle gezeigt werden.

Abfangfunktion Abgefangene Datenbankvorgänge
IDbCommandInterceptor Erstellen von Befehlen
Ausführen von Befehlen
Befehlsfehler
Verwerfen des DbDataReader des Befehls
IDbConnectionInterceptor Öffnen und Schließen von Verbindungen
Verbindungsfehler
IDbTransactionInterceptor Erstellen von Transaktionen
Mithilfe vorhandener Transaktionen
Committen von Transaktionen
Rollback von Transaktionen ausführen
Erstellen und Verwenden von Speicherpunkten
Transaktionsfehler

Die Basisklassen DbCommandInterceptor, DbConnectionInterceptor und DbTransactionInterceptor enthalten No-Op-Implementierungen für jede Methode in der entsprechenden Schnittstelle. Verwenden Sie die Basisklassen, um zu vermeiden, nicht verwendete Abfangmethode implementieren zu müssen.

Die Methoden für jeden Typ der Abfangfunktion sind paarweise vorhanden, wobei die erste aufgerufen wird, bevor der Datenbankvorgang gestartet wird, und die zweite nach Abschluss des Vorgangs. DbCommandInterceptor.ReaderExecuting wird beispielsweise aufgerufen, bevor eine Abfrage ausgeführt wird, und DbCommandInterceptor.ReaderExecuted wird aufgerufen, nachdem die Abfrage an die Datenbank gesendet wurde.

Jedes Methodenpaar verfügt über synchrone und asynchrone Variationen. Dies ermöglicht asynchrone E/A, wie beispielsweise das Anfordern eines Zugriffstokens, als Teil des Abfangens eines asynchronen Datenbankvorgangs.

Beispiel: Abfangen von Befehlen zum Hinzufügen von Abfragehinweisen

Tipp

Sie können das Beispiel für die Abfangfunktion von Befehlen aus GitHub herunterladen.

Eine IDbCommandInterceptor kann verwendet werden, um SQL zu ändern, bevor sie an die Datenbank gesendet wird. In diesem Beispiel wird gezeigt, wie Sie SQL so ändern, dass sie einen Abfragehinweis enthält.

Der schwierigste Teil des Abfangens besteht oft darin, festzustellen, wann der Befehl der Abfrage entspricht, die geändert werden muss. Das Parsen von SQL ist eine Option, ist jedoch tendenziell fragil. Eine weitere Option besteht darin, EF Core-Abfragetags zu verwenden, um jede Abfrage zu kennzeichnen, die geändert werden soll. Beispiel:

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

Dieses Tag kann dann in der Abfangfunktion erkannt werden, da es immer als Kommentar in die erste Zeile des Befehlstexts eingeschlossen wird. Beim Erkennen des Tags wird die Abfrage-SQL geändert, um den entsprechenden Hinweis hinzuzufügen:

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)";
        }
    }
}

Beachten Sie:

  • Die Abfangfunktion erbt von DbCommandInterceptor, um zu vermeiden, dass jede Methode in der Schnittstelle der Abfangfunktion implementiert werden muss.
  • Die Abfangfunktion implementiert sowohl synchrone als auch asynchrone Methoden. Dadurch wird sichergestellt, dass derselbe Abfragehinweis auf synchrone und asynchrone Abfragen angewendet wird.
  • Die Abfangfunktion implementiert die Executing-Methoden, die von EF Core mit dem generierten SQL aufgerufen werden, bevor er an die Datenbank gesendet wird. Im Gegensatz dazu werden die Executed-Methoden aufgerufen, nachdem der Datenbankaufruf zurückgegeben wurde.

Wenn Sie den Code in diesem Beispiel ausführen, wird Folgendes generiert, wenn eine Abfrage gekennzeichnet ist:

-- Use hint: robust plan

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

Wenn eine Abfrage hingegen nicht gekennzeichnet ist, wird sie unverändert an die Datenbank gesendet:

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

Beispiel: Abfangen von Verbindungen für die SQL Azure-Authentifizierung mit AAD

Tipp

Sie können das Beispiel für die Abfangfunktion von Verbindungen von GitHub herunterladen.

Eine IDbConnectionInterceptor kann verwendet werden, um die DbConnection zu manipulieren, bevor sie zum Herstellen einer Verbindung mit der Datenbank verwendet wird. Dies kann verwendet werden, um ein Azure Active Directory (AAD)-Zugriffstoken abzurufen. Beispiel:

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;
    }
}

Tipp

Microsoft.Data.SqlClient unterstützt jetzt die AAD-Authentifizierung über eine Verbindungszeichenfolge. Weitere Informationen finden Sie unter SqlAuthenticationMethod.

Warnung

Beachten Sie, dass die Abfangfunktion ausgelöst wird, wenn ein Synchronisierungsaufruf ausgeführt wird, um die Verbindung zu öffnen. Dies liegt daran, dass es keine nicht asynchrone Methode zum Abrufen des Zugriffstokens gibt, und dass es keine universelle und einfache Möglichkeit gibt, eine asynchrone Methode aus einem nicht asynchronem Kontext aufzurufen, ohne eine Deadlock zu riskieren.

Warnung

in einigen Fällen wird das Zugriffstoken möglicherweise nicht automatisch vom Azure-Tokenanbieter zwischengespeichert. Abhängig von der Art des angeforderten Tokens müssen Sie hier möglicherweise ihre eigene Zwischenspeicherung implementieren.

Beispiel: Erweitertes Abfangen von Befehlen für die Zwischenspeicherung

Tipp

Sie können das Beispiel für die Abfangfunktion erweiterter Befehle von GitHub herunterladen.

EF Core-Abfangfunktionen können Folgendes tun:

  • EF Core anweisen, die Ausführung des abgefangenen Vorgangs zu unterdrücken
  • Das Ergebnisses des an EF Core zurückgemeldeten Vorgangs ändern

Dieses Beispiel zeigt eine Abfangfunktion, welche diese Features verwendet, um sich wie ein primitiver Cache auf zweiter Ebene zu verhalten. Zwischengespeicherte Abfrageergebnisse werden für eine bestimmte Abfrage zurückgegeben, wodurch ein Datenbank-Roundtrip vermieden wird.

Warnung

Seien Sie vorsichtig, wenn Sie das EF Core-Standardverhalten auf diese Weise ändern. EF Core verhält sich möglicherweise auf unerwartete Weise, wenn es ein ungewöhnliches Ergebnis erhält, das nicht ordnungsgemäß verarbeitet werden kann. Außerdem werden in diesem Beispiel Konzepte von Abfangfunktionen veranschaulicht. Es ist nicht als Vorlage für eine robuste Cacheimplementierung auf zweiter Ebene gedacht.

In diesem Beispiel führt die Anwendung häufig eine Abfrage aus, um die letzte „tägliche Nachricht“ abzurufen:

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

Diese Abfrage ist gekennzeichnet, sodass sie in der Abfangfunktion leicht erkannt werden kann. Die Idee ist, die Datenbank nur einmal täglich nach einer neuen Nachricht abzufragen. Zu anderen Zeiten wird die Anwendung ein zwischengespeichertes Ergebnis verwenden. (Im Beispiel wird ein Verzögerung von 10 Sekunden verwendet, um einen neuen Tag zu simulieren.)

Zustand der Abfangfunktion

Diese Abfangfunktion ist zustandsbehaftet: Sie speichert die ID und den Nachrichtentext der zuletzt abgefragten täglichen Nachricht sowie den Zeitpunkt, zu dem diese Abfrage ausgeführt wurde. Aufgrund dieses Zustands benötigen wir auch eine Sperre, da für das Zwischenspeichern dieselbe Abfangfunktion von mehreren Kontextinstanzen verwendet werden muss.

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

Vor der Ausführung

In der Executing-Methode (d. h. vor dem Herstellen eines Datenbankaufrufs) erkennt die Abfragefunktion die gekennzeichnete Abfrage und überprüft dann, ob ein zwischengespeichertes Ergebnis vorhanden ist. Wenn ein solches Ergebnis gefunden wird, wird die Abfrage unterdrückt und stattdessen werden zwischengespeicherte Ergebnisse verwendet.

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);
}

Beachten Sie, wie der Code InterceptionResult<TResult>.SuppressWithResult aufruft und einen DbDataReader-Ersatz übergibt, der die zwischengespeicherten Daten enthält. Dieses InterceptionResult wird dann zurückgegeben, was zur Unterdrückung der Abfrageausführung führt. Der Ersatzleser wird stattdessen von EF Core als Ergebnisse der Abfrage verwendet.

Diese Abfangfunktion manipuliert auch den Befehlstext. Diese Manipulation ist nicht erforderlich, verbessert aber die Klarheit in Protokollnachrichten. Der Befehlstext muss keine gültige SQL sein, da die Abfrage jetzt nicht ausgeführt wird.

Nach der Ausführung

Wenn keine zwischengespeicherte Nachricht verfügbar ist, oder wenn sie abgelaufen ist, unterdrückt der obige Code das Ergebnis nicht. EF Core führt daher die Abfrage normal aus. Sie kehrt dann nach der Ausführung zur Executed-Methode der Abfangfunktion zurück. Wenn das Ergebnis zu diesem Zeitpunkt noch kein zwischengespeicherter Leser ist, wird die neue Nachrichten-ID und die Zeichenfolge aus dem tatsächlichen Leser extrahiert und für die nächste Verwendung dieser Abfrage zwischengespeichert.

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;
}

Demonstration

Das Beispiel der Abfangfunktion für das Zwischenspeichern enthält eine einfache Konsolenanwendung, die tägliche Nachrichten abfragt, um das Zwischenspeichern zu testen:

// 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;

Dadurch wird die folgende Ausgabe zurückgegeben:

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

Beachten Sie aus der Protokollausgabe, dass die Anwendung die zwischengespeicherte Nachricht weiterhin verwendet, bis das Timeout abläuft. Zu diesem Zeitpunkt wird die Datenbank erneut auf neue Nachrichten abgefragt.

Abfangen von SaveChanges

Tipp

Sie können das Beispiel für die Abfangfunktion von SaveChanges von GitHub herunterladen.

Die Abfangpunkte SaveChanges und SaveChangesAsync werden von der ISaveChangesInterceptor-Schnittstelle definiert. Wie bei anderen Abfangfunktionen wird die Basisklasse SaveChangesInterceptor aus Gründen der Bequemlichkeit mit No-Op-Methoden bereitgestellt.

Tipp

Abfangfunktionen sind leistungsfähig. In vielen Fällen kann es jedoch einfacher sein, die SaveChanges-Methode außer Kraft zu setzen oder die .NET-Ereignisse für SaveChanges zu verwenden, die für DbContext verfügbar gemacht werden.

Beispiel: Abfangen von SaveChanges für die Überwachung

SaveChanges kann abgefangen werden, um einen unabhängigen Überwachungsdatensatz der vorgenommenen Änderungen zu erstellen.

Hinweis

Dies ist nicht als robuste Überwachungslösung angedacht. Vielmehr ist es ein vereinfachtes Beispiel, das verwendet wird, um die Features des Abfangens zu veranschaulichen.

Der Anwendungskontext

Das Beispiel für die Überwachung verwendet einen einfachen DbContext mit Blogs und Beiträgen.

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; }
}

Beachten Sie, dass für jede DbContext-Instanz eine neue Instanz der Abfangfunktion registriert wird. Dies liegt daran, dass die Abfangfunktion für die Überwachungs den Zustand enthält, der mit der aktuellen Kontextinstanz verknüpft ist.

Der Überwachungskontext

Das Beispiel enthält auch einen zweiten DbContext und ein zweites Modell, das für die Überwachungsdatenbank verwendet wird.

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; }
}

Die Abfangfunktion

Die allgemeine Idee für die Überwachung mit der Abfangfunktion ist die Folgende:

  • Am Anfang von SaveChanges wird eine Überwachungsnachricht erstellt, und sie wird in die Überwachungsdatenbank geschrieben
  • SaveChanges darf dann fortfahren
  • Wenn SaveChanges erfolgreich ist, wird die Überwachungsmeldung aktualisiert, um den Erfolg anzuzeigen
  • Wenn SaveChanges fehlschlägt, wird die Überwachungsmeldung aktualisiert, um den Fehler anzuzeigen

Die erste Phase wird verarbeitet, bevor Änderungen mithilfe der Außerkraftsetzungen ISaveChangesInterceptor.SavingChanges und ISaveChangesInterceptor.SavingChangesAsync an die Datenbank gesendet werden.

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;
}

Durch das Außerkraftsetzen von synchronen und asynchronen Methoden wird sichergestellt, dass die Überwachung unabhängig davon erfolgt, ob SaveChanges oder SaveChangesAsync aufgerufen werden. Beachten Sie auch, dass das asynchrone Überladen selbst in der Lage ist, nicht blockierende asynchrone E/A mit der Überwachungsdatenbank auszuführen. Möglicherweise möchten Sie aus der Synchronisierungsmethode SavingChanges auslösen, um sicherzustellen, dass alle Datenbank-E/A asynchron sind. Dies erfordert dann, dass die Anwendung immer SaveChangesAsync aufruft und nie SaveChanges.

Die Überwachungsnachricht

Jede Methode der Abfangfunktion verfügt über einen eventData-Parameter, der kontextbezogene Informationen zum abgefangenen Ereignis bereitstellt. In diesem Fall ist der aktuelle DbContext der Anwendung in die Ereignisdaten eingeschlossen, die dann zum Erstellen einer Überwachungsnachricht verwendet werden.

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}' ");
}

Das Ergebnis ist eine SaveChangesAudit-Entität mit einer Sammlung von EntityAudit-Entitäten, eine für jedes Einfügen, Aktualisieren oder Löschen. Die Abfangfunktion fügt diese Entitäten dann in die Überwachungsdatenbank ein.

Tipp

ToString wird in jeder EF Core-Ereignisdatenklasse außer Kraft gesetzt, um die entsprechende Protokollmeldung für das Ereignis zu generieren. Beispielsweise generiert der Aufruf von ContextInitializedEventData.ToString den Eintrag „Entity Framework Core 5.0.0 initialisierte „BlogsContext“ mithilfe des Anbieters „Microsoft.EntityFrameworkCore.Sqlite“ mit den Optionen: Keine“.

Erkennen von Erfolg

Die Überwachungsentität wird auf der Abfangfunktion gespeichert, sodass erneut auf sie zugegriffen werden kann, sobald SaveChanges erfolgreich ist oder fehlschlägt. Für „Erfolg“ wird ISaveChangesInterceptor.SavedChanges oder ISaveChangesInterceptor.SavedChangesAsync aufgerufen.

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;
}

Die Überwachungsentität wird dem Überwachungskontext angefügt, da er bereits in der Datenbank vorhanden ist und aktualisiert werden muss. Anschließend legen Succeeded und EndTime fest, was diese Eigenschaften als geändert kennzeichnet, sodass SaveChanges eine Aktualisierung an die Überwachungsdatenbank senden wird.

Erkennen von Fehlern

„Fehler“ wird auf die gleiche Weise wie „Erfolg“ behandelt, aber in der ISaveChangesInterceptor.SaveChangesFailed- oder ISaveChangesInterceptor.SaveChangesFailedAsync-Methode. Die Ereignisdaten enthalten die Ausnahme, die ausgelöst wurde.

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);
}

Demonstration

Das Überwachungsbeispiel enthält eine einfache Konsolenanwendung, die Änderungen an der Blogdatenbank vornimmt und dann das erstellte Überwachungsereignis anzeigt.

// 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 = context.Blogs.Include(e => e.Posts).Single();

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

    context.SaveChanges();
}

// 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 context.SaveChangesAudits.Include(e => e.Entities).ToList())
    {
        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}");
        }
    }
}

Das Ergebnis zeigt den Inhalt der Überwachungsdatenbank:

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'.