Хабрахабр

Быстрорастворимое проектирование

Люди учатся архитектуре по старым книжкам, которые писались для Java. Книжки хорошие, но дают решение задач того времени инструментами того времени. Время поменялось, C# уже больше похож на лайтовую Scala, чем Java, а новых хороших книжек мало.

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

Кроме текста, под катом есть видеозапись и ссылка на слайды. Эта статья является расшифровкой моего доклада с конференции DotNext 2018 Moscow.

Слайды и страница доклада на сайте.

Коротко обо мне: я из Казани, работаю в компании «Хайтек Груп». Мы занимаемся разработкой ПО для бизнеса. С недавнего времени я преподаю в Казанском Федеральном Университете курс, который называется «Разработка корпоративного ПО». Время от времени я ещё пишу статьи на Хабр про инженерные практики, про разработку корпоративного ПО.

Как вы, наверное, могли догадаться, сегодня я буду говорить про разработку корпоративного ПО, а именно, как можно структурировать современные веб-приложения:

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

Сформулируем критерии. Мне очень не нравится, когда разговоры про проектирование ведутся в стиле «моё кунг-фу сильнее твоего кунг-фу». У бизнеса есть, в принципе, один конкретный критерий, который называется «деньги». Все знают, что время — это деньги, поэтому две вот эти составляющие чаще всего самые важные.

В принципе бизнес чаще всего просит нас «как можно больше фич в единицу времени», но с одним нюансом — эти фичи должны работать. Итак, критерии. То есть, вроде бы, программист сказал: «Я за три часа сделаю». И первый этап, на котором это может сломаться, это код ревью. Там ещё три — и сколько итераций код ревью прошло, на столько надо умножить три часа. Три часа прошло, пришло в код ревью, а тимлид говорит: «Ой, нет, переделывай».

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

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

У нас это называется «лапшекод», за рубежом говорят «спагетти-код». Поэтому получался вот такой архитектурный стиль. Естественно, программисты довольно быстро сообразили, что так дело не пойдёт, и надо какую-то структуру сделать, и решили, что нам помогут какие-то слои. Всё связано со всем: мы меняем что-то в точке А — в точке Б ломается, понять, что с чем связано, совершенно невозможно. Фарш остался фаршем, но теперь фарш из слоя № 1 не может просто так взять и пойти общаться с фаршем из слоя № 2. Вот если вы представите, что фарш — это код, а лазанья — это такие слои, вот вам иллюстрация слоёной архитектуры. Мы придали коду какой-то форму: даже на картинке вы можете увидеть, что лазанья более оформлена.

Бывают ещё всякие сервисы, фасады и слои, названные по имени архитектора, который уволился из компании, их может быть неограниченно много. С классической слоёной архитектурой, наверное, все знакомы: есть UI, есть бизнес-логика и есть Data Access layer.

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

Вся разница в том, что где-то в это время сформулировали принципы SOLID, и выяснилось, что в классической луковой есть проблема с инверсией зависимостей, потому что абстрактный доменный код почему-то зависит от реализации, от Data Access, поэтому Data Access решили развернуть, и сделали так, чтобы Data Access зависел от домена. На самом деле нет.

У меня получилось нечто среднее между многоугольником и кружками. Я вот здесь поупражнялся в рисовании и нарисовал луковую архитектуру, но не классически «колечками». Смысл в том, что домен в центре, его заворачивают в сервисы, они могут быть доменные или application-сервисы, кому как больше нравится. Я это сделал, чтобы просто показать, что, если вы встречали слова «луковая», «гексагональная» или «порты и адаптеры» — это всё одно и то же. А внешний мир в виде UI, тестов и инфраструктуры, куда переехал DAL — они общаются с доменом через этот сервисный слой.

Простой пример. Обновление email

Давайте посмотрим, как в такой парадигме будет выглядеть простой use case — обновление email'а пользователя.

Нам нужно отправить запрос, провести валидацию, обновить в базе данных значение, отправить на новый email уведомление: «Всё в порядке, вы поменяли email, мы знаем, всё хорошо», и ответить браузеру «200» — всё окей.

Вот нас есть стандартная ASP. Код может выглядеть примерно как-то так. Вроде как всё хорошо, да? NET MVC-валидация, есть ORM, чтобы прочитать и обновить данные, и есть какой-нибудь email-sender, который отправляет нотификацию. Один нюанс — в идеальном мире.

Смысл в том, что надо добавить авторизацию, проверку ошибок, форматирование, логирование и профилирование. В реальном мире ситуация чуть-чуть отличается. И вот тот маленький кусочек кода стал большим и страшным: с большой вложенностью, с большим количеством кода, с тем, что это тяжело читать, а главное, что инфраструктурного кода больше, чем доменного. Это всё не имеет никакого отношения к нашему use case'у, но это всё должно быть.

Я же записал всю логику в контроллеры. «Где же сервисы?» — скажете вы. Конечно, это проблема, сейчас я добавлю сервисы, и всё будет хорошо.

Добавляем сервисы, и действительно становится лучше, потому что вместо большой портянки получилась одна маленькая красивая строчка.

Стало! Стало лучше? Результат налицо. А еще мы теперь этот метод можем повторно использовать в разных контроллерах. Давайте посмотрим на реализацию этого метода.

Этот код никуда не делся. А вот здесь уже всё не так хорошо. Мы решили не решать проблему, а просто её замаскировать и перенести в другое место. Всё то же самое мы просто перенесли в сервисы. Вот и всё.

А валидацию мы должны делать в контроллере или здесь? Дополнительно к этому появляются некоторые другие вопросы. А если надо сходить в базу данных и посмотреть, что такой ID есть или что нет другого пользователя с таким email'ом? Ну, вроде как, в контроллере. А вот обработка ошибок здесь? Хмм, ну тогда в сервисе. А метод SaveChanges, он в сервисе или надо перенести его в контроллер? Эту обработку ошибок, наверное, здесь, а ту обработку ошибок, которая будет отвечать браузере, в контроллере. Вот такие размышления наводят на мысль, что, может быть, слои не решают каких-то проблем. Может быть и так, и так, потому что, если сервис вызывается один, логичнее вызвать в сервисе, а если у вас в контроллере три метода сервисов, которые надо вызвать, тогда надо вызывать его за пределами этих сервисов, чтобы транзакция была одна.

Если погуглить, по крайней мере три вот этих почтенных мужа пишут примерно об одном и том же. И эта идея пришла в голову не одному человеку. NET Junkie (к сожалению, не знаю его фамилию, потому что она нигде в интернете не фигурирует), автор IoС-контейнера Simple Injector. Сверху вниз: Стивен . И внизу Скотт Влашин, автор сайта «F# for fun and profit». Дальше Джимми Богард — автор AutoMapper'а.

Соответственно, вариант использования в C# может быть определён с помощью интерфейса IHandler. Все эти люди говорят об одном и том же и предлагают строить приложения не на основе слоёв, а на основе вариантов использования, то есть тех требований, о которых бизнес нас просит. У него есть входные значения, есть выходные значения и есть сам метод, который собственно выполняет этот сценарий использования.

Но если слоёв нет, как же быть? А внутри этого метода может быть как доменная модель, так и какая-нибудь денормализованная модель для чтения, может быть с помощью Dapper'а или с помощью Elastic Search'а, если надо что-то искать, а, возможно, у вас есть Legacy-система с хранимыми процедурами — нет проблем, а также сетевые запросы — ну и вообще всё что угодно, что вам там может потребоваться.

Уберём метод и создадим класс. Для начала давайте избавляться от UserService. А потом возьмём и уберём класс. И ещё уберём, и снова уберём.

Класс GetUser возвращает данные и ничего не меняет на сервере. Давайте подумаем, эти классы эквивалентны или нет? Классы UpdateEmail и BanUser возвращают результат операции и изменяют состояние. Это, например, про запрос «Дай мне ID пользователя». Например, когда мы говорим серверу: «Пожалуйста, измени состояние, надо вот что-то поменять».

Есть метод GET, который по спецификации протокола HTTP должен возвращать данные и не менять состояние сервера. Посмотрим на протокол HTTP.

И есть другие методы, которые могут менять состояние сервера и возвращать результат операции.

Query — это GET-операции, а команды — это PUT, POST, DELETE — не надо ничего придумывать. Парадигма CQRS как будто специально создана для протокола HTTP.

IQueryHandler, который отличается только тем, что мы повесили constraint о том, что тип входных значений – это IQuery. Доопределим наш Handler и определим дополнительные интерфейсы. Дженерик нам нужен для того, чтобы поставить constraint в QueryHandler'е, и теперь, объявляя QueryHandler, мы не можем туда передать не Query, а передавая туда объект Query, мы знаем его возвращаемое значение. IQuery — это маркерный интерфейс, в нём ничего нет, кроме вот этого дженерика. Вы пишете IQueryHandler, пишете туда реализацию, и в TOut вы не можете подставить другой тип возвращаемого значения. Это удобно, если у вас одни интерфейсы, чтобы потом не искать в коде их реализации, и опять же чтобы не напутать. Таким образом сразу видно, какие входные значения соответствуют каким входным данным. Это просто не скомпилируется.

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

Реализация Handler

Handler'ы мы объявили, какая же у них реализация?

Кажется, что-то не помогло. Какая-то проблема есть, да?

Декораторы спешат на помощь

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

Объявляем абстрактный базовый класс для того, чтобы потом наследовать, в конструктор передаётся само тело Handler'а, и объявляем абстрактный метод Handle, в котором и будет навешиваться дополнительная логика декораторов. Тогда у нас всё будет выглядеть следующим образом: есть входное Dto, оно входит в первый декоратор, во второй, третий, дальше мы заходим в Handler и так же выходим из него, проходим через все декораторы и возвращаем обратно Dto в браузере.

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

Объявляем декоратор. Начнём с валидации. Мы их все выполняем, проверяем, если валидация не прошла и тип возвращаемого значения — это IEnumerable<validationresult>, тогда мы его можем вернуть, потому что типы совпадают. В конструктор этого декоратора приходит IEnumerable из валидаторов типа T. А если это какой-то другой Hander, ну тогда придётся выкинуть Exception, потому что нету здесь никакого результата, тип другой возвращаемого значения.

Так же объявляем декоратор, делаем метод CheckPermission, проверяем. Следующий этап — это Security. Теперь после того, как мы провели все проверки и уверены, что всё хорошо, мы можем выполнять нашу логику. Если вдруг что-то пошло не так, всё, не продолжаем.

Одержимость примитивами

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

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

Это поможет с точки зрения структуры данных, но не поможет с такой валидацией, как проверка значений в БД. Чтобы валидация работала, мы добавляем сюда какие-то атрибуты, которыми рассказываем, что это за валидация. Вместо атрибутов, можно перейти к специальным типам, тогда эта проблема решится. Здесь просто EmailAddress, непонятно, как, где проверять, как эти атрибуты использовать для того, чтобы в базу сходить.

И в конструктор мы либо передаём эту сущность, либо передаём её Id, но при этом мы должны передать функцию, которая по Id может взять и вернуть, проверить, null там или не null. Вместо примитива int объявим такой тип Id, у которого есть дженерик, что это вот определённая сущность с int'овым ключом.

Преобразуем все Email'ы к нижней строке, чтобы у нас всё выглядело одинаково. Аналогично поступаем с Email. NET и здесь его просто вызываем. Дальше берём Email-атрибут, объявляем его как статический для совместимости с валидацией ASP. Для того, чтобы инфраструктура ASP. То есть так тоже можно делать. Кода там не очень много, он сравнительно простой, поэтому я не буду на этом останавливаться. NET всё это подхватила, придётся немножко изменить сериализацию и/или ModelBinding.

И после того, как отработали вот эти ModelBinder и обновлённый десериализатор, мы точно знаем, что эти значения корректны и в том числе, что такие значения есть в БД. После этих изменений, вместо примитивных типов, у нас здесь появляются специализированные типы: Id и Email. «Инварианты»

Мы работаем со сложной беизнес-логикой, поэтому нам важно, чтобы код был самодокументируемым. Следующий момент, на котором я хотел бы остановиться, это состояние инвариантов в классе, потому что довольно часто используется анемичная модель, в которой есть просто класс, много геттеров-сеттеров, совершенно непонятно, как они должны работать вместе. Здесь мы передаём уже не примитивный тип, а тип Email, он уже точно корректный, если это null, мы всё ещё выбрасываем Exception. Вместо этого лучше объявить настоящий конструктор вместе с пустым для ORM, его можно объявлять protected, чтобы программисты в своём прикладном коде не смогли его вызвать, а ORM смогла. Соответственно, там будет Non-nullable reference type, и лучше дождаться его поддержки в языке. Можно использовать какие-нибудь Fody, PostSharp, но скоро выходит C# 8. Следующий момент, если мы хотим поменять имя и фамилию, скорее всего мы хотим их менять вместе, поэтому должен быть соответствующий публичный метод, который меняет их вместе.

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

Вот если я хочу менять имя и фамилию, у меня может быть такая команда на изменение. Причём такие атрибуты можно повторно использовать в Dto. Вроде как стоит. А стоит ли добавлять тут специальной конструктор? Оно же лучше станет, никто не поменяет эти значения, не сломает их, они будут точно правильные.

Дело в том, что Dto в общем-то не совсем объекты. На самом деле не совсем. То есть они притворяются объектами, конечно, но у них есть всего одна ответственность — это быть сериализованными и десериализованными. Это такой словарик, в который мы засовываем десериализованные данные. Всё это хорошо описал Марк Симон в статье «На границах программы не объектно-ориентированы», если интересно — лучше прочитайте его пост, там всё это подробно описано. Если мы попытаемся бороться с этой структурой, начнём объявлять какие-то ModelBinder'ы с конструкторами, что-то такое делать, это невероятно утомительно, и, главное, это будет ломаться с новыми выходами новых фреймворков.

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

Handler

После того, как вот эти все изменения внесены, как у нас будет выглядеть Hander?

Данные точно корректны, потому что нас есть система типов, есть валидация, то есть железобетонно корректные данные, проверять их повторно не нужно. Я здесь написал две строчки для того, чтобы удобнее было читать, а вообще можно записать в одну. Однако ещё нет вызова метода SaveChanges, нет нотификации и нет логов и профайлеров, да? Такой пользователь тоже есть, другого пользователя с таким занятым email'ом нету, всё можно делать. Двигаемся дальше.

Events

Доменные события.

Там он предлагает просто объявить статический класс с методом Raise и выкидывать такие события. Наверное, в первый раз популяризовал эту концепцию Уди Дахан в его посте «Domain Events – Salvation». Чуть позже позже Джимми Богард предложил лучшую реализацию, она так и называется «A better domain events pattern».

Вместо того, чтобы выбрасывать события, мы можем объявить какой-то список, и в тех местах, где должна происходить какая-то реакция, прямо внутри сущности сохранять эти события. Я буду показывать сериализацию Богарда с одним небольшим изменением, но важным. То есть это настоящая инкапсуляция, а не профанация. В данном случае вот этот геттер email — это также класс User, и этот класс, это свойство не притворяется свойством с автогеттерами и сеттерами, а действительно что-то добавляет к этому. Это событие пока никуда не попадает, оно у нас есть только во внутреннем списке сущностей. Когда меняем, мы проверяем, что email другой, и выбрасываем событие.

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

С таким подходом есть ещё одно неочевидное преимущество. Реализация этого диспетчера — это тема отдельного разговора, там есть некоторые сложности с multiple dispatch в C#, но это всё тоже делается. Они абсолютно не связаны друг с другом, они пишут разный код, они связаны только на уровне этого доменного события одного класса Dto. Теперь, если у нас есть два разработчика, один может писать код, который изменяет вот этот самый email, а другой может делать модуль нотификаций. Первый разработчик этот класс просто в какой-то момент выбрасывает, второй на него реагирует и знает, что это надо отправлять на email, SMS, push-уведомления на телефон и все остальные миллион уведомлений с учётом всяких предпочтений пользователей, которые обычно бывают.

В статье Джимми используется перегрузка метода SaveChanges, и лучше этого не делать. Вот то самое небольшое, но важное замечание. С этим можно работать, но решения получаются чуть менее удобные и чуть менее красивые. А сделать это лучше в декораторе, потому что, если мы перегружаем метод SaveChanges и нам в Handler'е потребовался dbContext, мы получим циклические зависимости. Поэтому, если pipeline построен на декораторах, то смысла делать по-другому я не вижу.

Логирование и профилирование

Итого было три уровня вложенности, теперь каждый этот уровень вложенности находится в своем декораторе. Вложенность кода осталась, но в первоначальном примере у нас был сначала using MiniProfiler, потом — try catch, потом — if. Кроме того, видно, что в этих декораторах только одна ответственность. И внутри декоратора, который у нас отвечает за профилирование, у нас только один уровень вложенности, код читается отлично. Если декоратор отвечает за логирование, то он будет только логировать, если за профилирование, соответственно, только профилировать, всё остальное находится в других местах.

Response

После того, как весь pipeline отработал, нам остается только взять Dto и отправить дальше браузеру, сериализовать JSON.

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

Почему? Не могу здесь не упомянуть ещё раз Скотта Влашина и его доклад «Railway oriented programming». В F# это действительно работает очень хорошо, потому что F# — это функциональный язык, и Скотт использует возможности функционального языка. Оригинальный доклад целиком и полностью посвящён работе с ошибками на языке F#, тому, как можно организовать flow немножко по-другому и почему такой подход может быть более предпочтительным, чем использование Exception'ов.

Вместо того, чтобы выбрасывать исключения, мы объявляем такой класс Result, у которого есть успешная ветка и есть неуспешная ветка. Так как, наверное, большинство из вас всё-таки пишет на C#, то, если написать аналог на C#, то этот подход будет выглядеть примерно следующим образом. Класс может находиться только в одном состоянии. Соответственно два конструктора. Этот класс является частным случаем типа-объединения, discriminated union из F#, но переписанный на C#, потому что встроенной поддержки в C# нет.

Опять же, в F# это был бы встроенный в язык Pattern Matching, в C# приходится писать отдельный метод, в который мы передадим одну функцию, которая знает, что делать с успешным результатом операции, как его преобразовать дальше по цепочке, и что с ошибкой. Вместо того, чтобы объявлять публичные геттеры, которые в коде кто-то может не проверить на null, используется Pattern Matching. В F# это всё работает очень хорошо, потому что там есть функциональная композиция, ну и всё остальное, что я уже перечислил. То есть независимо от того, какая ветка у нас сработала, мы должны скастить это к одному возвращаемому результату. NET это работает несколько хуже, потому что как только у вас происходит не один Result, а много — а практически каждый метод может по тем или иным причинам закончиться неудачей — почти все ваши результирующие типы функции становятся типа Result, и вам надо их как-то комбинировать. В .

В общем-то получается калька с do-нотации Haskell или с тех же самых Computation Expressions в F#. Самый простой способ их скомбинировать — использовать LINQ, потому что вообще-то LINQ работает не только с IEnumerable, если доопределить методы SelectMany и Select правильным образом, тогда компилятор C# увидит, что можно использовать для этих типов LINQ-синтаксис. Вот у нас есть три результата операции, и если там во всех трёх случаях всё хорошо, тогда возьми эти результаты r1 + r2 + r3 и сложи. Как это следует читать? В общем-то, это даже рабочий подход, если бы не одно но. Тип результирующего значения тоже будет Result, но новый Result, который мы объявляем в Select'е.

«Это плохие страшные Exception'ы, не пишите их! Для всех остальных разработчиков, как только вы начинаете писать такой код на C#, вы начинаете выглядеть примерно вот так. Лучше пишите код, который никто не понимает и не сможет отладить!» Они — зло!

C# — это не F#, он несколько отличается, там нет разных концепций, на основе которых это делается, и когда мы так пытаемся натянуть сову на глобус, получается, мягко говоря, непривычно.

В ASP. Вместо этого можно использовать встроенные нормальные средства, которые задокументированы, которые все знают и которые не будут вызывать у разработчиков когнитивный диссонанс. NET есть глобальный Handler Exception'ов.

Если проблема с аутентификацией и авторизацией, есть 401 и 403. Мы знаем, что, если с валидацией какие-то проблемы, надо вернуть код 400 или 422 (Unprocessable Entity). А если что-то пошло не так и вы хотите сказать пользователю, что именно, определите свой тип Exception'а, скажите, что это IHasUserMessage, объявите в этом интерфейсе геттер Message и просто проверьте: если этот интерфейс реализован, значит, можно взять сообщение из Exception'а и передать его в JSON пользователю. Если что-то пошло не так, то что-то пошло не так. Если этот интерфейс не реализован, значит, там какая-то системная ошибка, и пользователям мы скажем просто, что что-то пошло не так, мы уже занимаемся, мы всё знаем — ну как обычно.

Query Pipeline

На этом с командами завершаем и смотрим, что же у нас в Read-стеке. То, что касается непосредственно запроса, валидации, ответа — это примерно всё то же самое, не будем отдельно останавливаться. Здесь может быть ещё дополнительно кэш, но в общем-то с кэшем тоже нет каких-то больших проблем.

Security

Посмотрим лучше на проверку безопасности. Там может быть тоже такой же декоратор Security, который проверяет, можно ли делать этот запрос или нет:

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

Мы можем доопределить интерфейс (IPermissionFilter), в который приходит оригинальный queryable и возвращается queryable. Проблема решается довольно просто. Опять же, если у вас есть два программиста, один программист идёт писать permission'ы, он знает, что ему надо написать просто очень много permissionFilter'ов и проверить, что по всем сущностям они работают правильно. Разница в том, что к тому queryable, который возвращается, мы уже навесили дополнительные условия where, проверили текущего пользователя и сказали: «Вот этому пользователю верни только те данные, которые…» — а дальше вся ваша логика, которая связана с permission'ами. Потому что они получают на входе уже не оригинальный queryable из dbContext, а ограниченный фильтрами. А другие программисты ничего не знают про permission'ы, в их списке просто всегда проходят правильные данные, вот и всё. В итоге получаем результирующий permissionFilter, который максимально сузит выборку данных с учётом всех условий, которые для данной сущности подходят. У такого permissionFilter'а тоже есть свойство компоновки, мы можем все permissionFilter'ы сложить и применить.

Опять же для того, чтобы не городить себе всякие циклические зависимости и не тащить в context всякую дополнительную историю про ваш бизнес-слой. Почему это не делать встроенными средствами ORM, например, Global Filters в entity-фреймворке?

Query Pipeline. Read Model

Осталось посмотреть на модель чтения. В парадигме CQRS не используется доменная модель в стеке чтения, вместо этого мы просто сразу формируем те Dto, которые нужны браузеру в данный момент.

Вообще эту задачу можно решить раз и навсегда вот таким LinqQueryHandler'ом. Если мы пишем на C#, то, скорее всего, мы используем LINQ, если нет только каких-то чудовищных требований по производительности, а если они есть, то, возможно, у вас не корпоративное приложение. Ещё она работает только с какими-то типами сущностей и знает, как эти сущности преобразовать к проекциям и вернуть список таких проекций уже в виде Dto в браузер. Здесь довольно страшный constraint на дженерик: это вот Query, который возвращает список проекций, и она ещё может фильтровать вот эти проекции и сортировать вот эти проекции.

На всякий случай проверим, реализует ли этот TQuery фильтр для изначальной сущности. Реализация метода Handle может быть такая, довольно простая. Если кто-то до сих пор не знает, AutoMapper может строить проекции в LINQ, то есть те, которые будут строить метод Select, а не маппить это в памяти. Дальше делаем проекцию, это queryable extension AutoMapper'а.

Как именно всё это делается, я рассказывал в Питере на DotNext, это ещё один целый доклад, он уже выложен в свободный доступ и расшифрован на Хабре, можете послушать, посмотреть, прочитать, как написать с помощью expression'ов фильтрацию, сортировку и проекции для чего угодно один раз и дальше повторно использовать. Дальше применяем фильтрацию, сортировку и выдаём всё это в браузер.

Не все выражения одинаково полезны транслируются в SQL

Двигаемся дальше. Одна тема, которую я не осветил на прошлом DotNext'е, — это проблемы с трансляцией в SQL. Потому что в Select мы, конечно, можем написать всё что мы хотим, но queryable-провайдеры не всё разберут.

У нас есть список постов, у них есть Title, и Title мы хотим вводить как название хаба, а потом название самого поста. Раз уж речь зашла про Хабр, давайте на примере Хабра. А вот если мы хотим вывести такой SubTitle, когда в последний раз обновляли статью, когда её создали, и хотим ещё использовать какой-то свой кастомный формат для этих дат, вот с этим queryable-провайдер уже не справится. Вот с этой проекцией проблем нет, всё преобразуется. Он не в курсе, что за кастомный формат объявлен в нашем коде.

Вместо того, чтобы пытаться сделать проекцию, мы делаем проекцию на примитивы. И есть один довольно простой трюк, который эту проблему решает. Далее помечаем это всё «JsonIgnore», чтобы сериализатор проигнорировал эти поля. То есть вытаскиваем всё, что нам нужно, сначала. То есть вместо того, чтобы делать это в проекции, мы это делаем уже в памяти. И объявляем тот метод, который нам нужен, в Dto. Тогда он возьмёт его, вызовет этот метод, и дальше мы уже в памяти домаппим то, что нам нужно, то, что мы не смогли преобразовать в проекции. Когда сериализатор начнёт преобразование класса в JSON, он увидит, что Created и LastUpdated он должен пропустить, а SubTitle — это публичное свойство, надо его взять. В большинстве случаев такой простой трюк решает проблему с тем, что какие-то выражения не могут быть преобразованы.

Они, в общем-то, довольно похожи и отличаются только тем, какие шаги мы собрали. Давайте посмотрим на оба стека вместе. Вот запросы мы будем кэшировать — а в командах это уже нам, допустим, не потребуется. В зависимости от того, какой pipeline, мы можем применять разные декораторы. Когда пайплайны собраны и мы понимаем, что их ограниченное количество, такие декораторы можно взять и оформить в виде отдельных библиотек, положить на NuGet, и дальше просто подключать в виде повторно используемых модулей. Аналогично, команды мы хотим вызывать в SaveChanges, а в Query не надо вызывать SaveChanges.

Вы можете отдельно писать домен, а инфраструктуру передать какому-то другому разработчику, которой оформит вам эти модули, и вы будете ими пользоваться. Потому что в коде декораторов нет ничего про домен. Соответственно, отличный вариант, если вы можете взять и сказать: «Нам нужны вот такие декораторы», — и их купить. Если вы знакомы, например, с трудами Брукса, наверное, знаете, что самый простой способ написать код — это его купить. Никакой ответственности.

Регистрация декораторов

Если декораторы такие замечательные, как же их регистрировать?

Не совсем красиво. Регистрировать их придётся как-то вот так.

Можно взять MediatR Джимми Богарда, в котором это всё уже есть и есть документация. Хотя руками, конечно, никто это не делает, это всё происходит через контейнеры. Но смысл тот же, там так же определены методы Request/Response, RequestHandler’ы и методы для регистрации этих декораторов. Всё, о чём я рассказывал, такие же декораторы — правда, у него в MediatR это называется pipeline behaviour. А можно взять Simple Injector, у которого декоратор — это прямо фишка фреймворка.

И сейчас вернёмся вот на этот слайд, помните, я говорил, что нам потребуется ещё раз этот дженерик, где TIn: ICommand.

То есть вы можете там, где будете регистрировать декоратор, указать, что если декоратор с constraint’ом, то он будет применяться только к тем Handler’ам, у которых есть такой constraint. Вот в Simple Injector’е поддерживается регистрация декораторов на основе constraint’ов дополнительно. Ну, получается ещё одна маленькая красивая фишечка, которая позволяет на системе типов строить вот такую логику приложения, что к чему должно применяться. Соответственно, если у нас есть constraint ICommand, мы можем сделать декоратор на SaveChanges тоже с constraint’ом на ICommand, и Simple Injector автоматически поймёт, что эти два constraint’а одинаковые, и будет применять этот декоратор только к соответствующему Handler’у.

Simple Injector или MeriatR — в принципе, на вкус и цвет все фломастеры разные, кроме того в Autofac’е, по-моему, тоже есть декораторы и в других контейнерах может быть тоже, я просто не слежу, не знаю. Что использовать? Если интересно, посмотрите.

Организация по модулям, а не слоям

Во всём моём текущем докладе не хватает ещё одного слова, чтобы кричать «бинго».

Нельзя же было упомянуть много умных людей и забыть про дядюшку Боба Мартина. Даже двух слов, а именно «Clean architecture».

Современные веб-приложения почему-то очень любят рассказывать о том, что они MVC, какие они замечательные, какая у них структура.

Вместо того, чтобы сказать: «Я — MVC-приложение», можно сказать: «У меня есть следующие Features, то есть такая функциональность: у меня есть менеджмент аккаунтов, у меня есть Blog и у меня есть какой-то Import, то есть три каких-то больших модуля». Вместо этого, и Боб Мартин, и многие другие, и в том числе Angular, кстати, уже предлагает структурировать приложение на основе того, какие там есть модули в системе, то есть какая функциональность.

Но для менеджмента MVC абсолютно неважно. Может быть, программистам, конечно, удобнее знать, что это MVC-приложение, нам нравится, что там какие-то технические подробности, детали. То есть эта структура соответствует функциональности системы. А вот такая структура, когда человек понимает, сколько у него есть фич — это для бизнеса гораздо важнее.

Я же обещал всё-таки не давить авторитетами и тем, что кунг-фу сильнее другого кунг-фу, поэтому я приведу и другие преимущества такого оформления.

Если у нас есть разные модули и мы хотим добавить новый модуль, это новая папка. Во-первых, код в таком случае добавляется, а не редактируется. Причём даже без того, что они изменили сигнатуры или что-то вроде, а просто у них где-то поменялись строчки, типы. Не получится такого, что в модуле А и в модуле Б есть какая-то работа с юзерами, поэтому программисты Вася и Петя оба пошли исправлять User Service, дальше отправили pull request’ы, и тут внезапно случился конфликт, потому что они оба изменили этот User Service в соответствии с тем, как считали нужным. Какие-то банальные технические вещи могут приводить к тому, что на этапе код ревью может случиться конфликт и это затянет релизный цикл.

Такая организация кода побуждает нас лучше думать о том, какие контексты есть в приложении и как его правильно делить. Следующий момент. Соответственно, там, где есть, мы узнаем, что они есть, на этапе разделения по модулям, потому что мы увидим, что вот этот модуль почему-то зависит от другого. То есть, если мы делим правильно, то в идеале мы должны не создавать вообще лишних зависимостей между модулями там, где их нет. Довольно удобно. И если так получилось, что наши модули полностью независимы (а так тоже можно сделать, но с некоторыми оговорками), тогда фичу можно будет удалить, просто нажав кнопочку «Delete»: мы удаляем папку, и её больше нет в программе, и всё.

И если бы код был написан в обычном слоёном стиле, так бы не получилось: все эти сервисы, относящиеся к разным модулям, мы бы не смогли выкинуть, потому что были бы лишние зависимости. В практике нам раза два приходилось проводить такие действия — слово «рефакторинг», наверное, не совсем правильно, когда выбрасываешь весь код и заново переписываешь, это скорее рерайт. Я не буду вдаваться в подробности, почему так пришлось сделать, но иногда бывает. А так мы просто выкинули несколько косячных модулей и потом переписали, когда руки дошли. То есть это произошло не потому, что были плохие и глупые программисты, а потому что так сложились обстоятельства.

Когда я говорю «численными методами», я опять же делаю реверанс в сторону менеджмента: мы начинаем считать количество фич, количество возвратов с код ревью, количество возвратов с тестирования и вот это вот всё. И последний момент: такое разделение упрощает работу численными методами и коммуникацию. А когда мы начинаем класть код таким образом, становится чуть легче. Помните, когда я формулировал критерии, обратил внимание на то, что довольно сложно отслеживать связь между регрессией, багами, которые дошли до продакшна, и тем, почему так произошло. И вот дальше мы уже смотрим историю изменения в VCS именно по этому модулю: а что ж он пролез-то на продакшн, какие там коммиты были? Потому что, если приходит какой-то pull request на редактирование существующих модулей, вариант номер один — изменились требования, вариант номер два — что-то пошло не так, баг пролез на продакшн. Если эти коммиты находятся в этом модуле, в них ещё как-то можно разобраться, а если они просто размазаны по всем нашим слоям, разбираться становится сильно сложнее.

А именно: это не работает из коробки. Несмотря на это, всё, о чем я говорил, не лишено недостатков. В идеале, написать свой шаблон проекта, в который уже будет подключено всё, что нужно, будет структура проектов. То есть если вы возьмёте просто шаблон проекта, вам придётся дописывать инфраструктурный код. Ну, один раз, допустим. Но уходит на это, наверное, не меньше рабочего дня, если вы с нуля это делаете. На то, чтобы собрать зависимости, у меня ушло несколько лет — с тем, как изменялась моя мысль о том, как писать код. Когда я говорю «рабочий день», это в смысле у нас уже всё готово, вам надо только зависимости собрать.

То есть к нему предъявляются более серьёзные требования в плане качества. Это иллюстрирует второй пункт данного слайда: этот инфраструктурный код писать, что-то переопределять, дописывать. А вот если вы начинаете публиковать какие-то библиотеки в свободный доступ и они с косяками, и кто-то их подключил и это уже не один проект, а на этом завязана у вас, допустим, работа всей компании или проекта клиентов, это становится сильно сложнее. В каком-то прикладном коде вы можете писать так, как у вас принято, потому что, если придёт баг, вы поправите. Риски этого дела выше.

Если вы захотите организовать работу с кодом следующим образом, вам нужно будет объявить вот такой IHandler в качестве основного строительного блока. Резюмируем. Он будет выполнять операции.

Очень круто звучащее словосочетание, значит на самом деле просто, что оно выполняется в рамках одной транзакции. Дальше этот IHandler расширяем двумя интерфейсами ICommandHandler и IQueryHandler и говорим, что это холистические абстракции. То есть, если есть CommandHandler, внутри него не будет другого CommandHandler’а, он действует на протяжении всего этого запроса.

Это исключает флейм на тему того, что можно там Query использовать в командах, команды в Query — вот это всё. Почему так? Если вам нужен повторно используемый код, который придётся использовать и там, и там, тогда вы объявляете Hander, если вы объявляете CommandHandler или QueryHandler, это значит какой-то конкретный use case, это не должно повторно использоваться.

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

Потому что позволяет на этапе компиляции узнавать об ошибках, а не в рантайме. Система типов и инварианты сильно лучше, чем валидация. Но не на границах, потому что на границах программы не объектно-ориентированы.

Не такая, конечно, крутая, как в функциональных языках программирования, но лучше. И мы всё ещё ждём C# 8, чтобы появился nullable reference type и наша система типов стала получше.

События можно трекать в рамках транзакции с помощью ChangeTracker’а ORM.

Есть вариант, в котором всё-таки надо отказаться от этих исключений, там может быть какое-то ограничение по производительности, например. И Exception’ы — это нормальный вариант для ошибок, если не писать на F#, если мы пишем на C#. NET. Но если у вас возникают ограничения по производительности, связанные с тем, что у вас слишком много Exception’ов, возможно, вам не нужен там и LINQ, и всё остальное, и всё, что я вам рассказывал, это не совсем для вас, вам нужны хранимые процедуры, Dapper и что-то ещё, и, может быть, даже не .

Да, это всё действительно тормозит, но тормозит какие-то миллисекунды, то есть это меньше, чем сетевые задержки к вашей базе данных. А если у нас нет таких страшных требований к производительности, тогда LINQ, автоматические проекции, permission’ы — это всё отлично. Ну и структурирование приложения по фиче, а не по слоям — более предпочтительный способ.

Вот ссылки: Я упомянул в докладе огромное количество людей и всяких идей.

Слева у нас нетленочка Эрика Эванса. Последний слайд — немножко рекомендуемой литературы. То есть идеи можно и на C# переносить, но за одним исключением, чтобы не выглядеть как на том слайде про Exception’ы. Вторая книжка — это книга Скотта Влашина «Domain Modeling Made Functional», она про F#, но даже если вы не хотите никогда писать на F#, я всё равно её рекомендую прочитать, потому что она здорово структурирована, там очень чётко изложены мысли, просто с точки зрения здравого смысла и того, что два плюс два равно четыре.

Я её здесь разместил не потому, что она про Entity Framework, а потому что там есть целый раздел про то, как использовать всякие варианты DDD с ORM, то есть то, где нам ORM начинает мешать в плане реализации DDD и как это обходить. И последняя, может быть, неочевидная книга — это «Entity Framework Core In Action».

15-16 мая 2019 состоится . Минутка рекламы. Программу можно посмотреть по ссылке, там же на сайте можно приобрести билеты. NET-конференция DotNext Piter, где я состою в программном комитете.

Теги
Показать больше

Похожие статьи

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

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

Кнопка «Наверх»
Закрыть