Freigeben über


Abfangjäger

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

Interceptors unterscheiden sich von der Protokollierung und Diagnose insofern als dass sie die Änderung oder Unterdrückung der abgefangenen Operation zulassen. Einfache Protokollierung oder Microsoft.Extensions.Logging sind bessere Auswahlmöglichkeiten für die Protokollierung.

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

Registrierung von Abfangmechanismen

Interceptors werden beim Konfigurieren einer DbContext-Instanz unter Verwendung von AddInterceptors 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 können Sie AddInterceptors als Teil von AddDbContext aufrufen oder es beim Erstellen einer DbContextOptions Instanz verwenden, die an den DbContext-Konstruktor übergeben wird.

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 erstellt wird.

Interceptors sind häufig zustandslos, was bedeutet, dass eine einzelne Interceptorinstanz 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 Interceptorinstanz muss eine oder mehrere Schnittstellen implementieren, die von IInterceptor abgeleitet sind. Jede Instanz sollte nur einmal registriert werden, auch wenn sie mehrere Interception-Schnittstellen implementiert. EF Core leitet Ereignisse für jede Schnittstelle entsprechend weiter.

Abfangen von Datenbanken

Hinweis

Die Datenbankinterception ist nur für relationale Datenbankanbieter verfügbar.

Die Abfangen von Datenbanken auf niedriger Ebene wird in die drei Schnittstellen aufgeteilt, die in der folgenden Tabelle dargestellt sind.

Abfangjäger Abgefangene Datenbankvorgänge
IDbCommandInterceptor Erstellen von Befehlen
Befehle ausführen
Befehlsfehler
Entsorgen des DbDataReaders
IDbConnectionInterceptor Verbindungsfehler beim Öffnen und Schließen von Verbindungen
IDbTransactionInterceptor Erstellen von Transaktionen
Verwendung vorhandener Transaktionen
Commit von Transaktionen
Rollback von Transaktionen
Erstellen und Verwenden von Savepoints
Transaktionsfehler

Die Basisklassen DbCommandInterceptor, DbConnectionInterceptorund DbTransactionInterceptor enthalten no-op Implementierungen für jede Methode in der entsprechenden Schnittstelle. Verwenden Sie die Basisklassen, um zu vermeiden, dass nicht verwendete Interception-Methoden implementiert werden müssen.

Die Methoden jedes Interceptor-Typs kommen paarweise vor. Die erste Methode wird aufgerufen, bevor der Datenbankvorgang gestartet wird, und die zweite Methode wird nach Abschluss des Datenbankvorgangs aufgerufen. Beispielsweise wird DbCommandInterceptor.ReaderExecuting 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 Synchronisierungs- und asynchrone Variationen. Dies ermöglicht asynchrone E/A, z. B. das Anfordern eines Zugriffstokens, als Teil des Abfangens von asynchronen Datenbankvorgängen.

Beispiel: Befehlsinterception zum Hinzufügen von Abfragehinweisen

Tipp

Sie können das Beispiel für einen Befehlsabfangjäger von 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.

Oft besteht der schwierigste Teil des Abfangens darin, zu bestimmen, wann der Befehl der Abfrage entspricht, die geändert werden muss. Das Analysieren von SQL ist eine Option, ist jedoch tendenziell zerbrechlich. Eine weitere Option besteht darin, EF Core-Abfragetags zu verwenden, um jede Abfrage zu kategorisieren, die geändert werden soll. Beispiel:

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

Dieses Tag kann dann im Interceptor 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:

  • Der Interceptor erbt von DbCommandInterceptor, um zu vermeiden, dass alle Methoden in der Interceptor-Schnittstelle implementiert werden müssen.
  • Der Interceptor implementiert sowohl Synchronisierungs- als auch asynchrone Methoden. Dadurch wird sichergestellt, dass derselbe Abfragehinweis auf die Synchronisierung und asynchrone Abfragen angewendet wird.
  • Der Interceptor implementiert die Executing Methoden, die von EF Core mit dem generierten SQL bevor es an die Datenbank gesendet wird, aufgerufen werden. Kontrastieren Sie dies mit den Methoden, die Executed aufgerufen werden, nachdem der Datenbankaufruf zurückgegeben wurde.

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

-- Use hint: robust plan

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

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

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

Beispiel: Unterbrechung der Verbindung für die SQL Azure-Authentifizierung mithilfe von Azure Active Directory (AAD)

Tipp

Sie können das Verbindungs-Interceptor-Beispiel von GitHub herunterladen.

Ein IDbConnectionInterceptor kann verwendet werden, um DbConnection zu bearbeiten, bevor es 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 verbindungszeichenfolge. Weitere Informationen finden Sie unter SqlAuthenticationMethod.

Warnung

Beachten Sie, dass der Interceptor 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 es gibt keine universelle und einfache Möglichkeit, eine asynchrone Methode aus nicht asynchronem Kontext aufzurufen, ohne 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 möglicherweise ihre eigene Zwischenspeicherung hier implementieren.

Beispiel: Erweiterte Befehlsinterception für die Zwischenspeicherung

Tipp

Sie können das Beispiel für den erweiterten Befehls-Interceptor von GitHub herunterladen.

EF Core Interceptors können:

  • EF Core anweisen, die Ausführung des interzeptierten Vorgangs zu unterdrücken
  • Ändere das Ergebnis des Vorgangs, das an EF Core gemeldet wird

Dieses Beispiel zeigt einen Interceptor, der diese Features verwendet, um sich wie ein primitiver Cache auf zweiter Ebene zu verhalten. Zwischengespeicherte Abfrageergebnisse werden für eine bestimmte Abfrage zurückgegeben und vermeiden ein Datenbank-Roundtrip.

Warnung

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

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 markiert , sodass sie im Interceptor leicht erkannt werden kann. Die Idee besteht darin, die Datenbank nur einmal täglich nach einer neuen Nachricht abzufragen. Zu anderen Zeiten verwendet die Anwendung ein zwischengespeichertes Ergebnis. (Im Beispiel wird eine Verzögerung von 10 Sekunden verwendet, um einen neuen Tag zu simulieren.)

Interceptor-Zustand

Dieser Interceptor ist statusbehaftet: Er 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 die Zwischenspeicherung derselbe Interceptor 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 der Interceptor die markierte Abfrage und überprüft dann, ob ein zwischengespeichertes Ergebnis vorhanden ist. Wenn ein solches Ergebnis gefunden wird, wird die Abfrage unterdrückt und stattdessen 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 Ersatz DbDataReader übergibt, der die zwischengespeicherten Daten enthält. Dieses InterceptionResult wird dann zurückgegeben, wodurch die Ausführung der Abfrage unterdrückt wird. Der Ersatzleser wird von EF Core verwendet, um die Ergebnisse der Abfrage zu liefern.

Dieser Interceptor bearbeitet auch den Befehlstext. Diese Manipulation ist nicht erforderlich, verbessert aber die Klarheit in Protokollnachrichten. Der Befehlstext muss nicht gültiger SQL-Wert sein, da die Abfrage jetzt nicht ausgeführt werden soll.

Nach Ausführung

Wenn keine zwischengespeicherte Nachricht verfügbar ist oder abgelaufen ist, unterdrückt der obige Code das Ergebnis nicht. EF Core führt daher die Abfrage normal aus. Dann kehrt sie nach der Ausführung zur Methode des Interceptors Executed zurück. Wenn das Ergebnis zu diesem Zeitpunkt noch kein zwischengespeicherter Reader ist, wird die neue Nachrichten-ID und Zeichenfolge aus dem tatsächlichen Reader 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;
}

Demo

Das Caching-Interceptor-Beispiel enthält eine einfache Konsolenanwendung, die tägliche Nachrichten abfragt, um die Zwischenspeicherung 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, an dem die Datenbank erneut für eine neue Nachricht abgefragt wird.

SaveChanges-Abfangen

Tipp

Sie können das SaveChanges-Interceptor-Beispiel von GitHub herunterladen.

SaveChanges- und SaveChangesAsync-Abfangpunkte werden durch die ISaveChangesInterceptor-Schnittstelle definiert. Wie bei anderen Interceptors wird die SaveChangesInterceptor Basisklasse mit no-op Methoden als eine Erleichterung zur Verfügung gestellt.

Tipp

Interceptors sind leistungsfähig. In vielen Fällen ist es jedoch einfacher, die SaveChanges-Methode zu überschreiben oder die .NET-Ereignisse für SaveChanges zu verwenden, die für DbContext verfügbar gemacht werden.

Beispiel: SaveChanges-Abhören für die Auditierung

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

Hinweis

Dies ist keine robuste Überwachungslösung. Vielmehr ist es ein einfaches Beispiel, das verwendet wird, um die Eigenschaften 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 des Interceptors registriert ist. Dies liegt daran, dass der Prüfungs-Interceptor den Zustand speichert, 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; }
}

Der Interceptor

Die allgemeine Idee für die Überwachung mit dem Interceptor lautet:

  • Am Anfang von SaveChanges wird eine Überwachungsnachricht erstellt und in die Überwachungsdatenbank geschrieben.
  • SaveChanges darf 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 behandelt, bevor Änderungen unter Verwendung von Überschreibungen mit 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 Überschreiben sowohl der synchronen als auch der asynchronen Methoden wird sichergestellt, dass die Überwachung unabhängig davon erfolgt, ob SaveChanges oder SaveChangesAsync aufgerufen werden. Beachten Sie auch, dass die asynchrone Überladung selbst fähig ist, nicht blockierende asynchrone E/A mit der Prüfdatenbank auszuführen. Sie könnten eine Ausnahme von der Methode SavingChanges werfen, um sicherzustellen, dass alle Datenbank-I/O asynchron sind. Dies erfordert dann, dass die Anwendung immer aufruft SaveChangesAsync und nie SaveChanges.

Die Überwachungsnachricht

Jede Interceptor-Methode verfügt über einen eventData Parameter, der kontextbezogene Informationen zum abgefangenen Ereignis bereitstellt. In diesem Fall ist die aktuelle Anwendung DbContext 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. Der Interceptor 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 ContextInitializedEventData.ToString "Entity Framework Core 5.0.0 initialisiert "BlogsContext" mithilfe des Anbieters "Microsoft.EntityFrameworkCore.Sqlite" mit Optionen: None".

Erkennen von Erfolg

Die Überwachungsentität wird auf dem Interceptor gespeichert, sodass erneut auf sie zugegriffen werden kann, sobald SaveChanges erfolgreich ist oder fehlschlägt. Zum Erfolg wird entweder 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 ist dem Überwachungskontext zugeordnet, da sie bereits in der Datenbank vorhanden ist und aktualisiert werden muss. Anschließend legen wir Succeeded diese Eigenschaften als geändert fest, EndTime sodass SaveChanges eine Aktualisierung an die Überwachungsdatenbank sendet.

Erkennen von Fehlern

Fehler werden in ähnlicher Weise wie Erfolg behandelt, jedoch 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);
}

Demo

Das Überwachungsbeispiel enthält eine einfache Konsolenanwendung, die Änderungen an der Blogdatenbank vorgibt, und zeigt dann die erstellte Überwachung an.

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

Das Ergebnis zeigt den Inhalt der Überwachungsdatenbank an:

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