Intercepteurs

Les intercepteurs Entity Framework Core (EF Core) permettent l’interception, la modification et/ou la suppression des opérations EF Core. Cela inclut des opérations de base de données de bas niveau, comme l’exécution d’une commande, ainsi que des opérations de niveau élevé, comme les appels à SaveChanges.

Les intercepteurs sont différents de la journalisation et des diagnostics, car ils autorisent la modification ou la suppression de l’opération interceptée. La journalisation simple ou Microsoft.Extensions.Logging constitue une meilleure option pour la journalisation.

Les intercepteurs sont inscrits par instance DbContext quand le contexte est configuré. Utilisez un écouteur de diagnostic pour obtenir les mêmes informations, mais pour l’ensemble des instances DbContext dans le processus.

Inscription d’intercepteurs

Les intercepteurs sont inscrits avec AddInterceptors lors de la configuration d’une instance DbContext. En général, cela consiste à substituer DbContext.OnConfiguring. Par exemple :

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

Il est également possible d’appeler AddInterceptors dans le cadre de AddDbContext ou lors de la création d’une instance DbContextOptions à transmettre au constructeur DbContext.

Conseil

OnConfiguring est toujours appelé quand AddDbContext est utilisé ou qu’une instance DbContextOptions est transmise au constructeur DbContext. C’est donc l’emplacement idéal pour appliquer la configuration du contexte, quel que soit le mode de construction du DbContext.

Les intercepteurs sont souvent sans état, ce qui signifie qu’une seule instance d’intercepteur peut être utilisée pour toutes les instances DbContext. Par exemple :

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

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

Chaque instance d’intercepteur doit implémenter une ou plusieurs interfaces dérivées de IInterceptor. Chaque instance ne doit être inscrite qu’une seule fois même si elle implémente plusieurs interfaces d’interception ; EF Core route les événements pour chaque interface selon les besoins.

Interception de base de données

Remarque

L’interception de base de données est réservée aux fournisseurs de bases de données relationnelles.

L’interception de base de données de bas niveau est divisée en trois interfaces indiquées dans le tableau suivant.

Intercepteur Opérations de base de données interceptées
IDbCommandInterceptor Création de commandes
Exécution de commandes
Échecs de commandes
Suppression du DbDataReader de la commande
IDbConnectionInterceptor Ouverture et fermeture de connexions
Échecs de connexion
IDbTransactionInterceptor Création de transactions
Utilisation de transactions existantes
Validation de transactions
Restauration de transactions
Création et utilisation de points d’enregistrement
Échecs de transactions

Les classes de base DbCommandInterceptor, DbConnectionInterceptor et DbTransactionInterceptor contiennent des implémentations sans opération pour chaque méthode de l’interface correspondante. Utilisez les classes de base pour éviter d’avoir à implémenter des méthodes d’interception inutilisées.

Les méthodes de chaque type d’intercepteur vont par deux, la première est appelée avant le démarrage de l’opération de base de données et la seconde une fois l’opération terminée. Par exemple, DbCommandInterceptor.ReaderExecuting est appelée avant l’exécution d’une requête et DbCommandInterceptor.ReaderExecuted est appelée une fois la requête envoyée à la base de données.

Chaque paire de méthodes a des variantes synchronisées et asynchrones. Cela permet d’avoir des E/S asynchrones, telles que la demande d’un jeton d’accès, dans le cadre de l’interception d’une opération de base de données asynchrone.

Exemple : interception de commande pour ajouter des indicateurs de requête

Conseil

Vous pouvez télécharger l’exemple d’intercepteur de commande à partir de GitHub.

Un IDbCommandInterceptor peut être utilisé pour modifier SQL avant d’être envoyé à la base de données. Cet exemple montre comment modifier SQL pour inclure un indicateur de requête.

Souvent, la partie la plus délicate de l’interception détermine quand la commande correspond à la requête qui doit être modifiée. L’analyse de SQL est une option, mais a tendance à être fragile. Une autre option consiste à utiliser des étiquettes de requête EF Core pour étiqueter chaque requête qui doit être modifiée. Par exemple :

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

Cette étiquette peut ensuite être détectée dans l’intercepteur, car elle sera toujours incluse en tant que commentaire dans la première ligne du texte de la commande. Lors de la détection de l’étiquette, la requête SQL est modifiée pour ajouter l’indicateur approprié :

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

Avis :

  • L’intercepteur hérite de DbCommandInterceptor pour éviter d’avoir à implémenter chaque méthode dans l’interface de l’intercepteur.
  • L’intercepteur implémente des méthodes à la fois synchronisées et asynchrones. Cela garantit que le même indicateur de requête est appliqué aux requêtes synchronisées et asynchrones.
  • L’intercepteur implémente les méthodes Executing appelées par EF Core avec le code SQL généré avant qu’il ne soit envoyé à la base de données. Opposez cela aux méthodes Executed, qui sont appelées après le retour de l’appel de base de données.

L’exécution du code dans cet exemple génère les éléments suivants lorsqu’une requête est étiquetée :

-- Use hint: robust plan

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

En revanche, lorsqu’une requête n’est pas étiquetée, elle est envoyée à la base de données non modifiée :

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

Exemple : Interception de connexion pour l’authentification SQL Azure avec AAD

Conseil

Vous pouvez télécharger l’exemple d’intercepteur de connexion à partir de GitHub.

Un IDbConnectionInterceptor peut être utilisé pour manipuler DbConnection avant qu’il ne soit utilisé pour se connecter à la base de données. Cela peut servir à obtenir un jeton d’accès Azure Active Directory (AAD). Par exemple :

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

Conseil

Microsoft.Data.SqlClient prend maintenant en charge l’authentification AAD via la chaîne de connexion. Consultez la rubrique SqlAuthenticationMethod (éventuellement en anglais) pour plus d'informations.

Avertissement

Notez que l’intercepteur lève une exception si un appel de synchronisation est effectué pour ouvrir la connexion. C’est parce qu’il n’existe aucune méthode non asynchrone pour obtenir le jeton d’accès et qu’il n’existe aucun moyen universel et simple d’appeler une méthode asynchrone à partir d’un contexte non asynchrone sans risquer d’interblocage.

Avertissement

Dans certaines situations, le jeton d’accès peut ne pas être mis en cache automatiquement par le fournisseur de jetons Azure. Selon le type de jeton demandé, vous devrez peut-être implémenter votre propre mise en cache ici.

Exemple : interception de commande avancée pour la mise en cache

Conseil

Vous pouvez télécharger l’exemple d’intercepteur de commande avancé à partir de GitHub.

Les intercepteurs EF Core peuvent :

  • Indiquer à EF Core de supprimer l’exécution de l’opération interceptée
  • Modifier le résultat de l’opération renvoyée à EF Core

Cet exemple montre un intercepteur qui utilise ces fonctionnalités pour se comporter comme un cache primitif de second niveau. Les résultats de requête mis en cache sont retournés pour une requête spécifique, ce qui évite un aller-retour dans la base de données.

Avertissement

Faites attention lorsque vous changez le comportement par défaut d’EF Core de cette façon. EF Core peut se comporter de manière inattendue s’il obtient un résultat anormal qu’il ne peut pas traiter correctement. De plus, cet exemple illustre les concepts de l’intercepteur ; il n’est pas destiné à être un modèle pour une implémentation robuste de cache de deuxième niveau.

Dans cet exemple, l’application exécute fréquemment une requête pour obtenir le « message quotidien » le plus récent :

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

Cette requête est étiquetée afin qu’elle puisse être facilement détectée dans l’intercepteur. L’idée est simplement d’interroger la base de données pour rechercher un nouveau message une fois par jour. Le reste du temps, l’application utilise un résultat mis en cache. (L’exemple utilise un délai de 10 secondes pour simuler un nouveau jour.)

État de l’intercepteur

Cet intercepteur est avec état : il stocke l’ID et le texte du message quotidien le plus récent interrogé, ainsi que l’heure à laquelle cette requête a été exécutée. En raison de cet état, nous avons également besoin d’un verrou, car la mise en cache nécessite que plusieurs instances de contexte utilise ce même intercepteur.

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

Avant l'exécution

Dans la méthode Executing (c’est-à-dire avant de passer un appel de base de données), l’intercepteur détecte la requête étiquetée, puis vérifie s’il existe un résultat mis en cache. Si un tel résultat est trouvé, la requête est supprimée et les résultats mis en cache sont utilisés à la place.

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

Notez comment le code appelle InterceptionResult<TResult>.SuppressWithResult et passe un DbDataReader de remplacement contenant les données mises en cache. Cet interceptionResult est ensuite retourné, entraînant la suppression de l’exécution de la requête. Le lecteur de remplacement est utilisé par EF Core comme résultats de la requête.

Cet intercepteur manipule également le texte de la commande. Cette manipulation n’est pas nécessaire, mais améliore la clarté dans les messages de journal. Le texte de la commande n’a pas besoin d’être du code SQL valide, car la requête ne va pas être exécutée.

Après l’exécution

Si aucun message mis en cache n’est disponible ou en cas d’expiration, le code ci-dessus ne supprime pas le résultat. EF Core exécute alors la requête normalement. Il retourne ensuite à la méthode Executed de l’intercepteur après l’exécution. À ce stade, si le résultat n’est pas déjà un lecteur mis en cache, le nouvel ID de message et la chaîne sont extraits du vrai lecteur et mis en cache, prêts pour la prochaine utilisation de cette requête.

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

Démonstration

L’exemple d’intercepteur de mise en cache contient une application console simple qui interroge les messages quotidiens pour tester la mise en cache :

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

Il s'ensuit la sortie suivante :

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

À partir de la sortie du journal, notez que l’application continue d’utiliser le message mis en cache jusqu’à expiration du délai, à la suite de quoi la base de données est de nouveau interrogée à la recherche de tout nouveau message.

Interception SaveChanges

Conseil

Vous pouvez télécharger l’exemple d’intercepteur SaveChanges à partir de GitHub.

Les points d’interception SaveChanges et SaveChangesAsync sont définis par l’interface ISaveChangesInterceptor. En ce qui concerne les autres intercepteurs, la classe de base SaveChangesInterceptor avec des méthodes sans opération est fournie pour des raisons pratiques.

Conseil

Les intercepteurs sont puissants. Toutefois, dans de nombreux cas, il peut être plus facile de substituer la méthode SaveChanges ou d’utiliser les événements .NET pour SaveChanges exposés sur DbContext.

Exemple : Interception SaveChanges pour l’audit

SaveChanges peut être intercepté pour créer un enregistrement d’audit indépendant des modifications apportées.

Remarque

Il ne s’agit pas d’une solution d’audit robuste. Il s’agit plutôt d’un exemple simpliste utilisé pour illustrer les fonctionnalités de l’interception.

Le contexte de l’application

L’exemple pour l’audit utilise un DbContext simple avec des blogs et des publications.

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

Notez qu’une nouvelle instance de l’intercepteur est inscrite pour chaque instance DbContext. C’est parce que l’intercepteur d’audit contient l’état lié à l’instance de contexte actuelle.

Le contexte d’audit

L’exemple contient également un deuxième DbContext et un modèle utilisés pour la base de données d’audit.

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

L’intercepteur

L’idée générale de l’audit avec l’intercepteur est la suivante :

  • Un message d’audit est créé au début de SaveChanges et écrit dans la base de données d’audit
  • SaveChanges est autorisé à continuer
  • Si SaveChanges réussit, le message d’audit est mis à jour pour indiquer la réussite
  • Si SaveChanges échoue, le message d’audit est mis à jour pour indiquer l’échec

La première phase est gérée avant l’envoi de toute modification à la base de données en utilisant les substitutions de ISaveChangesInterceptor.SavingChanges et de ISaveChangesInterceptor.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;
}

La substitution des méthodes synchronisées et asynchrones garantit que l’audit se produit, que SaveChanges ou SaveChangesAsync soit appelé. Notez également que la surcharge asynchrone est elle-même capable d’effectuer des E/S asynchrones non bloquantes dans la base de données d’audit. Vous souhaiterez peut-être lever une exception à partir de la méthode SavingChanges synchronisée pour vous assurer que toutes les E/S de base de données sont asynchrones. Cela demande ensuite que l’application appelle toujours SaveChangesAsync et jamais SaveChanges.

Le message d’audit

Chaque méthode d’intercepteur a un paramètre eventData fournissant des informations contextuelles sur l’événement en cours d’interception. Dans ce cas, le DbContext d’application actuel est inclus dans les données d’événement, qui sont ensuite utilisées pour créer un message d’audit.

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

Le résultat est une entité SaveChangesAudit avec une collection d’entités EntityAudit, une pour chaque insertion, mise à jour ou suppression. L’intercepteur insère ensuite ces entités dans la base de données d’audit.

Conseil

ToString est substitué dans chaque classe de données d’événement EF Core pour générer le message de journal équivalent pour l’événement. Par exemple, l’appel de ContextInitializedEventData.ToString génère « Entity Framework Core 5.0.0 initialisé 'BlogsContext' à l’aide du fournisseur 'Microsoft.EntityFrameworkCore.Sqlite' avec des options : None ».

Détection d’une réussite

L’entité d’audit est stockée sur l’intercepteur afin qu’elle soit de nouveau accessible une fois que SaveChanges réussit ou échoue. Pour obtenir une réussite, ISaveChangesInterceptor.SavedChanges ou ISaveChangesInterceptor.SavedChangesAsync est appelé.

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

L’entité d’audit est attachée au contexte d’audit, car elle existe déjà dans la base de données et doit être mise à jour. Nous définissons ensuite Succeeded et EndTime, qui marquent ces propriétés comme modifiées afin que SaveChanges envoie une mise à jour à la base de données d’audit.

Détection d’un échec

L’échec est géré de la même façon que la réussite, mais dans la méthode ISaveChangesInterceptor.SaveChangesFailed ou ISaveChangesInterceptor.SaveChangesFailedAsync. Les données d’événement contiennent l’exception qui a été levée.

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

Démonstration

L’exemple d’audit contient une application console simple qui apporte des modifications à la base de données de blogs et montre ensuite l’audit qui a été créé.

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

Le résultat montre le contenu de la base de données d’audit :

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