Примечание.
Для доступа к этой странице требуется авторизация. Вы можете попробовать войти или изменить каталоги.
Для доступа к этой странице требуется авторизация. Вы можете попробовать изменить каталоги.
Перехватчики Entity Framework Core (EF Core) позволяют перехватывать, изменять и/или подавлять операции EF Core. Это включает низкоуровневые операции базы данных, такие как выполнение команды, а также операции более высокого уровня, такие как вызовы SaveChanges.
Перехватчики отличаются от ведения журнала и диагностики тем, что они позволяют изменять либо подавлять перехватываемую операцию. Простое ведение журнала или Microsoft.Extensions.Logging лучше всего подходит для ведения журнала.
Перехватчики регистрируются для каждого экземпляра DbContext при настройке контекста. Используйте прослушиватель диагностики для получения той же информации, но для всех экземпляров DbContext, участвующих в процессе.
Доступные перехватчики
В следующей таблице показаны доступные интерфейсы перехватчика:
| Перехватчик | Операции перехвачены | Синглтон |
|---|---|---|
| IDbCommandInterceptor | Создание команд Выполнение команд Сбои команд Удаление DbDataReader команды |
нет |
| IDbConnectionInterceptor | Открытие и закрытие соединений Создание соединений Ошибки подключения |
нет |
| IDbTransactionInterceptor | Создание транзакций Использование существующих транзакций Фиксация транзакций Откат транзакций Создание и использование контрольных точекСбои транзакций |
нет |
| ISaveChangesInterceptor | СохранениеИзменений/ИзмененияСохранены ОшибкаСохраненияИзменений Оптимистичное управление параллелизмом |
нет |
| IMaterializationInterceptor | Создание, инициализация и финализация экземпляров сущностей из результатов запроса | Да |
| IQueryExpressionInterceptor | Изменение дерева выражений LINQ перед компиляцией запроса | Да |
| IIdentityResolutionInterceptor | Разрешение конфликтов идентичности при отслеживании сущностей | Да |
Регистрация перехватчиков
Перехватчики регистрируются с помощью AddInterceptors при настройке экземпляра DbContext. Обычно это делается при переопределении DbContext.OnConfiguring. Рассмотрим пример.
public class ExampleContext : BlogsContext
{
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder.AddInterceptors(new TaggedQueryCommandInterceptor());
}
Кроме того, AddInterceptors можно вызывать как часть AddDbContext или при создании экземпляра DbContextOptions для передачи в конструктор DbContext.
Подсказка
Метод OnConfiguring по-прежнему вызывается, когда используется AddDbContext или экземпляр DbContextOptions передается в конструктор DbContext. Это делает его идеальным местом для применения конфигурации контекста независимо от того, как создается DbContext.
Перехватчики часто являются бессерверными, что означает, что для всех экземпляров DbContext можно использовать один экземпляр перехватчика. Рассмотрим пример.
public class TaggedQueryCommandInterceptorContext : BlogsContext
{
private static readonly TaggedQueryCommandInterceptor _interceptor
= new TaggedQueryCommandInterceptor();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder.AddInterceptors(_interceptor);
}
Каждый экземпляр перехватчика должен реализовывать один или несколько интерфейсов, производных от IInterceptor. Каждый экземпляр должен быть зарегистрирован только один раз, даже если он реализует несколько интерфейсов перехвата; EF Core перенаправит события для каждого интерфейса соответствующим образом.
Перехватчики singleton
Некоторые перехватчики реализуют ISingletonInterceptor (см. таблицу выше); эти перехватчики регистрируются как одноэлементные службы во внутреннем поставщике услуг EF Core, что означает, что один экземпляр используется для всех экземпляров DbContext, использующих одного и того же поставщика услуг.
Так как одинарные перехватчики становятся частью внутренней конфигурации службы EF Core, каждый отдельный экземпляр перехватчика приводит к построению нового внутреннего поставщика услуг. Передача нового экземпляра одиночного перехватчика каждый раз при DbContext настройке, например, в AddDbContext, в конечном итоге вызовет ManyServiceProvidersCreatedWarning и приведет к снижению производительности.
Предупреждение
Всегда используйте один и тот же экземпляр перехватчика для всех экземпляров DbContext. Не создавайте новый экземпляр каждый раз при настройке контекста.
Например, следующее неправильно , так как для каждой конфигурации контекста создается новый экземпляр перехватчика:
// 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()));
Вместо этого повторно используйте тот же экземпляр:
// Correct: reuse a single interceptor instance
var interceptor = new MyMaterializationInterceptor();
services.AddDbContext<CustomerContext>(
b => b.UseSqlServer(connectionString)
.AddInterceptors(interceptor));
Или используйте статическое поле:
public class CustomerContext : DbContext
{
private static readonly MyMaterializationInterceptor _interceptor = new();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder.AddInterceptors(_interceptor);
}
Так как эти перехватчики являются одноэлементными, они должны быть потокобезопасны. Как правило, они не должны содержать изменяемое состояние. Если вам нужно получить доступ к службам с ограниченной областью (например, текущим DbContext), используйте Context или аналогичные свойства в данных события, передаваемых каждым методом перехватчика.
Перехват базы данных
Замечание
Перехват базы данных доступен только для поставщиков реляционных баз данных.
Перехват базы данных низкого уровня разделен на три интерфейса, показанные в следующей таблице.
| Перехватчик | Операции с базой данных перехвачены |
|---|---|
| IDbCommandInterceptor | Создание команд Выполнение команд Сбои команд Удаление DbDataReader команды |
| IDbConnectionInterceptor | Открытие и закрытие соединений Создание соединений Ошибки подключения |
| IDbTransactionInterceptor | Создание транзакций Использование существующих транзакций Фиксация транзакций Откат транзакций Создание и использование контрольных точекСбои транзакций |
Базовые классы DbCommandInterceptor, DbConnectionInterceptor, и DbTransactionInterceptor содержат no-op реализации для каждого метода в соответствующем интерфейсе. Используйте базовые классы, чтобы избежать необходимости реализации неиспользуемых методов перехвата.
Методы каждого типа перехватчика организованы в пары, где первый метод вызывается перед началом операции с базой данных, а второй вызывается после ее завершения. Например, DbCommandInterceptor.ReaderExecuting вызывается перед выполнением запроса и DbCommandInterceptor.ReaderExecuted вызывается после отправки запроса в базу данных.
Каждая пара методов имеет синхронные и асинхронные вариации. Это позволяет асинхронным операциям ввода-вывода, таким как запрос маркера доступа, происходить в рамках перехвата асинхронной операции базы данных.
Пример: Командный перехват для добавления подсказок к запросу
Подсказка
Вы можете загрузить пример перехватчика команд с GitHub.
Можно IDbCommandInterceptor использовать для изменения SQL перед отправкой в базу данных. В этом примере показано, как изменить SQL для включения указания запроса.
Часто самая сложная часть перехвата определяет, когда команда соответствует запросу, который необходимо изменить. Анализ SQL является одним из вариантов, но, как правило, хрупким. Другим вариантом является использование тегов запросов EF Core для маркировки каждого запроса, который следует изменить. Рассмотрим пример.
var blogs1 = await context.Blogs.TagWith("Use hint: robust plan").ToListAsync();
Затем этот тег можно обнаружить в перехватчике, так как он всегда будет включен в качестве комментария в первой строке текста команды. При обнаружении тега sql-запрос изменяется, чтобы добавить соответствующее указание:
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)";
}
}
}
Примечание.
- Перехватчик наследует от DbCommandInterceptor, чтобы не нужно было реализовывать каждый метод в интерфейсе перехватчика.
- Перехватчик реализует как синхронные, так и асинхронные методы. Это гарантирует, что для синхронизации и асинхронных запросов применяется та же подсказка запроса.
- Перехватчик реализует методы
Executing, которые вызываются EF Core с созданным SQL-кодом перед отправкой его в базу данных. Сравните это сExecutedметодами, которые вызываются после возврата вызова базы данных.
Выполнение кода в этом примере создает следующее при теге запроса:
-- Use hint: robust plan
SELECT [b].[Id], [b].[Name]
FROM [Blogs] AS [b] OPTION (ROBUST PLAN)
С другой стороны, если запрос не помечен, он отправляется в базу данных без изменений:
SELECT [b].[Id], [b].[Name]
FROM [Blogs] AS [b]
Пример. Перехват подключений для проверки подлинности SQL Azure с помощью AAD
Подсказка
Вы можете скачать пример перехватчика подключений из GitHub.
IDbConnectionInterceptor можно использовать для управления DbConnection перед его использованием для подключения к базе данных. Это можно использовать для получения токена доступа Azure Active Directory (AAD). Рассмотрим пример.
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;
}
}
Подсказка
Microsoft.Data.SqlClient теперь поддерживает проверку подлинности AAD через строку подключения. См. SqlAuthenticationMethod для получения дополнительной информации.
Предупреждение
Обратите внимание, что перехватчик выдает ошибку, если при открытии подключения выполняется синхронный вызов. Это связано с тем, что нет асинхронного метода для получения маркера доступа и нет универсального и простого способа вызова асинхронного метода из несинхронного контекста без риска взаимоблокировки.
Предупреждение
В некоторых ситуациях маркер доступа может не кэшироваться автоматически поставщиком маркеров Azure. В зависимости от типа запрошенного маркера, здесь может потребоваться реализовать собственное кэширование.
Пример: отложенная инициализация строки подключения
Строки подключения часто являются статическими ресурсами, считываемыми из файла конфигурации. Их можно легко передать в UseSqlServer или аналогичное при настройке DbContext. Однако иногда строка подключения может изменяться для каждого экземпляра контекста. Например, каждый арендатор в мультитенантной системе может иметь разную строку подключения.
IDbConnectionInterceptor Можно использовать для обработки динамических подключений и строк подключения. Это начинается с возможности настройки DbContext без каких-либо строки подключения. Рассмотрим пример.
services.AddDbContext<CustomerContext>(
b => b.UseSqlServer());
Затем можно реализовать один из IDbConnectionInterceptor методов, чтобы настроить подключение перед его использованием.
ConnectionOpeningAsync является хорошим выбором, так как он может выполнять асинхронную операцию для получения строки подключения, поиска токена доступа и т. д. Например, представьте службу, охватывающую текущий запрос и понимающую текущего арендатора.
services.AddScoped<ITenantConnectionStringFactory, TestTenantConnectionStringFactory>();
Предупреждение
Выполнение асинхронного поиска для строки подключения, маркера доступа или аналогичного каждый раз, когда это необходимо, может быть очень медленным. Рассмотрите возможность кэширования этих вещей и периодически обновляйте кэшированную строку или маркер. Например, маркеры доступа часто можно использовать в течение значительного периода времени перед обновлением.
Это можно внедрить в каждый DbContext экземпляр с помощью внедрения конструктора:
public class CustomerContext : DbContext
{
private readonly ITenantConnectionStringFactory _connectionStringFactory;
public CustomerContext(
DbContextOptions<CustomerContext> options,
ITenantConnectionStringFactory connectionStringFactory)
: base(options)
{
_connectionStringFactory = connectionStringFactory;
}
// ...
}
Затем эта служба используется при создании реализации перехватчика для контекста:
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder.AddInterceptors(
new ConnectionStringInitializationInterceptor(_connectionStringFactory));
Наконец, перехватчик использует эту службу для асинхронного получения строки подключения и устанавливает её при первом использовании подключения.
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;
}
}
Замечание
Строка подключения получается только в момент первого использования подключения. После этого момента строка подключения, хранящаяся на нёмDbConnection, будет использоваться без поиска новой строки подключения.
Подсказка
Этот перехватчик переопределяет асинхронный метод, который необходимо вызвать, так как служба для получения строка подключения должна вызываться из асинхронного ConnectionOpening пути кода.
Пример. Расширенный перехват команд для кэширования
Подсказка
Вы можете скачать пример расширенного перехватчика команд из GitHub.
Перехватчики EF Core могут:
- Сообщите EF Core, чтобы отключить выполнение операции, перехватываемой
- Измените результат операции, сообщаемый обратно в EF Core
В этом примере показан перехватчик, использующий эти функции для поведения как примитивный кэш второго уровня. Кэшированные результаты запроса возвращаются для определенного запроса, избегая обхода базы данных.
Предупреждение
Будьте осторожны при изменении поведения по умолчанию в EF Core таким образом. EF Core может вести себя неожиданно, если он получает ненормальный результат, который он не может правильно обрабатывать. Кроме того, в этом примере демонстрируются концепции перехватчиков; он не предназначен в качестве шаблона для надежной реализации кэша второго уровня.
В этом примере приложение часто выполняет запрос, чтобы получить последнее "ежедневное сообщение":
async Task<string> GetDailyMessage(DailyMessageContext context)
=> (await context.DailyMessages.TagWith("Get_Daily_Message").OrderBy(e => e.Id).LastAsync()).Message;
Этот запрос помечен так, чтобы его можно было легко обнаружить в перехватчике. Идея заключается в том, чтобы запрашивать базу данных для нового сообщения только один раз в день. В других случаях приложение будет использовать кэшированный результат. (В примере используется задержка в течение 10 секунд, чтобы имитировать новый день.)
Состояние перехватчика
Этот перехватчик является состоянием: он сохраняет идентификатор и текст последнего ежедневного сообщения, по которому был выполнен запрос, а также время выполнения этого запроса. Из-за этого состояния нам также нужен замок, поскольку кэширование требует, чтобы один и тот же перехватчик использовался в нескольких экземплярах контекста.
private readonly object _lock = new object();
private int _id;
private string _message;
private DateTime _queriedAt;
Перед выполнением
В методе Executing (т. е. перед вызовом базы данных) перехватчик обнаруживает помеченный запрос, а затем проверяет наличие кэшированного результата. Если такой результат найден, запрос подавляется и кэшируются результаты вместо него.
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);
}
Обратите внимание, как код вызывает InterceptionResult<TResult>.SuppressWithResult и передает замену DbDataReader , содержащую кэшированные данные. Этот результат перехвата затем возвращается, что вызывает подавление выполнения запроса. Заменяющий ридер вместо этого используется EF Core для предоставления результатов запроса.
Этот перехватчик также изменяет текст команды. Эта манипуляция не требуется, но улучшает ясность в сообщениях журнала. Текст команды не должен быть допустимым SQL, так как запрос теперь не будет выполнен.
После выполнения
Если кэшированное сообщение недоступно или истекло, приведенный выше код не подавляет результат. Следовательно, EF Core будет выполнять запрос как обычный. Затем, после выполнения, он вернется к методу перехватчика Executed. На этом этапе, если результат еще не является кэшируемым средством чтения, новый идентификатор сообщения и строка извлекаются из реального средства чтения и кэшируются для следующего использования этого запроса.
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;
}
Демонстрация
Пример использования перехватчика кэширования включает простое консольное приложение, которое отправляет запросы на получение ежедневных сообщений для тестирования действия кэширования:
// 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;
Результат должен быть таким:
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
Обратите внимание на выходные данные журнала, что приложение продолжает использовать кэшированное сообщение до истечения срока ожидания, после чего база данных запрашивается повторно для любого нового сообщения.
Пример. Ведение журнала статистики запросов SQL Server
В этом примере показаны два перехватчика, которые работают вместе для отправки статистики запросов SQL Server в журнал приложений. Чтобы сгенерировать статистику, нам необходимо IDbCommandInterceptor для выполнения двух действий.
Во-первых, перехватчик добавит к командам префикс SET STATISTICS IO ON, который сообщает SQL Server отправить статистику клиенту после обработки результирующего набора.
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);
}
Во-вторых, перехватчик реализует DataReaderClosingAsync метод, который вызывается после того как DbDataReader завершит использование результатов, но до его закрытия. Когда SQL Server отправляет статистику, она помещается во второй результат в объекте чтения, поэтому на этом этапе перехватчик считывает этот результат, вызывая NextResultAsync, который добавляет статистику в подключение.
public override async ValueTask<InterceptionResult> DataReaderClosingAsync(
DbCommand command,
DataReaderClosingEventData eventData,
InterceptionResult result)
{
await eventData.DataReader.NextResultAsync();
return result;
}
Второй перехватчик необходим для получения статистики из соединения и вывода их в журнал приложения. Для этого мы будем использовать IDbConnectionInterceptor, реализуя метод ConnectionCreated.
ConnectionCreated вызывается сразу после создания подключения EF Core, поэтому его можно использовать для выполнения дополнительной настройки этого подключения. В этом случае перехватчик получает ILogger и затем подключается к SqlConnection.InfoMessage событию, чтобы вести журнал сообщений.
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;
}
Это важно
Методы ConnectionCreating и ConnectionCreated вызываются только при создании DbConnection EF Core. Они не будут вызываться, если приложение создает DbConnection и передает его в EF Core.
Фильтрация по источнику команд
Поставляемые источникам диагностики и перехватчикам CommandEventData содержат свойство CommandSource, указывающее, какая часть EF отвечает за создание команды. Это можно использовать в качестве фильтра в перехватчике. Например, может потребоваться перехватчик, который применяется только к командам, поступающим из 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;
}
}
Перехват функции SaveChanges
Подсказка
Вы можете скачать пример перехватчика SaveChanges из GitHub.
SaveChanges и SaveChangesAsync точки перехвата определяются интерфейсом ISaveChangesInterceptor . Что касается других перехватчиков, базовый класс SaveChangesInterceptor с методами no-op предоставляется для удобства.
Подсказка
Перехватчики мощны. Однако во многих случаях может быть проще переопределить метод SaveChanges или использовать события .NET для SaveChanges , предоставляемые в DbContext.
Пример: перехватчик SaveChanges для проверки
SaveChanges можно интерцептировать для создания независимой аудиторской записи о внесенных изменениях.
Замечание
Это не предназначено для надежного решения аудита. Скорее это простой пример, используемый для демонстрации функций перехвата.
Контекст приложения
Образец для аудита использует простой DbContext с блогами и публикациями.
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; }
}
Обратите внимание, что для каждого экземпляра DbContext создается и регистрируется новый объект перехватчика. Это связано с тем, что перехватчик аудита содержит состояние, связанное с текущим экземпляром контекста.
Контекст аудита
Пример также содержит вторую базу данных DbContext и модель, используемую для базы данных аудита.
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; }
}
Перехватчик
Идея использования перехватчика для аудита заключается в следующем:
- Сообщение аудита создается в начале SaveChanges и записывается в базу данных аудита.
- Разрешено продолжить выполнение SaveChanges.
- Если SaveChanges успешно выполнена, сообщение аудита обновляется, чтобы указать успешность
- Если выполнение SaveChanges завершается ошибкой, сообщение аудита обновляется, чтобы указать на провал.
Первый этап обрабатывается перед отправкой изменений в базу данных с использованием переопределений ISaveChangesInterceptor.SavingChanges и 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;
}
Переопределение как синхронных, так и асинхронных методов гарантирует, что аудит будет выполняться независимо от того, вызывается ли SaveChanges или SaveChangesAsync. Обратите внимание, что асинхронная перегрузка сама по себе может выполнять неблокирующие асинхронные операции ввода-вывода в базу данных аудита. Возможно, вы хотите выдать исключение из синхронного метода SavingChanges, чтобы убедиться, что все операции ввода-вывода базы данных асинхронны. Затем это требует, чтобы приложение всегда вызывало SaveChangesAsync и никогда не вызывало SaveChanges.
Сообщение аудита
Каждый метод перехватчика имеет eventData параметр, предоставляющий контекстную информацию о перехватываемом событии. В этом случае текущее приложение DbContext включается в данные события, которые затем используются для создания сообщения аудита.
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}' ");
}
Результатом является сущность SaveChangesAudit с коллекцией сущностей EntityAudit, каждая из которых соответствует вставке, обновлению или удалению. Затем перехватчик вставляет эти сущности в базу данных аудита.
Подсказка
ToString переопределяется в каждом классе данных событий EF Core, чтобы создать эквивалентное сообщение журнала для события. Например, вызов ContextInitializedEventData.ToString генерирует сообщение "Entity Framework Core 5.0.0 инициализировал 'BlogsContext' с помощью поставщика 'Microsoft.EntityFrameworkCore.Sqlite' с параметрами: Никакие".
Обнаружение успеха
Сущность аудита хранится на перехватчике, чтобы к ней можно было снова получить доступ после завершения SaveChanges, будь то успешное завершение или сбой. Для достижения успеха вызывается ISaveChangesInterceptor.SavedChanges или 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;
}
Сущность аудита присоединена к контексту аудита, так как она уже существует в базе данных и должна быть обновлена. Затем мы задали Succeeded и EndTime, пометили эти свойства как измененные, таким образом SaveChanges отправит обновление в базу данных аудита.
Обнаружение сбоя
Сбой обрабатывается почти так же, как и успех, но в методах ISaveChangesInterceptor.SaveChangesFailed или ISaveChangesInterceptor.SaveChangesFailedAsync. Данные события содержат исключение, которое было вызвано.
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);
}
Демонстрация
Пример аудита содержит простое консольное приложение, которое вносит изменения в базу данных блогов, а затем отображает созданный аудит.
// 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}");
}
}
}
Результат показывает содержимое базы данных аудита:
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'.
Пример: перехват оптимистического параллелизма
EF Core поддерживает шаблон оптимистичной конкуренции, проверяя, что количество строк, фактически подвергшихся изменению обновлением или удалением, совпадает с количеством ожидаемых затронутых строк. Это часто связано с токеном параллельного доступа; то есть значение столбца, которое будет соответствовать только ожидаемому значению, если строка не была обновлена после считывания ожидаемого значения.
EF сигнализирует о нарушении оптимистического параллелизма, вызывая DbUpdateConcurrencyExceptionисключение.
ISaveChangesInterceptor имеет методы ThrowingConcurrencyException и ThrowingConcurrencyExceptionAsync, которые вызываются перед тем, как DbUpdateConcurrencyException выбрасывается. Эти точки перехвата позволяют подавлять исключение, возможно, в сочетании с асинхронными изменениями базы данных для устранения нарушения.
Например, если два запроса пытаются удалить одну и ту же сущность почти одновременно, то второе удаление может завершиться неудачей, так как запись в базе данных больше не существует. Это может быть нормально -- итог заключается в том, что сущность была удалена в любом случае. Следующий перехватчик демонстрирует, как это можно сделать:
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));
}
Следует отметить несколько вещей, которые касаются этого перехватчика:
- Реализованы синхронные и асинхронные методы перехвата. Это важно, если приложение может вызвать либо
SaveChanges, либоSaveChangesAsync. Однако, если весь код приложения асинхронен, необходимо реализовать толькоThrowingConcurrencyExceptionAsync. Аналогичным образом, если приложение никогда не использует асинхронные методы базы данных, необходимо реализовать толькоThrowingConcurrencyException. Обычно это верно для всех перехватчиков с синхронными и асинхронными методами. - Перехватчик имеет доступ к EntityEntry объектам для сохраненных сущностей. В этом случае это используется для проверки того, происходит ли нарушение параллелизма для операции удаления.
- Если приложение использует провайдера реляционной базы данных, объект ConcurrencyExceptionEventData можно преобразовать в объект RelationalConcurrencyExceptionEventData. Это предоставляет дополнительные реляционные сведения о выполняемой операции базы данных. В этом случае реляционный текст команды выводится в консоль.
- Возврат
InterceptionResult.Suppress()сообщает EF Core, чтобы подавить действие, которое он собирался предпринять - в данном случае, выброс исключениеDbUpdateConcurrencyException. Эта возможность изменить поведение EF Core, а не просто наблюдать за тем, что делает EF Core, является одной из самых мощных функций перехватчиков.
Перехват материализации
IMaterializationInterceptor поддерживает перехват до и после создания экземпляра сущности, а также до и после инициализации свойств этого экземпляра. Перехватчик может изменять или заменять экземпляр сущности в каждой точке. Позволяет:
- Задание несопоставленных свойств или вызов методов, необходимых для проверки, вычисляемых значений или флагов.
- Использование фабрики для создания экземпляров.
- Создание экземпляра сущности, который не создается EF обычно, например, экземпляр из кэша или прокси-типа.
- Внедрение сервисов в экземпляр сущности.
Замечание
IMaterializationInterceptor является синглтон-перехватчиком, т. е. один экземпляр совместно используется всеми экземплярами DbContext.
Пример. Простые действия по созданию сущностей
Представьте, что мы хотим отслеживать время получения сущности из базы данных, возможно, чтобы ее можно было отобразить для пользователя, редактирующего данные. Для этого сначала мы определим интерфейс:
public interface IHasRetrieved
{
DateTime Retrieved { get; set; }
}
Использование интерфейса распространено с перехватчиками, так как позволяет одному перехватчику работать с различными типами сущностей. Рассмотрим пример.
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; }
}
Обратите внимание, что атрибут используется для указания того, что [NotMapped] это свойство используется только при работе с сущностью и не должно сохраняться в базе данных.
Затем перехватчик должен реализовать соответствующий метод из IMaterializationInterceptor и установить полученное время.
public class SetRetrievedInterceptor : IMaterializationInterceptor
{
public object InitializedInstance(MaterializationInterceptionData materializationData, object instance)
{
if (instance is IHasRetrieved hasRetrieved)
{
hasRetrieved.Retrieved = DateTime.UtcNow;
}
return instance;
}
}
Экземпляр этого перехватчика регистрируется, когда производится настройка 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");
}
Подсказка
Этот перехватчик не имеет состояния, что распространено, поэтому создается один экземпляр, который совместно используется между всеми экземплярами DbContext.
Теперь, когда Customer запрашивается из базы данных, свойство Retrieved будет задано автоматически. Рассмотрим пример.
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()}'");
}
Создает выходные данные:
Customer 'Alice' was retrieved at '9/22/2022 5:25:54 PM'
Пример: Внедрение сервисов в объекты
EF Core уже имеет встроенную поддержку внедрения некоторых специальных служб в экземпляры контекста; например, см. отложенную загрузку без прокси-серверов, которая работает путем внедрения службы ILazyLoader.
Можно использовать IMaterializationInterceptor для обобщения этого для любой службы. В следующем примере показано, как внедрить ILogger в сущности таким образом, чтобы они могли выполнять собственный логгинг.
Замечание
Внедрение служб в сущности связывает эти типы сущностей с внедренными службами, что некоторые люди считают антипаттерном.
Как и раньше, интерфейс используется для определения того, что можно сделать.
public interface IHasLogger
{
ILogger? Logger { get; set; }
}
И типы сущностей, которые будут вести журнал, должны реализовать этот интерфейс. Рассмотрим пример.
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; }
}
На этот раз перехватчик должен реализовать IMaterializationInterceptor.InitializedInstance, который вызывается после создания каждого экземпляра сущности и инициализации значений его свойств. Перехватчик получает ILogger из контекста и инициализирует IHasLogger.Logger с ним:
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;
}
}
На этот раз новый экземпляр перехватчика используется для каждого экземпляра DbContext, так как полученный ILogger может изменяться для каждого экземпляра DbContext, а ILogger кэшируется на перехватчике:
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder.AddInterceptors(new LoggerInjectionInterceptor());
Теперь, когда изменяется Customer.PhoneNumber, это изменение будет записано в журнал приложения. Рассмотрим пример.
info: CustomersLogger[1]
Updating phone number for 'Alice' from '+1 515 555 0123' to '+1 515 555 0125'.
Перехват выражений запроса
IQueryExpressionInterceptor позволяет перехватывать дерево выражений LINQ для запроса перед компиляцией. Это можно использовать для динамического изменения запросов способами, применяемыми в приложении.
Замечание
IQueryExpressionInterceptor является перехватчиком-одиночкой, то есть один экземпляр обычно используется для всех экземпляров DbContext.
Предупреждение
Перехватчики мощные, но при работе с деревьями выражений легко ошибиться. Всегда учитывайте, есть ли более удобный способ достижения нужных задач, например изменение запроса напрямую.
Пример. Внедрение порядка в запросы для стабильной сортировки
Рассмотрим метод, который возвращает страницу клиентов:
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();
}
Подсказка
Этот запрос использует EF.Property метод, чтобы указать свойство для сортировки по. Это позволяет приложению динамически передавать имя свойства, разрешая сортировку по любому свойству типа сущности. Помните, что сортировка по неиндексированных столбцам может быть медленной.
Это будет работать нормально, если свойство, используемое для сортировки, всегда возвращает стабильное упорядочение. Но это может быть не всегда так. Например, приведенный выше запрос LINQ создает следующее в SQLite при заказе по 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
Если есть несколько клиентов с одинаковым значением City, упорядоченность запроса не является стабильной. Это может привести к отсутствию или дублированию результатов, когда пользователь просматривает данные.
Распространенный способ устранения этой проблемы — выполнить вторичную сортировку по первичному ключу. Однако вместо того, чтобы вручную добавлять это в каждый запрос, перехватчик может динамически добавлять вторичное упорядочивание. Чтобы упростить эту задачу, мы определим интерфейс для любой сущности, которая имеет целый ключ:
public interface IHasIntKey
{
int Id { get; }
}
Этот интерфейс реализуется интересующими типами сущностей.
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; }
}
Затем нам нужен перехватчик, реализующий 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);
}
}
}
Это, вероятно, выглядит довольно сложно — и это действительно так! Работа с деревьями выражений обычно не легко. Давайте рассмотрим, что происходит:
По сути, перехватчик инкапсулирует объект ExpressionVisitor. Посетитель переопределяет VisitMethodCall, который будет вызываться всякий раз при вызове метода в дереве выражений запроса.
Посетитель проверяет, является ли это вызовом метода OrderBy, в котором мы заинтересованы или нет.
Если это так, посетитель дополнительно проверяет, является ли вызов универсального метода типом, реализующим наш
IHasIntKeyинтерфейс.На этом этапе мы знаем, что вызов метода имеет форму
OrderBy(e => ...). Мы извлекаем лямбда-выражение из этого вызова и получаем параметр, используемый в этом выражении, т. еe.Теперь мы создадим новый MethodCallExpression с помощью метода билдера Expression.Call. В этом случае вызывается
ThenBy(e => e.Id)метод. Мы создадим это с помощью параметра, извлеченного выше, и доступа кIdсвойствуIHasIntKeyинтерфейса.Входные данные в этот вызов являются исходными
OrderBy(e => ...), поэтому конечный результат является выражением дляOrderBy(e => ...).ThenBy(e => e.Id).Это измененное выражение возвращается от посетителя, что означает, что запрос LINQ теперь был соответствующим образом изменен для включения
ThenByвызова.EF Core продолжает и компилирует это выражение запроса в соответствующий SQL для используемой базы данных.
Регистрация этого перехватчика и выполнение GetPageOfCustomers теперь создает следующий 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
Теперь это всегда будет производить стабильный заказ, даже если есть несколько клиентов с одинаковыми City.
Во многих случаях одно и то же можно сделать проще, изменив запрос напрямую. Рассмотрим пример.
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();
}
В этом случае ThenBy просто добавляется в запрос. Да, это может потребоваться сделать отдельно для каждого запроса, но это просто, легко понять и всегда будет работать.
Перехват разрешения идентификаций
IIdentityResolutionInterceptor позволяет перехватить конфликты разрешения идентификации, когда DbContext начинает отслеживание новых экземпляров сущностей.
Замечание
Этот перехватчик в настоящее время вызывается только тогда, когда DbContext.Update, DbContext.Attach и аналогичные методы используются для отслеживания сущностей, которые уже мониторятся с тем же ключом. Он не вызывается для сущностей, возвращаемых из запросов. Это может измениться в будущем выпуске; см. эту проблему.
Объект DbContext может отслеживать только один экземпляр сущности с любым заданным значением первичного ключа. Это означает, что несколько экземпляров сущности с тем же значением ключа должны быть объединены в один экземпляр. Перехватчик этого типа вызывается с существующим отслеживаемым экземпляром и новым экземпляром и должен перенести все значения свойств и изменения связей из нового экземпляра в существующий. Затем новый экземпляр удаляется.
EF Core предоставляет встроенную реализацию, UpdatingIdentityResolutionInterceptor которая обновляет существующую отслеживаемую сущность значениями из нового экземпляра. Это можно зарегистрировать при настройке контекста:
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder
.AddInterceptors(new UpdatingIdentityResolutionInterceptor());
Чтобы реализовать логику разрешения пользовательской идентификации, создайте класс, который реализует IIdentityResolutionInterceptor и переопределяет метод UpdateTrackedInstance.
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);
}
}