Какими бывают репозитории?
При разработке приложения, взаимодействующего с базой данных, используют множество шаблонов, техник и методик для сокрытия этого факта, преследуя различные цели:
- Упростить написание кода.
- Повысить читаемость кода через укрепеление однородности конструкций.
- Избежать дублирования логики запросов.
- Допустить возможность unit-тестирования без написания интеграционных тестов для борьбы с ленью и уменьшением времени выполнения тестов.
- Избежать резкого падения производительности при выполнении условий выше.
Методология DDD призвана решить наболевшую проблему при помощи шаблонов Репозиторий и Спецификация. Однако при реализации «в лоб» можно столкнуться с проблемами отсутствия контроля над формированием запроса, что может быть печальным фактом с позиций производительности. Вопрос, обсуждаемый в статье, поднимался не раз и не два. Существует еще множество точек зрения, но всех их можно разделить на группы:
- Отказаться от этих идей, использовать только ORM (NHibernate, например), а тесты выполнять на БД в памяти. Инъектить ISession прямо в сервис всегда и везде.
- Усложнить шаблон Спецификация, добавив в него плюшек от ORM (описание проекций, отложенных запросов, планов выборки), а заодно и группировку с управлением порядка следования, получив в конечном итоге Query Object, который нельзя полноценно протестировать без БД в памяти, да и писать его становится сложно, неудобно.
- Использовать DDD поверх легкой реализации CQRS, описанной здесь и здесь. То есть по факту получаем две DDD-модели: модель на чтение данных и модель на запись. Пусть даже для одного и того же источника данных без таких хитрых штук как Event Sourcing и Enterprise Serial Message Bus.
Вряд ли ошибусь, если поддержу инженерный принцип использования инструмента для задачи из соответствующей весовой категории. Отсюда напрашивается вывод этих самых весовых категорий для запросов. Во-первых, запросы можно разделить на две большие группы: запросы вставки и запросы выборки. Первые еще называют командами. Если к командам не предъявляют жестких требований по производительности, то все они однородны и сводятся, как правило, к созданию нескольких сущностей и сохранению их в источник, например, через метод Put Репозитория по канону:
1 2 3 4 5 6 7 8 9 | public interface Repository<T> where T : Entity { T this[TKey key] { get; } void Remove(T o); void Put(T o); //SaveOrUpdate, Put -- короче. Решается написанием комментария. IEnumerable Find(Specification<T> specification); T FindOne(Specification<T> specification); } |
Команду, выполняющую вставку в множество мест, обычно, оборачивают небольшим Unit Of Work:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | public class SomeServiceImpl : SomeService { private readonly Repository _repositoryEntityA; private readonly Repository _repositoryEntityB; private readonly Func _uow public SomeServiceImpl( Repository ar, Repository br, Func uow) { _repositoryEntityB = br; _repositoryEntityA = ar; _uow = uow; } public void ChangeState(int stateA, int stateB) { using(uow()) { _repositoryEntityA.Put(new EntityA(stateA)); _repositoryEntityB.Put(new EntityB(stateB)); } } } |
Как видно из примера выше, решающую роль сыграл Ioc.
Описание Unit of Work может быть таким:
1 2 3 4 | public interface UnitOfWorkProvider : IDisposable { void Rollback(); //Commit на Dispose, чтобы не засорять код кодом. } |
Код тестов для абстрактной операции приводить бессмысленно, однако, если завернуть Repository в Mock так, чтобы он обращался к IList<EntityA> и IList<EntityB> вместо БД, то его выполнение будет почти мгновенным, а реализация менее хрупкой. Получаем такой хороший модульный тест бизнес-логики без жесткого включения в него ответственности еще и за тестирования ORM-слоя, что явно лишнее, ибо это тестировалось уже до нас. NHibernate уже содержит тесты, выполнение еще и их можете добавить в свой билд-процесс при желании.
Если требуется изменить какую-то сущность, а не создавать новую, то всегда можно передать идентификатор сущности, или саму сущность с заполненным полем идентификатора, NHibernate понимает и такое, однако это плохо ложится в вышеприведенную реализацию, в случае выше лучше просто long или Guid, или кортеж из них обоих под соусом ComandRequest, содержащем еще и новые данные.
Что с операциями чтения?
Для них вариантов может быть несколько:
- Простая операция выборки по условию или их композиции. Покрывается шаблоном Спецификация более чем. Пример репозитория приведен выше. Можно взять стороннюю библиотеку для этой цели.
Например: выборка пользователя по его имени и паролю с целью аутентификации. - Операция требующая других типов Linq-выражений, кроме Where, например: GroupBy, Select, OrderBy и так далее. Важно, что операция не является слишком чувствительной к производительности, достаточно обойтись проекцией и параметрами маппинга. Короче, можно без fine-grained performance tuning.
- Операция, как правило, выбирающая данные для какого-нибудь особо хитрого и большого отчета с жесткими требованиями по времени выполнения: результат нужен здесь и сейчас, скорее.
Как видно выше, если проблему разложить на составляющие, то и нет проблемы а есть составляющие. Репозиторий выше покрывает варианты использования с командами и запросами первого типа. Тестируемость проста, поддерживаемость кода тоже. Есть риск разрастания классов-спецификаций, но это легко решаемо при помощи ad-hoc от LinqSpecs.
Для второго варианта запросов, можно поступить хитрее, достаточно написать LinqProvider по интерфейсу ниже:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | public interface QueryableProvider<T> { IQueryable<T> Linq(); } public sealed class QueryableProviderNHibernateImpl<T> : QueryableProvider<T> { private readonly Func _session; public QueryableProviderNHibernateImpl(Func session) { _session = session; } public IQueryable<T> Linq() { return _session().Query(); } } |
И выполнить его привязку в Ioc:
1 2 3 4 | container .RegisterSingleOpenGeneric( typeof(QueryableProvider<>), typeof(QueryableProviderNHibernateImpl<>)); |
В результате, мы всегда можем выполнить инъекцию полноценного IQueryable при помощи Ioc, при необходимости. Есть множество преимуществ этого подхода в отличие от традиционного:
- При помощи умных средств анализа кода можно всегда составить список непростых запросов.
- Тестировать также просто, потому что любую коллекцию можно представить в виде IQueryable и написать обобщенный QueryableProviderMock.
- Для Where-выражений можно использовать композицию Спецификаций, написанных для случая 1.
- Использовать хранимые процедуры, написать обертку поверх ISession для удобного их вызова. Тестировать только на БД и средствами ее администрирования. Плюс ко всему еще и поддерживать трудоемко из-за Update’ов моделей NHibernate. Все руками, никакой автоматики, hardcore-путь. Зато быстро.
- Использовать записанные SQL-запросы текстом на полученном ISession от Ioc. Не слишком хороший вариант, имеет недостатки все и сразу. Тестировать можно только на стендах, что уже похоже на вариант с хранимими процедурами, SQL получится зависимым от СУБД, средства рефакторинга не видят зависимостей, все руками, никакой автоматики, hardcore-путь, медленнее, чем вариант с хранимыми процедурами. Лучше этим вариантом не пользоваться вообще.
- Использовать QueryOver через какой-нибудь QueryOverProvider, зарегистрированный в Ioc. Строгая типизация не нарушается, можно тестировать на БД в памяти (SQLite, MSSQL Compact, Firebird embedded), есть fine-grained performance tuning с возможностью указания проекций, планов выборки, отложенных запросов и кеширования.
- Использовать LINQ, но вместе с NHibernate.Linq.LinqExtensionMethods и NHibernate.Linq.EagerFetchingExtensionMethods. Этот вариант, несмотря на свою видимую привлекательность, наиболее мутный, потому что нарушает сразу три принципа из SOLID: SRP, LSP, ISP. Попытка вызвать Fetch на IQueryable, полученном от IList<T>, увенчается исключением типа InvalidOperationException, причем известно это будет исключительно в Run-time. Единственная привлекательная возможность, которую представляет этот метод: использование комозиции Спецификаций в сложных запросах с тонкой настройкой поведенческих аспектов NHibernate. Для DDD это важно, однако для тестирования выглядит не очень-то хорошей перспективой.
По факту мы имеем следующее:
- Перестать врать себе и
начать житьиспользовать QueryOver, реализация которого в Nhibernate выполнена стабильнее нежели Linq. Для тестирования использовать медленный, но целиком рабочий способ в виде DB-in-memory с возможностью в консоли теста (или даже через Assert) верификации построения SQL. - Использовать IQueryable с плюшками от NHibernate, предварительно обернув их в свои Extensions, которые бы гасили высплывающее исключение, если тип IQueryable предоставлен от EnumerableQuery. Это значило бы, что мы тестируем, подставляя IList<T> вместо реальной БД. В качестве бонуса мы получаем возможность использования композиции Спецификаций.
Если в Вашем проекте Спецификации играют важную роль, то вариант с гашением исключения можно рассматривать как рабочий, хотя и весьма компромиссный с позиции стройности архитектуры и отсутствия костылей на инфраструктурном уровне, в противном случае, используйте QueryOver с ручной верификацией SQL-запросов, ведь вы же именно для этого используете QueryOver.
Отдельно замечу, что способность составления производительных запросов выборки типа 1 и 2 прямым образом зависит от правильного выбора корней агрегации в модели по DDD. Если корни представляют собой действительно важные сущности, играющие большую роль в предметной области, то можно в маппингах моделей определить наиболее часто используемые планы выборки, что существенно может сказаться на производительности.
В следующий раз спустимся на уровень абстракции вниз и посмотрим на реализацию репозитория для NHibernate.
comments powered by HyperComments