Какими бывают репозитории?

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

  1. Упростить написание кода.
  2. Повысить читаемость кода через укрепеление однородности конструкций.
  3. Избежать дублирования логики запросов.
  4. Допустить возможность unit-тестирования без написания интеграционных тестов для борьбы с ленью и уменьшением времени выполнения тестов.
  5. Избежать резкого падения производительности при выполнении условий выше.

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

  1. Отказаться от этих идей, использовать только ORM (NHibernate, например), а тесты выполнять на БД в памяти. Инъектить ISession прямо в сервис всегда и везде.
  2. Усложнить шаблон Спецификация, добавив в него плюшек от ORM (описание проекций, отложенных запросов, планов выборки), а заодно и группировку с управлением порядка следования, получив в конечном итоге Query Object, который нельзя полноценно протестировать без БД в памяти, да и писать его становится сложно, неудобно.
  3. Использовать 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, содержащем еще и новые данные.

Что с операциями чтения?

 Для них вариантов может быть несколько:

  1. Простая операция выборки по условию или их композиции. Покрывается шаблоном Спецификация более чем. Пример репозитория приведен выше. Можно взять стороннюю библиотеку для этой цели.
    Например: выборка пользователя по его имени и паролю с целью аутентификации. 
  2. Операция требующая других типов Linq-выражений, кроме Where, например: GroupBy, Select, OrderBy и так далее. Важно, что операция не является слишком чувствительной к производительности, достаточно обойтись проекцией и параметрами маппинга. Короче, можно без fine-grained performance tuning.
  3. Операция, как правило, выбирающая данные для какого-нибудь особо хитрого и большого отчета с жесткими требованиями по времени выполнения: результат нужен здесь и сейчас, скорее.

Как видно выше, если проблему разложить на составляющие, то и нет проблемы а есть составляющие. Репозиторий выше покрывает варианты использования с командами и запросами первого типа. Тестируемость проста, поддерживаемость кода тоже. Есть риск разрастания классов-спецификаций, но это легко решаемо при помощи 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&lt;T&gt;
{
	IQueryable&lt;T&gt; Linq();
}
 
public sealed class QueryableProviderNHibernateImpl&lt;T&gt; : QueryableProvider&lt;T&gt;
{
	private readonly Func _session;
 
	public QueryableProviderNHibernateImpl(Func session)
	{
		_session = session;
	}
 
	public IQueryable&lt;T&gt; Linq()
	{
		return _session().Query();
	}
}

И выполнить его привязку в Ioc:

1
2
3
4
container
 .RegisterSingleOpenGeneric(
  typeof(QueryableProvider&lt;&gt;), 
  typeof(QueryableProviderNHibernateImpl&lt;&gt;));

В результате, мы всегда можем выполнить инъекцию полноценного IQueryable при помощи Ioc, при необходимости. Есть множество преимуществ этого подхода в отличие от традиционного:

  1. При помощи умных средств анализа кода можно всегда составить список непростых запросов.
  2. Тестировать также просто, потому что любую коллекцию можно представить в виде IQueryable и написать обобщенный QueryableProviderMock.
  3. Для Where-выражений можно использовать композицию Спецификаций, написанных для случая 1.
Если же мы столкнулись с ситуацией 3, то есть следующие варианты:
  1. Использовать хранимые процедуры, написать обертку поверх ISession для удобного их вызова. Тестировать только на БД и средствами ее администрирования. Плюс ко всему еще и поддерживать трудоемко из-за Update’ов моделей NHibernate. Все руками, никакой автоматики, hardcore-путь. Зато быстро.
  2. Использовать записанные SQL-запросы текстом на полученном ISession от Ioc. Не слишком хороший вариант, имеет недостатки все и сразу. Тестировать можно только на стендах, что уже похоже на вариант с хранимими процедурами, SQL получится зависимым от СУБД, средства рефакторинга не видят зависимостей, все руками, никакой автоматики, hardcore-путь, медленнее, чем вариант с хранимыми процедурами. Лучше этим вариантом не пользоваться вообще.
  3. Использовать QueryOver через какой-нибудь QueryOverProvider, зарегистрированный в Ioc. Строгая типизация не нарушается, можно тестировать на БД в памяти (SQLite, MSSQL Compact, Firebird embedded), есть fine-grained performance tuning с возможностью указания проекций, планов выборки, отложенных запросов и кеширования.
  4. Использовать 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

08. Октябрь 2012 by gulin.serge
Categories: Server-Side | Tags: , | Leave a comment