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

от автора

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

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

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

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

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

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

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

Этот шаблон выделяет такие спецификации (правила) в бизнес логике, которые подходят для «сцепления» с другими. Объект бизнес логики наследует свою функциональность от абстрактного аггрегирующего класса 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

ссылка на оригинал статьи https://habr.com/ru/company/singularis/blog/485328/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *