Nota:
El acceso a esta página requiere autorización. Puede intentar iniciar sesión o cambiar directorios.
El acceso a esta página requiere autorización. Puede intentar cambiar los directorios.
Los interceptores de Entity Framework Core (EF Core) permiten interceptar, modificar o suprimir operaciones de EF Core. Esto incluye operaciones de base de datos de bajo nivel, como ejecutar un comando, así como operaciones de nivel superior, como llamadas a SaveChanges.
Los interceptores se diferencian del registro y diagnóstico en que permiten modificar o suprimir la operación que se intercepta. El registro simple o Microsoft.Extensions.Logging son mejores opciones para el registro.
Los interceptores se registran en cada instancia de DbContext cuando se configura el contexto. Use un escuchador de diagnóstico para obtener la misma información, pero para todas las instancias de DbContext del proceso.
Interceptores disponibles
En la tabla siguiente se muestran las interfaces de interceptor disponibles:
| Interceptador | Operaciones interceptadas | Singleton |
|---|---|---|
| IDbCommandInterceptor | Creación de comandos Al ejecutar comandos , se produce un error de comando al eliminar dbDataReader del comando. |
No |
| IDbConnectionInterceptor | Apertura y cierre de conexiones Creación de conexiones Fallas de conexión |
No |
| IDbTransactionInterceptor | Crear transacciones Uso de transacciones existentes Confirmando transacciones Revirtiendo transacciones Creando y usando puntos de guardado Fallos de transacción |
No |
| ISaveChangesInterceptor | SavingChanges/SavedChanges SaveChangesFailed Control de simultaneidad optimista |
No |
| IMaterializationInterceptor | Creación, inicialización y finalización de instancias de entidad a partir de los resultados de la consulta | Sí |
| IQueryExpressionInterceptor | Modificación del árbol de expresiones LINQ antes de compilar una consulta | Sí |
| IIdentityResolutionInterceptor | Resolución de conflictos de identidad al realizar el seguimiento de entidades | Sí |
Registro de interceptores
Los interceptores se registran mediante AddInterceptors al configurar una instancia de DbContext. Esto suele hacerse en una anulación de DbContext.OnConfiguring. Por ejemplo:
public class ExampleContext : BlogsContext
{
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder.AddInterceptors(new TaggedQueryCommandInterceptor());
}
Como alternativa, AddInterceptors se puede llamar como parte de AddDbContext o al crear una DbContextOptions instancia para pasar al constructor DbContext.
Sugerencia
Se sigue llamando a OnConfiguring cuando se usa AddDbContext o se pasa una instancia dbContextOptions al constructor DbContext. Esto hace que sea el lugar ideal para aplicar la configuración de contexto independientemente de cómo se construye DbContext.
Los interceptores a menudo no tienen estado, lo que significa que se puede usar una sola instancia de interceptor para todas las instancias de DbContext. Por ejemplo:
public class TaggedQueryCommandInterceptorContext : BlogsContext
{
private static readonly TaggedQueryCommandInterceptor _interceptor
= new TaggedQueryCommandInterceptor();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder.AddInterceptors(_interceptor);
}
Cada instancia del interceptor debe implementar una o varias interfaces derivadas de IInterceptor. Cada instancia solo debe registrarse una vez aunque implemente varias interfaces de interceptación; EF Core enrutará los eventos de cada interfaz según corresponda.
Interceptores singleton
Algunos interceptores implementan ISingletonInterceptor (consulte la tabla anterior); estos interceptores se registran como servicios singleton en el proveedor de servicios interno de EF Core, lo que significa que una sola instancia se comparte en todas las DbContext instancias que usan el mismo proveedor de servicios.
Dado que los interceptores singleton forman parte de la configuración del servicio interno de EF Core, cada instancia de interceptor distinta hace que se cree un nuevo proveedor de servicios interno. Pasar una nueva instancia de un interceptor singleton cada vez que se configura DbContext, por ejemplo, en AddDbContext, finalmente desencadenará un ManyServiceProvidersCreatedWarning y degradará el rendimiento.
Advertencia
Vuelva a usar siempre la misma instancia del interceptor singleton para todas las DbContext instancias. No cree una nueva instancia cada vez que se configure el contexto.
Por ejemplo, lo siguiente es incorrecto porque se crea una nueva instancia de interceptor para cada configuración de contexto:
// 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()));
En su lugar, vuelva a usar la misma instancia:
// Correct: reuse a single interceptor instance
var interceptor = new MyMaterializationInterceptor();
services.AddDbContext<CustomerContext>(
b => b.UseSqlServer(connectionString)
.AddInterceptors(interceptor));
O bien, use un campo estático:
public class CustomerContext : DbContext
{
private static readonly MyMaterializationInterceptor _interceptor = new();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder.AddInterceptors(_interceptor);
}
Dado que estos interceptores son singletons, deben ser seguros para subprocesos. Por lo general, no deben mantener estado cambiado. Si necesita acceder a los servicios con ámbito (como el actual DbContext), use Context u otras propiedades similares de los datos de evento pasados a cada método interceptor.
Interceptación de base de datos
Nota:
La interceptación de bases de datos solo está disponible para proveedores de bases de datos relacionales.
La interceptación de base de datos de bajo nivel se divide en las tres interfaces que se muestran en la tabla siguiente.
| Interceptador | Operaciones de base de datos interceptadas |
|---|---|
| IDbCommandInterceptor | Creación de comandos Al ejecutar comandos , se produce un error de comando al eliminar dbDataReader del comando. |
| IDbConnectionInterceptor | Apertura y cierre de conexiones Creación de conexiones Fallos de conexión |
| IDbTransactionInterceptor | Crear transacciones Uso de transacciones existentes Confirmando transacciones Revirtiendo transacciones Creando y usando puntos de guardado Fallos de transacción |
Las clases DbCommandInterceptorbase , DbConnectionInterceptory DbTransactionInterceptor contienen no-op implementaciones para cada método de la interfaz correspondiente. Use las clases base para evitar la necesidad de implementar métodos de interceptación sin usar.
Los métodos de cada tipo interceptor vienen en pares, con el primero al que se llama antes de que se inicie la operación de base de datos y el segundo después de que se haya completado la operación. Por ejemplo, DbCommandInterceptor.ReaderExecuting se llama a antes de ejecutar una consulta y DbCommandInterceptor.ReaderExecuted se llama después de que se haya enviado la consulta a la base de datos.
Cada par de métodos tiene variaciones sincronizadas y asincrónicas. Esto permite que la E/S asincrónica, como solicitar un token de acceso, se produzca como parte de la interceptación de una operación asincrónica de base de datos.
Ejemplo: Interceptar comandos para añadir indicaciones de consulta
Sugerencia
Puede descargar el ejemplo de interceptor de comandos desde GitHub.
IDbCommandInterceptor Se puede usar para modificar SQL antes de enviarlo a la base de datos. En este ejemplo se muestra cómo modificar SQL para incluir una sugerencia de consulta.
A menudo, la parte más complicada de la interceptación es determinar cuándo el comando corresponde a la consulta que debe modificarse. Analizar SQL es una opción, pero tiende a ser frágil. Otra opción es usar etiquetas de consulta de EF Core para etiquetar cada consulta que se debe modificar. Por ejemplo:
var blogs1 = await context.Blogs.TagWith("Use hint: robust plan").ToListAsync();
A continuación, esta etiqueta se puede detectar en el interceptor, ya que siempre se incluirá como comentario en la primera línea del texto del comando. Al detectar la etiqueta , la consulta SQL se modifica para agregar la sugerencia adecuada:
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)";
}
}
}
Aviso:
- El interceptor hereda de DbCommandInterceptor para evitar tener que implementar todos los métodos en la interfaz del interceptor.
- El interceptor implementa métodos síncronos y asíncronos. Esto garantiza que la misma sugerencia de consulta se aplica a las consultas sincronizadas y asincrónicas.
- El interceptor implementa los métodos que son llamados por EF Core con el SQL generado
Executingde enviarlo a la base de datos. Contrasta esto con los métodosExecuted, que se llaman después de que la llamada a la base de datos haya sido devuelta.
Al ejecutar el código de este ejemplo, se genera lo siguiente cuando se etiqueta una consulta:
-- Use hint: robust plan
SELECT [b].[Id], [b].[Name]
FROM [Blogs] AS [b] OPTION (ROBUST PLAN)
Por otro lado, cuando una consulta no está etiquetada, se envía a la base de datos sin modificar:
SELECT [b].[Id], [b].[Name]
FROM [Blogs] AS [b]
Ejemplo: Interceptación de conexión para la autenticación de SQL Azure mediante AAD
Sugerencia
Puede descargar el ejemplo del interceptor de conexión desde GitHub.
IDbConnectionInterceptor se puede usar para manipular DbConnection antes de que se use para conectarse a la base de datos. Esto se puede usar para obtener un token de acceso de Azure Active Directory (AAD). Por ejemplo:
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;
}
}
Sugerencia
Microsoft.Data.SqlClient ahora admite la autenticación de AAD a través de la cadena de conexión. Consulte SqlAuthenticationMethod para obtener más información.
Advertencia
Observe que el interceptor produce si se realiza una llamada de sincronización para abrir la conexión. Esto se debe a que no hay ningún método asincrónico para obtener el token de acceso y no hay ninguna manera universal y sencilla de llamar a un método asincrónico desde contexto no asincrónico sin riesgo de interbloqueo.
Advertencia
en algunas situaciones, es posible que el token de acceso no se almacene en caché automáticamente en el proveedor de tokens de Azure. Según el tipo de token solicitado, es posible que tenga que implementar aquí su propio almacenamiento en caché.
Ejemplo: Inicialización diferida de una cadena de conexión
Las cadenas de conexión suelen ser recursos estáticos leídos desde un archivo de configuración. Estos se pueden pasar fácilmente a UseSqlServer o a algo similar al configurar un DbContext. Sin embargo, a veces la cadena de conexión puede cambiar para cada instancia de contexto. Por ejemplo, cada inquilino de un sistema multiinquilino puede tener una cadena de conexión diferente.
IDbConnectionInterceptor Se puede usar para controlar las conexiones dinámicas y las cadenas de conexión. Esto comienza con la capacidad de configurar el DbContext sin ninguna cadena de conexión. Por ejemplo:
services.AddDbContext<CustomerContext>(
b => b.UseSqlServer());
Uno de los IDbConnectionInterceptor métodos se puede implementar para configurar la conexión antes de que se utilice.
ConnectionOpeningAsync es una buena opción, ya que puede realizar una operación asincrónica para obtener la cadena de conexión, buscar un token de acceso, etc. Por ejemplo, imagine un servicio cuyo ámbito está limitado a la solicitud actual que comprende el inquilino actual.
services.AddScoped<ITenantConnectionStringFactory, TestTenantConnectionStringFactory>();
Advertencia
Realizar una búsqueda asincrónica para una cadena de conexión, un token de acceso o similar cada vez que sea necesario puede ser muy lento. Considere la posibilidad de almacenar en caché estos elementos y actualizar periódicamente la cadena o el token almacenados en caché. Por ejemplo, los tokens de acceso a menudo se pueden usar durante un período de tiempo significativo antes de tener que actualizarse.
Esto se puede insertar en cada DbContext instancia mediante la inserción de constructores:
public class CustomerContext : DbContext
{
private readonly ITenantConnectionStringFactory _connectionStringFactory;
public CustomerContext(
DbContextOptions<CustomerContext> options,
ITenantConnectionStringFactory connectionStringFactory)
: base(options)
{
_connectionStringFactory = connectionStringFactory;
}
// ...
}
A continuación, este servicio se usa al construir la implementación del interceptor para el contexto:
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder.AddInterceptors(
new ConnectionStringInitializationInterceptor(_connectionStringFactory));
Por último, el interceptor usa este servicio para obtener la cadena de conexión de forma asincrónica y establecerla la primera vez que se usa la conexión:
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;
}
}
Nota:
La cadena de conexión solo se obtiene la primera vez que se usa una conexión. Después de eso, la cadena de conexión almacenada en DbConnection se usará sin buscar una nueva cadena de conexión.
Sugerencia
Este interceptor invalida el método no asincrónico para iniciar, ya que el servicio para obtener la cadena de conexión debe llamarse desde una ruta de acceso de código asincrónica ConnectionOpening.
Ejemplo: Interceptación avanzada de comandos para el almacenamiento en caché
Sugerencia
Puede descargar el ejemplo de interceptor de comandos avanzado de GitHub.
Los interceptores de EF Core pueden:
- Indique a EF Core que suprima la ejecución de la operación que se intercepta.
- Cambiar el resultado de la operación reportado a EF Core
En este ejemplo se muestra un interceptor que usa estas características para comportarse como una caché primitiva de segundo nivel. Los resultados de la consulta almacenadas en caché se devuelven para una consulta específica, lo que evita un recorrido de ida y vuelta de la base de datos.
Advertencia
Tenga cuidado al cambiar el comportamiento predeterminado de EF Core de esta manera. EF Core puede comportarse de maneras inesperadas si obtiene un resultado anómalo que no puede procesar correctamente. Además, en este ejemplo se muestran los conceptos del interceptor; no está pensado como plantilla para una implementación sólida de caché de segundo nivel.
En este ejemplo, la aplicación ejecuta con frecuencia una consulta para obtener el "mensaje diario" más reciente:
async Task<string> GetDailyMessage(DailyMessageContext context)
=> (await context.DailyMessages.TagWith("Get_Daily_Message").OrderBy(e => e.Id).LastAsync()).Message;
Esta consulta se etiqueta para que se pueda detectar fácilmente en el interceptor. La idea es consultar solo la base de datos de un nuevo mensaje una vez al día. En otras ocasiones, la aplicación usará un resultado almacenado en caché. (En el ejemplo se utiliza un retraso de 10 segundos para simular un nuevo día).
Estado del interceptor
Este interceptor es con estado: almacena el identificador y el texto del mensaje del mensaje diario más reciente consultado, además de la hora en que se ejecutó esa consulta. Debido a este estado, también necesitamos un bloqueo , ya que el almacenamiento en caché requiere que varias instancias de contexto usen el mismo interceptor.
private readonly object _lock = new object();
private int _id;
private string _message;
private DateTime _queriedAt;
Antes de la ejecución
En el Executing método (es decir, antes de realizar una llamada a una base de datos), el interceptor detecta la consulta etiquetada y, a continuación, comprueba si hay un resultado almacenado en caché. Si se encuentra este resultado, se suprime la consulta y se usan en su lugar los resultados almacenados en caché.
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);
}
Nota cómo el código llama InterceptionResult<TResult>.SuppressWithResult y pasa un reemplazo DbDataReader que contiene los datos en caché. A continuación, se devuelve este InterceptionResult, lo que causa la supresión de la ejecución de consultas. EF Core usa en su lugar el lector de reemplazo como resultado de la consulta.
Este interceptor también manipula el texto del comando. Esta manipulación no es necesaria, pero mejora la claridad en los mensajes de registro. No es necesario que el texto del comando sea SQL válido, debido a que la consulta no se va a ejecutar.
Después de la ejecución
Si no hay ningún mensaje almacenado en caché disponible o si ha expirado, el código anterior no suprime el resultado. Por lo tanto, EF Core ejecutará la consulta como normal. Volverá al método del interceptor después de la ejecución Executed. En este punto, si el resultado aún no es un lector almacenado en caché, el nuevo identificador de mensaje y la cadena se extrae del lector real y se almacena en caché listo para el siguiente uso de esta consulta.
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;
}
Demostración
El ejemplo de interceptor de almacenamiento en caché contiene una aplicación de consola sencilla que consulta los mensajes diarios para probar el almacenamiento en caché:
// 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;
Esto da como resultado la siguiente salida:
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
Observe en la salida del registro que la aplicación sigue usando el mensaje almacenado en caché hasta que expire el tiempo de espera, momento en el que la base de datos se consulta de nuevo para cualquier mensaje nuevo.
Ejemplo: Registro de estadísticas de consulta de SQL Server
En este ejemplo se muestran dos interceptores que funcionan conjuntamente para enviar estadísticas de consulta de SQL Server al registro de aplicaciones. Para generar las estadísticas, necesitamos un IDbCommandInterceptor para hacer dos cosas.
En primer lugar, el interceptor prefija los comandos con SET STATISTICS IO ON, lo que indica a SQL Server que envíe estadísticas al cliente después de que se haya consumido el conjunto de resultados.
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);
}
En segundo lugar, el interceptor implementará el método DataReaderClosingAsync, al que se llama una vez que el DbDataReader haya terminado de consumir resultados, pero antes de que se haya cerrado. Cuando SQL Server envía estadísticas, las coloca en un segundo resultado en el lector, por lo que, en este momento, el interceptor lee ese resultado mediante una llamada a NextResultAsync la que rellena las estadísticas en la conexión.
public override async ValueTask<InterceptionResult> DataReaderClosingAsync(
DbCommand command,
DataReaderClosingEventData eventData,
InterceptionResult result)
{
await eventData.DataReader.NextResultAsync();
return result;
}
El segundo interceptor es necesario para obtener las estadísticas de la conexión y escribirlas en el registrador de la aplicación. Para ello, usaremos un IDbConnectionInterceptor, implementando el ConnectionCreated método .
ConnectionCreated se llama inmediatamente después de que EF Core haya creado una conexión, por lo que se puede usar para realizar una configuración adicional de esa conexión. En este caso, el interceptor obtiene un ILogger y, a continuación, se enlaza al evento SqlConnection.InfoMessage para registrar los mensajes.
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
Solo se llama a los métodos ConnectionCreating y ConnectionCreated cuando EF Core crea un DbConnection. No se llamará si la aplicación crea DbConnection y la pasa a EF Core.
Filtrado por origen de comandos
El CommandEventData proporcionado para los orígenes de diagnóstico y los interceptores contiene una CommandSource propiedad que indica qué parte de EF era responsable de crear el comando. Esto se puede usar como filtro en el interceptor. Por ejemplo, es posible que deseemos un interceptor que solo se aplique a los comandos que proceden de 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;
}
}
Interceptación de SaveChanges
Sugerencia
Puede descargar el ejemplo del interceptor SaveChanges de GitHub.
SaveChanges y SaveChangesAsync son los puntos de interceptación definidos por la interfaz ISaveChangesInterceptor. En cuanto a otros interceptores, la SaveChangesInterceptor clase base con no-op métodos se proporciona como facilidad.
Sugerencia
Los interceptores son potentes. Sin embargo, en muchos casos puede ser más fácil invalidar el método SaveChanges o usar los eventos de .NET para SaveChanges expuestos en DbContext.
Ejemplo: Interceptación de SaveChanges para la auditoría
SaveChanges se puede interceptar para crear un registro de auditoría independiente de los cambios realizados.
Nota:
Esto no está pensado para ser una solución de auditoría sólida. En su lugar, se trata de un ejemplo simplista que se usa para mostrar las características de la interceptación.
Contexto de la aplicación
El ejemplo de auditoría usa un dbContext simple con blogs y publicaciones.
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; }
}
Observe que se registra una nueva instancia del interceptor para cada instancia de DbContext. Esto se debe a que el interceptor de auditoría contiene el estado vinculado a la instancia de contexto actual.
Contexto de auditoría
El ejemplo también contiene un segundo DbContext y un modelo que se usan para la base de datos de auditoría.
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; }
}
El interceptor
La idea general para la auditoría con el interceptor es:
- Se crea un mensaje de auditoría al inicio del proceso SaveChanges y se escribe en la base de datos de auditoría.
- SaveChanges puede continuar
- Si SaveChanges se realiza correctamente, el mensaje de auditoría se actualiza para indicar que se ha realizado correctamente.
- Si se produce un error en SaveChanges, el mensaje de auditoría se actualiza para indicar el error.
La primera fase se controla antes de que los cambios se envíen a la base de datos mediante invalidaciones de ISaveChangesInterceptor.SavingChanges y 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 invalidación de métodos sincronizados y asincrónicos garantiza que la auditoría se produzca independientemente de si se llama a SaveChanges o a SaveChangesAsync. Observe también que la sobrecarga asincrónica es capaz de realizar E/S asincrónica sin bloqueo en la base de datos de auditoría. Es posible que desee lanzar desde el método de sincronización SavingChanges para asegurarse de que toda la E/S de la base de datos sea asincrónica. Esto requiere que la aplicación siempre llame SaveChangesAsync y nunca SaveChanges.
Mensaje de auditoría
Cada método interceptor tiene un eventData parámetro que proporciona información contextual sobre el evento que se intercepta. En este caso, la aplicación actual DbContext se incluye en los datos del evento, que luego se usa para crear un mensaje de auditoría.
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}' ");
}
El resultado es una SaveChangesAudit entidad con una colección de EntityAudit entidades, una para cada inserción, actualización o eliminación. A continuación, el interceptor inserta estas entidades en la base de datos de auditoría.
Sugerencia
ToString se sobrescribe en cada clase de datos de eventos de EF Core para crear el mensaje de registro equivalente del evento. Por ejemplo, la llamada a ContextInitializedEventData.ToString genera "Entity Framework Core 5.0.0 inicializado 'BlogsContext' mediante el proveedor 'Microsoft.EntityFrameworkCore.Sqlite' con opciones: Ninguna".
Detección de éxito
La entidad de auditoría se almacena en el interceptor para que se pueda acceder nuevamente tras el éxito o fallo de SaveChanges. Para lograr el éxito, se llama a 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;
}
La entidad de auditoría está adjunta al contexto de auditoría, ya que ya existe en la base de datos y necesita ser actualizada. A continuación, establecemos Succeeded y EndTime, que marca estas propiedades como modificadas para que SaveChanges envíe una actualización a la base de datos de auditoría.
Detección de errores
El error se maneja de la misma manera que el éxito, pero en el método ISaveChangesInterceptor.SaveChangesFailed o ISaveChangesInterceptor.SaveChangesFailedAsync. Los datos del evento contienen la excepción que se produjo.
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);
}
Demostración
El ejemplo de auditoría contiene una aplicación de consola sencilla que realiza cambios en la base de datos del blog y, a continuación, muestra la auditoría creada.
// 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}");
}
}
}
El resultado muestra el contenido de la base de datos de auditoría:
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'.
Ejemplo: Interceptación de simultaneidad optimista
EF Core admite el patrón de simultaneidad optimista comprobando que el número de filas realmente afectados por una actualización o eliminación es el mismo que el número de filas que se espera que se vean afectados. A menudo, esto se combina con un token de simultaneidad; es decir, un valor de columna que solo coincidirá con su valor esperado si la fila no se ha actualizado desde que se leyó el valor esperado.
EF indica una infracción de la simultaneidad optimista lanzando un DbUpdateConcurrencyException.
ISaveChangesInterceptor tiene métodos ThrowingConcurrencyException y ThrowingConcurrencyExceptionAsync que se llaman antes de que DbUpdateConcurrencyException se lance. Estos puntos de interceptación permiten suprimir la excepción, posiblemente junto con cambios asincrónicos en la base de datos para resolver la infracción.
Por ejemplo, si dos solicitudes intentan eliminar la misma entidad casi al mismo tiempo, la segunda eliminación puede producir un error porque la fila de la base de datos ya no existe. Esto puede ser correcto: el resultado final es que la entidad se ha eliminado de todos modos. El interceptor siguiente muestra cómo se puede hacer esto:
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));
}
Hay varias cosas que merece la pena tener en cuenta sobre este interceptor:
- Se implementan los métodos de interceptación sincrónica y asincrónica. Esto es importante si la aplicación puede llamar a
SaveChangesoSaveChangesAsync. Sin embargo, si todo el código de aplicación es asincrónico, soloThrowingConcurrencyExceptionAsyncdebe implementarse. Del mismo modo, si la aplicación nunca usa métodos de base de datos asincrónicos, soloThrowingConcurrencyExceptiondebe implementarse. Esto suele ser cierto para todos los interceptores con métodos sincronizados y asincrónicos. - El interceptor tiene acceso a EntityEntry objetos para las entidades que se están guardando. En este caso, se utiliza para verificar si se está produciendo una violación de concurrencia durante una operación de eliminación.
- Si la aplicación usa un proveedor de bases de datos relacionales, el ConcurrencyExceptionEventData objeto se puede convertir en un RelationalConcurrencyExceptionEventData objeto. Esto proporciona información adicional específica sobre la operación de la base de datos relacional que se está realizando. En este caso, el texto del comando relacional se imprime en la consola.
- Devolver
InterceptionResult.Suppress()indica a EF Core que suprima la acción que estaba a punto de realizar; en este caso, lanzar elDbUpdateConcurrencyException. Esta capacidad para cambiar el comportamiento de EF Core, en lugar de simplemente observar lo que hace EF Core, es una de las características más eficaces de los interceptores.
Intercepción de materialización
IMaterializationInterceptor admite la interceptación antes y después de crear una instancia de entidad y antes y después de inicializar las propiedades de esa instancia. El interceptor puede cambiar o reemplazar la instancia de entidad en cualquier punto. Esto permite:
- Establecer propiedades no asignadas o métodos de llamada necesarios para la validación, los valores calculados o las marcas.
- Uso de un generador para crear instancias.
- La creación de una instancia de entidad que sea diferente a la que EF crearía normalmente, por ejemplo, una instancia de una memoria caché o de un tipo de proxy.
- Insertar servicios en una instancia de entidad.
Nota:
IMaterializationInterceptor es un interceptor singleton, lo que significa que una sola instancia se comparte entre todas las DbContext instancias.
Ejemplo: Acciones simples en la creación de entidades
Imagine que queremos realizar un seguimiento del tiempo en que se recuperó una entidad de la base de datos, quizás para que se pueda mostrar a un usuario editando los datos. Para ello, primero definimos una interfaz:
public interface IHasRetrieved
{
DateTime Retrieved { get; set; }
}
El uso de una interfaz es común con interceptores, ya que permite que el mismo interceptor funcione con muchos tipos de entidad diferentes. Por ejemplo:
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; }
}
Observe que el [NotMapped] atributo se usa para indicar que esta propiedad solo se usa mientras se trabaja con la entidad y no debe conservarse en la base de datos.
A continuación, el interceptor debe implementar el método adecuado desde IMaterializationInterceptor y establecer el tiempo recuperado:
public class SetRetrievedInterceptor : IMaterializationInterceptor
{
public object InitializedInstance(MaterializationInterceptionData materializationData, object instance)
{
if (instance is IHasRetrieved hasRetrieved)
{
hasRetrieved.Retrieved = DateTime.UtcNow;
}
return instance;
}
}
Se registra una instancia de este interceptor al configurar el 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");
}
Sugerencia
Este interceptor no tiene estado, lo que es común, por lo que se crea y se comparte una sola instancia entre todas las instancias DbContext.
Ahora, cada vez que se consulta la base de datos desde Customer, la propiedad Retrieved se establecerá automáticamente. Por ejemplo:
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()}'");
}
Genera la salida:
Customer 'Alice' was retrieved at '9/22/2022 5:25:54 PM'
Ejemplo: Inserción de servicios en entidades
EF Core ya tiene compatibilidad integrada para la inserción de algunos servicios especiales en instancias de contexto. Por ejemplo, consulte Carga diferida sin servidores proxy, que funciona insertando el servicio ILazyLoader.
IMaterializationInterceptor Se puede usar para generalizar esto en cualquier servicio. En el ejemplo siguiente se muestra cómo insertar una clase ILogger en entidades de modo que puedan realizar su propio registro.
Nota:
La inserción de servicios en entidades enlaza esos tipos de entidad a los servicios insertados, que algunas personas consideran que es un antipatrón.
Como antes, se usa una interfaz para definir lo que se puede hacer.
public interface IHasLogger
{
ILogger? Logger { get; set; }
}
Y los tipos de entidad que registrarán deben implementar esta interfaz. Por ejemplo:
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; }
}
Esta vez, el interceptor debe implementar IMaterializationInterceptor.InitializedInstance, el cual se ejecuta después de que se haya creado cada instancia de entidad y se hayan inicializado sus valores de propiedad. El interceptor obtiene un ILogger del contexto e inicializa IHasLogger.Logger con él:
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;
}
}
Esta vez se usa una nueva instancia del interceptor para cada instancia de DbContext, ya que el objeto ILogger obtenido puede cambiar para cada instancia de DbContext, y ILogger se almacena en caché en el interceptor.
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder.AddInterceptors(new LoggerInjectionInterceptor());
Ahora, siempre que Customer.PhoneNumber se cambie, este cambio se registrará en el registro de la aplicación. Por ejemplo:
info: CustomersLogger[1]
Updating phone number for 'Alice' from '+1 515 555 0123' to '+1 515 555 0125'.
Interceptación de expresiones de consulta
IQueryExpressionInterceptor permite la interceptación del árbol de expresiones LINQ para una consulta antes de compilarse. Esto se puede usar para modificar dinámicamente las consultas de maneras que se aplican a través de la aplicación.
Nota:
IQueryExpressionInterceptor es un interceptor singleton, lo que significa que una sola instancia se comparte normalmente entre todas las DbContext instancias.
Advertencia
Los interceptores son poderosos, pero es fácil cometer errores al trabajar con árboles de expresión. Considere siempre si hay una manera más sencilla de lograr lo que desea, como modificar la consulta directamente.
Ejemplo: Incorpora ordenamiento en las consultas para una clasificación estable
Considere un método que devuelva una página de clientes:
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();
}
Sugerencia
Esta consulta usa el EF.Property método para especificar la propiedad por la que se va a ordenar. Esto permite que la aplicación pase dinámicamente el nombre de propiedad, lo que permite ordenar por cualquier propiedad del tipo de entidad. Tenga en cuenta que la ordenación por columnas no indexadas puede ser lenta.
Esto funcionará bien siempre que la propiedad utilizada para ordenar siempre devuelva una ordenación estable. Pero esto no siempre puede ser el caso. Por ejemplo, la consulta LINQ anterior genera lo siguiente en SQLite al ordenar por 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
Si hay varios clientes con el mismo City, la ordenación de esta consulta no es estable. Esto podría dar lugar a resultados faltantes o duplicados a medida que el usuario navega a través de los datos.
Una manera común de solucionar este problema es realizar una ordenación secundaria por clave principal. Sin embargo, en lugar de agregarlo manualmente a cada consulta, un interceptor puede agregar dinámicamente la ordenación secundaria. Para facilitar esto, definimos una interfaz para cualquier entidad que tenga una clave principal de entero:
public interface IHasIntKey
{
int Id { get; }
}
Esta interfaz se implementa mediante los tipos de entidad de interés:
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; }
}
A continuación, necesitamos un interceptor que implemente 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);
}
}
}
Esto probablemente se ve bastante complicado---y lo es! Trabajar con árboles de expresión normalmente no es fácil. Echemos un vistazo a lo que está sucediendo:
Fundamentalmente, el interceptor encapsula un ExpressionVisitor. El visitante sobrescribe VisitMethodCall, el cual será llamado cada vez que haya una llamada a un método en el árbol de expresiones de consulta.
El visitante comprueba si se trata de una llamada al método OrderBy en el que estamos interesados.
Si es así, el visitante comprueba aún más si la llamada al método genérico es para un tipo que implementa nuestra
IHasIntKeyinterfaz.En este punto sabemos que la llamada al método es de la forma
OrderBy(e => ...). Extraemos la expresión lambda de esta llamada y obtenemos el parámetro usado en esa expresión, es decir, ele.Ahora construimos un nuevo MethodCallExpression con el método constructor Expression.Call. En este caso, el método al que se llama es
ThenBy(e => e.Id). Construimos esto utilizando el parámetro extraído anteriormente y un acceso a la propiedadIdde la interfazIHasIntKey.La entrada en esta llamada es la original
OrderBy(e => ...)y, por tanto, el resultado final es una expresión paraOrderBy(e => ...).ThenBy(e => e.Id).Esta expresión modificada se devuelve del visitante, lo que significa que la consulta LINQ se ha modificado correctamente para incluir una llamada a
ThenBy.EF Core continúa y compila esta expresión de consulta en el SQL adecuado para la base de datos que se usa.
El registro de este interceptor y la GetPageOfCustomers ejecución ahora generan el siguiente código SQL:
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
Esto siempre producirá una ordenación estable, incluso si hay varios clientes con el mismo City.
En muchos casos, se puede lograr lo mismo simplemente modificando la consulta directamente. Por ejemplo:
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();
}
En este caso, simplemente ThenBy se agrega a la consulta. Sí, es posible que tenga que realizarse por separado en todas las consultas, pero es sencillo, fácil de entender y siempre funcionará.
Interceptación de resolución de identidades
IIdentityResolutionInterceptor permite la interceptación de conflictos en la resolución de identidades cuando el DbContext comienza a seguir nuevas instancias de entidad.
Nota:
Actualmente, este interceptor solo se llama cuando se usan DbContext.Update, DbContext.Attach y métodos similares para rastrear las entidades que ya se está siguiendo con la misma clave. No se solicitan para las entidades devueltas de las consultas. Esto puede cambiar en una versión futura; consulte este problema.
Una instancia de DbContext solo puede realizar un seguimiento de una instancia de entidad con un valor de clave principal determinado. Esto significa que se deben resolver varias instancias de una entidad con el mismo valor de clave en una sola instancia. Un interceptor de este tipo recibe la instancia existente que se está rastreando y la nueva instancia, y debe incorporar cualquier valor de propiedad y cambios en las relaciones desde la nueva instancia a la instancia existente. A continuación, se descarta la nueva instancia.
EF Core proporciona una implementación integrada, , UpdatingIdentityResolutionInterceptorque actualiza la entidad de seguimiento existente con valores de la nueva instancia. Esto se puede registrar al configurar el contexto:
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder
.AddInterceptors(new UpdatingIdentityResolutionInterceptor());
Para implementar la lógica de resolución de identidades personalizada, cree una clase que implemente IIdentityResolutionInterceptor e invalide el UpdateTrackedInstance método :
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);
}
}