Спецификации на стероидах
Тема абстракций и всяких прелестных паттернов — хорошая почва для развития холиваров и вечных споров: с одной стороны, мы имеем следование мейнстриму, всяким модным словам и чистому коду, с другой стороны, мы имеем практику и реальность, которые всегда диктуют свои правила.
Что делать, если абстракции начинают «подтекать», как воспользоваться фишками языка и что можно выжать из паттерна «спецификация» — смотри под катом.
Итак, приступим к делу. Статья будет содержать следующие разделы: для начала, мы рассмотрим, что такое паттерн «спецификация» и почему его применение к выборкам из БД в чистом виде вызывает трудности.
далее, мы обратимся к деревьям выражений, которые являются очень мощным инструментом, и посмотрим, как они могут нам помочь.
напоследок я продемонстрирую свою реализацию «спецификация» на стероидах.
Начнем с базовых вещей. Я думаю, что о паттерне «спецификация» слышали уже все, но для тех кто не слышал, вот его определение с Википедии :
«Спецификация» в программировании — это шаблон проектирования, посредством которого представление правил бизнес логики может быть преобразовано в виде цепочки объектов, связанных операциями булевой логики.Этот шаблон выделяет такие спецификации (правила) в бизнес логике, которые подходят для «сцепления» с другими. Объект бизнес логики наследует свою функциональность от абстрактного аггрегирующего класса CompositeSpecification, который содержит всего один метод IsSatisfiedBy, возвращающий булево значение. После инстанцирования, объект объединяется в цепочку с другими объектами. В результате, не теряя гибкости в настройке бизнес логики, мы можем с лёгкостью добавлять новые правила.
Иными словами, спецификация — это объект, который реализует следующий интерфейс (отбросив методы для построения цепочек):
public interface ISpecification
{
bool IsSatisfiedBy(object candidate);
}
Тут все просто и понятно. Но теперь рассмотрим пример из реального мира, в котором помимо домена существует инфраструктура, которая та еще безжалостная личность: обратимся к случаю использованию ORM, СУБД и спецификации для фильтрации данных в БД.
Для того, чтобы не быть голословным и не показывать на пальцам, возмем в качестве примера следующую предметную область: предположим, что мы разрабатываем ММОРПГ, у нас есть пользователи, у каждого пользователя есть 1 или больше персонажей, а у каждого персонажа есть набор предметов (сделаем допущение, что предметы уникальны для каждого пользователя), и к каждому из предметов, в свою очередь, могут быть применены руны улучшения. Итого в виде диаграммы (класс ReadCharacter мы рассмотрим немного позже, когда поговорим о вложенных запросах):
Данная модель слабо связана с реальным миром, к тому же содержит поля, отражающие некоторую связь с используемыми 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().ToListAsync();
var filter = new CreatedAfter(new DateTime(2020, 1, 1));
var newCharacters = characters.Where(x => filter.IsSatisfiedBy(x)).ToArray();
До тех пор, пока наша база мала, все будет работать красиво и быстро, но стоит нашей игре стать более-менее популярной и набрать пару десятков тысяч пользователей, как вся эта прелесть станет жрать память, время и деньги, и лучше этого зверя сразу пристрелить, т.к. он не жилец. На этой грустной ноте мы отложим спецификацию и обратимся немного к моей практике.
Давным-давно, в одном очень-очень далеком проекте были у меня в коде классы, которые содержали логику по получению данных из БД. Выглядели они примерно так:
public class ICharacterDal
{
IEnumerable GetCharactersCreatedAfter(DateTime date);
IEnumerable GetCharactersCreatedBefore(DateTime date);
IEnumerable GetCharactersCreatedBetween(DateTime from, DateTime to);
...
}
и их использование:
var dal = new CharacterDal();
var createdCharacters = dal.GetCharactersCreatedAfter(new DateTime(2020, 1, 1));
Внутри классов скрывалась логика по работе с СУБД (в то время это был ADO.NET).
Вроде бы все было неплохо, но с разрастанием проекта эти классы тоже росли, превращались в трудно поддерживаемые объекты. К тому же, был неприятный осадок — вроде бы бизнес правила, но хранились они на уровне инфраструктуры, потому что были завязаны на конкретную реализацию.
На смену такому подходу пришел репозиторий IQueryable
public interface IRepository
{
T Get(object id);
IQueryable 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: ISpecification
{
}
Помимо этого в интерфейсе содержится свойство ResultType, которое возвращает тип сущности, получаемое в итоге выполнения запроса.
Его реализация содержится классе Specification
Кроме этого, есть еще класс SpecificationExtension, который содержит расширяющие методы для объединения запросов в цепочки.
Поддерживается два типа объединения: конкатенация (можно рассматривать как объединение по условию «И») и объединение по условию «ИЛИ».
Вернемся к нашему примеру и реализуем два наших правила:
public class CreatedAfter: Specification
{
public CreatedAfter(DateTime target)
{
Query = Source().Where(x => x.CreatedAt > target);
}
}
public class CreatedBefore: Specification
{
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 — позволяет включить связанные данные в формируемый запрос с целью оптимизации. Конечно, это немного криво, когда у нас особенности реализации инфраструктуры влияют на бизнес-правила, но, как я уже говорил, реальность сурова и чистые абстракции — это вещь довольно теоретическая.
Where — IQuery объявляет две перегрузки данного метода, одна принимает в себя просто лямбда-выражение для фильтрации в виде Expression
В модели у нас присутствует класс ReadCharacter — предположим, что у нас модель представлена в виде read-части, которая содержит денормализованные данные и служит для быстрой отдачи, и write-части, которая содержит ссылки, нормализованные данные и т.д. Мы хотим вывести всех персонажей, у которых пользователь имеет почту на определенном домене.
public class CharactersForUserWithEmailDomain: Specification
{
public CharactersForUserWithEmailDomain(string domain)
{
var usersQuery = Source(x => x.Email.Contains(domain)).Projection(x => x.Id);
Query = Source().Where((x, ctx) => ctx.GetQueryResult(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