Спецификации на стероидах

в 9:33, , рубрики: .net, .net core, entity framework core, nhibernate, orm, patterns and practices, Блог компании Singularis

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

Что делать, если абстракции начинают «подтекать», как воспользоваться фишками языка и что можно выжать из паттерна «спецификация» — смотри под катом.

Итак, приступим к делу. Статья будет содержать следующие разделы: для начала, мы рассмотрим, что такое паттерн «спецификация» и почему его применение к выборкам из БД в чистом виде вызывает трудности.

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

напоследок я продемонстрирую свою реализацию «спецификация» на стероидах.

Начнем с базовых вещей. Я думаю, что о паттерне «спецификация» слышали уже все, но для тех кто не слышал, вот его определение с Википедии :

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

Этот шаблон выделяет такие спецификации (правила) в бизнес логике, которые подходят для «сцепления» с другими. Объект бизнес логики наследует свою функциональность от абстрактного аггрегирующего класса CompositeSpecification, который содержит всего один метод IsSatisfiedBy, возвращающий булево значение. После инстанцирования, объект объединяется в цепочку с другими объектами. В результате, не теряя гибкости в настройке бизнес логики, мы можем с лёгкостью добавлять новые правила.

Иными словами, спецификация — это объект, который реализует следующий интерфейс (отбросив методы для построения цепочек):

public interface ISpecification
{
  bool IsSatisfiedBy(object candidate);
}

Тут все просто и понятно. Но теперь рассмотрим пример из реального мира, в котором помимо домена существует инфраструктура, которая та еще безжалостная личность: обратимся к случаю использованию ORM, СУБД и спецификации для фильтрации данных в БД.

Для того, чтобы не быть голословным и не показывать на пальцам, возмем в качестве примера следующую предметную область: предположим, что мы разрабатываем ММОРПГ, у нас есть пользователи, у каждого пользователя есть 1 или больше персонажей, а у каждого персонажа есть набор предметов (сделаем допущение, что предметы уникальны для каждого пользователя), и к каждому из предметов, в свою очередь, могут быть применены руны улучшения. Итого в виде диаграммы (класс ReadCharacter мы рассмотрим немного позже, когда поговорим о вложенных запросах):

image

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

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

public class CreatedAfter: ISpecification
{
  private readonly DateTime _target;

  public CreatedAfter(DateTime target)
  {
    _target = target;
  }

  bool IsSatisfiedBy(object candidate)
  {
    var character = candidate as Character;
    if(character == null)
  	return false;

    return character.CreatedAt > target;
  }
}

Ну и далее, для применения этой спецификации мы делаем следующее (здесь и далее я буду рассматривать код на основе NHibernate):

var characters = await session.Query<Character>().ToListAsync();
var filter = new CreatedAfter(new DateTime(2020, 1, 1));
var newCharacters = characters.Where(x => filter.IsSatisfiedBy(x)).ToArray();

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

Давным-давно, в одном очень-очень далеком проекте были у меня в коде классы, которые содержали логику по получению данных из БД. Выглядели они примерно так:

public class ICharacterDal
{
  IEnumerable<Character> GetCharactersCreatedAfter(DateTime date);
  IEnumerable<Character> GetCharactersCreatedBefore(DateTime date);
  IEnumerable<Character> GetCharactersCreatedBetween(DateTime from, DateTime to);
  ...
}

и их использование:

var dal = new CharacterDal();
var createdCharacters = dal.GetCharactersCreatedAfter(new DateTime(2020, 1, 1));

Внутри классов скрывалась логика по работе с СУБД (в то время это был ADO.NET).

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

На смену такому подходу пришел репозиторий IQueryable<T>, который позволил вынести все правила прямо в слой домена.

public interface IRepository<T>
{
  T Get(object id);
  IQueryable<T> List();
  void Delete(T obj);
  void Save(T obj);
}

который использовался примерно так:

var repository = new Repository();
var targetDate = new DateTime(2020, 1, 1);
var createdUsers = await repository.List().Where(x => x.CreatedAd > targetDate).ToListAsync();

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

Этот подход скрывает в себе еще одну проблему — если не материализовать запрос, то есть шанс выполнить несколько запросов к БД, вместо одного, что, естественно, пагубно сказывается на производительности системы.

И вот тут на одном из проектов один коллега предложил использовать библиотеку , которая предлагала реализацию паттерна «спецификация» на основе деревьев выражений.

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

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

  • оператор склейки по ИЛИ не работал;
  • объединение работает только для запросов, содержащих фильтры типа Where, а хотелось более богатых правил (вложенные запросы, skip/take, получение проекций);
  • код спецификаций зависел от выбранной ORM;
  • не было возможности использовать фичи ORM, т.к. это приводило к включению зависимости на нее в слой бизнес-логики (например, нельзя было делать fetch).

Результатом решения данных проблем стал мини-фреймворк Singularis.Secification, который состоит из нескольких сборок:

  • Singularis.Specification.Definition – определяет объект спецификации, а также содержит интерфейс IQuery, с помощью которого формируется правило.
  • Singularis.Specification.Executor.* – реализует репозиторий и объект для исполнения спецификаций под конкретные ORM (на данный момент поддерживается ef.core и NHibernate, в рамках экспериментов я также делал реализацию для mongodb, но в продакшен этот код не пошел).

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

Интерфейс спецификации определяет публичное свойство, которые содержит правило спецификации:

public interface ISpecification
{
  IQuery Query { get; }
  Type ResultType { get; }
}

public interface ISpefication<T>: ISpecification
{
}

Помимо этого в интерфейсе содержится свойство ResultType, которое возвращает тип сущности, получаемое в итоге выполнения запроса.

Его реализация содержится в классе Specification<T>, которая реализует свойство ResultType, вычисляя его на основе правила, которое хранится в Query, а также два метода: Source() и Source<TSource>(). Эти методы служат для формирования источника правила. Source() создает правило с типом, совпадающим с аргументом класса спецификации, а Source<TSource>() позволяет создать правило для произвольного класса (используется при формировании вложенных запросов).

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

Поддерживается два типа объединения: конкатенация (можно рассматривать как объединение по условию «И») и объединение по условию «ИЛИ».

Вернемся к нашему примеру и реализуем два наших правила:

public class CreatedAfter: Specification<Character>
{
  public CreatedAfter(DateTime target)
  {
       Query = Source().Where(x => x.CreatedAt > target);
  }
}

public class CreatedBefore: Specification<Character>
{
  public CreatedBefore(DateTime target)
  {
    Query = Source().Where(x => x.CreatedAt < target);

  }
}

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

var specification = new CreatedAfter(new DateTime(2019, 1, 1).Combine(new CreatedBefore(new DateTime(2020, 1, 1));
var users = repository.List(specification);

Объединение с помощью метода Combine поддерживает произвольные правила. Главное, чтобы результирующий тип левой части совпадал с входным типом правой части. Таким образом, вы можете построить правила, содержащие проекции, skip/take для пагинации, правила сортировки, fetch’a и т.д.

Правило Or более ограничено — оно поддерживает только цепочки, содержащие условия фильтрации Where. Рассмотрим использование на примере: найдем всех персонажей созданных до 2000 года или после 2020:

var specification = new CreatedAfter(new DateTime(2020, 1, 1).Or(new CreatedBefore(new DateTime(2000, 1, 1));
var users = repository.List(specification );

Интерфейс IQuery во многом повторяет интерфейс IQueryable, поэтому особых вопросов тут не должно быть. Остановимся только на специфичных методах:

Fetch/ThenFetch — позволяет включить связанные данные в формируемый запрос с целью оптимизации. Конечно, это немного криво, когда у нас особенности реализации инфраструктуры влияют на бизнес-правила, но, как я уже говорил, реальность сурова и чистые абстракции — это вещь довольно теоретическая.

WhereIQuery объявляет две перегрузки данного метода, одна принимает в себя просто лямбда-выражение для фильтрации в виде Expression<Func<T, bool>>, а вторая также принимает в себя дополнительные параметр IQueryContext, который позволяет выполнять вложенные подзапросы. Рассмотрим на примере.

В модели у нас присутствует класс ReadCharacter — предположим, что у нас модель представлена в виде read-части, которая содержит денормализованные данные и служит для быстрой отдачи, и write-части, которая содержит ссылки, нормализованные данные и т.д. Мы хотим вывести всех персонажей, у которых пользователь имеет почту на определенном домене.

public class CharactersForUserWithEmailDomain: Specification<ReadCharacter>
{
  public CharactersForUserWithEmailDomain(string domain)
  {
    var usersQuery = Source<User>(x => x.Email.Contains(domain)).Projection(x => x.Id);
    Query = Source().Where((x, ctx) => ctx.GetQueryResult<int>(usersQuery).Contains(x.Id));
  }
}

В результате выполнение будет сформирован следующий sql-запрос:

select
    readcharac0_.id as id1_3_,
    readcharac0_.UserId as userid2_3_,
    readcharac0_.Name as name3_3_
from
    ReadCharacters readcharac0_
where
    readcharac0_.UserId in (
        select
            user1_.Id
        from
            Users user1_
        where
            user1_.Email like ('%'+@p0+'%')
    );
@p0 = '@inmagna.ca' [Type: String (4000:0:0)]

Для выполнения всех этих замечательных правил определен интерфейс IRepository, который позволяет получать элементы по идентификатору, получать один (первый подходящий) или список объектов по спецификации, а также сохранять и удалять элементы из хранилища.
С определением запросов мы разобрались, теперь осталось научить наши ORM понимать это.
Для этого разберем сборку Singularis.Infrastructure.NHibernate (для ef.core все выглядит аналогично, только со спецификой ef.core).

Точкой доступа к данных является объект Repository, который реализует интерфейс IRepository. В случае получения объекта по идентификатору, а также для модификации хранилища (сохранения/удаления) данный класс оборачивает сессию и скрывает конкретную реализацию от бизнес-слоя. В случае работы со спецификациями он формирует объект IQueryable, отражающий наш запрос в терминах IQuery, после чего выполняет его на объекте сессии.

Основная магия и самый некрасивый код кроется в классе, отвечающем за преобразование IQuery в IQueryable — SpecificationExecutor. Этот класс содержит очень много рефлексии, с помощью которой вызываются методы Queryable или расширяющих методов конкретной ORM (EagerFetchingExtensionsMethods для NHiberante).

Данная библиотека активно используется в наших проектах (если быть честным, то для наших проектов используется уже обновленная библиотека, но постепенно все эти изменения будут выкладываться и в публичный доступ) постоянно претерпевает изменения. Буквально пару недель назад была выпущена очередная версия, которая перешла на асинхронные методы, были исправлены ошибки в executor’e для ef.core, добавлены тесты и семплы. Вполне вероятно, что библиотека содержит ошибки и сотню мест для оптимизации — она родилась как побочный проект в рамках работы над основными проектами, поэтому я буду рад предложениям по улучшению. Кроме того, не стоит кидаться использовать ее — вполне вероятно, что в конкретно вашем случае это будет излишним или неприменимым.

Когда же стоит использовать описанное решение? Наверное, проще исходить из вопроса “когда не следует”:

  • highload — если вам нужна высокая производительность, то само использование ORM вызывает вопрос. Хотя, конечно, никто не запрещает реализовать executor, который будет транслировать запросы в SQL и выполнять их…
  • совсем маленькие проекты — это очень субъективно, но, согласитесь, что тянуть в проект “todo list” ORM и весь сопутствующий зоопарк — выглядит как стрельба по воробьям из пушки.

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

Чуть не забыл — код проекта доступен на GitHub’e — https://github.com/SingularisLab/singularis.specification

Автор: CapitanBlood

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js