Ограничивая абстракции (.NET, ASP.NET MVC)

Original author: Jimmy Bogard
  • Translation

Прошло почти три года с тех пор как я впервые написал о своём отказе от такой абстракции как репозиторий (Repository). С тех пор я практически не использовал никаких концепций репозитория в системах, которые мы разрабатываем. Я не убирал из проектов уже существующие репозитории, но теперь я просто не нахожу в них никакой ценности в качестве абстракций.

Репозитории, которые создают разработчики, в основном бывают двух видов:
  • Абстракции вокруг ORM-фреймворка,
  • Инкапсуляция запросов.

Примером первого случая может быть что-нибудь вроде этого:
public interface IConferenceRepository
{
    IRavenQueryable<Conference> Query();
    Conference Load(Guid id);
}

Инкапсуляция запросов обычно занимает несколько больше строк:
public interface IConferenceRepository
{
    IEnumerable<Conference> FindAll();
    IEnumerable<Conference> FindFuture();
    IEnumerable<Conference> FindFree();
    IEnumerable<Conference> FindPaid();
}

Здесь каждый метод инкапсулирует один запрос. Оба случая представляют ценность в определённых сценариях. Если у меня цель — абстрагироваться от моей ORM, я пойду первым путём, и, возможно, включу второй тоже.

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

Копаем глубже

В первую очередь мы должны вернуться к вопросу, для чего мы стали использовать шаблон репозиторий? Наверняка это было сделано во имя «тестируемости». Тогда давайте начнём с чего-то подобного:
public ActionResult Index()
{
	RavenQueryStatistics stats;
	var posts = RavenSession.Query<Post>()
		.Include(x => x.AuthorId)
		.Statistics(out stats)
		.WhereIsPublicPost()
		.OrderByDescending(post => post.PublishAt)
		.Paging(CurrentPage, DefaultPage, PageSize)
		.ToList();

	return ListView(stats.TotalResults, posts);
}

Кажется сложным? Нет. Хотя если сложность будет расти, мы всё ещё будем ограничивать её масштаб одним этим методом. Если мы выведем этот запрос в отдельный класс, репозиторий или метод расширения (extension method), сам запрос всё равно останется в одном методе. С точки зрения метода контроллера, имеет ли значение, где этот код находится – в контроллере или другом классе?

Как насчёт более сложного примера:
public ActionResult Archive(int year, int? month, int? day)
{
	RavenQueryStatistics stats;
	var postsQuery = RavenSession.Query<Post>()
		.Include(x => x.AuthorId)
		.Statistics(out stats)
		.WhereIsPublicPost()
		.Where(post => post.PublishAt.Year == year);

	if (month != null)
		postsQuery = postsQuery.Where(post => post.PublishAt.Month == month.Value);

	if (day != null)
		postsQuery = postsQuery.Where(post => post.PublishAt.Day == day.Value);

	var posts = 
		postsQuery.OrderByDescending(post => post.PublishAt)
		.Paging(CurrentPage, DefaultPage, PageSize)
		.ToList();

	return ListView(stats.TotalResults, posts);
}

Опять, это просто набор запросов. Я всё ещё хочу инкапсулировать это в одном месте, но я не вижу причин перемещать этот код оттуда, где он уже сейчас. Если запрос поменяется, я просто поменяю код в одном месте. Дополнительная абстракция в этом случае может только сбить с толку.

Нюанс возникает в случае, если у меня несколько концепций, с которыми я работаю в методе контроллера. Давайте посмотрим на метод контроллера, который должен делать несколько вещей:
[ValidateInput(false)]
[HttpPost]
public ActionResult Comment(CommentInput input, int id, Guid key)
{
	var post = RavenSession
		.Include<Post>(x => x.CommentsId)
		.Load(id);

	if (post == null || post.IsPublicPost(key) == false)
		return HttpNotFound();

	var comments = RavenSession.Load<PostComments>(post.CommentsId);
	if (comments == null)
		return HttpNotFound();

	var commenter = RavenSession.GetCommenter(input.CommenterKey);
	if (commenter == null)
	{
		input.CommenterKey = Guid.NewGuid();
	}

	ValidateCommentsAllowed(post, comments);
	ValidateCaptcha(input, commenter);

	if (ModelState.IsValid == false)
		return PostingCommentFailed(post, input, key);

	TaskExecutor.ExcuteLater(new AddCommentTask(input, Request.MapTo<AddCommentTask.RequestValues>()
		 ,id));
	CommenterUtil.SetCommenterCookie(Response, input.CommenterKey.MapTo<string>());

	return PostingCommentSucceeded(post, input);
}

В этом случае присутствует много валидации, но настоящая работа отдана объекту AddCommentTask. Это объект-команда, которая позаботится о выполнении задачи вне MVC, валидаций, ActionResult и тому подобное.

Мы сделали из наших абстракций некоторые концепции (задачи, как AddCommentTask) и в случае чего мы можем сделать тоже самое с запросами.

Стратегии тестирования

Моя стратегия тестирования на сегодняшний день это:
  • Юнит-тестирование изолированных компонентов (доменные модели и другие уже изолированные классы)
  • Интеграционное тестирование всего остального

Я не использую контейнеров для авто-мокинга. Я выношу в стабы компоненты, которые я не могу проконтролировать. В противном случае это превращается в стратегию по запихиванию логики всё глубже и глубже.

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

В своих контроллерах я просто предпочту иметь интерфейс (seam, шов — прим. ред.) для тестирования. В проекте RaccoonBlog это означает, что простой заменой механизма хранения RavenDB на in-memory сделает мои тесты намного быстрее.

Но даже в противном случае – я не беспокоюсь о добавлении репозитория. По моему опыту, введение репозитория только для того, чтобы вынести что-то наружу – потеря времени. Это добавляет ненужную абстракцию в том месте, где было бы достаточно какой-то концепции (например, инкапсулирования объекта запроса).

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

Jimmy Bogard – архитектор в компании Headsrping, создатель AutoMapper и соавтор книги ASP.NET MVC in Action. В своём блоге он фокусируется на DDD, CQRS, распределенных системах и сопряжённых архитектурах и методологиях.
Share post
AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 24

    0
    То, что в примерах называется RavenSession — по сути репозиторий.

    Интересно, что будет при таком подходе, когда вместо того, чтобы поменять в одном месте реализацию, например, FindFree, придется менять LINQ запрос во всех местах где он используется.

    Ну и про unit of work полезно помнить тоже.
      0
      Ну так автор вроде как и предлагает не сооружать вокруг него еще один слой репозитория.

      По второму — я такие случаи решаю выносом LINQ выражений в extension methods к IQueryable, или ещe какие-нибудь объекты-запросы. Но тоже не буду пытаться абстрагироваться от ORM в большинстве случаев. Хотя бывают датацетричные приложения, которые этого требуют.
        0
        «Не сооружать репозиторий» приводит к обозначенным проблемам.

        Чаще всего написать экстеншен метод вы решите после пары итераций рефакторинга уже написанного кода.

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

          0
          К каким обозначенным? Если изменить FindFree — то он у меня уже есть в моих extension методах- я его в одном месте меняю. Ну только в этих extension методах я не пытаюсь создать обертки вокруг SingleOrDefault и других тривиальных случаев, чем придется заниматься репозиторию
            0
            И более того — такие IQueryable estensions позволяют объединять их в цепочки:
            например, var userActivePosts = posts.IsActive().ForUser(user)
        0
        Интересная мысль.

        По-моему, подобный подход может привести к дубляжу кода запросов. Хорошо, когда запрос — это просто «загрузи все посты». А представьте, если посты могут быть помеченными как «archived» с помощью флага. Пользователь же всегда должен работать с активными постами, и есть редкие случаи, когда ему показываются и архивные посты тоже.

        В таком случае надо будет искать все Query к постам и добавлять везде, где надо, .IsNotArchived(), нежели просто поменять методы в репозитории Post. Имхо, это уже дубляж пойдет.
          0
          Ну так автор и не предлагает оставить дублицированный код как есть. Просто вместо создания слоя репозитария вокруг ORM (готового репозитария) — использовать более тонкие концепции — вынося этот код в них.

          Как я написал выше — для меня так работают доп классы с extension методами к iqueryable, или классы генерирующие деревья запросов.
          0
          Единственный минус такого подхода, если вы захотите заменить тот же RavenDB на Redis или SqlServer, при этом выбор механизма хранения будет зависеть от конкретного клиента.
            +1
            Это в сферическом вакууме можно разработать систему, которая легко переживёт смену провайдера данных. В реальных системах сколько бы абстракций вы не нагородили при смене провайдера придётся много чего допиливать, если, конечно, у вас система не построена только на простейших запросах, которые можно через LINQ все написать (eager fetch, кэш запросов, разные реализации постраничных результатов, batching запросов, query plans hints и т.п.).

            Да и сколько раз в жизни вы меняли провайдер данных у работающей системы? :)
              0
              Текущий проект уже работает с 7 разными провайдерами =) Но у меня специфическая тема. В основном LDAP или LDAP over SQL и тд. Пока еще дошли руки попробовать свои LINQ провайдеры к ним писать =)
                0
                Ну, в специфичных проектах — не спорю. Но я думаю, что подавляющее большинство обычных проектов никогда не меняли и не будут менять провайдера.
                  +1
                  Еще зависит от сложности системы и уровней абстракции, так как например возможны репозитории для UI, которые будут инкапсулировать логику создания моделей и тд. А так же возможны ситуации, когда часть логики системы может быть InProc так и Remote, в зависимости от нагрузки и сценариев развертывания.

                  Меня лично очень напрягли толстые контроллеры в посте, но это ИМХО. Возможно в небольшом проекте это будет ок, но при увеличении сложности, может встать колом, допустим, при аудите изменений объектов и логировании.
                    0
                    Да, про это и речь — зачастую делают репозиторий поверх ORM репозиториев без реальной необходимости. Абстракции «на будущее» это такое же зло как и преждевременная оптимизация.
                    0
                    Более того использование более одного провайдера, это гарантированная деградация производительности и/или удобства. В итоге вместо использования фич. Oracle, приложение ограничено функционалом MySQL. Хотя вся контора давно и плотно сидит на Oracle и имеет кучу ДБА высокой квалификации.

              +1
              public interface IConferenceRepository
              {
                  IEnumerable<Conference> FindAll();
                  IEnumerable<Conference> FindFuture();
                  IEnumerable<Conference> FindFree();
                  IEnumerable<Conference> FindPaid();
              }
              


              На мой взгляд, данный код в любом случае придется писать в случае клиент-серверного программирования. Вы же не позволите удаленному клиенту вручную управлять базами данных. А вот максимально удобно реализовать указанный выше интерфейс сейчас можно с помощью ASP.NET Web API, ну или WCF(если не ошибаюсь)

              Как понимаю, нет смысла писать абстракцию для методов, реализованных в указанном интерфейсе, и именно в коде контроллера размещать запросы Linq.
                +1
                Есть ещё другой способ — он чуть более «многословный», но вполне элегантный — перевод всех операций вашего домена в форму Command/Query классов.

                Проблема больших репозиториев ещё и в том, что они подрывают всю идею dependency injection — скажем, если у вас сервис зависит от какого-нибудь репозитория с кучей методов, то вы никогда не поймёте а какие же именно методы использует сервис пока не посмотрите его код. Соответственно, трудно изолировать injected функционал.

                Я уже не говорю о том, что подобные репозитории нарушают SRP.

                Вообще, конечно, очень много систем написано подобным способом и они отлично работают. Но в данном случае мы говорим о «полировании» архитектуры системы под конкретные нужды.
                  0
                  По-моему, как раз из-за желания соблюдать SRP и возник паттерн «Репозиторий». Перенося его функциональность (создание запросов) в модель или контроллер мы добавляем им ещё одну ответственность.

                  А проблема больших репозиториев достаточно легко, имхо, решается декомпозицией, возможно наследованием и параметризацией. Зачастую в репозитории, скажем в UserRepository, создаются методы практически на автомате для каждого запроса, возвращающего объекты User, отличающегося от другого одним-двумя параметрами запроса, например проверкой поля active или archive. То есть получаются репозитории с набором методов, почти дублирующих функциональность друг друга, типа findAll() и findActive(), findById() и findActiveByID и т. п., а ведь можно было бы сделать два репозитория AllUserRepository и ActiveUserRepository и в пару раз сократить количество методов в каждом — общее одинаково, но разделены между двумя классами.
                    0
                    Репозитории которые есть у большинства проектов это в лучшем случае свалка запросов вида от QueryActiveUsers до QueryUserIdsFromRegionByName. Это далеко от SRP в его чистом виде. Конечно, всегда можно сказать, что, мол, repository выполняет одну задачу «выборка информации» и что это SRP — но тогда можно сказать про любой код «он выполняет действия» и это тоже будет SRP.

                    В общем, если уж заморачиваться с этим делом нормально, то нужно каждый метод расписывать Role Interface-ом и в конечном итоге придём к Commands/Queries классам (к слову, очень достойная архитектура).

                    Другое дело, что это не всегда оправданно. Вот тут уже нужно задать себе вопрос — а если это не оправданно, то зачем вообще надстраивали свой репозиторий над ORM-ским репозиторием?
                      0
                      Так репозиторий не просто выполняет действия, а вполне определенные — выборку информации определенного типа из хранилища.

                      Репозитории, емнип, и рекомендуются в случае сложных систем, для приложений сводящихся к CRUD они действительно избыточны. Субъективно оцениваю так: появляется (или точно знаю, что появится) дублирование кода запросов — пора делать репозиторий. Правда ещё один нюанс возникает, требующий принятия решений — если сделал репозиторий для одного типа объектов домена, то надо ли делать для остальных ради однообразия архитектуры.
                        0
                        Ещё нужно не забывать про то, что нам уже зачастую ORM даёт готовый репозиторий и нужно ещё обосновать необходимость новой абстракции поверх этого. Дублирование кода запросов достаточно просто решить и без репозиториев (к примеру, спецификациями или, на крайний случай, методами расширения как предлагал Ayende).
                          0
                          Многое зависит от ORM и от языка. Согласен с
                          Абстракции «на будущее» это такое же зло как и преждевременная оптимизация.
                          , но с небольшим уточнением — если точно знаешь, абстракция понадобится завтра, через неделю или через год, то лучше сделать её сейчас. А вот оптимизация может подождать, даже если точно знаешь, что она понадобится. Но вот абстракцию под неё лучше заранее создать :)
                            0
                            Ну, так же можно говорить про оптимизацию, мол, закладывать её нужно на будущее, а то ведь никак.
                            Абстракция может также подождать до нужного момента — потом рефакторинг.
                              0
                              Архитектура не более. Если пойти по минимальному пути и добавлять «хотелки» уже после, вы добавите себе работы в разы, или даже на порядки.

                              Чем кстати и плох agile, вроде идея нормальная, но требует крутого спеца, который держит все в голове, и выкладывает по мере необходимости.
                  –1
                  Существенные минусы, на мой взгляд: «тяжеловесные» контроллеры, увеличенная связанность, необходимость тестирования логики выборки данных вместе с логикой обработки данных. По моему, этого достаточно, чтобы потратить время на инкапсуляцию.

                  Only users with full accounts can post comments. Log in, please.