Condividi tramite


Intercettori

Gli intercettori Entity Framework Core (EF Core) abilitano l'intercettazione, la modifica e/o l'eliminazione delle operazioni di EF Core. Sono incluse operazioni di database di basso livello, ad esempio l'esecuzione di un comando, nonché operazioni di livello superiore, ad esempio chiamate a SaveChanges.

Gli intercettori sono diversi dalla registrazione e dalla diagnostica in quanto consentono la modifica o l'eliminazione dell'operazione intercettata. La registrazione semplice o Microsoft.Extensions.Logging sono scelte migliori per la registrazione.

Gli intercettori vengono registrati per ogni istanza dbContext quando il contesto è configurato. Usare un listener di diagnostica per ottenere le stesse informazioni, ma per tutte le istanze DbContext nel processo.

Intercettori disponibili

La tabella seguente illustra le interfacce dell'intercettore disponibili:

Intercettore Operazioni intercettate Singleton
IDbCommandInterceptor Creazione di comandi
Esecuzione di comandi
Errori dei comandi
Eliminazione del DbDataReader del comando
NO
IDbConnectionInterceptor Apertura e chiusura di connessioni
Creazione di connessioni
Errori di connessione
NO
IDbTransactionInterceptor Creazione di transazioni
Utilizzo di transazioni esistenti
Commit delle transazioni
Rollback delle transazioni
Creazione e utilizzo di punti di salvataggio
Errori di transazione
NO
ISaveChangesInterceptor SavingChanges/SavedChanges
SaveChangesFailed
Ottimistica gestione della concorrenza
NO
IMaterializationInterceptor Creazione, inizializzazione e finalizzazione di istanze di entità dai risultati della query
IQueryExpressionInterceptor Modifica dell'albero delle espressioni LINQ prima della compilazione di una query
IIdentityResolutionInterceptor Risoluzione dei conflitti di identità durante il rilevamento delle entità

Registrazione degli intercettori

Gli intercettori vengono registrati usando AddInterceptors durante la configurazione di un'istanza DbContext. Questa operazione viene in genere eseguita in un override di DbContext.OnConfiguring. Per esempio:

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

In alternativa, AddInterceptors è possibile chiamare come parte di AddDbContext o quando si crea un'istanza DbContextOptions da passare al costruttore DbContext.

Suggerimento

OnConfiguring viene comunque chiamato quando si usa AddDbContext o un'istanza DbContextOptions viene passata al costruttore DbContext. Ciò rende la posizione ideale per applicare la configurazione del contesto indipendentemente dalla modalità di costruzione di DbContext.

Gli intercettori sono spesso senza stato, il che significa che una singola istanza dell'intercettore può essere usata per tutte le istanze di DbContext. Per esempio:

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

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

Ogni istanza dell'intercettore deve implementare una o più interfacce derivate da IInterceptor. Ogni istanza deve essere registrata una sola volta anche se implementa più interfacce di intercettazione; EF Core instrada gli eventi per ogni interfaccia in base alle esigenze.

Intercettori Singleton

Alcuni intercettori implementano ISingletonInterceptor (vedere la tabella precedente). Questi intercettori vengono registrati come servizi singleton nel provider di servizi interno di EF Core, ovvero una singola istanza viene condivisa in tutte le DbContext istanze che usano lo stesso provider di servizi.

Poiché gli intercettori singleton diventano parte della configurazione interna del servizio di EF Core, ogni istanza distinta dell'intercettore provoca la creazione di un nuovo provider di servizi interno. Passando una nuova istanza di un intercettore singleton ogni volta che si configura un DbContext, ad esempio in AddDbContext, alla lunga attiverà un ManyServiceProvidersCreatedWarning e degraderà le prestazioni.

Avvertimento

Riutilizza sempre la stessa istanza di intercettore singleton per tutte le istanze di DbContext. Non creare una nuova istanza ogni volta che il contesto è configurato.

Ad esempio, il codice seguente non è corretto perché viene creata una nuova istanza dell'intercettore per ogni configurazione del contesto:

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

Riutilizzare invece la stessa istanza:

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

In alternativa, usare un campo statico:

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

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

Poiché questi interceptor sono dei singleton, devono essere sicuri per i thread. In genere non devono mantenere uno stato mutabile. Se è necessario accedere ai servizi delineati, come l'oggetto corrente DbContext, usare le proprietà Context o simili sui dati dell'evento passati a ogni metodo dell'intercettore.

Intercettazione del database

Annotazioni

L'intercettazione del database è disponibile solo per i provider di database relazionali.

L'intercettazione di database di basso livello è suddivisa in tre interfacce illustrate nella tabella seguente.

Intercettore Operazioni di database intercettate
IDbCommandInterceptor Creazione di comandi
Esecuzione di comandi
Errori dei comandi
Eliminazione del DbDataReader del comando
IDbConnectionInterceptor Apertura e chiusura di connessioni
Creazione di connessioni
Errori di connessione
IDbTransactionInterceptor Creazione di transazioni
Utilizzo di transazioni esistenti
Commit delle transazioni
Rollback delle transazioni
Creazione e utilizzo di punti di salvataggio
Errori di transazione

Le classi DbCommandInterceptordi base , DbConnectionInterceptore DbTransactionInterceptor contengono no-op implementazioni per ogni metodo nell'interfaccia corrispondente. Usare le classi di base per evitare la necessità di implementare metodi di intercettazione inutilizzati.

I metodi per ogni tipo di intercettore sono in coppia, con il primo chiamato prima dell'avvio dell'operazione di database e il secondo dopo il completamento dell'operazione. Ad esempio, DbCommandInterceptor.ReaderExecuting viene chiamato prima dell'esecuzione di una query e DbCommandInterceptor.ReaderExecuted viene chiamato dopo l'invio della query al database.

Ogni coppia di metodi ha varianti di sincronizzazione e asincrone. Ciò consente di eseguire operazioni di I/O asincrone, ad esempio la richiesta di un token di accesso, come parte dell'intercettazione di un'operazione asincrona del database.

Esempio: Intercettazione dei comandi per aggiungere suggerimenti di query

Suggerimento

È possibile scaricare l'esempio di intercettore di comandi da GitHub.

Un IDbCommandInterceptor oggetto può essere usato per modificare SQL prima che venga inviato al database. In questo esempio viene illustrato come modificare SQL per includere un hint per la query.

Spesso, la parte più complessa dell'intercettazione è determinare quando il comando corrisponde alla query che deve essere modificata. L'analisi di SQL è un'opzione, ma tende a essere fragile. Un'altra opzione consiste nell'usare i tag di query di EF Core per contrassegnare ogni query che deve essere modificata. Per esempio:

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

Questo tag può quindi essere rilevato nell'intercettore perché verrà sempre incluso come commento nella prima riga del testo del comando. Nel rilevamento del tag, la query SQL viene modificata per aggiungere l'hint appropriato:

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

Avviso:

  • L'intercettore eredita da DbCommandInterceptor per evitare di dover implementare ogni metodo nell'interfaccia dell'intercettore.
  • L'intercettore implementa sia metodi di sincronizzazione che asincroni. In questo modo si garantisce che lo stesso hint di query venga applicato alle query sincrone e asincrone.
  • L'intercettore implementa i metodi Executing chiamati da EF Core con il SQL generato prima dell'invio al database. Al contrario, i metodi Executed vengono chiamati dopo che la chiamata al database è stata restituita.

L'esecuzione del codice in questo esempio genera quanto segue quando viene contrassegnata una query:

-- Use hint: robust plan

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

D'altra parte, quando una query non è contrassegnata, viene inviata al database senza modifiche:

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

Esempio: intercettazione della connessione per l'autenticazione di SQL Azure tramite AAD

Suggerimento

È possibile scaricare l'esempio di intercettore di connessione da GitHub.

Un IDbConnectionInterceptor oggetto può essere utilizzato per modificare l'oggetto DbConnection prima che venga utilizzato per connettersi al database. Può essere usato per ottenere un token di accesso di Azure Active Directory (AAD). Per esempio:

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

Suggerimento

Microsoft.Data.SqlClient supporta ora l'autenticazione AAD tramite la stringa di connessione. Per altre informazioni, vedere SqlAuthenticationMethod.

Avvertimento

Si noti che l'intercettore genera un'eccezione se viene effettuata una chiamata di sincronizzazione per aprire la connessione. Questo perché non esiste alcun metodo non asincrono per ottenere il token di accesso e non esiste un modo universale e semplice per chiamare un metodo asincrono dal contesto non asincrono senza rischiare il deadlock.

Avvertimento

in alcune situazioni, il token di accesso potrebbe non essere memorizzato automaticamente nella cache del provider di token di Azure. A seconda del tipo di token richiesto, potrebbe essere necessario implementare la propria cache qui.

Esempio: inizializzazione pigra di una stringa di connessione

Le stringhe di connessione sono spesso asset statici letti da un file di configurazione. Questi possono essere passati facilmente a UseSqlServer o simili durante la configurazione di un oggetto DbContext. Tuttavia, a volte il stringa di connessione può cambiare per ogni istanza del contesto. Ad esempio, ogni tenant in un sistema multi-tenant può avere un stringa di connessione diverso.

Può IDbConnectionInterceptor essere usato per gestire connessioni dinamiche e stringhe di connessione. Questo inizia con la possibilità di configurare il DbContext senza alcuna stringa di connessione. Per esempio:

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

È quindi possibile implementare uno dei IDbConnectionInterceptor metodi per configurare la connessione prima di usarla. ConnectionOpeningAsyncè una buona scelta, poiché può eseguire un'operazione asincrona per ottenere il stringa di connessione, trovare un token di accesso e così via. Si supponga, ad esempio, che un servizio con ambito limitato alla richiesta corrente riconosca il tenant corrente.

services.AddScoped<ITenantConnectionStringFactory, TestTenantConnectionStringFactory>();

Avvertimento

Eseguire una ricerca asincrona per un stringa di connessione, un token di accesso o un token di accesso simile ogni volta che è necessario può essere molto lento. Prendere in considerazione la memorizzazione nella cache di questi elementi e aggiornare periodicamente solo la stringa o il token memorizzato nella cache. Ad esempio, i token di accesso possono essere spesso usati per un periodo di tempo significativo prima di dover essere aggiornati.

Questa operazione può essere inserita in ogni DbContext istanza usando l'inserimento del costruttore:

public class CustomerContext : DbContext
{
    private readonly ITenantConnectionStringFactory _connectionStringFactory;

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

    // ...
}

Questo servizio viene quindi usato quando si costruisce l'implementazione dell'intercettore per il contesto:

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

Infine, l'intercettore usa questo servizio per ottenere il stringa di connessione in modo asincrono e impostarlo la prima volta che viene usata la connessione:

public class ConnectionStringInitializationInterceptor : DbConnectionInterceptor
{
    private readonly ITenantConnectionStringFactory _connectionStringFactory;

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

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

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

        return result;
    }
}

Annotazioni

La stringa di connessione viene ottenuta solo la prima volta che viene utilizzata una connessione. Successivamente, la stringa di connessione archiviata in DbConnection verrà usata senza cercare una nuova stringa di connessione.

Suggerimento

Questo intercettore esegue l'override del metodo non asincrono ConnectionOpening da generare perché il servizio per ottenere il stringa di connessione deve essere chiamato da un percorso di codice asincrono.

Esempio: Intercettazione avanzata dei comandi per la memorizzazione nella cache

Suggerimento

È possibile scaricare l'esempio avanzato di intercettore di comandi da GitHub.

Gli intercettori EF Core possono:

  • Indicare a EF Core di eliminare l'esecuzione dell'operazione intercettata
  • Modificare il risultato dell'operazione restituita a EF Core

Questo esempio mostra un intercettore che usa queste funzionalità per comportarsi come una cache di secondo livello primitiva. I risultati delle query memorizzati nella cache vengono restituiti per una query specifica, evitando un round trip del database.

Avvertimento

Prestare attenzione quando si modifica il comportamento predefinito di EF Core in questo modo. EF Core può comportarsi in modi imprevisti se ottiene un risultato anomalo che non è in grado di elaborare correttamente. In questo esempio vengono inoltre illustrati i concetti dell'intercettore; non è concepito come modello per un'implementazione affidabile della cache di secondo livello.

In questo esempio l'applicazione esegue spesso una query per ottenere il "messaggio giornaliero" più recente:

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

Questa query viene contrassegnata in modo che possa essere facilmente rilevata nell'intercettore. L'idea è eseguire una query solo sul database per un nuovo messaggio una volta al giorno. In altri casi, l'applicazione userà un risultato memorizzato nella cache. L'esempio usa un ritardo di 10 secondi nell'esempio per simulare un nuovo giorno.

Stato dell'intercettore

Questo intercettore è con stato: archivia l'ID e il testo del messaggio del messaggio giornaliero più recente, più l'ora in cui è stata eseguita la query. A causa di questo stato è necessario anche un blocco perché la memorizzazione nella cache richiede che lo stesso intercettatore debba essere usato da più istanze di contesto.

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

Prima dell'esecuzione

Executing Nel metodo (ad esempio, prima di effettuare una chiamata al database), l'intercettore rileva la query con tag e quindi controlla se è presente un risultato memorizzato nella cache. Se viene trovato un risultato di questo tipo, la query viene eliminata e i risultati memorizzati nella cache vengono invece usati.

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

Si noti che il codice chiama InterceptionResult<TResult>.SuppressWithResult e passa una sostituzione DbDataReader contenente i dati memorizzati nella cache. L'oggetto InterceptionResult viene quindi restituito, causando l'eliminazione dell'esecuzione della query. Il lettore sostitutivo viene invece usato da EF Core per ottenere i risultati della query.

Questo intercettore modifica anche il testo del comando. Questa manipolazione non è necessaria, ma migliora la chiarezza nei messaggi di log. Il testo del comando non deve essere SQL valido perché la query non verrà ora eseguita.

Dopo l'esecuzione

Se non è disponibile alcun messaggio memorizzato nella cache o se è scaduto, il codice precedente non elimina il risultato. EF Core eseguirà quindi la query come di consueto. Verrà quindi restituito al metodo dell'intercettore Executed dopo l'esecuzione. A questo punto, se il risultato non è già un lettore memorizzato nella cache, il nuovo ID messaggio e la nuova stringa vengono estratti dal lettore reale e memorizzati nella cache pronti per l'uso successivo di questa query.

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

Dimostrazione

L'esempio di intercettore di memorizzazione nella cache contiene una semplice applicazione console che esegue query per i messaggi giornalieri per testare la memorizzazione nella 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;

Si ottiene l'output seguente:

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

Si noti che l'output del log indica che l'applicazione continua a usare il messaggio memorizzato nella cache fino alla scadenza del timeout, a questo punto viene nuovamente eseguita una query sul database per qualsiasi nuovo messaggio.

Esempio: Registrazione delle statistiche delle query di SQL Server

Questo esempio mostra due intercettori che interagiscono per inviare statistiche di query di SQL Server al log applicazioni. Per generare le statistiche, è necessario un IDbCommandInterceptor eseguire due operazioni.

In primo luogo, l'intercettore prefissa i comandi con SET STATISTICS IO ON, il che indica a SQL Server di inviare statistiche al client dopo che un set di risultati è stato consumato.

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

    return new(result);
}

In secondo luogo, l'intercettore implementerà il DataReaderClosingAsync metodo, che viene chiamato dopo che DbDataReader ha finito di elaborare i risultati, ma prima della chiusura. Quando SQL Server invia statistiche, le inserisce in un secondo risultato nel reader, quindi a questo punto l'intercettore legge tale risultato chiamando NextResultAsync, che popola le statistiche sulla connessione.

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

    return result;
}

Il secondo intercettore è necessario per ottenere le statistiche dalla connessione e scriverle nel logger dell'applicazione. A questo scopo, si userà un IDbConnectionInterceptor, implementando il ConnectionCreated metodo . ConnectionCreated viene chiamato immediatamente dopo che EF Core ha creato una connessione e quindi può essere usato per eseguire una configurazione aggiuntiva di tale connessione. In questo caso, l'intercettore ottiene un oggetto ILogger e quindi si aggancia all'evento SqlConnection.InfoMessage per registrare i messaggi.

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

Importante

I ConnectionCreating metodi e ConnectionCreated vengono chiamati solo quando EF Core crea un oggetto DbConnection. Non verranno chiamati se l'applicazione crea DbConnection e la passa a EF Core.

Filtro in base all'origine dei comandi

L'oggetto CommandEventData fornito alle origini di diagnostica e agli intercettori contiene una proprietà CommandSource che indica quale parte di EF è stata responsabile della creazione del comando. Può essere usato come filtro nell'intercettore. Ad esempio, potrebbe essere necessario un intercettore che si applica solo ai comandi provenienti da SaveChanges:

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

        return result;
    }
}

Intercettazione Salva modifiche

Suggerimento

È possibile scaricare l'esempio di intercettore SaveChanges da GitHub.

SaveChanges e SaveChangesAsync i punti di intercettazione sono definiti dall'interfaccia ISaveChangesInterceptor . Per quanto riguarda altri intercettori, la SaveChangesInterceptor classe base con metodi di no-op viene fornita per praticità.

Suggerimento

Gli intercettori sono potenti. In molti casi, tuttavia, potrebbe essere più semplice eseguire l'override del metodo SaveChanges o usare gli eventi .NET per SaveChanges esposti in DbContext.

Esempio: Intercettazione di SaveChanges per revisione

È possibile intercettare SaveChanges per creare un record di controllo indipendente delle modifiche apportate.

Annotazioni

Non si tratta di una soluzione di controllo affidabile. Piuttosto è un esempio semplicistico usato per dimostrare le caratteristiche dell'intercettazione.

Contesto dell'applicazione

L'esempio per il controllo usa un oggetto DbContext semplice con blog e post.

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

Si noti che una nuova istanza dell'intercettore viene registrata per ogni istanza di DbContext. Ciò è dovuto al fatto che l'intercettore di controllo contiene lo stato collegato all'istanza del contesto corrente.

Contesto di controllo

L'esempio contiene anche un secondo dbContext e un modello usati per il database di controllo.

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

Intercettore

L'idea generale per il controllo con l'intercettore è:

  • Un messaggio di controllo viene creato all'inizio di SaveChanges e viene scritto nel database di controllo
  • SaveChanges può continuare
  • Se SaveChanges ha esito positivo, il messaggio di controllo viene aggiornato per indicare l'esito positivo
  • Se SaveChanges ha esito negativo, il messaggio di controllo viene aggiornato per indicare l'errore

La prima fase viene gestita prima che tutte le modifiche vengano inviate al database usando override di ISaveChangesInterceptor.SavingChanges e 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;
}

L'override di entrambi i metodi sincronizzati e asincroni garantisce che il controllo avvenga indipendentemente dal fatto che vengano chiamati SaveChanges o SaveChangesAsync. Si noti anche che l'overload asincrono è in grado di eseguire operazioni di I/O asincrone non bloccanti nel database di controllo. È possibile generare un'eccezione dal metodo di sincronizzazione SavingChanges per assicurarsi che tutte le operazioni di I/O del database siano asincrone. A questo punto è necessario che l'applicazione chiami SaveChangesAsync sempre e mai SaveChanges.

Messaggio di controllo

Ogni metodo intercettore ha un eventData parametro che fornisce informazioni contestuali sull'evento intercettato. In questo caso l'applicazione corrente DbContext è inclusa nei dati dell'evento, che viene quindi usata per creare un messaggio di controllo.

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

Il risultato è un'entità SaveChangesAudit con una raccolta di EntityAudit entità, una per ogni inserimento, aggiornamento o eliminazione. L'intercettore inserisce quindi queste entità nel database di controllo.

Suggerimento

ToString viene sottoposto a override in ogni classe di dati di evento di EF Core per generare il messaggio di log equivalente per l'evento. Ad esempio, la chiamata ContextInitializedEventData.ToString genera "Entity Framework Core 5.0.0 inizializzato 'BlogsContext' usando il provider 'Microsoft.EntityFrameworkCore.Sqlite' con opzioni: Nessuno".

Individuazione del successo

L'entità di controllo viene archiviata nell'intercettore in modo che sia possibile accedervi di nuovo una volta che SaveChanges ha esito positivo o negativo. Per il successo, viene chiamato ISaveChangesInterceptor.SavedChanges o 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à di controllo è collegata al contesto di controllo, poiché esiste già nel database e deve essere aggiornata. Si impostano Succeeded e EndTime, il che contrassegna queste proprietà come modificate così SaveChanges invia un aggiornamento al database di controllo.

Rilevamento di un errore

L'errore viene gestito in modo analogo al successo, ma nel metodo ISaveChangesInterceptor.SaveChangesFailed o ISaveChangesInterceptor.SaveChangesFailedAsync. I dati dell'evento contengono l'eccezione generata.

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

Dimostrazione

L'esempio di controllo contiene una semplice applicazione console che apporta modifiche al database di blogging e quindi mostra il controllo creato.

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

Il risultato mostra il contenuto del database di controllo:

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

Esempio: Intercettazione della concorrenza ottimistica

EF Core supporta il modello di concorrenza ottimistica verificando che il numero di righe effettivamente interessate da un aggiornamento o un'eliminazione corrisponda al numero di righe previste. Questo è spesso associato a un token di concorrenza; ovvero, un valore di colonna che corrisponderà solo al valore previsto se la riga non è stata aggiornata dopo la lettura del valore previsto.

EF segnala una violazione della concorrenza ottimistica generando un'eccezione DbUpdateConcurrencyException. ISaveChangesInterceptor dispone di metodi ThrowingConcurrencyException e ThrowingConcurrencyExceptionAsync che vengono chiamati prima che venga generata l'eccezione DbUpdateConcurrencyException . Questi punti di intercettazione consentono di eliminare l'eccezione, possibilmente abbinata a modifiche asincrone del database per risolvere la violazione.

Ad esempio, se due richieste tentano di eliminare la stessa entità contemporaneamente, la seconda eliminazione potrebbe non riuscire perché la riga nel database non esiste più. Può andar bene così—andrà comunque a finire che l'entità è stata eliminata. L'intercettore seguente illustra come eseguire questa operazione:

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

            return InterceptionResult.Suppress();
        }

        return result;
    }

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

Ci sono diverse cose che vale la pena notare su questo intercettore:

  • Vengono implementati sia i metodi di intercettazione sincroni che asincroni. Questo è importante se l'applicazione può chiamare SaveChanges o SaveChangesAsync. Tuttavia, se tutto il codice dell'applicazione è asincrono, è necessario implementare solo ThrowingConcurrencyExceptionAsync . Analogamente, se l'applicazione non usa mai metodi di database asincroni, è necessario implementare solo ThrowingConcurrencyException . Questo vale in genere per tutti gli intercettori con metodi sincroni e asincroni.
  • L'intercettore ha accesso agli EntityEntry oggetti per le entità salvate. In questo caso, viene usato per verificare se si verifica o meno la violazione della concorrenza per un'operazione di eliminazione.
  • Se l'applicazione usa un provider di database relazionale, è possibile eseguire il cast dell'oggetto ConcurrencyExceptionEventData a un RelationalConcurrencyExceptionEventData oggetto . In questo modo vengono fornite informazioni aggiuntive specifiche relazionali sull'operazione di database eseguita. In questo caso, il testo del comando relazionale viene stampato nella console.
  • La restituzione di InterceptionResult.Suppress() indica ad EF Core di sopprimere l'azione che stava per intraprendere, in questo caso, sollevando il DbUpdateConcurrencyException. Questa possibilità di modificare il comportamento di EF Core, anziché semplicemente osservare le operazioni di EF Core, è una delle funzionalità più potenti degli intercettori.

Intercettazione della materializzazione

IMaterializationInterceptor supporta l'intercettazione sia prima che dopo la creazione di un'istanza di entità, sia prima che dopo l'inizializzazione delle proprietà di tale istanza. L'intercettore può modificare o sostituire l'istanza dell'entità in ogni punto. Questo consente:

  • Impostazione di proprietà non mappate o metodi di chiamata necessari per la convalida, i valori calcolati o i flag.
  • Uso di una factory per creare istanze.
  • La creazione di un'istanza di entità diversa rispetto a quella che l'Entity Framework solitamente crea, ad esempio un'istanza da una cache o un'istanza di un tipo proxy.
  • Inserimento di servizi in un'istanza di entità.

Annotazioni

IMaterializationInterceptor è un intercettore singleton, ovvero una singola istanza viene condivisa tra tutte le DbContext istanze.

Esempio: Azioni semplici per la creazione di entità

Si supponga di voler tenere traccia dell'ora in cui un'entità è stata recuperata dal database, forse in modo che possa essere visualizzata a un utente che modifica i dati. A tale scopo, definiamo prima un'interfaccia:

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

L'uso di un'interfaccia è comune con gli intercettori perché consente allo stesso intercettore di lavorare con molti tipi di entità diversi. Per esempio:

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

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

Si noti che l'attributo [NotMapped] viene usato per indicare che questa proprietà viene usata solo durante l'utilizzo dell'entità e non deve essere mantenuta nel database.

L'intercettore deve quindi implementare il metodo appropriato da IMaterializationInterceptor e impostare l'ora recuperata:

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

Un'istanza di questo intercettore viene registrata durante la configurazione di DbContext:

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

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

Suggerimento

Questo intercettore è senza stato, che è comune, quindi viene creata e condivisa una singola istanza tra tutte le DbContext istanze.

A questo punto, ogni volta che viene eseguita una Customer query dal database, la Retrieved proprietà verrà impostata automaticamente. Per esempio:

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

Produce output:

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

Esempio: inserimento di servizi in entità

EF Core dispone già del supporto predefinito per l'inserimento di alcuni servizi speciali nelle istanze di contesto; ad esempio, vedere Caricamento differita senza proxy, che funziona inserendo il ILazyLoader servizio.

Un IMaterializationInterceptor oggetto può essere utilizzato per generalizzare questo oggetto in qualsiasi servizio. Nell'esempio seguente viene illustrato come inserire un oggetto ILogger in entità in modo che possano eseguire la propria registrazione.

Annotazioni

L'inserimento di servizi in entità associa tali tipi di entità ai servizi inseriti, che alcune persone considerano un anti-modello.

Come in precedenza, viene usata un'interfaccia per definire le operazioni che è possibile eseguire.

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

E i tipi di entità che registrano devono implementare questa interfaccia. Per esempio:

public class Customer : IHasLogger
{
    private string? _phoneNumber;

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

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

            _phoneNumber = value;
        }
    }

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

Questa volta, l'intercettore deve implementare IMaterializationInterceptor.InitializedInstance, chiamato dopo che ogni istanza di entità è stata creata e i suoi valori delle proprietà sono stati inizializzati. L'intercettore ottiene un oggetto ILogger dal contesto e lo inizializza IHasLogger.Logger con esso:

public class LoggerInjectionInterceptor : IMaterializationInterceptor
{
    private ILogger? _logger;

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

        return instance;
    }
}

Questa volta, per ogni istanza di DbContext, viene utilizzata una nuova istanza dell'intercettore, poiché l'oggetto ILogger ottenuto può variare per ciascuna istanza di DbContext e ILogger è memorizzato nella cache all'interno dell'intercettore.

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

A questo punto, ogni volta che Customer.PhoneNumber viene modificato, questa modifica verrà registrata nel log dell'applicazione. Per esempio:

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

Intercettazione di espressioni di query

IQueryExpressionInterceptor consente l'intercettazione dell'albero delle espressioni LINQ per una query prima della compilazione. Può essere usato per modificare dinamicamente le query in modi che si applicano all'interno dell'applicazione.

Annotazioni

IQueryExpressionInterceptor è un intercettore singleton, ovvero una singola istanza viene in genere condivisa tra tutte le DbContext istanze.

Avvertimento

Gli intercettori sono potenti, ma è facile commettere errori quando si lavora con un albero delle espressioni. Considerare sempre se esiste un modo più semplice per ottenere ciò che si desidera, ad esempio la modifica diretta della query.

Esempio: Aggiungere l'ordinamento alle query per un ordinamento stabile.

Si consideri un metodo che restituisce una pagina di clienti:

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

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

Suggerimento

Questa query usa il EF.Property metodo per specificare la proprietà in base alla quale eseguire l'ordinamento. Ciò consente all'applicazione di passare dinamicamente il nome della proprietà, consentendo l'ordinamento in base a qualsiasi proprietà del tipo di entità. Tenere presente che l'ordinamento in base a colonne non indicizzate può essere lento.

Questa operazione funzionerà correttamente, purché la proprietà utilizzata per l'ordinamento restituisca sempre un ordinamento stabile. Ma questo potrebbe non essere sempre il caso. Ad esempio, la query LINQ precedente genera quanto segue in SQLite ordinando per Customer.City:

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

Se sono presenti più clienti con lo stesso City, l'ordinamento di questa query non è stabile. Ciò potrebbe causare risultati mancanti o duplicati mentre l'utente sfoglia i dati.

Un modo comune per risolvere questo problema consiste nell'eseguire un ordinamento secondario in base alla chiave primaria. Tuttavia, anziché aggiungerlo manualmente a ogni query, un intercettore può aggiungere dinamicamente l'ordinamento secondario. Per semplificare questa operazione, viene definita un'interfaccia per qualsiasi entità con una chiave primaria integer:

public interface IHasIntKey
{
    int Id { get; }
}

Questa interfaccia viene implementata dai tipi di entità di interesse:

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

È quindi necessario un intercettore che implementa IQueryExpressionInterceptor:

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

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

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

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

            return base.VisitMethodCall(methodCallExpression);
        }
    }
}

Questo probabilmente sembra piuttosto complicato- ed è! L'utilizzo degli alberi di espressioni in genere non è facile. Vediamo cosa sta accadendo:

  • Fondamentalmente, l'intercettore incapsula un oggetto ExpressionVisitor. Il visitatore sovrascrive VisitMethodCall, che verrà chiamato ogni volta che viene eseguita una chiamata a un metodo nell'albero delle espressioni di query.

  • Il visitatore controlla se si tratta o meno di una chiamata al OrderBy metodo a cui si è interessati.

  • In caso affermativo, il visitatore verifica ulteriormente se la chiamata al metodo generico è per un tipo che implementa la nostra interfaccia IHasIntKey.

  • A questo punto si sa che la chiamata al metodo è nel formato OrderBy(e => ...). L'espressione lambda viene estratta da questa chiamata e viene ottenuto il parametro usato in tale espressione, ovvero .e

  • Ora costruiamo un nuovo MethodCallExpression con il metodo builder Expression.Call. In questo caso, il metodo chiamato è ThenBy(e => e.Id). Questa operazione viene costruita usando il parametro estratto in precedenza e un accesso alla proprietà Id dell'interfaccia IHasIntKey.

  • L'input in questa chiamata è l'originale OrderBy(e => ...)e quindi il risultato finale è un'espressione per OrderBy(e => ...).ThenBy(e => e.Id).

  • Questa espressione modificata viene restituita dal visitatore, il che significa che la query LINQ è stata modificata in modo appropriato per includere una ThenBy chiamata.

  • EF Core continua e compila questa espressione di query nel codice SQL appropriato per il database in uso.

La registrazione di questo intercettore ed esecuzione GetPageOfCustomers genera ora il codice SQL seguente:

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

Questo ora produrrà sempre un ordinamento stabile, anche se sono presenti più clienti con lo stesso City.

In molti casi, la stessa cosa può essere ottenuta più semplicemente modificando direttamente la query. Per esempio:

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

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

In questo caso l'oggetto ThenBy viene semplicemente aggiunto alla query. Sì, potrebbe essere necessario eseguirlo separatamente per ciascuna query, ma è semplice, facile da capire e funzionerà sempre.

Intercettazione nella risoluzione dell'identità

IIdentityResolutionInterceptor consente l'intercettazione dei conflitti di risoluzione delle identità quando il DbContext inizia il tracciamento di nuove istanze di entità.

Annotazioni

Questo intercettore viene attualmente chiamato solo quando DbContext.Update, DbContext.Attache metodi simili vengono usati per tenere traccia delle entità già rilevate con la stessa chiave. La funzione non viene chiamata per le entità restituite dalle query. Questo potrebbe cambiare in una versione futura; vedere questo problema.

L'entità DbContext può tenere traccia solo di un'istanza con un dato valore di chiave primaria. Ciò significa che più istanze di un'entità con lo stesso valore di chiave devono essere risolte in una singola istanza. Un intercettore di questo tipo viene chiamato con l'istanza rilevata esistente e la nuova istanza e deve applicare eventuali valori di proprietà e modifiche di relazione dalla nuova istanza all'istanza esistente. La nuova istanza viene quindi rimossa.

EF Core fornisce un'implementazione predefinita, UpdatingIdentityResolutionInterceptor, che aggiorna l'entità rilevata esistente con valori della nuova istanza. Questa operazione può essere registrata durante la configurazione del contesto:

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

Per implementare la logica di risoluzione delle identità personalizzata, creare una classe che implementa IIdentityResolutionInterceptor ed esegue l'override del UpdateTrackedInstance metodo :

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