Partager via


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, telles que l’exécution d’une commande, ainsi que des opérations de niveau supérieur, telles que 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 est un meilleur choix pour la journalisation.

Les intercepteurs sont inscrits pour chaque instance de DbContext lorsque le contexte est paramétré. Utilisez un écouteur de diagnostic pour obtenir les mêmes informations, mais pour toutes les instances DbContext dans le processus.

Enregistrement d’intercepteurs

Les intercepteurs sont inscrits lors de la AddInterceptors en utilisant . Cela est généralement effectué dans un remplacement de DbContext.OnConfiguring. Par exemple:

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

Alternativement, AddInterceptors peut être appelé dans le cadre de AddDbContext ou lors de la création d'une instance DbContextOptions à passer au constructeur DbContext.

Conseil / Astuce

OnConfiguring est toujours appelé lorsque AddDbContext est utilisé ou qu’une instance DbContextOptions est passée au constructeur DbContext. Cela permet d’appliquer la configuration de contexte, quel que soit le mode de construction de 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 n’est disponible que pour les 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 des connexions
Échecs de connexion
IDbTransactionInterceptor Création de transactions
Utilisation de transactions existantes
Validation des transactions
Annulation des transactions
Création et utilisation des points de sauvegarde
Échecs de transaction

Les classes DbCommandInterceptorde base , DbConnectionInterceptoret DbTransactionInterceptor contiennent no-op implémentations pour chaque méthode de l’interface correspondante. Utilisez les classes de base pour éviter la nécessité d’implémenter des méthodes d’interception inutilisées.

Les méthodes de chaque type d’intercepteur sont fournies en paires, la première étant 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é avant l’exécution d’une requête et DbCommandInterceptor.ReaderExecuted est appelé après l’envoi de la requête à la base de données.

Chaque paire de méthodes a des variantes de synchronisation et asynchrones. Cela permet d’effectuer 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 commandes pour ajouter des indicateurs de requête

Conseil / Astuce

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

Un IDbCommandInterceptor peut être utilisé pour modifier le SQL avant qu'il ne soit envoyé à la base de données. Cet exemple montre comment modifier le code 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 du sql est une option, mais a tendance à être fragile. Une autre option consiste à utiliser des balises de requête EF Core pour baliser chaque requête qui doit être modifiée. Par exemple:

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

Cette balise 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 la balise, 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 de l'interface d'intercepteur.
  • L’intercepteur implémente à la fois des méthodes de synchronisation et asynchrones. Cela garantit que le même indicateur de requête est appliqué à la synchronisation et aux requêtes asynchrones.
  • L’intercepteur implémente les Executing méthodes appelées par EF Core avec le SQL généré avant qu'il soit envoyé à la base de données. Contrastez cela avec les Executed méthodes appelées après 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 marqué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 à l’aide d’AAD

Conseil / Astuce

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

Un IDbConnectionInterceptor peut être utilisé pour manipuler le DbConnection avant qu’il ne soit utilisé pour se connecter à la base de données. Cela peut être utilisé pour 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 / Astuce

Microsoft.Data.SqlClient prend désormais en charge l’authentification AAD via la chaîne de connexion. Pour plus d’informations, consultez SqlAuthenticationMethod.

Avertissement

Notez que l'intercepteur lève une exception lorsqu'un appel de synchronisation est effectué pour ouvrir la connexion. Cela est dû au fait 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 risque 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 avancée des commandes pour la mise en cache

Conseil / Astuce

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 de second niveau primitif. Les résultats de requête mis en cache sont retournés pour une requête spécifique, évitant ainsi un aller-retour de base de données.

Avertissement

Prenez soin de modifier 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. En outre, cet exemple illustre les concepts de l’intercepteur ; elle n’est pas conçue comme modèle pour une implémentation robuste du 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 d’interroger uniquement la base de données pour un nouveau message une fois par jour. À d’autres moments, l’application utilisera un résultat mis en cache. (Cet exemple utilise un délai de 10 secondes pour simuler un nouveau jour.)

État de l’intercepteur

Cet intercepteur est doté d'un état : il stocke l’ID et le texte du message quotidien le plus récent qui a été 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 le même intercepteur soit utilisé par plusieurs instances de contexte.

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

Avant l'exécution

Dans la Executing méthode (c’est-à-dire avant d’effectuer un appel de base de données), l’intercepteur détecte la requête marqué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 transmet un remplacement DbDataReader contenant les données mises en cache. Cette InterceptionResult est ensuite retournée, ce qui entraîne la suppression de l’exécution de la requête. EF Core utilise plutôt le lecteur de remplacement en tant que résultat 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 sql valide, car la requête ne sera pas exécutée.

Après l’exécution

Si aucun message mis en cache n’est disponible ou s’il a expiré, le code ci-dessus ne supprime pas le résultat. EF Core exécute donc la requête normalement. Il reviendra ensuite à la méthode de l’intercepteur Executed après son 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 lecteur réel et mis en cache prêts pour l’utilisation suivante 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

Notez à partir de la sortie du journal que l’application continue d’utiliser le message mis en cache jusqu’à ce que le délai d’expiration expire, à quel moment la base de données est interrogée à nouveau pour tout nouveau message.

Interception de la fonction Enregistrer les modifications

Conseil / Astuce

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

SaveChanges et SaveChangesAsync les points d’interception sont définis par l’interface ISaveChangesInterceptor . Quant à d’autres intercepteurs, la SaveChangesInterceptor classe de base avec des méthodes no-op est fournie de manière pratique.

Conseil / Astuce

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

Exemple : l'interception de '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 caractéristiques de l’interception.

Contexte de l’application

L’exemple d’audit utilise un dbContext simple avec des blogs et des billets.

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 un état lié à l'instance de contexte actuelle.

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

Intercepteur

L’idée générale d’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 étape est gérée avant que les modifications ne soient envoyées à la base de données en utilisant les remplacements de ISaveChangesInterceptor.SavingChanges et 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 surcharge des méthodes synchrones et asynchrones garantit que l'audit se produit, que SaveChanges ou SaveChangesAsync soient appelés. Notez également que la surcharge asynchrone peut elle-même réaliser des opérations d'E/S asynchrone non bloquantes sur la base de données d’audit. Vous pourriez envisager de lancer une exception depuis la méthode de synchronisation SavingChanges pour vous assurer que toutes les opérations d'E/S sur la base de données sont asynchrones. Cela nécessite ensuite que l’application appelle SaveChangesAsync toujours et jamais SaveChanges.

Message d’audit

Chaque méthode d’intercepteur a un eventData paramètre fournissant des informations contextuelles sur l’événement intercepté. Dans ce cas, l’application actuelle DbContext est incluse 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 SaveChangesAudit entité 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 / Astuce

ToString est redéfini 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 ContextInitializedEventData.ToString génère « Entity Framework Core 5.0.0 initialisé « BlogsContext » à l’aide du fournisseur « Microsoft.EntityFrameworkCore.Sqlite » avec des options : Aucun ».

Détection de la réussite

L’entité d’audit est stockée sur l’intercepteur afin qu’elle soit à nouveau accessible une fois que SaveChanges réussit ou échoue. Pour réussir, on appelle ISaveChangesInterceptor.SavedChanges ou 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;
}

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, ce qui marque ces propriétés comme modifiées, de sorte 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 manière que le succès, mais dans la méthode ISaveChangesInterceptor.SaveChangesFailed ou ISaveChangesInterceptor.SaveChangesFailedAsync. Les données d’événement contiennent l’exception 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, puis affiche l’audit 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 = 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}");
        }
    }
}

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