Что нужно, чтобы начать пользоваться EF Core в .NET? Создать новый проект, добавить пакет с самим EF, добавить пакет с нужным провайдером, унаследовать DbContext, настроить его, указав строку подключения и провайдер, подготовить и подключить модель, создать миграции - не так уж много. Но по мере роста проекта и добавления новых механик, вы начнете выполнять все больше рутинной работы дублировать код из-за различных комбинаций фильтров запросов. А в какой-то момент у вас вообще может сложиться ощущение, что очередная задача невыполнима средствами EF.

В данной статье я расскажу о некоторых винтиках EF, которые можно пошевелить, чтобы работать с ним было чуть приятнее. Все, что я здесь затрону, буду начинать с задач, где-то с реальных, где то с абстрактных (но на реальных событиях). Вероятно, вы сразу вспомните, что сталкивались с чем-то подобным и статья поможет вам решить эти задачи иначе в следующий раз. А, возможно, вы напишите в комментариях, что решать это лучше иным способом. Очень надеюсь на предметную беседу в конце. Приступим.

PS задачи будут касаться веб сервисов и вся настройка будет производиться для ASP и коробочного IoC контейнера. Однако, так или иначе, сводиться она будет к работе с DbContextOptions, поэтому использовать это можно будет в любых ситуациях.

Генерация значений на стороне приложения

Что бы вы ни создавали в базе данных, вам, вероятно, захочется хранить время создания и обновления некоторых сущностей. Конечно, можно положиться в этом на СУБД, но в различных ситуациях вам может захотеться управлять формированием дат вручную. А может, это и не дата вовсе, а пользователь приложения, от имени которого создается сущность.

Я перечислю известные мне для этого способы (кроме толстых моделей и прямой установки значения, конечно, так как это отношения к EF не имеет). Наша задача - централизовать и иметь возможность переиспользовать функционал.

  1. Перегрузка SaveChanges и SaveChangesAsync. Потребуется пройтись по всем отредактированным и/или новым сущностям, определить принадлежность их определенному интерфейсу, например и изменить/задать значения нужных свойств. Из минусов я бы назвал необходимость инжектить в конструктор контекста(ов) базы данных сервисы, которые отдают нужные нам значения. В итоге конструктор нашего контекста начинает выглядеть как то так

public MyDbContext(DbContextOptions<MyDbContext> options,
                   IDateTimeService dateTimeService,
                   ICurrentCompanyAccessor currentCompanyAccessor,
                   IWheatherService wheatherService, 
                   ISomeOtherService...)                   

Кроме того, переиспользовать такую конструкцию будет крайне сложно. Вам фактически придется копировать много кода из проекта в проект из контекста в контекст с последующей фильтрацией лишнего в конкретном контексте.

  1. Использование HasValueGenerator(...). Вы можете либо передать тип генератора (в том числе, в виде универсального аргумента), либо же передать лямбду-фабрику генераторов. Фабрика нужна, чтобы можно было в генераторе использовать аргументы конструктора. То есть ни о каком DI тут речи не идет. В целом, минусы тут те же, что и в первом способе.

  2. Использование перехватчиков. Всего перехватчиков есть 4 вида, но мы поговорим об одном из них - перехватчике сохранения ISaveChangesInterceptor. Добавить перехватчики можно в одном из двух мест: в перегруженном методе контекста OnConfiguring(DbContextOptionsBuilder optionsBuilder) или в том месте, где вы регистрируете контекст через AddDbContext. Делается это через метод DbContextOptionsBuilder.AddInterceptors(interceptors).

    В данный метод нужно добавлять готовые экземпляры перехватчиков. На первый взгляд может показаться, что все зависимости перехватчиков опять уйдут в конструктор контекста, как в прошлых двух примерах, но это можно обойти.

    Метод расширения AddDbContext() принимает лямбду с параметром типа DbContextOptionsBuilder. Но есть и перегрузка, которая принимает лямбду с двумя параметрами: DbContextOptionsBuilder и IServiceProvider. Возможно, вы этого не знали, но конфигурация (код этой лямбды) выполняется каждый раз при создании контекста базы. И сервис провайдер туда приходит из текущего скоупа. А значит, мы можем создать перехватчик, который будет резольвить остальные перехватчики и выполнять их.

    Регистрация может выглядеть как-то так

services.AddScoped<MySaveChangesInterceptor>();
services.AddDbContext<InterceptorsDbContext>((sp, options) =>
{
    options.UseInMemoryDatabase("dbName");
  	// Здесь перехватчики регистрируются по конкретной принадлежности
  	// (SaveChanges, например) потому что под капотом для каждого типа
  	// создается собственный декоратор, реализующий нужный интерфейс
  	// перехватчика.
    options.RegisterSaveChangesInterceptors(sp, interceptorsRegistrar =>
    {
        interceptorsRegistrar.AddInterceptor<MySaveChangesInterceptor>();
    });
});

Остальное можно подключить как библиотеку. "Остальное" должно включать в себя метод расширения для DbContextOptionsBuilder, регистратор перехватчиков и декораторы для нужных типов перехватчиков (я ограничился декоратором перехватчиков сохранения).

Конечно же, все вышеперечисленное мы никак не провернем, если будем работать через пул контекстов, потому что там контекст создается один раз и не имеет прямого доступа к скоупу. Надеюсь инжектить в контекст IHttpContextAccessor никому не придет.

Использование хранимых процедур

Если честно, я не сталкивался с необходимостью и не смог придумать кейса, когда нам может потребоваться созидательная функция, поэтому сосредоточимся на тех, что просто возвращают результат.

Некоторые скажут, что хранимые процедуры - это просачивание бизнес логики в СУБД, а значит является плохим тоном. Я же на это отвечу тем, что мы не перестаем пользоваться различными агрегатными функциями, вроде Min, Max, математическими функциями. Так почему бы нам не создать собственные, которые будут лишь выполнять нужные нам расчеты в отвязке от бизнес логики. Например, мало-ли для чего вам потребуется для целочисленного значения столбца вернуть число из ряда Фибоначчи, индекс которого равен нашему значению. Вероятно, для этого можно создать отдельный столбец и считать значение при сохранении на стороне клиента (клиента БД), но здесь, опять же, есть нюансы. Например, сохранение происходит слишком часто, а чтение очень редко.

Для решения задачи мы, конечно, можем вытянуть все данные из базы и произвести расчеты за пределами СУБД, ведь вычисление числа из ряда Фибоначчи в любом случае потенциально займет большую часть времени, нежели выгрузка данных, поэтому условно будем считать, что наша функция будет выполняться мгновенно, а именно вычисление числа Фибоначчи я привел просто для того, чтобы вы не сказали: "Так тут же просто формулу можно вставить в запрос, зачем городить функцию". Ведь вычисление числа Фибоначчи, все таки, алгоритмическая задача и формулой вы ее не замените.

Итак, мы создадим локальную функцию int Fibonacci(int index) и такую же в БД. Теперь нам надо, чтобы наш запрос на уровне EF, похожий на тот что ниже, транслировался в SQL.

context.MyEntities.Where(x => MyMath.Fibonacci(x.SomeProperty) < 100);

Это можно сделать двумя способами.

Самый простой - повесить над функцией атрибут [DbFunction]. Мне это не очень нравится, так как у нашей математической библиотеки появится зависимость от EF.

Второй способ

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
  modelBuilder.HasDbFunction(() => MyMath.Fibonacci(default));
}

Также, HasDbFunction может принять MethodInfo.

Очень просто. Есть и третий способ. Он несколько сложнее, но у него есть свои плюсы.

Создание расширений для EntityFramework Core

Да, мы можем подключить функцию через расширение. Зачем нужны эти сложности? Например, потому, что мы можем таким образом отвязать нашу сборку с контекстом от нашей математической библиотеки, то есть сделать всю работу сбоку, а сборки с контекстом и математикой переиспользовать по отдельности в разных проектах.

Не знаю почему, но использование расширений из построителя настроек контекста скрыто, хоть и не запрещено.

public class DbContextOptionsBuilder : IDbContextOptionsBuilderInfrastructure
{
	...
	void IDbContextOptionsBuilderInfrastructure.AddOrUpdateExtension<TExtension>(
      	TExtension extension)
	...
}

То есть подключить свое расширение мы можем как-то так

public static class ExtensionRegistrationExtensions
{
    public static DbContextOptionsBuilder UseMyExtensions(this DbContextOptionsBuilder builder)
    {
        ((IDbContextOptionsBuilderInfrastructure)builder)
      		.AddOrUpdateExtension(new MyEfExtension());

        return builder;
    }
}

Расширение обязательно должно реализовывать интерфейс IDbContextOptionsExtension.

public class MyEfExtension : IDbContextOptionsExtension
{
    public void ApplyServices(IServiceCollection services)
    {
        
    }

    public void Validate(IDbContextOptions options)
    {
        
    }

    public DbContextOptionsExtensionInfo Info { get; }
}

Теперь разберем что у нас в этом интерфейсе содержится.

ApplyServices(IServiceCollection services) - здесь мы можем зарегистрировать сервисы. Однако, это внутренняя коллекция уровня EF. То есть на наше приложение в целом данные зависимости никак не повлияют. Мы можем зарегистрировать здесь сервисы, которые в дальнейшем можем использовать внутри контекста (DbContext дает доступ ко внутреннему ServiceProvider), однако, я не нашел этому применение. Зато, мы можем при помощи EntityFrameworkRelationalServicesBuilder прокинуть в зависимости некоторые плагины. Об этом чуть ниже.

public void Validate(IDbContextOptions options) - здесь мы можем проверить, что все настройки контекста позволяют правильно работать нашему расширению. Если это не так, мы должны выбросить исключение (или использовать иной механизм для остановки работы нашего расширения, во избежание не правильной работы EF с нашей БД. Если настройки на расширение никак не влияют, просто оставляем метод пустым.

public DbContextOptionsExtensionInfo Info { get; } - здесь нам надо вернуть указанный объект. Тип абстрактный, поэтому нужно его реализовать.

public class MyDbContextOptionsExtensionInfo : DbContextOptionsExtensionInfo
{
    public MyDbContextOptionsExtensionInfo(IDbContextOptionsExtension extension) : base(extension)
    {
    }

    public override int GetServiceProviderHashCode() => 0;

    public override bool ShouldUseSameServiceProvider(DbContextOptionsExtensionInfo other) => true;

    public override void PopulateDebugInfo(IDictionary<string, string> debugInfo)
    {
        
    }

    public override bool IsDatabaseProvider => false;

    public override string LogFragment => "MyExtension";
}

Здесь я не стану перегружать вас описанием методов и свойств, вы можете просто скопировать код "как есть". При написании более сложных расширений или провайдеров, вам придется обратиться к документации от Microsoft, в данном же случае это не нужно. В конструктор объекта передайте само расширение (this), вы ведь создадите его в расширении.

А теперь вернемся к плагинам.

public void ApplyServices(IServiceCollection services)
{
  new EntityFrameworkRelationalServicesBuilder(services).TryAddProviderSpecificServices(m =>
    m.TryAddSingletonEnumerable<IMethodCallTranslatorPlugin, MyMethodCallTranslatorPlugin>());
}

Таким образом мы можем подключить собственные плагины. Я насчитал их 7 штук, но расскажу про 2.

IMethodCallTranslatorPlugin - позволяет подключить трансляторы методов.

IMemberTranslatorPlugin - позволяет подключить трансляторы других членов (например, свойств).

В этих плагинах подключаются трансляторы просто как коллекции.

public class MyMethodCallTranslatorPlugin : IMethodCallTranslatorPlugin
{
    public IEnumerable<IMethodCallTranslator> Translators { get; } = new List<IMethodCallTranslator>
    {
        new MyMethodCallTranslator()
    };
}

А вот что происходит в трансляторе методов.

public class MyMethodCallTranslator : IMethodCallTranslator
{
    private MethodInfo _myMethod =
        typeof(MyMath).GetMethod(nameof(MyMath.Fibonacci), BindingFlags.Public | BindingFlags.Static);
        
    public SqlExpression? Translate(SqlExpression? instance, MethodInfo method, IReadOnlyList<SqlExpression> arguments,
        IDiagnosticsLogger<DbLoggerCategory.Query> logger)
    {
        if (method == _myMethod)
            return new SqlFunctionExpression("Fibonacci", arguments, false, arguments.Select(_ => false), typeof(int), null);

        return null;
    }
}

Я думаю, здесь особо нечего объяснять. Лучше расскажу, что я хотел провернуть при помощи плагинов, но потерпел неудачу.

Техлид на одном из проектов познакомил меня с библиотекой NSpecifications (реализация шаблона спецификации). В числе прочего, там был тип спецификаций, имеющий свойство типа Expression<Function<T, bool>>. В общем предикат. Спецификация могла неявно приводиться к выражениям, что позволяло писать код таким образом

var mySpec = new Spec<MyEntity>(x => x.Prop == 10);
var result = context.MyEntities.Where(mySpec);

Кроме этого, можно было использовать операторы и/или между спецификациями.

var olderThenTen = new Spec<Person>(x => x.Age > 10);
var ivans = new Spec<Person>(x => x.Name == "Ivan");
var result = context.Persons.Where(olderThenTen & ivans);

А еще, выражения компилировались, поэтому мы могли использовать спецификации не только в запросах к БД, но и при локальных выборках. Вероятно, вы сталкивались с ситуациями, когда в вашем коде то там то тут дублировались одни и те же выражения?

Expression<Func<Person, bool>> olderThenTen = x => x.Age > 10;
Expression<Func<Person, bool>> ivans = x => x.Name == "Ivan";
Expression<Func<Person, bool>> ivansOlderThenTen = x => x.Age > 10 && x.Name == "Ivan";

// а помимо этого у нас еще отдельно есть функция или лямбда
// для локального использования
Func<Person, bool> olderThenTen = x => x.Age > 10;

Но с функциями, хотя бы, можно выполнять логические операции, чего напрямую не позволяют Expressions.

А еще, библиотека содержит некоторые методы расширения. Например, функция bool Is<T>(this T object, Spec<T> specification).

var person = GetPerson();
var olderThenTen = new Spec<Person>(x => x.Age > 10);
if (person.Is(olderThenTen))
  // some code here

Очень лаконично выглядит.

Как-то давно я делал методы расширения для Expressions, позволяющие выполнять логические операции, мержить и кое-что еще. expression1.And(expression2). Были и готовые библиотеки, но они не содержали все необходимые мне методы. Да и реализованы были не очень, на мой взгляд. Поэтому я создал свои. На тот момент это позволило сильно продвинуться в динамическом построении запросов, а так же составлении запросов из заранее подготовленных предикатов, основанных на бизнес логике. Это выглядело довольно просто, порог входа был низким, но... Кое что я тогда решить не смог. А именно, вставка выражения в выражение.

Expression<Func<UserGroup, bool>> fromUsersOlderThenTen = g => g.Users.All(u => u.Age > 10);

Но у нас ведь уже есть предикат на пользователя старше 10 лет. И как его использовать? То есть у нас есть определенное бизнес правило, связанное с возрастом 10 лет. Если этот возраст когда-нибудь изменится на 11? Ну да, мы можем 10 вынести в константу или в настройки. А если добавится еще одно условие к этой категории людей? Например старше 10 лет и выше 150 см. Придется лазить по коду и менять. Есть шанс что-то где-то забыть. И тут появляется функция Is(...).

Expression<Func<UserGroup, bool>> fromUsersOlderThenTen = g => g.Users.All(u => u.Is(olderThanTen));

Вот! Теперь мы можем переиспользовать наши выражения (которые теперь спецификации)! Отлично! Но как скормить это EF? Он ведь не знает функцию Is. Более того, ее не нужно транслировать в функцию БД. Нам вообще не нужно как то по особому ее транслировать. Фактически, код выше эквивалентен коду еще выше :)

То есть нам нужно преобразовать выражение, удалив из нее использование функции Is, чтобы транслятор в SQL код мог транслировать выражение без лишних проволочек. Конечно, тут необходимо прогнать выражение через ExpressionVisitor, но в какой момент? Делать это с каждым запросом перед использованием в Where? Мне тогда эта идея не понравилась и я полез в исходники Ef Core, чтобы найти точку входа, где я могу перехватить оригинальное выражение до всех преобразований, сделанных EF. Но я не нашел. Я обратился к разработчикам EF. https://github.com/dotnet/efcore/issues/27064

Изначально мой вопрос заключался в том, как мне получить значения констант, в которые превращаются все параметры запросов. Я тогда хотел изменить выражение через PreProcessor. Но позже диалог привел к перехвату выражения, на что один из разработчиков ответил, что этого сделать нельзя.

It seems to me that the issue has been discussed extensively and that what you are specifically asking for is not something that EF supports. If that's not the case, then can state your question again, as specifically as possible?

В итоге, я нашел способ, однако он подразумевает использование инструментов, помеченных как Internal API, которые в любой момент могут перестать быть публичными. Тем не менее, я хочу поделиться своей находкой.

У DbContextOptionsBuilder есть метод ReplaceService<I, C>(). Это позволяет нам подменить реализацию внутреннего сервиса EF. Для подмены я выбрал IQueryCompiler. Исполнение запроса начинается именно с него. Конечно, свою реализацию я начал не с интерфейса, а с уже реализованного QueryCompiler, перегрузив метод public override TResult ExecuteAsync<TResult>(Expression query, CancellationToken cancellationToken = new CancellationToken()) .

В том, что этот сервис помечен как Internal API есть и плюс. Провайдеры не будут его заменять, а значит использовать этот способ можно с любым провайдером в текущей версии EF (6.0.3).

Перед тем, как отдать код на выполнение базовому компилятору, нам нужно прогнать выражение через наш ExpressionVisitor.

Для простоты использования, я создал библиотеку, которая позволяет подключать к EF собственные посетители выражений.

https://github.com/HighTechnologiesCenter/EfCoreExtension

Заключение

К сожалению, я не охватил все возможности настройки EF Core, одной статьи для этого мало. Не уверен, что буду писать продолжение, поэтому здесь описал наиболее сложные (для меня), а так же меняющие представление о работе с EF возможности.

Комментарии (0)