Это продолжение текста про архитектуры интерпрайз-систем. Рассуждения это хорошо, но какой в них толк без практического применения. Я покажу свой фреймворк в деле.
Всё началось с того, что я рассказывал про проблематику проектирования приложений на .NET и ныл про нелёгкую жизнь в кровавом интерпрайзе. Затем я описал решение, которое сам придумал и реализовал — Reinforced.Tecture. То была теория, концептуальные рассуждения, визионёрство и снова нытьё. На этот раз о том, что на дворе 2020 год, а HKT в C# так и не завезли.
Сегодня я продемонстрирую свой подход в действии на примере простенького проекта и покажу профиты, которые он даёт: от сокращения количества кода до автоматизации тестирования и оригинального подхода к документации. Как советовал старина Торвальдс: "Болтовня ничего не стоит, покажите мне код".
Итак, надо сделать на Tecture что-нибудь простое, но работающее. Раз мы говорим про интерпрайз — я подберу пример, отдалённо напоминающий настоящий бизнес.
Нам понадобится:
- Простая сущность. В голову сразу приходят продукты и заказы. Пусть будут продукты;
- EF-ный DbContext и локальная база данных;
- Игрушечная бизнес-логика;
- Простенький web-проект. Всё чин по чину, ASP.NET Core, WebAPI. В него логику и воткнём.
Подготовка
Структура проекта будет такая:
Я подключил EF.Core к сборке Data
, закинул туда DbContext и glue-код для миграций. Потому что хочу оставить логику на .NET Standard и не тащить EF с собой.
Обычно сущности кладутся в сборку с DAL-ом, рядом с контекстом. Здесь же зависимость развёрнута в обратную сторону — сборка с контекстом знает о всей логике. Это кажется контринтуитивным, но на самом деле вполне нормально для Tecture. Не стоит этому удивляться.
Поведение Tecture мы будем смотреть на примере работы с продуктами. Вот его сущность, а логика вокруг неё будет простая и очень глупая:
Код DbContext
-а абсолютно шаблонный, спрячу-ка я его под спойлер. В рамках тестового проекта я кладу болт на управление строкой подключения и тонкую настройку контекста — это сейчас не важно. EF я использую как рантайм, не более. Он не оказывает влияния на логику и вообще его поддержка подтягивается из отдельного пакета. EF для меня — удобный инструмент реализации ORM-аспекта. Но моя логика при этом остаётся чистой и свободной от сопутствующих EF-зависимостей.
Между делом я сделал базу данных в локальном инстансе MS SQL Express. Это первое что попалось мне под руку — окружение уже было настроено на моей домашней машине. EF.Core более-менее поддерживает и остальные базы — вроде MySQL и PostgreSQL. Однако, на уровне аспекта, где работает Tecture, становится совершенно пофигу. Просто мне так удобнее проводить демонстрацию. Никаких скрытых зависимостей от базы данных тут нет.
Короче, пора втащить в логику первую зависимость. Докинем в неё Reinforced.Tecture и Reinforced.Tecture.Aspects.Orm.
Я рассказал про каналы в предыдущей статье и вот они мне пригодились. У меня будет простой канал для базы данных с единственным аспектом, отвечающим за O/RM:
Тут же, чтобы два раза не вставать я сразу сделаю экстеншоны для доставания сущностей по Id. Они используются везде и в своих экспериментах я копипащу эти строки заранее, дабы исключить появление репозиториев на раннем этапе. Чуток кода и готово:
Интеграция
На этом этапе проект уже вполне рабочий. Осталось только присоеденить Tecture к желаемому end-user решению. В нашем случае это web-проект. Я открываю его, иду в Startup.cs
, и ищу там метод ConfigureServices
. Это внутренний DI-контейнер, который идёт в комплекте с ASP.NET MVC. Для демонстрационных целей он вполне норм, поэтому я заталкиваю в него AcmeDbContext
:
Теперь самое время поставить рантайм Tecture для взаимодействия с базой через EF. Прямо в web-проект. В моём рантайме реализованы 2 аспекта: O/RM и DirectSQL. DirectSQL я не буду здесь демонстрировать, но он есть. Как я уже сказал, в сборке с бизнес-логикой рантайм не нужен. Он должен подключаться только в ту часть системы, где бизнес-логика непосредственно вызывается, что сильно облегчает dll-ку с логикой на предмет зависимостей. Сам же рантайм вполне может реализовывать несколько аспектов за раз. Это не запрещено и — опять же — экономит зависимости:
Теперь надо засунуть Tecture в контейнер. Это делается через фэктори метод. Для наглядности я вынес его отдельно и снабдил комментариями. Тут я просто извлекаю из контейнера AcmeDbContext
, заворачиваем его в LazyDisposable
(это гибрид Lazy и Disposable, как следует из названия) и скармливаем его рантайму. Далее, для каждого канала мы указываем что вот именно этот контекст EF и надо использовать. Делается это с помощью входящих в комплект поставки рантайма fluent-методов:
По задумке такой интеграционный код пишется только один раз и не меняется примерно никогда. Принцип "настроил и забыл" в действии. Для сложных многоканальных систем, конечно, можно нагородить всякие обёртки над построителем Tecture, чтобы настраивать его на работу с разными комбинациями внешних систем, но в нашем приложении такое пока не нужно. Тут мы видим separation of concerns: пусть лучше действительно сложный и чувствительный кусок системы будет краток, прост, читаем и — главное — написан один раз. Далее, на ваше усмотрение — можете вынести его в отдельный репозиторий, единожды собрать и просто использовать.
Вообще я стараюсь возложить максимум ответственности на рантаймы и аспекты. По сути я выношу в них всю реальную проектировочную работу, которую необходимо сделать в приложении. По задумке, обслуживать это дело должен системный архитектор. Я полагаю, в любой компании найдётся бородатый разработчик, который сможет один раз в этом разобраться, настроить и больше никогда не трогать. Но если такого нет — можно воспользоваться моими аспектами и моим рантаймом. Таким образом, пресловутые separation of concerns я поднимаю на организационный уровень.
Но в приложении всё равно остаётся вот это вот узкое место, где всё собирается воедино: задаётся конекшон стринг к базе, поднимаются подключения к кэшам, проверяется связь с очередью. Я и сам не могу долго держать в голове всё хитросплетение конфигурации и быстро устаю от работы с интеграционным glue-кодом. Всё, что я могу сделать — свести к минимуму количество мест, требующих такой концентрации внимания.
Tecture предоставляет единую точку связывания и не даёт размазывать интеграционный код по системе, чтобы его поддержкой не занимались все подряд. Пусть лучше всё тот же архитект отвечает и за это место.
Как бы то ни было, теперь можно использовать ITecture
из контроллеров. Давайте поиграем с ним.
Запросы
Я хочу написать совершенно тупой и очевидный веб-метод, который будет отдавать нам продукт по Id. Держу пари, у всех такой есть. И у всех на такой случай есть DTOшка. Вот, например, моя:
Теперь мы идём в контроллер, прокидываем ITecture
в конструктор, выдёргиваем его в отдельное поле, через которое получаем долгожданный доступ к методу From<>
. Используем его для того, чтобы вытянуть продукт по Id и смапить на DTO-шку:
И на этом, в общем, всё. Таким способом можно писать все методы для возврата всех сущностей по Id с DTO-шками без единого репозитория. Можно дать волю фантазии и не стесняться использовать компилятор C# на полную. Скажем, возврат множества DTO-шек можно обыграть таким способом:
Или таким:
Можно даже фигачить расширения непосредственно для IQueryable
, дёргая их из читального конца канала через метод All<>
, предоставляемый аспектом. Тут действительно можно дать фантазии развернуться. Хочешь — делай развесистый построитель запроса вокруг конца канала, со своими промежуточными абстракциями. Хочешь — строй фасады к AutoMapper, используя его копирующие expression-ы. Всё это в любом случае — статика без контекста. Она легко покрывается тестами при желании, не требует базы данных и сборки контейнера. Можно даже сделать свой аспект и выкинуть туда всё, что касается запросов. Короче, бескрайнее поле для творчества и самореализации. Я же не буду в нём зависать, потому что впереди ещё много интересного.
Операции
К сожалению, продуктов у нас в базе нет и что-то запрашивать из неё бесполезно. Нужен способ заслать туда данные. Как я уже говорил, добавление в Tecture делается через сервисы. Вот я и сделаю сервис для работы с продуктами. За полсекунды, я набросал вот такую заготовку вот в этом месте:
Я уже говорил, что в сервисах есть тулинги и они очень удобны. Я хочу остановиться и прям показать как они работают используя анимацию. Конкретно эти тулинги взяты из ORM-аспекта — зацените механику:
Кстати, нам понадобится Id свежедобавленного продукта. Когда я делал аспект ORM — долго думал как это обыгрыть. Ведь создание сущности — это команда, а получение Id — запрос. Как быть? Я выкрутился: команда Add
экстендит интерфейс IAddition<>
. После логической операции, саму команды можно смело вернуть из метода логики как IAddition<Product>
. А уже после сохранения скормить аддишен методу Key
читального конца канала. Это даст нам вожделенный Id. Но надо ещё указать где именно у сущности первичный ключ. Я реализовал эту механику через интерфейс IPrimaryKey<>
. Вот:
Готово. Теперь можно вернуться в контроллер и наконец-то призвать наш сервис:
Postman сказал мне что это работает и вернул Id нового продукта.
Теперь у нас есть маленькое бизнес-приложение, сделанное на Tecture. Мы уже получили чёткую грануляцию проекта, удобные примитивы и минимум кода, но это только начало. Сейчас я перейду к самому интересному — к конкретным преимуществам, которыми меня щедро осыпает такой подход.
Я делаю свой архитектурный фреймворк чтобы срезать косты. Никогда бы в это не ввязался, если бы не понимал насколько правильный дизайн и архитектура может сократить затраты на обслуживание и какой груз можно снять с плеч разработчиков.
Я стараюсь быть практичным и всегда считал что архитектура решения сильнее всего влияет на цену поддержки и развития. Ещё в университете на парах по управлению проектами мне показывали статистику запоротых разработок в американской авиаиндустрии. Я чётко помню что в 90% случаев провал определялся ошибками на стадии раннего проектирования и управления требованиями. Это наталкивает на мысль, что если сначала думать, а потом делать — то можно не только предотвратить наступание на грабли, но и драматически снизить стоимость поддержки.
Чем ниже цена, которую платит бизнес за поддержку софта — тем круче. Чем меньше накладные расходы — тем больше денег остаётся и можно потратить их на развитие. А ещё лучше — положить мне в карман.
Я убеждён что только очень меркантильный человек мог придумать следующие фичи.
Трейсы
Вернёмся к коду контроллера. В Tecture есть неприметные методы BeginTrace
и EndTrace
. Я окружаю ими содержимое экшона по периметру. Вот так:
Помимо них тут есть вызов Explain
. Так я прошу Tecture объяснить мне что происходит между началом и концом трассировки. Втыкаю точку останова на return
и запускаю:
Бам! Человеческим языком мне рассказали что конкретно произошло в логике. Само собой, тут и логика-то из трёх с половиной действий. Но когда проект обрастёт сервисами, тонкой нюансировкой, зависимостями и прочими сложностями, которые порождает бизнес — трейс всё так же будет объяснять что прочитано и записано в базы, очереди, кэши, файлы. И если мы не будем лениться, а приложим совсем немного усилий добавляя командам и запросам аннотации, то текст трейса станет ещё более осмысленным:
Запросы аннотируются методом .Describe
.
Это большой прирост информативности и вот почему: писать доку к интерпрайз-приложению сложнее чем уволиться. Это долго, дорого, трудно и требует коммуникации с заказчиком, который сам уже забыл что хотел. А после написания — такую доку надо постоянно держать в актуальном состоянии, докидывая туда информации после каждой новой фичи.
Писать комменты в коде, конечно, тоже можно. Но логика меняется, куски кода в ней переставляются с места на место и через пару спринтов никакой уверенности в актуальности комментариев и их смысловой связанности не остаётся. Я бы предпочёл чтобы за этим следила машина, а не человек. Конечно я не призываю отменить комментарии в коде, но надо признать что они слабоваты и их возможностей не хватает.
Tecture предлагает пойти другим путём: прибить описание происходящего к командам и запросам. Дать маленькое пояснение к каждому конкретному действию. Это сверх-дёшево, занимает считанные секунды и устойчиво к перестановке кусков кода с места на место. Потом фреймворк сам сложит эти описания вместе и красиво, последовательно и предельно понятно расскажет что же всё-таки случилось, когда вы нажали ту мелкую кнопку в углу экрана. К тому же, это композабельно. То есть если один метод документирован через аннотации, то его вызов будет выдавать эту документацию где бы вы его ни использовали, позволяя оценить происходящее в динамике и поделиться этим знанием с товарищами. Knowledge management!
Ещё есть интерфейс IDescriptive
, который можно реализовывать, скажем, у сущностей. Он нужен чтобы вместо "User entity" у вас выводилось "User Vasiliy Pupkin". Это сделает трейс совсем похожим на человеческий текст и если звёзды сложатся, то он будет пригоден для обсуждения с заказчиком. Гораздо удобнее планировать изменения в системе, когда все понимают что она делает.
В прошлой статье я говорил, что по сути бизнес-логика отдаёт команды внешним системам. Explain
трейса — это отчёт с авторскими комментариями о том, какие внешние системы были затронуты, что у них запрошено и что им отдано. Это ли не киберпанк, который мы заслужили: приложение, которое само является источником информации о себе независимо от изменений требований.
Но это ещё не всё.
Захват данных
Трейс включает в себя глубокие копии всех ответов на все запросы. Эту информацию можно извлечь в любую структуру данных и я распоряжусь ей элегантно. Докинем в web-проект ещё один пакет: Reinforced.Tecture.Testing
. Он тяжеловат — по зависимостям тянет за собой Roslyn. Не надо так делать в живых приложениях, но я это сделаю исключительно в демонстрационных целях. И вот для чего:
Этот пакет добавил трейсу 2 эктеншона. GenerateData
и GenerateValidation
. Нас интересует первый метод, который проще всего вызвать:
Смотрите: все ответы на запросы, которые произошли в этом куске логики сконвертились в C#-класс. Я просто нажал пару кнопок, а Tecture уже подготовил мне fake-данные для тестирования. Мне не надо вбивать их руками, не надо подбирать, ставить, и настраивать фейк-генератор, не надо использовать сервисы в духе Mockaroo. У меня уже есть девелоперская база с какими-то данными — я на ней проект дебажу. Для тестов мне придётся эти данные хардкодить, так почему бы не автоматизировать этот процесс?
Но это полдела. Есть ещё один метод из Reinforced.Tecture.Testing
. Его вызвать гораздо сложнее, на целых 4 строчки:
На пальцах: есть бизнес-логика, она генерирует цепочку каких-то команд при определённых вводных (данных из внешних систем + пользовательский ввод). Данные из внешних систем мы уже дампнули в файл с кодом. Значит и команды тоже можем!
Я срезаю углы и минуя все промежуточные форматы, сразу генерю из очереди команд последовательность проверок. В форм-факторе огромной функции-предиката. Разумеется, уровень дотошности проверок можно регулировать.
А всё для того, чтобы...
Unit-тесты
Положим, по нашему куску логики достигнут консенсус относительно корректности. QA и заказчик в один голос кричат: "во, оставь, сейчас работает как надо!". После чего мы берём этот кусок логики, запускаем в тестовом окружении, выдираем данные и валидацию, а потом запечатываем это добро в регрессионный unit-тест.
Настройка CI/CD пайплайна под подобные тесты — дело пары минут. Не надо понимать окружение, базы, кэши, очереди. Не надо ждать пока они запустятся и прогреются. И подчищать их после запуска тоже не надо. Тесты, построенные на захвате данных Tecture и его валидации самодостаточны и запускаются прямо из коробки без всего. И с блеском решаю задачу контроля регрессии: если кто-то меняет логику, эти тесты послушно падают. При том падают тоже композабельно — по всей цепочке связанной функциональности. Цепью красных шариков можно проследить какие части системы похерены.
Вдовесок тесты расскажут что именно пошло не так — запрос изменился, появились лишние действия в логике, какие-то действия исчезли и так далее. Так или иначе, к куску системы, который внезапно стал работать не так, как договаривались будет привлечено внимание. А если всё в порядке — можно будет со спокойной душой снести старые тестовые данные и сгенерить новые — писать руками ведь ничего не надо, достаточно просто перезапустить.
И моки здесь совершенно не нужны. А значит не надо прятать сервисы за интерфейсами. И городить репозитории для запросов тоже не надо.
Дело за малым — подобрать тестовую инфраструктуру, чтобы удобно обыграть вызовы GenerateData
и GenerateValidation
. Тут я ничего конкретного не предлагаю и в NuGet не публикую. В тестовом проекте я сделал вот так, просто для примера.
В частности, с этой инфраструктурой можно писать тесты таким вот изящным способом:
А вот что я делаю, когда логика под тестами меняется:
Таким образом, я трачу на написание осмысленных unit-тестов от силы по 5 минут своего рабочего времени. Это чистый, концентрированный профит.
Остаётся только добавить, что автогенерированный код можно править руками (относясь с осторожностью при перегенерации). Так-то я не планировал полностью вытеснить обычные unit-тесты. Но автогенерация, собака, эффективная, а я слишком ленивый чтобы делать что-то ещё.
Такие вот дела.
Пост-скриптум
На разработку и продумывание этого подхода у меня ушёл год с небольшим. Огромное спасибо всем моим коллегам из разных стран, которые подкидывали мне идеи и рассказывали про беды своих проектов. Я надеюсь, что получилось довольно продуктивно и интересно.
Сейчас мне на работе выделили небольшой кусок системы, чтобы обкатать Tecture. Не хочу говорить ничего раньше времени, но по результатам будет обзорная статья. Само собой, в ходе живого тестирования я калибрую своё решение под нужды реалий — проектирую новые аспекты и нахожу узкие места, которые нужно подкрутить. В целом пока что этот вариант выглядит вполне себе рабочим.
Многое осталось за кадром — управление транзакциями, аспект DirectSql, не описаны протоколы создания рантаймов и аспектов. Я не хочу сильно нагружать читателей информацией, плюс мне нужен небольшой перерыв от публичной активности. Но я не прощаюсь.
Пакеты опубликованы, исходники есть, я на связи в твиттере, телеграме и на github. Если вам вдруг хочется пополнить ряды early adopters и взять Tecture для своего пет-проекта — напишите мне, я постараюсь помочь.
Отдельная благодарность fillpackart, arttom и их сообществу "Мы обречены" за информационную поддержку и редактуру. Смотрите их подкаст, он крутой. Есть даже выпуск со мной.
Успехов!