Pull to refresh
606.15
Альфа-Банк
Лучший мобильный банк по версии Markswebb

Блеск и нищета паттерна «Спецификация» в С#. Оцениваем планы запросов

Level of difficultyHard
Reading time20 min
Views11K

О чём статья? 

О паттерне «Спецификация», который позволяет улучшить структуру приложения, и, следовательно, увеличить гибкость, уменьшив при этом объем кода, а значит - сократить количество ошибок, но это не точно. Почему? - читаем ниже. 

Этого мало, что ещё? 

К статье приложен пример Web API приложения, написанный с использованием анемичной доменной модели, библиотеки MediatR, Postgres и Docker Compose. Всё, как Вы любите и, таки да, анемичной доменной моделью мы нарушим инкапсуляцию, без этого никуда. 

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

Исходники примера: тут

Предметная область для примера

В качестве предметной области я выбрал перевозку пассажиров авиатранспортом. Будем рассуждать на её примере.

Получение данных без использования спецификаций

Большинство из нас видели что-то подобное, предположим, это метод выборки данных о рейсах пассажиров:

// Метод на Over9000 строк.
public async Task<List<какаятоМодель>> GetPessengersFlightNoAsync(какойтоЗапрос)
{
    var query = from smth1  in какойтоКонтекст.Something1
                  join smth2 in какойтоКонтекст.Something2
                  …..
                  join smth20 in какойтоКонтекст.Something20
                  where какие-то захардкоженные условия
                  select new // анонимный тип
                  { 
                      Field1,
                      …
                      Field30
                  };

    if (request.Field1 != null)
    {
        query = query.Where( x => условие_зависящее_от_полей_реквеста1);
    }
    …
    if (request.Field15 != null)
    {
        query = query.Where( x => условие_зависящее_от_полей_реквеста15);
    }
    var anonimousModels = await query.ToListAsync();

    return anonimousModels.ToКакиетоТоМодели():
}

В чём проблема такого метода? Он огромен и его невозможно просто так отрефакторить в набор небольших методов в отдельном классе потому, что в секции select мы используем анонимный тип. 

C# не позволяет определять методы с анонимными типами в качестве параметров, следовательно, чтобы разбить этот метод нам придется ввести промежуточную модель данных. Тогда можно будет фильтрацию отделить от декларации запроса, поместить их в отдельные методы и передавать между ними нашу промежуточную модель. 

Казалось бы - успешный успех, но такую промежуточную модель придётся добавлять для каждого подобного метода. Количество классов вырастет как попкорн во время нагрева, а оно нам надо? - нет!

Можно использовать кортежи в секции select, но в этом случае рано или поздно получим обращение к данным в виде какойтоКортеж.Item1, что противоречит чувству прекрасного и требованиям к нэймингу.

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

Поговорим об архитектуре

Есть у подобного подхода еще больший недостаток, чем невозможность недорогого рефакторинга. Одним из ключевых моментов в Domain Driven Design является защита ограниченного контекста, защита бизнес логики от перетекания на периферию. Критерии отбора данных - это часть бизнес логики и она находится у нас … на периферии, на уровне инфраструктуры. Собственно, это корень многих проблем и источник бед.

Как сейчас обрабатывается запрос на выборку данных? Примерно так, как на рисунке ниже:

И, если мы захотим добавить ещё один запрос на выборку данных, например, о местах посадки пассажиров в самолёте, то получим вторую цепочку, а если N-ый запрос, то будет N цепочек.

Это проблема, потому что нам придётся копипастить 90% кода из одного метода в другой, повторяя большую часть join-ов и значительную часть if-ов, т.к. код метода не поддаётся рефакторингу.

Паттерн "Спецификация". Блеск

Можно сократить количество методов репозитория с N до 1 и сделать условия отбора отдельными классами с возможностью их рекомбинации и переиспользования  с помощью паттерна “Спецификация”, сократив количество методов на уровне инфраструктуры:

В идеале мы получим по одному методу на один DbSet определённый в DbContext, т.е. по одному методу на сущность. Например, если у нас 100 различных методов “GetPassengerЧтоТоТам” в классе PassengerController, то все они на уровне инфраструктуры будут обработаны 1 методом GetPassengers,

ну или GetPassengersWithSpecifications, если мы хотим показать в названии, что использовали спецификации.

При этом, формирование условий отбора «переезжает» на уровень домена, что дает нам гибкость в использовании, а также дает возможность недорого заменить одну технологию доступа к БД на другую. Postgres можно заменить на MongoDb и спецификации в домене менять не придётся, т.к. спецификации формируют Expression, которые могут быть приняты в качестве аргумента как методами EF так и методами MongoDbDriver.

Итак, в DbContext  добавляем базовые файлы спецификаций в наш домен.

У нас будет 4 файла: 

Specification.cs - базовый класс, где определяем операторы конъюнкции, дизъюнкции и отрицания. Если есть желание, можете добавить остальные.

/// <summary>
/// Базовый класс спецификаций.
/// </summary>
public abstract class Specification<T>
{
    // Конвертируем нашу спецификацию в Expression.
    public abstract Expression<Func<T, bool>> ToExpression();

    // Проверяем наше условие на истинность.
    public virtual bool IsSatisfiedBy(T entity)
    {
        Func<T, bool> predicate = ToExpression().Compile();
        return predicate(entity);
    }

    /// <summary>
    /// Операция конъюнкции, логического "И" над двумя спецификациями.
    /// Над текущей и той, которая передана в качестве аргумента.
    /// из двух спецификаций генерируем новую.
    /// </summary>
    /// <param name="specification"></param>
    /// <returns></returns>
    public Specification<T> And(Specification<T> specification) =>
        new AndSpecification<T>(this, specification);

    /// <summary>
    /// Операция дизъюнкции, логического "ИЛИ" над двумя спецификациями. 
    /// Над текущей и той, которая передана в качестве аргумента.
    /// из двух спецификаций генерируем новую.
    /// </summary>
    /// <param name="specification"></param>
    /// <returns></returns>
    public Specification<T> Or(Specification<T> specification) =>
        new OrSpecification<T>(this, specification);

    /// <summary>
    /// Операция логического отрицания.
    /// генерируем новую спецификацию, которая является отрицанием исходной.
    /// </summary>
    /// <returns></returns>
    public Specification<T> Not() => new NotSpecification<T>(this);

    /// <summary>
    /// Определяем false для спецификации.
    /// </summary>
    /// <param name="specification"></param>
    /// <returns></returns>
    public static bool operator false (Specification<T> specification) => false;

    /// <summary>
    /// Определяем true для спецификации.
    /// </summary>
    /// <param name="specification"></param>
    /// <returns></returns>
    public static bool operator true(Specification<T> specification) => true;

    /// <summary>
    /// Оператор логического "И".
    /// </summary>
    /// <param name="left"></param>
    /// <param name="right"></param>
    /// <returns></returns>
    public static Specification<T> operator &(Specification<T> left, Specification<T> right) =>
        left.And(right);

    /// <summary>
    /// Оператор логического "ИЛИ".
    /// </summary>
    /// <param name="left"></param>
    /// <param name="right"></param>
    /// <returns></returns>
    public static Specification<T> operator |(Specification<T> left, Specification<T> right) =>
        left.Or(right);

    /// <summary>
    /// Оператор логического отрицания.
    /// </summary>
    /// <param name="left"></param>
    /// <param name="right"></param>
    /// <returns></returns>
    public static Specification<T> operator !(Specification<T> specification) => specification.Not();
}

AndSpecification.cs - Класс предназначен для генерации новой спецификации, которая является результатом конъюнкции двух переданных в конструктор спецификаций, т.е. чтобы можно было написать VasyaAndPetia =  Vasya & Petia.

OrSpecification.cs - Класс предназначен для генерации новой спецификации, которая является результатом дизъюнкции двух переданных в конструктор спецификаций, т.е. чтобы можно было написать VasyaAndPetia =  Vasya | Petia.

NotSpecification.cs - Класс предназначен для инвертирования спецификации, чтобы можно было писать NotVasya = !Vasya.

Под капотом AndSpecification, OrSpecification и NotSpecification операторы AndAlso, OrElse и Not класса Expression и это позволяет нам задавать довольно сложные комбинации спецификаций. Например, если бы мы хотели получить данные о детях, которые летят определенным рейсом в аэропорт Лос-Анджелеса по билетам стоимостью не менее $300, то мы могли бы написать так: 

var laKidsHigher300 = AgeUnder18Spec & ArrivalAirportLASpec & !PriceUnder300Spec;

Как видим, спецификации позволяют переиспользовать условия отбора, тем самым сократив объем кода. Меньше кода - меньше ошибок, вернее, больше переиспользования - меньше ошибок.

Базовые классы спецификаций разобрали, можно переходить к конкретным реализациям. Где они расположены в примере показывает следующий скрин:

На базе Specification.cs разработаем класс для отбора пассажиров по их идентификаторам. 

PassengerIdsSpecification.cs

/// <summary>
/// Спецификация для отбора по вхождению идентификатора пассажира в заданный массив.
/// </summary>
/// <param name="passnegerIds">Идентификаторы пассажиров.</param>
public class PassengerIdsSpecification<T>(int[] passnegerIds) : Specification<T>
    where T : IFiltrationFieldsSet
{
    private readonly int[] passengerIds = passnegerIds;

    public override Expression<Func<T, bool>> ToExpression() =>
        p => passengerIds.Contains(p.Id);
}

Собственно, это всё, что нужно для отбора по вхождению идентификаторов в заранее заданный список.

/// <summary>
/// Спецификация для отбора по вхождению даты обновления данных о пассажире (UpdateTs) в заданный интервал.
/// </summary
public class UpdateTsIntervalSpecification<T>(DateTime? updateTsStrart, DateTime? updateTsEnd) : Specification<T>
    where T : IFiltrationFieldsSet
{
    private readonly DateTime? updateTsStart = updateTsStrart;
    private readonly DateTime? updateTsEnd = updateTsEnd;

    public override Expression<Func<T, bool>> ToExpression()
    {
        if (updateTsStart.HasValue && !updateTsEnd.HasValue)
        {
            var dateFrom = GetStartDate(updateTsStart.Value);

            return x => x.UpdateTs >= dateFrom || x.UpdateTs == null;
        }

        if (updateTsEnd.HasValue && !updateTsStart.HasValue)
        {
            var dateTo = GetEndDate(updateTsEnd.Value);

            return x => x.UpdateTs < dateTo || x.UpdateTs == null;
        }

        if (updateTsEnd.HasValue && updateTsStart.HasValue)
        {
            var dateFrom = GetStartDate(updateTsStart.Value);
            var dateTo = GetEndDate(updateTsEnd.Value);

            return x =>
                (x.UpdateTs >= dateFrom && x.UpdateTs < dateTo) || x.UpdateTs == null;
        }

        return x => true;
    }

    private static DateTime GetStartDate(DateTime requestTo) => requestTo.Date;

    private static DateTime GetEndDate(DateTime requestFrom) => requestFrom.Date.AddDays(1);
}

Теперь напишем спецификацию для отбора по датам изменения данных о пассажирах

IFiltrationFieldsSet - интерфейс, в котором перечислены поля, по которым будем фильтровать, он пригодится нам позже, на данном этапе можно было его заменить классом Passenger, который унаследован от IFiltrationFieldsSet , т.е. написать так:

public class UpdateTsIntervalSpecification<T>(DateTime? updateTsStrart, DateTime? updateTsEnd) : Specification<T>
    where T : Passenger

Спецификации для фильтрации пассажиров готовы, можно их применять

В доменном сервисе разместим метод, который будет формировать спецификацию в зависимости от входных параметров. Легенда такая: нам нужно отобрать пассажиров, которые входят в список идентификаторов PassengerIds, не входят во второй список идентификаторов PassengerIdsToExclude и должны быть отредактированы в определенный день. Причём только первое условие обязательно, PassengerIdsToExclude и отбор по датам - опциональны.

/// <summary>
/// Конструируем спецификацию для фильтрации данных.
/// </summary>
/// <param name="request"></param>
/// <returns></returns>
private static Specification<T> ConstructSpecification<T>(GetPassengerRequest request)
    where T : IFiltrationFieldsSet
{
    // Выборка по идентификаторам пассажиров, используем соответствующую спецификацию.
    var filter = (Specification<T>) new PassengerIdsSpecification<T>(request.PassengerIds);

    if (request.PassengerIdsToExclude != null)
    {
        // если заданы идентификаторы подлежащие исключению из выходного набора данных,
        // то переиспользуем спецификацию выборки по идентификаторам инвертировав её, с помощью оператора "!".
        filter &= !(new PassengerIdsSpecification<T>(request.PassengerIdsToExclude));
    }

    if (request.UpdateTsStart.HasValue || request.UpdateTsEnd.HasValue)
    {
        // если заданы границы временного интервала, то фильтруем по датам.
        filter &= new UpdateTsIntervalSpecification<T>(request.UpdateTsStart, request.UpdateTsEnd);
    }

    return filter;
}

Получаем данные о рейсах пассажиров.

/// <summary>
/// Получаем данные о номерах рейсов пассажиров.
/// </summary>
/// <param name="request"></param>
/// <returns></returns>
public Task<IReadOnlyCollection<PassengerFlightNumModel>> GetPessengersFlightNoAsync(GetPassengerRequest request)
{
    // формируем спецификацию для фильтрации данных.
    var filter = ConstructSpecification<Passenger>(request);

    // получаем данные. EF сгенерирует запрос опираясь на список полей в выходном наборе и переданную спецификацию.
    // При изменении списка полей спецификации EF может радикально изменять запрос.
    return _passengerSpecificationProvider.GetPassengersWithSpecificationsAsync(
        filter.ToExpression(),
        p => new PassengerFlightNumModel
        {
            Id = p.Id,
            FirstName = p.FirstName,
            LastName = p.LastName,
            FlightNum = p.Booking.BookingLegals.FirstOrDefault().Flight.FlightNo
        });
}

Получаем данные о телефонах пассажиров.

/// <summary>
/// Получаем данные о номерах мест пассажиров.
/// </summary>
/// <param name="request"></param>
/// <returns></returns>
public Task<IReadOnlyCollection<PassengerPhoneModel>> GetPessengersPhoneAsync(GetPassengerRequest request)
{
    // формируем спецификацию для фильтрации данных.
    var filter = ConstructSpecification<Passenger>(request);

    return _passengerSpecificationProvider.GetPassengersWithSpecificationsAsync(
        filter.ToExpression(),
        p => new PassengerPhoneModel
        {
            Id = p.Id,
            FirstName = p.FirstName,
            LastName = p.LastName,
            PhoneNumber = p.Account.Phones.FirstOrDefault(x => x.PrimaryPhone).PhoneValue,
        });
}

Методы отличаются только значением второго параметра, в котором мы передаем функцию, формирующую выходные модели, но EF создаст разные запросы в БД, с разными соединениями (join) таблиц БД. Для метода GetPessengersFlightNoAsync EF сгенерирует такой SQL - запрос:

 SELECT p.passenger_id AS "Id", p.first_name AS "FirstName", p.last_name AS "LastName", (
            SELECT f.flight_no
            FROM postgres_air.booking_leg AS b0
            LEFT JOIN postgres_air.flight AS f ON b0.flight_id = f.flight_id
             WHERE b.booking_id IS NOT NULL AND b.booking_id = b0.booking_id
             LIMIT 1) AS "FlightNum"
         FROM postgres_air.passenger AS p
         LEFT JOIN postgres_air.booking AS b ON p.booking_id = b.booking_id
         WHERE p.passenger_id = ANY(@__passengerIds_0) AND NOT(p.passenger_id = ANY(@__passengerIds_1) AND p.passenger_id = ANY(@__passengerIds_1) IS NOT NULL)

для метода GetPessengersPhoneAsync будет сгенерирован запрос:

SELECT p.passenger_id AS "Id", p.first_name AS "FirstName", p.last_name AS "LastName", (
                        SELECT p0.phone
                        FROM postgres_air.phone AS p0
                        WHERE a.account_id IS NOT NULL AND a.account_id = p0.account_id AND p0.primary_phone
                        LIMIT 1) AS "PhoneNumber"
        FROM postgres_air.passenger AS p
        LEFT JOIN postgres_air.account AS a ON p.account_id = a.account_id
        WHERE p.passenger_id = ANY(@__passengerIds_0) AND NOT(p.passenger_id = ANY(@__passengerIds_1) AND p.passenger_id = ANY(@__passengerIds_1) IS NOT NULL) AND(p.update_ts < @__dateTo_2 OR p.update_ts IS NULL)

для метода GetPessengersSeatAsync будет сгенерирован следующий запрос:

 SELECT p.passenger_id AS "Id", p.first_name AS "FirstName", p.last_name AS "LastName", (
            SELECT b.seat
            FROM postgres_air.boarding_pass AS b
            WHERE p.passenger_id = b.passenger_id
             LIMIT 1) AS "BoardingPassSeat"
         FROM postgres_air.passenger AS p
         WHERE p.passenger_id = ANY(@__passengerIds_0) AND NOT(p.passenger_id = ANY(@__passengerIds_1) AND p.passenger_id = ANY(@__passengerIds_1) IS NOT NULL) AND(p.update_ts < @__dateTo_2 OR p.update_ts IS NULL)

Как видим, копипастить из метода в метод join-ы, if-ы не пришлось. Join-ы теперь в навигационных полях контекста, if-ы теперь в спецификациях. Бизнес логика получения данных теперь на уровне домена. 

Казалось бы, всё ок, но на самом деле  - нет. 

Оценим SQL. Нищета

В примере приложенном к статье реализованы два варианта получения данных. В классе PassengerNoSpecificationProvider.cs находятся методы получения данных без спецификаций, а в классе PassengerSpecificationProvider.cs. со спецификациями. Посмотрим, какой SQL генерирует EF в методе GetPessengersFlightNoAsync в обоих классах.

SQL без спецификаций:

 SELECT p.passenger_id AS "Id", p.first_name AS "FirstName", p.last_name AS "LastName", f.flight_no AS "FlightNum"
         FROM postgres_air.passenger AS p
         LEFT JOIN postgres_air.booking AS b ON p.booking_id = b.booking_id
         LEFT JOIN postgres_air.flight AS f ON(
             SELECT b0.flight_id
             FROM postgres_air.booking_leg AS b0
             WHERE b0.booking_id = b.booking_id
             LIMIT 1) = f.flight_id
         WHERE p.passenger_id = ANY(@__request_PassengerIds_0)
         AND NOT(p.passenger_id = ANY(@__request_PassengerIdsToExclude_1)
         AND p.passenger_id = ANY(@__request_PassengerIdsToExclude_1) IS NOT NULL)

план запроса:
        "QUERY PLAN"
        "Hash Left Join  (cost=26608.37..36174.63 rows=5 width=20)"
        "  Hash Cond: ((SubPlan 1) = f.flight_id)"
        "  ->  Nested Loop Left Join  (cost=0.87..64.54 rows=5 width=24)"
        "        ->  Index Scan using passenger_pkey on passenger p  (cost=0.43..42.29 rows=5 width=20)"
        "              Index Cond: (passenger_id = ANY ('{1,2,3,4,5}'::integer[]))"
        "              Filter: ((passenger_id <> ALL ('{2,3}'::integer[])) OR ((passenger_id = ANY ('{2,3}'::integer[])) IS NULL))"
        "        ->  Index Only Scan using booking_pkey on booking b  (cost=0.43..4.45 rows=1 width=8)"
        "              Index Cond: (booking_id = p.booking_id)"
        "  ->  Hash  (cost=15398.78..15398.78 rows=683178 width=8)"
        "        ->  Seq Scan on flight f  (cost=0.00..15398.78 rows=683178 width=8)"
        "  SubPlan 1"
        "    ->  Limit  (cost=0.00..27326.28 rows=1 width=4)"
        "          ->  Seq Scan on booking_leg b0  (cost=0.00..355241.70 rows=13 width=4)"
        "                Filter: (booking_id = b.booking_id)"   

SQL с использованием спецификаций:

 SELECT p.passenger_id AS "Id", p.first_name AS "FirstName", p.last_name AS "LastName", (
            SELECT f.flight_no
            FROM postgres_air.booking_leg AS b0
            LEFT JOIN postgres_air.flight AS f ON b0.flight_id = f.flight_id
             WHERE b.booking_id IS NOT NULL AND b.booking_id = b0.booking_id
             LIMIT 1) AS "FlightNum"
         FROM postgres_air.passenger AS p
         LEFT JOIN postgres_air.booking AS b ON p.booking_id = b.booking_id
         WHERE p.passenger_id = ANY(@__passengerIds_0) AND NOT(p.passenger_id = ANY(@__passengerIds_1) AND p.passenger_id = ANY(@__passengerIds_1) IS NOT NULL)

план запроса:
        "QUERY PLAN"
        "Nested Loop  (cost=0.87..136740.13 rows=5 width=48)"
        "  ->  Index Scan using passenger_pkey on passenger p  (cost=0.43..42.29 rows=5 width=20)"
        "        Index Cond: (passenger_id = ANY ('{1,2,3,4,5}'::integer[]))"
        "        Filter: ((passenger_id <> ALL ('{2,3}'::integer[])) OR ((passenger_id = ANY ('{2,3}'::integer[])) IS NULL))"
        "  ->  Index Only Scan using booking_pkey on booking b  (cost=0.43..4.45 rows=1 width=8)"
        "        Index Cond: (booking_id = p.booking_id)"
        "  SubPlan 1"
        "    ->  Limit  (cost=0.42..27335.12 rows=1 width=4)"
        "          ->  Nested Loop Left Join  (cost=0.42..355351.45 rows=13 width=4)"
        "                ->  Seq Scan on booking_leg b0  (cost=0.00..355241.70 rows=13 width=4)"
        "                      Filter: (b.booking_id = booking_id)"
        "                ->  Index Scan using flight_pkey on flight f  (cost=0.42..8.44 rows=1 width=8)"
        "                      Index Cond: (flight_id = b0.flight_id)"

Как видим, SQL без спецификаций более эффективен. Он эффективнее потому, что мы руками намекнули EF как именно соединять таблицы, такая ситуация складывается не всегда, зачастую EF генерирует вполне хороший SQL на основе навигационных свойств и связей, которые мы описываем в контексте БД, но всё равно это повод задуматься как исправить ситуацию. 

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

Спецификации на основе промежуточных моделей. Встаём с колен

Сейчас озвучу немного странную идею, но она работает. На данный момент, в нашем примере присутствуют три запроса с разными наборами join-ов. Напишем запрос, в котором будут использованы все join-ы , то есть создадим запрос с избыточным количеством соединений. EF не сможет его оптимизировать и сгенерирует SQL, в котором будут присутствовать лишние join-ы. EF не может пока понять, что поля, находящиеся в секции select, вовсе не требуют всех тех соединений, которые мы ему указали в linq - запросе. 

Казалось бы, мы проиграем в производительности на лишних соединениях, но дело в том, что оптимизатор запросов Postgres уверенно выбрасывает лишние join-ы из запроса, если они не требуются для получения полей указанных в select-е. Это подтверждают планы запросов приведенные в примере. Планы запросов с использованием спецификаций и без их использования будут в этом случае одинаковы.

Чтобы всё это заработало, мы конвертируем анонимные классы, используемые в linq‑запросах, в обычные классы, то есть вводим промежуточные модели данных и используем их в спецификациях.

Почему «промежуточные»? — потому что они будут содержать не только поля, которые будут в выходной модели метода, но и поля, которые мы будем использовать только для фильтрации данных, и в таком виде передавать эту модель на уровень выше — нерационально.

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

Теперь на уровне инфраструктуры в репозитории метод будет выглядеть так:

public async Task<IReadOnlyCollection<T>> GetPassengersWithSpecificationsRawAsync<T>(
    Expression<Func<PassengerRawModel, bool>> filter,
    Expression<Func<PassengerRawModel, T>> selector)
{
    var query = from ps in airContext.Passengers

                // FlightNum
                join bkngs in airContext.Bookings
                on ps.BookingId equals bkngs.Id into bookings
                from bk in bookings.DefaultIfEmpty()

                let bl = airContext.BookingLegals
                    .Where(x => x.BookingId.Equals(bk.Id))
                    .FirstOrDefault()

                join flts in airContext.Flights
                  on bl.FlightId equals flts.Id into flights
                from fl in flights.DefaultIfEmpty()

                // PhoneNumber
                join accs in airContext.Accounts
                on ps.AccountId equals accs.Id into accounts
                from acc in accounts.DefaultIfEmpty()

                let phn = airContext.Phones
                    .Where(x => x.AccountId.Equals(acc.Id))
                    .FirstOrDefault()

                // Seat
                let bp = airContext.BoardingPasses
                        .Where(x => x.PassengerId.Equals(ps.Id))
                        .FirstOrDefault()

                select new PassengerRawModel
                {
                    Id =  ps.Id,
                    FirstName = ps.FirstName,
                    LastName = ps.LastName,
                    UpdateTs = ps.UpdateTs,

                    FlightNum = fl.FlightNo,
                    PhoneNumber = phn != null
                        ? phn.PhoneValue
                        : null,

                    BoardingPassSeat = bp != null
                        ? bp.Seat
                        : null,
                };

    query = query.Where(filter);

    var resultQuery = query.Select(selector);

    return await resultQuery.ToListAsync();
}

на уровне домена так:

public Task<IReadOnlyCollection<PassengerFlightNumModel>> GetPessengersFlightNoRawAsync(GetPassengerRequest request)
    {
    // Запрос, который формирует EF по выражению полученному из спецификации.

    // SELECT p.passenger_id AS "Id", p.first_name AS "FirstName", p.last_name AS "LastName", f.flight_no AS "FlightNum"
    // FROM postgres_air.passenger AS p
    // LEFT JOIN postgres_air.booking AS b ON p.booking_id = b.booking_id
    // LEFT JOIN postgres_air.flight AS f ON(
    //     SELECT b0.flight_id
    //     FROM postgres_air.booking_leg AS b0
    //     WHERE b0.booking_id = b.booking_id
    //     LIMIT 1) = f.flight_id
    // LEFT JOIN postgres_air.account AS a ON p.account_id = a.account_id
    // WHERE p.passenger_id = ANY(@__passengerIds_0)
    // AND NOT(p.passenger_id = ANY(@__passengerIds_1)
    // AND p.passenger_id = ANY(@__passengerIds_1) IS NOT NULL) AND(p.update_ts < @__dateTo_2 OR p.update_ts IS NULL)

    //"QUERY PLAN"
    //"Hash Left Join  (cost=26608.37..36174.63 rows=5 width=20)"
    //"  Hash Cond: ((SubPlan 1) = f.flight_id)"
    //"  ->  Nested Loop Left Join  (cost=0.87..64.54 rows=5 width=24)"
    //"        ->  Index Scan using passenger_pkey on passenger p  (cost=0.43..42.29 rows=5 width=24)"
    //"              Index Cond: (passenger_id = ANY ('{1,2,3,4,5}'::integer[]))"
    //"              Filter: ((passenger_id <> ALL ('{2,3}'::integer[])) OR ((passenger_id = ANY ('{2,3}'::integer[])) IS NULL))"
    //"        ->  Index Only Scan using booking_pkey on booking b  (cost=0.43..4.45 rows=1 width=8)"
    //"              Index Cond: (booking_id = p.booking_id)"
    //"  ->  Hash  (cost=15398.78..15398.78 rows=683178 width=8)"
    //"        ->  Seq Scan on flight f  (cost=0.00..15398.78 rows=683178 width=8)"
    //"  SubPlan 1"
    //"    ->  Limit  (cost=0.00..27326.28 rows=1 width=4)"
    //"          ->  Seq Scan on booking_leg b0  (cost=0.00..355241.70 rows=13 width=4)"
    //"                Filter: (booking_id = b.booking_id)"

    // формируем спецификацию для фильтрации данных.
    var filter = ConstructSpecification<PassengerRawModel>(request);

    // получаем данные. EF сгенерирует запрос опираясь на список полей в выходном наборе и переданную спецификацию.
    // При изменении списка полей и специйикации EF может радикально мзменять запрос.
    return _passengerSpecificationProvider.GetPassengersWithSpecificationsRawAsync(
        filter.ToExpression(),
        p => new PassengerFlightNumModel
        {
            Id = p.Id,
            FirstName = p.FirstName,
            LastName = p.LastName,
            FlightNum = p.FlightNum,
        });
}

или так:

public Task<IReadOnlyCollection<PassengerSeatModel>> GetPessengersSeatRawAsync(GetPassengerRequest request)
{
    // Запрос, который формирует EF по выражению полученному из спецификации.   

    // SELECT p.passenger_id AS "Id", p.first_name AS "FirstName", p.last_name AS "LastName", (
    //     SELECT b1.seat
    //     FROM postgres_air.boarding_pass AS b1
    //     WHERE b1.passenger_id = p.passenger_id
    //     LIMIT 1) AS "BoardingPassSeat"
    // FROM postgres_air.passenger AS p
    // LEFT JOIN postgres_air.booking AS b ON p.booking_id = b.booking_id
    // LEFT JOIN postgres_air.flight AS f ON(
    //     SELECT b0.flight_id
    //     FROM postgres_air.booking_leg AS b0
    //     WHERE b0.booking_id = b.booking_id
    //     LIMIT 1) = f.flight_id
    // LEFT JOIN postgres_air.account AS a ON p.account_id = a.account_id
    // WHERE p.passenger_id = ANY(@__passengerIds_0)
    // AND NOT(p.passenger_id = ANY(@__passengerIds_1)
    // AND p.passenger_id = ANY(@__passengerIds_1) IS NOT NULL) AND(p.update_ts < @__dateTo_2 OR p.update_ts IS NULL)

    //"QUERY PLAN"
    //"Index Scan using passenger_pkey on passenger p  (cost=0.43..961587.41 rows=5 width=48)"
    //"  Index Cond: (passenger_id = ANY ('{1,2,3,4,5}'::integer[]))"
    //"  Filter: ((passenger_id <> ALL ('{2,3}'::integer[])) OR ((passenger_id = ANY ('{2,3}'::integer[])) IS NULL))"
    //"  SubPlan 1"
    //"    ->  Limit  (cost=0.00..192309.02 rows=1 width=3)"
    //"          ->  Seq Scan on boarding_pass b1  (cost=0.00..576927.07 rows=3 width=3)"
    //"                Filter: (passenger_id = p.passenger_id)"
    //"JIT:"
    //"  Functions: 18"
    //"  Options: Inlining true, Optimization true, Expressions true, Deforming true"

    var filter = ConstructSpecification<PassengerRawModel>(request);

    return _passengerSpecificationProvider.GetPassengersWithSpecificationsRawAsync(
        filter.ToExpression(),
        p => new PassengerSeatModel
        {
            Id = p.Id,
            FirstName = p.FirstName,
            LastName = p.LastName,
            BoardingPassSeat = p.BoardingPassSeat, 
        });
}

Оба доменных метода, приведенных выше, будут использовать один метод репозитория, ну, или провайдера данных, кому как угодно будет назвать класс на уровне инфраструктуры. Этот класс только запрашивает данные и не содержит методов по их изменению, поэтому у меня он называется PassengerSpecificationProvider.

Спецификации по прежнему позволят нам сократить количество кода на уровне инфраструктуры, при этом сохранив ту же эффективность SQL запросов, что была ранее.

Рост эффективности паттерна "Спецификация" в зависимости от размера выходной модели

Подход с избыточным числом соединений работает, но претит чувству прекрасного. В реальной жизни мы, естественно, будем использовать наиболее эффективные варианты запросов без избыточных соединений, хотя бы потому, что они легче читаются. У нас всё также будет Expression<RawModel,bool> в параметрах методов в репозитории, которые мы можем получить с помощью метода ToExpression из наших спецификаций всячески комбинируя их между собой.

Для операций «не», «и», «или» над ними мы получим как минимум

 

возможных комбинаций поисковых выражений, где n - количество полей промежуточной модели,  k - количество бинарных комбинаций. Таким образом, при использовании традиционного подхода, где request передаётся напрямую в репозиторий, нам пришлось бы все эти комбинации вынести в отдельные методы. Выигрыш очевиден - мы напишем меньше кода.

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

_repository.GetPassengersAsync(x => x.Id > 100 && x.FirstName.Equals(“Иван”))

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

_repository.GetPassengersAsync(x =>  x.Id > 100 && x.FirstName.Contains(“Иван”)
&& !(x.SecondName.Contains(“Иванович”) || x.SecondName.Contains(“Валерьевич”) || x.SecondName.Contains(“Семёнович”) || x.SecondName.Contains(“Игоревич”))

Гораздо лучше будут читаться выражения состоящие из спецификаций.

_repository.GetPassengersAsync(x =>  new IdAbove(100) & new FirstName(“Иван”) 
& new UnWandedSecondName(“Иванович”,”Валерьевич”,”Семёнович”,”Игоревич”))

Напрашивается очевидный вывод: паттерн “спецификация” выгоднее всего использовать там, где модели состоят из нескольких десятков или сотен полей и к ним нужно применять сложные критерии фильтрации данных. 

В чём сила, брат?

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

Спецификации, фактически, это знания о том, как мы можем использовать систему, знания бизнес-логики о самой себе, т.е. мы наращиваем интеллектуальность нашего главного слоя, слоя бизнес-логики. И если мы в целом взглянем на эволюцию видов на нашей планете, то увидим, что победили те виды, которые были наиболее интеллектуальными. Конкуренция приложений на рынке IT - это та же эволюция, возможно, спецификации позволят выжить именно вашему приложению, т.к. ваше приложение будет более гибким. :) Удачи.

P.S. О примере

Используемая в примере БД является приложением к замечательной книге «Домбровская, Бейликова, Новиков: Оптимизация запросов PostgreSQL», авторы любезно заполнили БД данными, в некоторых таблицах до 11М записей.

Бэкап можно также скачать здесь, файл postgres_air_2023.backup

Скачав бэкап Вам нужно будет положить его в папку %\SpecificationPatternPoC\pgAdmin\pgAdmin_storage\admin_admin.com, а затем поднять бэкап в pgAmdin (localhost:5050)

Материалы других авторов, использованных мною для погружения в тематику статьи. Спасибо им всем. 🙂

https://habr.com/ru/companies/jugru/articles/423891/

https://enterprisecraftsmanship.com/posts/specification-pattern-c-implementation/

https://fabio.marreco.dev/blog/2018/specificationpattern-with-entityframework/

Tags:
Hubs:
Total votes 45: ↑44 and ↓1+43
Comments47

Articles

Information

Website
digital.alfabank.ru
Registered
Founded
1990
Employees
over 10,000 employees
Location
Россия
Representative
София Никитина