Хочу поделиться с вами подходом который я уже много лет использую в разработке приложений, в том числе и веб-приложений. Многим разработчикам настольных, серверных и мобильных приложений этот подход хорошо знаком, т.к. является фундаментальным при построении таких приложений, однако в вебе он представлен очень скудно, хотя желающие использовать такой подход однозначно есть. Кроме того на таком подходе написан редактор VS Code.
В результате применения этого подхода вы отвяжетесь от конкретного фреймворка. Сможете легко переключать библиотеку представления внутри вашего приложения, например React, Preact, Vue, Mithril без переписывания бизнес логики, а в большинстве случаев даже вьюхи. Если у вас есть приложение на Angular 1, вы без проблем сможете перевести его на Angular 2+, React, Svelte, WebComponents или даже свою библиотеку представления. Если у вас есть приложение на Angular 2+, но нету специалистов для него, то вы без проблем сможете перевести приложение на более популярную библиотеку без переписывания бизнес логики. А в итоге вообще забыть про проблему миграции с фремворка на фреймворк. Что же это за магия такая?
Для того что бы понять это, лучше всего прочитать книгу Мартина Роберта «Чистая Архитектура» (Robert C.Martin «Clean Architecture»). Краткая выдержка из которого приведена в статье по ссылке.
Основные идеи заложенные в архитектуру:
Идеи описанные в этой книге уже много лет являются основой для построения сложных приложений в самых разных сферах.
Достигается такая гибкость за счет разделения приложения на слои Service, Repository, Model. Я же добавил к Чистой Архитектуре подход MVC и получил следующие слои:
Назначение каждого слоя рассмотрим ниже.
Веб разработка прошла большой путь развития, начиная от простого скриптования на jquery до разработки больших SPA приложений. И сейчас веб приложения стали настолько большими что количество бизнес логики стало сопоставимо или даже превосходить серверные, настольные и мобильные приложения.
Разработчикам которые пишут сложные и большие приложения, а также переносят бизнес логику с сервера на веб приложения для экономии на стоимости серверов, Чистая Архитектура поможет организовать код и отмасштабироваться без проблем до огромных масштабов.
В тоже время если ваша задача просто верстка и анимация лендингов, то Чистую Архитектуру просто некуда вставить. Если ваша бизнес логика находиться на бекенде и ваша задача получить данные, отобразить клиенту и обработать клик по кнопке, то вы не прочувствуете гибкости Чистой Архитектуры, но она может стать отличным плацдармом для взрывного роста приложения.
Чистая архитектура не привязана к какому то конкретному фреймворку, платформе или языку программирования. Десятилетия ее используют для написания настольных приложений. Его эталонную реализацию можно найти во фреймворках для серверных приложений Asp.Net Core, Java Spring и NestJS. Так же она очень популярна при написании iOs и Android приложений. Но в веб разработке он предстал в крайне неудачном виде во фреймворках Angular.
Так как я сам не только Typescript, но и C# разработчик, то для примера возьму эталонную реализацию этой архитектуры для Asp.Net Core.
Вот упрощенный пример приложения:
Если вы не понимаете что в нем написано ничего страшного, дальше мы разберем его по частям каждый фрагмент.
Пример приведен для Asp.Net Core приложения, но для Java Spring, WinForms, Android, React архитектура и код будут такие же, меняется только язык и работа с вьюхой (если она есть).
Единственный фреймворк который пытался использовать Чистую архитектуру был Angular. Но получилось это просто ужасно, что в 1, что в 2+.
И причин для этого много:
Но что же другие фреймворки? React, Vue, Preact, Mithril и прочие являются исключительно библиотеками представления и не предоставляют никакой архитектуры… а архитектура у нас уже есть… осталось собрать всё в единое целое!
Рассматривать Чистую Архитектуру будем на примере выдуманного приложения, максимально приближенного к реальному веб-приложению. Это кабинет в страховой компании, который отображает профиль пользователя, страховые случаи, предлагаемые тарифы страхования и инструменты для работы с этими данными.
В примере будет реализована лишь малая часть функционала, но по ней можно понять где и как располагать остальной функционал. Начинать создавать приложение начнем со слоя Controller, а View слой подключим в самом конце. А по ходу создания рассмотрим каждый слой детальнее.
Controller — отвечает за взаимодействие пользователя с приложением. Это может быть клик по кнопке на веб странице, настольном приложении, мобильном приложении, или ввод команды в консоли линукса, или сетевой запрос, или любое другое IO событие приходящее в приложение.
Самый простой контроллер в чистой архитектуре выглядит следующим образом:
Его задача получить от пользователя событие и запустить бизнес процессы. В идеальном случае Controller ничего не знает про View, и тогда его можно переиспользовать между платформами, например Web, React-Native или Electron.
А теперь давайте напишем контроллер для нашего приложения. Его задача получить профиль пользователя, имеющиеся тарифы и предложить наилучший тариф пользователю:
У нас получился обычной контроллер без чистой архитектуры, если отнаследовать его от React.Component то получим рабочий компонент с логикой. Так пишут очень много разработчиков веб-приложений, но у такого подхода есть много существенных недостатков. Главный из которых невозможность переиспользования логики между компонентами. Ведь рекомендуемый тариф может выводиться не только в личном кабинете, но и на лендинге и множестве других мест для привлечения клиента к услуге.
Для того что бы иметь возможность переиспользовать логику между компонентами ее необходимо вынести в специальный слой, который называется Service.
Service — отвечает за всю бизнес логику приложения. Если Controller'у понадобилось получить, обработать, отправить какие то данные — он делает это через Service. Если нескольким контроллерам понадобилась одна и та же логика, они работают с Service. Но сам слой Service ничего не должен знать о слое Controller и View и окружении в котором он работает.
Давайте вынесем логику из контроллера в сервис и внедрим сервис в контроллер:
Теперь если нескольким контроллерам понадобиться получить профиль пользователя или тарифы, они смогу переиспользовать одну и туже логику из сервисов. В сервисах главное не забывать про SOLID принципы и что каждый сервис отвечает за свою зону ответственности. В данном случае один сервис отвечает за работу с профилем пользователя, а другой сервис за работу с тарифами.
Но что делать если источник данных поменяется, например fetch может смениться на websocket или grps или базу данных, а реальные данные понадобиться заменить тестовыми? И вообще зачем бизнес логике что то знать о источнике данных? Для решениях этих проблем существует слой Repository.
Repository — отвечает за общение с хранилищем данных. В качестве хранилища может выступать сервер, база данных, память, localstorage, sessionstorage или любое другое хранилище. Его задача абстрагировать слой Service от конкретной реализации хранилища.
Давайте вынесем сетевые запросы из сервисов в репозитории, контроллер при этом не меняем:
Теперь достаточно один раз написать запрос к данным и любой сервис сможет переиспользовать этот запрос. Позже мы рассмотрим пример как переопределить репозиторий не трогая код сервиса и внедрить моковый репозиторий на время тестирования.
В сервисе UserProfilService может показаться что он не нужен и контроллер может напрямую обратиться к репозиторию за данными, но это не так. В любой момент в бизнес слое могут появиться или измениться требования, может потребоваться дополнительный запрос или обогатить данные. Поэтому даже когда в слое сервиса нету логики цепочка Controller — Service — Repository должна сохраняться. Это вклад в ваше завтра.
Настало время разобраться что за заданные получает репозиторий, корректные ли они вообще. За это отвечает слой Models.
Models — отвечает за описание структур с которыми работает приложение. Такое описание очень помогает новым разработчикам проекта понять с чем работает приложение. Кроме того его очень удобно использовать для построения баз данных или проведений валидаций данных хранящихся в модели.
Модели в зависимости от типа использования делятся на разные паттерны:
Добавим в приложение модель профиля пользователя и другие модели, и сообщим остальным слоям что теперь мы работаем не с абстрактным объектом, а с вполне конкретным профилем:
Теперь в каком бы слое приложения мы не находились мы точно знаем с какими данными мы работаем. Так же благодаря описанию модели мы нашли ошибку в нашем сервисе. В логике сервиса использовалось свойство userProfile.age, которого на самом деле нет, но есть дата рождения. А для высчитывания возраста необходимо вызвать метод модели userProfile.getAge().
Но есть одна проблема. Если мы попытаемся воспользоваться методами из модели что предоставил текущий репозиторий, то получим исключение. Все дело в том что методы response.json() и JSON.parse() возвращает не нашу модель, а объект JSON, который никак не связан с нашей моделью. Убедиться в этом можно если исполнить команду userProfile instanceof UserProfileDto, получится ложное утверждение. Для того что бы преобразовать данные полученные из внешнего источника к описанной модели существует процесс Десериализации данных.
Deserialization — процесс восстановления необходимой структуры из последовательности байт. Если в данных будет информация не указанная в моделях, она будет проигнорирована. Если в данных будет информация противоречащая описании модели — произойдет ошибка десериализации.
И самое интересно тут то, что при проектировании ES2015 и добавлении ключевого слово class забыли добавить десериализацию… То что во всех языках присутствует из коробки, в ES2015 просто забыли…
Для решения этой проблемы мною была написана библиотека для десериализации TS-Serializable, статью о которой можно прочитать по этой ссылке. Цель которой вернуть потерянный функционал.
Добавляем поддержку десериализации в модели и саму десериализацию в репозиторий:
Теперь во всех слоях приложения можно быть абсолютно уверенным в том что мы работаем именно с теми моделями которые ожидаем. В представлении, контроллере и других слоях можно вызывать методы описанной модели.
Теперь у нас есть почти готовое приложение. Настало время протестировать логику написанную в слоях Controller, Service и Models. Для этого нам необходимо в слое Repository вместо реального запроса на сервер вернуть специально подготовленные тестовые данные. Но как же подменить Repository не трогая того кода который пойдет в продакшен. Для этого существует паттерн Dependency Injection.
Dependency Injection — внедряет зависимости в слои Contoller, Service, Repository и дает возможность переопределить эти зависимости за пределами этих слоев.
В программе слой Controller зависит от слоя Service, а он зависит от слоя Repository. В текущем виде слои сами вызывают свои зависимости через создание экземпляра. А для того что бы переопределить зависимость, слою необходимо из вне задать эту зависимость. Для этого есть множество способов, но самый популярным является передача зависимости как параметр в конструкторе.
Тогда создание программы со всеми зависимости будет выглядеть следующим образом:
Согласитесь — выглядит ужасно. Даже с учетом того что в программе всего две зависимости, это уже выглядит ужасно. Что уже говорить про программы в которых сотни и тысячи зависимостей.
Для решения проблемы понадобится специальный инструмент, а для этого его необходимо найти. Если обратиться к опыту других платформ, например Asp.Net Core то там регистрация зависимостей происходит на этапе инициализации программы и выглядит примерно следующим образом:
а далее фреймворк при создании контроллера уже сам создаст и внедрит эту зависимость.
Но тут есть три существенные проблемы:
В веб приложениях DI используется только в Angular 2+. В Angular 1 при регистрации зависимостей вместо интерфейса использовалась строка, в InversifyJS вместо интрерфейса используется Symbol. И все это реализовано настолько ужасно, что лучше уже много new как в первом примере этого раздела чем эти решения.
Для решения всех трех проблем был придуман собственный DI, а решение для него мне помог найти фреймворк Java Spring и его декоратор autowired. Описание принципа работы этого DI можно прочитать в статье по ссылке, а репозиторий GitHub.
Настало время применить получившийся DI в нашем приложении.
Для внедрения DI на все слои накинем декоратор reflection, который заставит typescript генерировать дополнительную метаинформацию о типах зависимостей. В контроллере где необходимо вызвать зависимости повесим декоратор autowired. А в том месте где программа инициализируется определим в каком окружении какая зависимость будет реализована.
Для репозитория UserProfilRepository создадим такой же репозиторий, но с тестовыми данными вместо реального запроса. В итоге получаем следующий код:
Теперь в любом месте программы есть возможность поменять реализацию любой логики. В нашем примере вместо реального запроса профиля пользователя на сервер в тестовом окружении будут использованы тестовые данные.
В реальной жизни могут встречаться замены в любом месте, например можно поменять логику в каком либо сервисе, реализовывая в продакшене старый сервис, а в рефакторинге уже новый. Проводить A/B тесты с бизнес логикой, менять документо-ориентированную базу на реляционную, и вообще сменить сетевой запрос на вебсокеты. И все это без остановки разработки на переписывание решения.
Настало время увидеть результат работы программы. Для этого существует слой View.
Слой View отвечает за представление данных которые содержатся в слое Controller пользователю. Я в примере буду использовать для этого React, но на его месте может быть любой другой, например Preact, Svelte, Vue, Mithril, WebComponent или любой другой.
Для этого просто отнаследуем наш контроллер от React.Component, и добавим ему метод render с отображением представления:
Добавив всего две строчки и шаблон представления и наш контроллер превратился в компонент реакта с рабочей логикой.
Но даже в такой реализации получилось что мы завязались на библиотеку React с его жизненным циклом, реализации view, и принципе инвалидации представления что противоречит концепции Чистой Архитектуры. А логика и верстка находится в одном файле, что затрудняет параллельную работу верстальщика и разработчика.
Для решения обоих проблем слой view вынесем в отдельный файл, а вместо компонента React сделаем базовый компонент, который будет абстрагировать наш контроллер от конкретной библиотеки представления. Заодно опишем атрибуты которые может принимать компонент.
Получаем следующие изменения:
Теперь наше представление находится в отдельном файле, а контроллер о нем ничего не знает, кроме того что оно есть. Возможно не стоит инжектировать представление через свойство контроллера, а делать это через декоратор как это делает Angular, но это тема для отдельных размышлений.
В базовом компоненте также находится абстракция от жизненного цикла фреймворка. Во всех фреймворках они разные, но они есть во всех фреймворках. Angular это ngOnInit, ngOnChanges, ngOnDestroy. В React и Preact это componentDidMount, shouldComponentUpdate, componentWillUnmount. В Vue это created, updated, destroyed. В Mithril это oncreate, onupdate, onremove. В WebComponents это connectedCallback, attributeChangedCallback, disconnectedCallback. И так в каждой библиотеке. У большинства даже одинаковый или похожий интерфейс.
Кроме того, теперь компоненты библиотеки можно расширять собственной логикой для последующего переиспользования между всеми компонентами. Например внедряя инструменты аналитики, мониторинга, логирования и др.
Осталось лишь оценить что же получилось. Вся программа имеет следующий финальный вид:
В итоге получили модульное масштабируемое приложение с очень маленьким количеством бойлерплейта (1 базовый компонент, 3 строчки на внедрение зависимостей на класс) и очень низкими накладными расходами (фактически только на внедрение зависимостей, все остальное логика). Так же мы не завязаны на какую либо библиотеку представления. Когда умер Angular 1 многие начали переписывать приложения на React. Когда кончились разработчики для Angular 2, многие компании стали страдать из-за скорости разработки. Когда умрет React в очередной раз придется переписывать решения завязанные на его фреймворк и экосистему. Но с Чистой Архитектурой про завязыванием на фреймворк можно забыть.
Для того что бы понять в чем разница, давайте посмотрим как ведется себя Redux при росте приложения.
Как видно из схемы с ростом Redux приложения масштабируется вертикально, Store и количество Reducers также увеличивается и превращается в бутылочное горлышко. А количество накладных расходов на пересоздание Store и поиск нужного Reducer начинает превышать полезную нагрузку.
Проверить соотношение накладных расходов к полезной нагрузке на приложении среднего размера можно простым тестом.
На пересоздания Store в 100 свойств ушло в 8 раз больше времени чем на саму логику. При 1000 элементов это уже 50 раз больше. Кроме того одно действие пользователя может породить целую цепочку экшенов, вызов которых тяжело отлавливать и отлаживать. Можно конечно возразить что 0,04 мс на пересоздание Store это очень мало и тормозить не будет. Но 0,04 мс это на процессоре Core i7 и на один экшен. С учетом более слабых мобильных процессоров и тем что одно действие пользователя может породить десятки экшенов, все это приводит к тому что расчеты не вписываются в 16 мс и создается ощущение что приложение тормозит.
Давайте сравним с тем как растет приложение на Чистой Архитектуре:
Как видно за счет разделение логики и обязанностей между слоями приложение масштабируется горизонтально. Если какому либо компоненту понадобиться обработать данные, он обратиться к соответствующему сервису, не трогая не связанную с его задачей сервисы. После получения данных произойдет перерисовка только одного компонента. Цепочка обработки данных очень короткая и очевидная, и максимум за 4 перехода по коду можно найти нужную логику и отладить ее. Кроме того, если провести аналогию с кирпичной стеной, то мы можем изъять любой кирпичик из этой стены и заменить его на другой никак не повлияв на устойчивость этой стены.
Бонусом получили возможность дополнять или изменять поведения компонентов библиотеки представления. Например реакт при ошибке в представлении не отрисовывает все приложение, если сделать небольшую доработку:
То теперь реакт будет не отрисовывать только тот компонент в котором произошла ошибка. Таким же нехитрым способом можно добавить, мониторинг, аналитику и прочие ништяки.
Модели помимо описания данных и переноса данных между слоями обладают большим запасом возможностей. Например если подключить библиотеку class-validator, то простым навешиванием декораторов можно валидировать данные в этих моделях, в т.ч. при небольшой доработке можно валидировать веб формы.
Кроме того если вам понадобилась работа с локальной базой данных, вы можете подключить библиотеку typeorm и ваши модели превратятся в сущности, по которым будет генерироваться и работать база данных.
Если вам понравилась статья или подход ставьте лайки и не бойтесь экспериментировать. Если вы адепт Redux и вам не нравится инакомыслие, то объясните пожалуйста в комментариях как вы масштабируете, тестируете и валидируете данные в вашем приложении.
В результате применения этого подхода вы отвяжетесь от конкретного фреймворка. Сможете легко переключать библиотеку представления внутри вашего приложения, например React, Preact, Vue, Mithril без переписывания бизнес логики, а в большинстве случаев даже вьюхи. Если у вас есть приложение на Angular 1, вы без проблем сможете перевести его на Angular 2+, React, Svelte, WebComponents или даже свою библиотеку представления. Если у вас есть приложение на Angular 2+, но нету специалистов для него, то вы без проблем сможете перевести приложение на более популярную библиотеку без переписывания бизнес логики. А в итоге вообще забыть про проблему миграции с фремворка на фреймворк. Что же это за магия такая?
Что такое Чистая Архитектура
Для того что бы понять это, лучше всего прочитать книгу Мартина Роберта «Чистая Архитектура» (Robert C.Martin «Clean Architecture»). Краткая выдержка из которого приведена в статье по ссылке.
Основные идеи заложенные в архитектуру:
- Независимость от фреймворка. Архитектура не зависит от существования какой-либо библиотеки. Это позволяет использовать фреймворк в качестве инструмента, вместо того, чтобы втискивать свою систему в рамки его ограничений.
- Тестируемость. Бизнес-правила могут быть протестированы без пользовательского интерфейса, базы данных, веб-сервера или любого другого внешнего компонента.
- Независимоcть от UI. Пользовательский интерфейс можно легко изменить, не изменяя остальную систему. Например, веб-интерфейс может быть заменен на консольный, без изменения бизнес-правил.
- Независимоcть от базы данных. Вы можете поменять Oracle или SQL Server на MongoDB, BigTable, CouchDB или что-то еще. Ваши бизнес-правила не связаны с базой данных.
- Независимость от какого-либо внешнего сервиса. По факту ваши бизнес правила просто ничего не знают о внешнем мире.
Идеи описанные в этой книге уже много лет являются основой для построения сложных приложений в самых разных сферах.
Достигается такая гибкость за счет разделения приложения на слои Service, Repository, Model. Я же добавил к Чистой Архитектуре подход MVC и получил следующие слои:
- View — выводит данные клиенту, фактически визуализирует состояние логики клиенту.
- Controller — отвечает за взаимодействие с пользователем посредством IO (ввод-вывод).
- Service — отвечает за бизнес логику и ее переиспользование между компонентами.
- Repository — отвечает за получение данных из внешних источников, такие как база данных, api, локальное хранилище и пр.
- Models — отвечает за перенос данных между слоями и системами, а также за логику обработки этих данных.
Назначение каждого слоя рассмотрим ниже.
Кому подойдет Чистая Архитектура
Веб разработка прошла большой путь развития, начиная от простого скриптования на jquery до разработки больших SPA приложений. И сейчас веб приложения стали настолько большими что количество бизнес логики стало сопоставимо или даже превосходить серверные, настольные и мобильные приложения.
Разработчикам которые пишут сложные и большие приложения, а также переносят бизнес логику с сервера на веб приложения для экономии на стоимости серверов, Чистая Архитектура поможет организовать код и отмасштабироваться без проблем до огромных масштабов.
В тоже время если ваша задача просто верстка и анимация лендингов, то Чистую Архитектуру просто некуда вставить. Если ваша бизнес логика находиться на бекенде и ваша задача получить данные, отобразить клиенту и обработать клик по кнопке, то вы не прочувствуете гибкости Чистой Архитектуры, но она может стать отличным плацдармом для взрывного роста приложения.
Где уже применяется?
Чистая архитектура не привязана к какому то конкретному фреймворку, платформе или языку программирования. Десятилетия ее используют для написания настольных приложений. Его эталонную реализацию можно найти во фреймворках для серверных приложений Asp.Net Core, Java Spring и NestJS. Так же она очень популярна при написании iOs и Android приложений. Но в веб разработке он предстал в крайне неудачном виде во фреймворках Angular.
Так как я сам не только Typescript, но и C# разработчик, то для примера возьму эталонную реализацию этой архитектуры для Asp.Net Core.
Вот упрощенный пример приложения:
Пример приложения на Asp.Net Core
/**
* View
*/
@model WebApplication1.Controllers.Profile
<div class="text-center">
<h1>Добро пожаловать @Model.FirstName</h1>
</div>
/**
* Controller
*/
public class IndexController : Controller
{
private static int _counter = 0;
private readonly IUserProfileService _userProfileService;
public IndexController(IUserProfileService userProfileService)
{
_userProfileService = userProfileService;
}
public async Task<IActionResult> Index()
{
var profile = await this._userProfileService.GetProfile(_counter);
return View("Index", profile);
}
public async Task<IActionResult> AddCounter()
{
_counter += 1;
var profile = await this._userProfileService.GetProfile(_counter);
return View("Index", profile);
}
}
/**
* Service
*/
public interface IUserProfileService
{
Task<Profile> GetProfile(long id);
}
public class UserProfileService : IUserProfileService
{
private readonly IUserProfileRepository _userProfileRepository;
public UserProfileService(IUserProfileRepository userProfileRepository)
{
this._userProfileRepository = userProfileRepository;
}
public async Task<Profile> GetProfile(long id)
{
return await this._userProfileRepository.GetProfile(id);
}
}
/**
* Repository
*/
public interface IUserProfileRepository
{
Task<Profile> GetProfile(long id);
}
public class UserProfileRepository : IUserProfileRepository
{
private readonly DBContext _dbContext;
public UserProfileRepository(DBContext dbContext)
{
this._dbContext = dbContext;
}
public async Task<Profile> GetProfile(long id)
{
return await this._dbContext
.Set<Profile>()
.FirstOrDefaultAsync((entity) => entity.Id.Equals(id));
}
}
/**
* Model
*/
public class Profile
{
public long Id { get; set; }
public string FirstName { get; set; }
public string Birthdate { get; set; }
}
Если вы не понимаете что в нем написано ничего страшного, дальше мы разберем его по частям каждый фрагмент.
Пример приведен для Asp.Net Core приложения, но для Java Spring, WinForms, Android, React архитектура и код будут такие же, меняется только язык и работа с вьюхой (если она есть).
Применение в веб-приложениях
Единственный фреймворк который пытался использовать Чистую архитектуру был Angular. Но получилось это просто ужасно, что в 1, что в 2+.
И причин для этого много:
- Angular монолитный фреймворк. И это его основная проблема. Если тебе в нем что то не нравится, ты вынужден давиться этим ежедневно, и ничего с этим не поделать. Мало того что в нем масса проблемных мест, так это еще и противоречит идеологии чистой архитектуры.
- Ужасная адаптация патерна DI. Его просто перенесли как есть, без учета особенностей веб приложений и игнорируя модульную систему импортов современного Javascript.
- Ужасный движок представлений. Он очень примитивный и сильно уступает простоте JSX. Данные не типизируются на этапе написания кода, а на этапе компиляции научился отлавливать ошибки только в версии 6, а до этого только в рантайме. А прокинуть в компонент два шаблона и получить контент прокинутого и прокидывающего контроллера из разряда фантастики.
- Старый бандлер. В то время как бандлер Rollup позволял собирать ES2015 и делать 2 бандла для старых и новых браузеров уже 4 года, то сборщик angular научился это делать только в версии 9.
- И еще много проблем. В целом до ангуляр современные технологии докатываются с задержкой лет в 5 относительно React.
Но что же другие фреймворки? React, Vue, Preact, Mithril и прочие являются исключительно библиотеками представления и не предоставляют никакой архитектуры… а архитектура у нас уже есть… осталось собрать всё в единое целое!
Начинаем создавать приложение
Рассматривать Чистую Архитектуру будем на примере выдуманного приложения, максимально приближенного к реальному веб-приложению. Это кабинет в страховой компании, который отображает профиль пользователя, страховые случаи, предлагаемые тарифы страхования и инструменты для работы с этими данными.
В примере будет реализована лишь малая часть функционала, но по ней можно понять где и как располагать остальной функционал. Начинать создавать приложение начнем со слоя Controller, а View слой подключим в самом конце. А по ходу создания рассмотрим каждый слой детальнее.
Паттерн Controller
Controller — отвечает за взаимодействие пользователя с приложением. Это может быть клик по кнопке на веб странице, настольном приложении, мобильном приложении, или ввод команды в консоли линукса, или сетевой запрос, или любое другое IO событие приходящее в приложение.
Самый простой контроллер в чистой архитектуре выглядит следующим образом:
export class SimpleController { // extends React.Component<object, object>
public todos: string[] = []; // состояние контроллера
public addTodo(todo: string): void { // вызывает событие от пользователя
this.todos.push(todo);
}
public removeTodo(index: number): void { // вызывает событие от пользователя
this.todos.splice(index, 1);
}
// public render(): JSX.Element {...} // view injection
}
Его задача получить от пользователя событие и запустить бизнес процессы. В идеальном случае Controller ничего не знает про View, и тогда его можно переиспользовать между платформами, например Web, React-Native или Electron.
А теперь давайте напишем контроллер для нашего приложения. Его задача получить профиль пользователя, имеющиеся тарифы и предложить наилучший тариф пользователю:
UserPageController. Контроллер с бизнес логикойexport class UserPageControlle { public userProfile: any = {}; public insuranceCases: any[] = []; public tariffs: any[] = []; public bestTariff: any = {}; constructor() { this.activate(); } public activate(): void { this.requestUserProfile(); this.requestTariffs(); } public async requestUserProfile(): Promise<void> { // получение данных try { const response = await fetch("./api/user-profile"); this.userProfile = await response.json(); this.findBestTariff(); } catch (e) { console.error(e); } } public async requestTariffs(): Promise<void> { // получение данных try { const response = await fetch("./api/tariffs"); this.tariffs = await response.json(); this.findBestTariff(); } catch (e) { console.error(e); } } public findBestTariff(): void { // метод с бизнес логикой if (this.userProfile && this.tariffs) { this.bestTariff = this.tariffs.find((tarif: any) => { return tarif.ageFrom <= this.userProfile.age && this.userProfile.age < tarif.ageTo; }); } } /** * ... множество других методов, запрос страховых случаев, * редактирование профиля, выбор тарифа и прочее */ }
У нас получился обычной контроллер без чистой архитектуры, если отнаследовать его от React.Component то получим рабочий компонент с логикой. Так пишут очень много разработчиков веб-приложений, но у такого подхода есть много существенных недостатков. Главный из которых невозможность переиспользования логики между компонентами. Ведь рекомендуемый тариф может выводиться не только в личном кабинете, но и на лендинге и множестве других мест для привлечения клиента к услуге.
Для того что бы иметь возможность переиспользовать логику между компонентами ее необходимо вынести в специальный слой, который называется Service.
Паттерн Service
Service — отвечает за всю бизнес логику приложения. Если Controller'у понадобилось получить, обработать, отправить какие то данные — он делает это через Service. Если нескольким контроллерам понадобилась одна и та же логика, они работают с Service. Но сам слой Service ничего не должен знать о слое Controller и View и окружении в котором он работает.
Давайте вынесем логику из контроллера в сервис и внедрим сервис в контроллер:
UserPageController. Контроллер без бизнес логикиimport { UserProfilService } from "./UserProfilService"; import { TariffService } from "./TariffService"; export class UserPageController { public userProfile: any = {}; public insuranceCases: any[] = []; public tariffs: any[] = []; public bestTariff: any = {}; // внедряем сервисы в контроллер private readonly userProfilService: UserProfilService = new UserProfilService(); private readonly tarifService: TariffService = new TariffService(); constructor() { this.activate(); } public activate(): void { this.requestUserProfile(); this.requestTariffs(); } public async requestUserProfile(): Promise<void> { try { // используем сервисы для получения данных this.userProfile = await this.userProfilService.getUserProfile(); this.bestTariff = await this.tarifService.findBestTariff(this.userProfile); } catch (e) { console.error(e); } } public async requestTariffs(): Promise<void> { try { // используем сервис для получения данных this.tariffs = await this.tarifService.getTariffs(); } catch (e) { console.error(e); } } /** * ... множество других методов, запрос страховых случаев, * редактирование профиля, выбор тарифа и прочее */ }
UserProfilService. Сервис для работы с профилем пользователяexport class UserProfilService { public async getUserProfile(): Promise<any> { // получение данных const response = await fetch("./api/user-profile"); return await response.json(); } /** * ... множество других методов для работы с профилем пользователя */ }
TariffService. Сервис для работы с тарифамиexport class TariffService { // получение данных public async getTariffs(): Promise<any> { const response = await fetch("./api/tariffs"); return await response.json(); } // метод с бизнес логикой public async findBestTariff(userProfile: any): Promise<any> { const tariffs = await this.getTariffs(); return tariffs.find((tarif: any) => { return tarif.ageFrom <= userProfile.age && userProfile.age < tarif.ageTo; }); } /** * ... множество других методов для работы с тарифами */ }
Теперь если нескольким контроллерам понадобиться получить профиль пользователя или тарифы, они смогу переиспользовать одну и туже логику из сервисов. В сервисах главное не забывать про SOLID принципы и что каждый сервис отвечает за свою зону ответственности. В данном случае один сервис отвечает за работу с профилем пользователя, а другой сервис за работу с тарифами.
Но что делать если источник данных поменяется, например fetch может смениться на websocket или grps или базу данных, а реальные данные понадобиться заменить тестовыми? И вообще зачем бизнес логике что то знать о источнике данных? Для решениях этих проблем существует слой Repository.
Паттерн Repository
Repository — отвечает за общение с хранилищем данных. В качестве хранилища может выступать сервер, база данных, память, localstorage, sessionstorage или любое другое хранилище. Его задача абстрагировать слой Service от конкретной реализации хранилища.
Давайте вынесем сетевые запросы из сервисов в репозитории, контроллер при этом не меняем:
UserProfilService. Сервис для работы с профилем пользователяimport { UserProfilRepository } from "./UserProfilRepository"; export class UserProfilService { private readonly userProfilRepository: UserProfilRepository = new UserProfilRepository(); public async getUserProfile(): Promise<any> { return await this.userProfilRepository.getUserProfile(); } /** * ... множество других методов для работы с профилем пользователя */ }
UserProfilRepository. Сервис для работы с хранилищем профилей пользователяexport class UserProfilRepository { public async getUserProfile(): Promise<any> { // получение данных const response = await fetch("./api/user-profile"); return await response.json(); } /** * ... множество других методов для работы с профилем пользователя */ }
TariffService. Сервис для работы с тарифамиimport { TariffRepository } from "./TariffRepository"; export class TariffService { private readonly tarifRepository: TariffRepository = new TariffRepository(); public async getTariffs(): Promise<any> { return await this.tarifRepository.getTariffs(); } // метод с бизнес логикой public async findBestTariff(userProfile: any): Promise<any> { // запрашиваем у источника данных const tariffs = await this.tarifRepository.getTariffs(); return tariffs.find((tarif: any) => { return tarif.ageFrom <= userProfile.age && userProfile.age < tarif.ageTo; }); } /** * ... множество других методов для работы с тарифами */ }
TariffRepository. Репозиторий для работы с хранилищем тарифовexport class TariffRepository { // получение данных public async getTariffs(): Promise<any> { const response = await fetch("./api/tariffs"); return await response.json(); } /** * ... множество других методов для работы с хранилищем тарифов */ }
Теперь достаточно один раз написать запрос к данным и любой сервис сможет переиспользовать этот запрос. Позже мы рассмотрим пример как переопределить репозиторий не трогая код сервиса и внедрить моковый репозиторий на время тестирования.
В сервисе UserProfilService может показаться что он не нужен и контроллер может напрямую обратиться к репозиторию за данными, но это не так. В любой момент в бизнес слое могут появиться или измениться требования, может потребоваться дополнительный запрос или обогатить данные. Поэтому даже когда в слое сервиса нету логики цепочка Controller — Service — Repository должна сохраняться. Это вклад в ваше завтра.
Настало время разобраться что за заданные получает репозиторий, корректные ли они вообще. За это отвечает слой Models.
Модели: DTO, Entities, ViewModels
Models — отвечает за описание структур с которыми работает приложение. Такое описание очень помогает новым разработчикам проекта понять с чем работает приложение. Кроме того его очень удобно использовать для построения баз данных или проведений валидаций данных хранящихся в модели.
Модели в зависимости от типа использования делятся на разные паттерны:
- Entities — отвечают за работу с базой данных и представляют из себя структуру повторяющую таблицу или документ в базе данных.
- DTO (Data Transfer Object) — служат для переноса данных между разными слоями приложения.
- ViewModel — содержат заранее подготовленную информацию необходимую для отображении в представлении.
Добавим в приложение модель профиля пользователя и другие модели, и сообщим остальным слоям что теперь мы работаем не с абстрактным объектом, а с вполне конкретным профилем:
UserPageController. Вместо типа any используются описанные моделиimport { UserProfilService } from "./UserProfilService"; import { TariffService } from "./TariffService"; import { UserProfileDto } from "./UserProfileDto"; import { TariffDto } from "./TariffDto"; import { InsuranceCaseDto } from "./InsuranceCasesDto"; export class UserPageController { /** * Используем модель для типа и пустую модель для первой отрисовки * как заглушку до тех пока данные из сервиса не придут. */ public userProfile: UserProfileDto = new UserProfileDto(); public insuranceCases: InsuranceCaseDto[] = []; public tariffs: TariffDto[] = []; public bestTariff: TariffDto | void = void 0; private readonly userProfilService: UserProfilService = new UserProfilService(); private readonly tarifService: TariffService = new TariffService(); constructor() { this.activate(); } public activate(): void { this.requestUserProfile(); this.requestTariffs(); } public async requestUserProfile(): Promise<void> { try { this.userProfile = await this.userProfilService.getUserProfile(); this.bestTariff = await this.tarifService.findBestTariff(this.userProfile); } catch (e) { console.error(e); } } public async requestTariffs(): Promise<void> { try { this.tariffs = await this.tarifService.getTariffs(); } catch (e) { console.error(e); } } /** * ... множество других методов, запрос страховых случаев, * редактирование профиля, выбор тарифа и прочее */ }
UserProfilService. Вместо any указываем возвращаемую модельimport { UserProfilRepository } from "./UserProfilRepository"; import { UserProfileDto } from "./UserProfileDto"; export class UserProfilService { private readonly userProfilRepository: UserProfilRepository = new UserProfilRepository(); public async getUserProfile(): Promise<UserProfileDto> { // возвращаем модель return await this.userProfilRepository.getUserProfile(); } /** * ... множество других методов для работы с профилем пользователя */ }
TariffService. Вместо any указываем возвращаемую модельimport { TariffRepository } from "./TariffRepository"; import { TariffDto } from "./TariffDto"; import { UserProfileDto } from "./UserProfileDto"; export class TariffService { private readonly tarifRepository: TariffRepository = new TariffRepository(); public async getTariffs(): Promise<TariffDto[]> { // возвращаем модель return await this.tarifRepository.requestTariffs(); } // возвращаем модель public async findBestTariff(userProfile: UserProfileDto): Promise<TariffDto | void> { const tariffs = await this.tarifRepository.requestTariffs(); return tariffs.find((tarif: TariffDto) => { // было userProfile.age стало userProfile.getAge() const age = userProfile.getAge(); return age && tarif.ageFrom <= age && age < tarif.ageTo; }); } /** * ... множество других методов для работы с тарифами */ }
UserProfilRepository. Вместо any указываем возвращаемую модельimport { UserProfileDto } from "./UserProfileDto"; export class UserProfilRepository { public async getUserProfile(): Promise<UserProfileDto> { // возвращаем модель const response = await fetch("./api/user-profile"); return await response.json(); } /** * ... множество других методов для рабоыт с профилем пользователя */ }
TariffRepository. Вместо any указываем возвращаемую модельimport { TariffDto } from "./TariffDto"; export class TariffRepository { public async requestTariffs(): Promise<TariffDto[]> { // возвращаем модель const response = await fetch("./api/tariffs"); return await response.json(); } /** * ... множество других методов для работы с хранилищем тарифов */ }
UserProfileDto. Модель с описанием данных с которыми мы работаемexport class UserProfileDto { // <-- модель с описанием данных и логикой public firstName: string | null = null; public lastName: string | null = null; public birthdate: Date | null = null; public getAge(): number | null { if (this.birthdate) { const ageDifMs = Date.now() - this.birthdate.getTime(); const ageDate = new Date(ageDifMs); return Math.abs(ageDate.getUTCFullYear() - 1970); } return null; } public getFullname(): string | null { return [ this.firstName ?? "", this.lastName ?? "" ] .join(" ") .trim() || null; } }
TariffDto. Модель с описанием данных с которыми мы работаемexport class TariffDto { public ageFrom: number = 0; public ageTo: number = 0; public price: number = 0; }
Теперь в каком бы слое приложения мы не находились мы точно знаем с какими данными мы работаем. Так же благодаря описанию модели мы нашли ошибку в нашем сервисе. В логике сервиса использовалось свойство userProfile.age, которого на самом деле нет, но есть дата рождения. А для высчитывания возраста необходимо вызвать метод модели userProfile.getAge().
Но есть одна проблема. Если мы попытаемся воспользоваться методами из модели что предоставил текущий репозиторий, то получим исключение. Все дело в том что методы response.json() и JSON.parse() возвращает не нашу модель, а объект JSON, который никак не связан с нашей моделью. Убедиться в этом можно если исполнить команду userProfile instanceof UserProfileDto, получится ложное утверждение. Для того что бы преобразовать данные полученные из внешнего источника к описанной модели существует процесс Десериализации данных.
Десериализация данных
Deserialization — процесс восстановления необходимой структуры из последовательности байт. Если в данных будет информация не указанная в моделях, она будет проигнорирована. Если в данных будет информация противоречащая описании модели — произойдет ошибка десериализации.
И самое интересно тут то, что при проектировании ES2015 и добавлении ключевого слово class забыли добавить десериализацию… То что во всех языках присутствует из коробки, в ES2015 просто забыли…
Для решения этой проблемы мною была написана библиотека для десериализации TS-Serializable, статью о которой можно прочитать по этой ссылке. Цель которой вернуть потерянный функционал.
Добавляем поддержку десериализации в модели и саму десериализацию в репозиторий:
TariffRepository. Добавляем процесс десериализацииimport { UserProfileDto } from "./UserProfileDto"; export class UserProfilRepository { public async getUserProfile(): Promise<UserProfileDto> { const response = await fetch("./api/user-profile"); const object = await response.json(); return new UserProfileDto().fromJSON(object); // добавляем десериализацию } /** * ... множество других методов для рабоыт с профилем пользователя */ }
TariffRepository. Добавляем процесс десериализацииimport { TariffDto } from "./TariffDto"; export class TariffRepository { public async requestTariffs(): Promise<TariffDto[]> { // возвращаем модель const response = await fetch("./api/tariffs"); const objects: object[] = await response.json(); return objects.map((object: object) => { return new TariffDto().fromJSON(object); // добавляем десериализацию }); } /** * ... множество других методов для работы с хранилищем тарифов */ }
ProfileDto . Добавляем поддержку десериализацииimport { Serializable, jsonProperty } from "ts-serializable"; export class UserProfileDto extends Serializable { // <-- наследуемся от базового класса @jsonProperty(String, null) // <-- вспомогательный декоратор public firstName: string | null = null; @jsonProperty(String, null) // <-- вспомогательный декоратор public lastName: string | null = null; @jsonProperty(Date, null) // <-- вспомогательный декоратор public birthdate: Date | null = null; public getAge(): number | null { if (this.birthdate) { const ageDifMs = Date.now() - this.birthdate.getTime(); const ageDate = new Date(ageDifMs); return Math.abs(ageDate.getUTCFullYear() - 1970); } return null; } public getFullname(): string | null { return [ this.firstName ?? "", this.lastName ?? "" ] .join(" ") .trim() || null; } }
TariffDto. Добавляем поддержку десериализацииimport { Serializable, jsonProperty } from "ts-serializable"; export class TariffDto extends Serializable { // <-- наследуемся от базового класса @jsonProperty(Number, null) // <-- вспомогательный декоратор public ageFrom: number = 0; @jsonProperty(Number, null) // <-- вспомогательный декоратор public ageTo: number = 0; @jsonProperty(Number, null) // <-- вспомогательный декоратор public price: number = 0; }
Теперь во всех слоях приложения можно быть абсолютно уверенным в том что мы работаем именно с теми моделями которые ожидаем. В представлении, контроллере и других слоях можно вызывать методы описанной модели.
Для чего Serializable и jsonProperty ?
Для работы библиотеки необходимо наследоваться от базового класса Serializable — это необходимо для корректного расширения возможностей базового объекта Ecmascript и последующей с ним работы в Typescript. Также необходимо использовать вспомогательный декоратор jsonProperty для описания тех типов которые мы ожидаем от данных, это связано с тем что у Typescript очень не развитая рефлексия и он генерирует не корректную информацию для uniontypes. Но возможно при развитии рефлексии или трансформеров от них можно будет отказаться.
Теперь у нас есть почти готовое приложение. Настало время протестировать логику написанную в слоях Controller, Service и Models. Для этого нам необходимо в слое Repository вместо реального запроса на сервер вернуть специально подготовленные тестовые данные. Но как же подменить Repository не трогая того кода который пойдет в продакшен. Для этого существует паттерн Dependency Injection.
Dependency Injection — внедряем зависимости
Dependency Injection — внедряет зависимости в слои Contoller, Service, Repository и дает возможность переопределить эти зависимости за пределами этих слоев.
В программе слой Controller зависит от слоя Service, а он зависит от слоя Repository. В текущем виде слои сами вызывают свои зависимости через создание экземпляра. А для того что бы переопределить зависимость, слою необходимо из вне задать эту зависимость. Для этого есть множество способов, но самый популярным является передача зависимости как параметр в конструкторе.
Тогда создание программы со всеми зависимости будет выглядеть следующим образом:
var programm = new IndexPageController(new ProfileService(new ProfileRepository()));
Согласитесь — выглядит ужасно. Даже с учетом того что в программе всего две зависимости, это уже выглядит ужасно. Что уже говорить про программы в которых сотни и тысячи зависимостей.
Для решения проблемы понадобится специальный инструмент, а для этого его необходимо найти. Если обратиться к опыту других платформ, например Asp.Net Core то там регистрация зависимостей происходит на этапе инициализации программы и выглядит примерно следующим образом:
DI.register(IProfileService,ProfileService);
а далее фреймворк при создании контроллера уже сам создаст и внедрит эту зависимость.
Но тут есть три существенные проблемы:
- При транспиляции Typescript в Javascript от интерфейсов не остается и следа.
- Все что попало в классический DI остается в нем навсегда. Его очень сложно вычистить при рефакторинге. А в веб приложении необходимо экономить каждый байт.
- Почти все библиотеки представлений не используют DI и конструкторы контроллеров заняты параметрами.
В веб приложениях DI используется только в Angular 2+. В Angular 1 при регистрации зависимостей вместо интерфейса использовалась строка, в InversifyJS вместо интрерфейса используется Symbol. И все это реализовано настолько ужасно, что лучше уже много new как в первом примере этого раздела чем эти решения.
Для решения всех трех проблем был придуман собственный DI, а решение для него мне помог найти фреймворк Java Spring и его декоратор autowired. Описание принципа работы этого DI можно прочитать в статье по ссылке, а репозиторий GitHub.
Настало время применить получившийся DI в нашем приложении.
Соединяем все в единое целое
Для внедрения DI на все слои накинем декоратор reflection, который заставит typescript генерировать дополнительную метаинформацию о типах зависимостей. В контроллере где необходимо вызвать зависимости повесим декоратор autowired. А в том месте где программа инициализируется определим в каком окружении какая зависимость будет реализована.
Для репозитория UserProfilRepository создадим такой же репозиторий, но с тестовыми данными вместо реального запроса. В итоге получаем следующий код:
Main.ts. Место инициализации программыimport { override } from "first-di"; import { UserProfilRepository } from "./UserProfilRepository"; import { MockUserProfilRepository } from "./MockUserProfilRepository"; if (process.env.NODE_ENV === "test") { // для тестового окружение подменяем реальный запрос на тестовые данные override(UserProfilRepository, MockUserProfilRepository); }
UserPageController. Внедряем зависимость через декоратор autowiredimport { UserProfilService } from "./UserProfilService"; import { TariffService } from "./TariffService"; import { UserProfileDto } from "./UserProfileDto"; import { TariffDto } from "./TariffDto"; import { InsuranceCaseDto } from "./InsuranceCasesDto"; import { autowired } from "first-di"; export class UserPageController { public userProfile: UserProfileDto = new UserProfileDto(); public insuranceCases: InsuranceCaseDto[] = []; public tariffs: TariffDto[] = []; public bestTariff: TariffDto | void = void 0; @autowired() // внедряет зависимость private readonly userProfilService!: UserProfilService; @autowired() // внедряет зависимость private readonly tarifService!: TariffService; constructor() { // конструктор для внедрения не используется, т.к. занят фреймворком this.activate(); } public activate(): void { this.requestUserProfile(); this.requestTariffs(); } public async requestUserProfile(): Promise<void> { try { this.userProfile = await this.userProfilService.getUserProfile(); this.bestTariff = await this.tarifService.findBestTariff(this.userProfile); } catch (e) { console.error(e); } } public async requestTariffs(): Promise<void> { try { this.tariffs = await this.tarifService.getTariffs(); } catch (e) { console.error(e); } } /** * ... множество других методов, запрос страховых случаев, * редактирование профиля, выбор тарифа и прочее */ }
UserProfilService. Внедряем генерацию рефлексии и зависимостиimport { UserProfilRepository } from "./UserProfilRepository"; import { UserProfileDto } from "./UserProfileDto"; import { reflection } from "first-di"; @reflection // заставляет typescript генерировать рефлексию export class UserProfilService { private readonly userProfilRepository: UserProfilRepository; constructor(userProfilRepository: UserProfilRepository) { // внедряем зависимость через конструктор this.userProfilRepository = userProfilRepository; } public async getUserProfile(): Promise<UserProfileDto> { return await this.userProfilRepository.getUserProfile(); } /** * ... множество других методов для работы с профилем пользователя */ }
TariffService. Внедряем генерацию рефлексии и зависимостиimport { TariffRepository } from "./TariffRepository"; import { TariffDto } from "./TariffDto"; import { UserProfileDto } from "./UserProfileDto"; import { reflection } from "first-di"; @reflection // заставляет typescript генерировать рефлексию export class TariffService { private readonly tarifRepository: TariffRepository; constructor(tarifRepository: TariffRepository) { // внедряем зависимость через конструктор this.tarifRepository = tarifRepository; } public async getTariffs(): Promise<TariffDto[]> { return await this.tarifRepository.requestTariffs(); } public async findBestTariff(userProfile: UserProfileDto): Promise<TariffDto | void> { const tariffs = await this.tarifRepository.requestTariffs(); return tariffs.find((tarif: TariffDto) => { const age = userProfile.getAge(); return age && tarif.ageFrom <= age && age < tarif.ageTo; }); } /** * ... множество других методов для работы с тарифами */ }
UserProfilRepository. Внедряем генерацию рефлексииimport { UserProfileDto } from "./UserProfileDto"; import { reflection } from "first-di"; @reflection // заставляет typescript генерировать рефлексию export class UserProfilRepository { public async getUserProfile(): Promise<UserProfileDto> { const response = await fetch("./api/user-profile"); const object = await response.json(); return new UserProfileDto().fromJSON(object); } /** * ... множество других методов для работы с профилем пользователя */ }
MockUserProfilRepository. Новый репозиторий для тестированияimport { UserProfileDto } from "./UserProfileDto"; import { reflection } from "first-di"; @reflection // заставляет typescript генерировать рефлексию export class MockUserProfilRepository { // репозиторий для тестов public async getUserProfile(): Promise<UserProfileDto> { const profile = new UserProfileDto(); profile.firstName = "Констанция"; profile.lastName = "Константинопольская"; profile.birthdate = new Date(Date.now() - 1.5e12); return Promise.resolve(profile); // возвращаем тестовые данные } /** * ... множество других методов для рабоыт с профилем пользователя */ }
TariffRepository. Внедряем генерацию рефлексииimport { TariffDto } from "./TariffDto"; import { reflection } from "first-di"; @reflection // заставляет typescript генерировать рефлексию export class TariffRepository { public async requestTariffs(): Promise<TariffDto[]> { const response = await fetch("./api/tariffs"); const objects: object[] = await response.json(); return objects.map((object: object) => { return new TariffDto().fromJSON(object); }); } /** * ... множество других методов для работы с хранилищем тарифов */ }
Теперь в любом месте программы есть возможность поменять реализацию любой логики. В нашем примере вместо реального запроса профиля пользователя на сервер в тестовом окружении будут использованы тестовые данные.
В реальной жизни могут встречаться замены в любом месте, например можно поменять логику в каком либо сервисе, реализовывая в продакшене старый сервис, а в рефакторинге уже новый. Проводить A/B тесты с бизнес логикой, менять документо-ориентированную базу на реляционную, и вообще сменить сетевой запрос на вебсокеты. И все это без остановки разработки на переписывание решения.
Настало время увидеть результат работы программы. Для этого существует слой View.
Внедряем View
Слой View отвечает за представление данных которые содержатся в слое Controller пользователю. Я в примере буду использовать для этого React, но на его месте может быть любой другой, например Preact, Svelte, Vue, Mithril, WebComponent или любой другой.
Для этого просто отнаследуем наш контроллер от React.Component, и добавим ему метод render с отображением представления:
Main.ts. Запускает отрисовку React компонентаimport { override } from "first-di"; import { UserProfilRepository } from "./UserProfilRepository"; import { MockUserProfilRepository } from "./MockUserProfilRepository"; import { UserPageController } from "./UserPageController"; import React from "react"; import { render } from "react-dom"; if (process.env.NODE_ENV === "test") { // для тестового окружение подменяем реальный запрос на тестовые данные override(UserProfilRepository, MockUserProfilRepository); } render(React.createElement(UserPageController), document.body);
UserPageController. Наследует от React.Component и добавляет метод renderimport { UserProfilService } from "./UserProfilService"; import { TariffService } from "./TariffService"; import { UserProfileDto } from "./UserProfileDto"; import { TariffDto } from "./TariffDto"; import { InsuranceCaseDto } from "./InsuranceCasesDto"; import { autowired } from "first-di"; import React from "react"; // наследуем контроллер от React.Component export class UserPageController extends React.Component<object, object> { public userProfile: UserProfileDto = new UserProfileDto(); public insuranceCases: InsuranceCaseDto[] = []; public tariffs: TariffDto[] = []; public bestTariff: TariffDto | void = void 0; @autowired() private readonly userProfilService!: UserProfilService; @autowired() private readonly tarifService!: TariffService; // реакт занял конструктор constructor(props: object, context: object) { super(props, context); } // после создания компонента запускаем запросы public componentDidMount(): void { this.activate(); } public activate(): void { this.requestUserProfile(); this.requestTariffs(); } public async requestUserProfile(): Promise<void> { try { this.userProfile = await this.userProfilService.getUserProfile(); this.bestTariff = await this.tarifService.findBestTariff(this.userProfile); this.forceUpdate(); // обновляем view после получения данных } catch (e) { console.error(e); } } public async requestTariffs(): Promise<void> { try { this.tariffs = await this.tarifService.getTariffs(); this.forceUpdate(); // обновляем view после получения данных } catch (e) { console.error(e); } } // внедрение слоя view public render(): JSX.Element { return ( <> <div className="user"> <div className="user-name"> Имя пользователя: {this.userProfile.getFullname()} </div> <div className="user-age"> Возраст: {this.userProfile.getAge()} </div> </div> <div className="tarifаs"> {/* остальная часть вьюхи */} </div> </> ); } /** * ... множество других методов, запрос страховых случаев, * редактирование профиля, выбор тарифа и прочее */ }
Добавив всего две строчки и шаблон представления и наш контроллер превратился в компонент реакта с рабочей логикой.
Почему вызывается forceUpdate вместо setState?
В коде можно заметить что forceUpdate вызывается напрямую, вместо использования setState. Дело в том что за любым методом перерисовки компонента setState, Redux, mobX или др., в любом фреймворке на любой платформе стоит вызов перерисовки представления. В React и Preact это forceUpdate, в Ангуляре ChangeDetectorRef.detectChanges(), в Mithril сразу redraw в остальных аналогично. Поэтому можно использовать ручное управление перерисовкой или создать Observable обертку над компонентом, как это делает MobX или Angular, для получение реактивности. Это дело вкуса и в данной статье не рассматривается.
Но даже в такой реализации получилось что мы завязались на библиотеку React с его жизненным циклом, реализации view, и принципе инвалидации представления что противоречит концепции Чистой Архитектуры. А логика и верстка находится в одном файле, что затрудняет параллельную работу верстальщика и разработчика.
Разделение Controller и View
Для решения обоих проблем слой view вынесем в отдельный файл, а вместо компонента React сделаем базовый компонент, который будет абстрагировать наш контроллер от конкретной библиотеки представления. Заодно опишем атрибуты которые может принимать компонент.
Получаем следующие изменения:
UserPageView. Вынесли представление в отдельный файлimport { UserPageOptions, UserPageController } from "./UserPageController"; import React from "react"; export const userPageView = <P extends UserPageOptions, S>( ctrl: UserPageController<P, S>, props: P ): JSX.Element => ( <> <div className="user"> <div className="user-name"> Имя пользователя: {ctrl.userProfile.getFullname()} </div> <div className="user-age"> Возраст: {ctrl.userProfile.getAge()} </div> </div> <div className="tarifаs"> {/* остальная часть вьюхи */} </div> </> );
UserPageOptions. Вынесли view и React в отдельный файлimport { UserProfilService } from "./UserProfilService"; import { TariffService } from "./TariffService"; import { UserProfileDto } from "./UserProfileDto"; import { TariffDto } from "./TariffDto"; import { InsuranceCaseDto } from "./InsuranceCasesDto"; import { autowired } from "first-di"; import { BaseComponent } from "./BaseComponent"; import { userPageView } from "./UserPageview"; export interface UserPageOptions { param1?: number; param2?: string; } // наследуем контроллер от BaseComponent export class UserPageController<P extends UserPageOptions, S> extends BaseComponent<P, S> { public userProfile: UserProfileDto = new UserProfileDto(); public insuranceCases: InsuranceCaseDto[] = []; public tariffs: TariffDto[] = []; public bestTariff: TariffDto | void = void 0; // иньекция представления public readonly view = userPageView; @autowired() private readonly userProfilService!: UserProfilService; @autowired() private readonly tarifService!: TariffService; // типизированные пропсы constructor(props: P, context: S) { super(props, context); } // запустится при componentDidMount, см. BaseComponent public activate(): void { this.requestUserProfile(); this.requestTariffs(); } public async requestUserProfile(): Promise<void> { try { this.userProfile = await this.userProfilService.getUserProfile(); this.bestTariff = await this.tarifService.findBestTariff(this.userProfile); this.forceUpdate(); } catch (e) { console.error(e); } } public async requestTariffs(): Promise<void> { try { this.tariffs = await this.tarifService.getTariffs(); this.forceUpdate(); } catch (e) { console.error(e); } } /** * ... множество других методов, запрос страховых случаев, * редактирование профиля, выбор тарифа и прочее */ }
BaseComponent. Компонент который абстрагирует нас от конкретного фреймворкаimport React from "react"; export class BaseComponent<P, S> extends React.Component<P, S> { // внедряем view public view?: (ctrl: this, props: P) => JSX.Element; constructor(props: P, context: S) { super(props, context); // можно дописать свою логику } // абстрагируем от жизненного цикла конкретного фреймворка public componentDidMount(): void { this.activate && this.activate(); } // абстрагируем от жизненного цикла конкретного фреймворка public shouldComponentUpdate( nextProps: Readonly<P>, nextState: Readonly<S>, nextContext: any ): boolean { return this.update(nextProps, nextState, nextContext); } public componentWillUnmount(): void { this.dispose(); } public activate(): void { // метод для переопределения } public update(nextProps: Readonly<P>, nextState: Readonly<S>, nextContext: any): boolean { // метод для переопределения return false; } public dispose(): void { // метод для переопределения } // внедрение слоя view public render(): React.ReactElement<object> { if (this.view) { return this.view(this, this.props); } else { return React.createElement("div", {}, "Представление не определено"); } } }
Теперь наше представление находится в отдельном файле, а контроллер о нем ничего не знает, кроме того что оно есть. Возможно не стоит инжектировать представление через свойство контроллера, а делать это через декоратор как это делает Angular, но это тема для отдельных размышлений.
В базовом компоненте также находится абстракция от жизненного цикла фреймворка. Во всех фреймворках они разные, но они есть во всех фреймворках. Angular это ngOnInit, ngOnChanges, ngOnDestroy. В React и Preact это componentDidMount, shouldComponentUpdate, componentWillUnmount. В Vue это created, updated, destroyed. В Mithril это oncreate, onupdate, onremove. В WebComponents это connectedCallback, attributeChangedCallback, disconnectedCallback. И так в каждой библиотеке. У большинства даже одинаковый или похожий интерфейс.
Кроме того, теперь компоненты библиотеки можно расширять собственной логикой для последующего переиспользования между всеми компонентами. Например внедряя инструменты аналитики, мониторинга, логирования и др.
Смотрим результат
Осталось лишь оценить что же получилось. Вся программа имеет следующий финальный вид:
Main.ts. Файл с которого запускается программаimport { override } from "first-di"; import { UserProfilRepository } from "./UserProfilRepository"; import { MockUserProfilRepository } from "./MockUserProfilRepository"; import { UserPageController } from "./UserPageController"; import React from "react"; import { render } from "react-dom"; if (process.env.NODE_ENV === "test") { override(UserProfilRepository, MockUserProfilRepository); } render(React.createElement(UserPageController), document.body);
UserPageView. Представление одного из компонентов программы.import { UserPageOptions, UserPageController } from "./UserPageController"; import React from "react"; export const userPageView = <P extends UserPageOptions, S>( ctrl: UserPageController<P, S>, props: P ): JSX.Element => ( <> <div className="user"> <div className="user-name"> Имя пользователя: {ctrl.userProfile.getFullname()} </div> <div className="user-age"> Возраст: {ctrl.userProfile.getAge()} </div> </div> <div className="tarifаs"> {/* остальная часть вьюхи */} </div> </> );
UserPageController. Логика одного из компонентов для взаимодействия с пользователемimport { UserProfilService } from "./UserProfilService"; import { TariffService } from "./TariffService"; import { UserProfileDto } from "./UserProfileDto"; import { TariffDto } from "./TariffDto"; import { InsuranceCaseDto } from "./InsuranceCasesDto"; import { autowired } from "first-di"; import { BaseComponent } from "./BaseComponent"; import { userPageView } from "./UserPageview"; export interface UserPageOptions { param1?: number; param2?: string; } export class UserPageController<P extends UserPageOptions, S> extends BaseComponent<P, S> { public userProfile: UserProfileDto = new UserProfileDto(); public insuranceCases: InsuranceCaseDto[] = []; public tariffs: TariffDto[] = []; public bestTariff: TariffDto | void = void 0; public readonly view = userPageView; @autowired() private readonly userProfilService!: UserProfilService; @autowired() private readonly tarifService!: TariffService; // запустится при componentDidMount, см. BaseComponent public activate(): void { this.requestUserProfile(); this.requestTariffs(); } public async requestUserProfile(): Promise<void> { try { this.userProfile = await this.userProfilService.getUserProfile(); this.bestTariff = await this.tarifService.findBestTariff(this.userProfile); this.forceUpdate(); } catch (e) { console.error(e); } } public async requestTariffs(): Promise<void> { try { this.tariffs = await this.tarifService.getTariffs(); this.forceUpdate(); } catch (e) { console.error(e); } } /** * ... множество других методов, запрос страховых случаев, * редактирование профиля, выбор тарифа и прочее */ }
BaseComponent. Базовый класс для всех компонентов программыimport React from "react"; export class BaseComponent<P, S> extends React.Component<P, S> { public view?: (ctrl: this, props: P) => JSX.Element; constructor(props: P, context: S) { super(props, context); } public componentDidMount(): void { this.activate && this.activate(); } public shouldComponentUpdate( nextProps: Readonly<P>, nextState: Readonly<S>, nextContext: any ): boolean { return this.update(nextProps, nextState, nextContext); } public componentWillUnmount(): void { this.dispose(); } public activate(): void { // метод для переопределения } public update(nextProps: Readonly<P>, nextState: Readonly<S>, nextContext: any): boolean { // метод для переопределения return false; } public dispose(): void { // метод для переопределения } public render(): React.ReactElement<object> { if (this.view) { return this.view(this, this.props); } else { return React.createElement("div", {}, "Представление не определено"); } } }
UserProfilService. Сервис для переиспользования логики между компонентами для работы с профилем пользователяimport { UserProfilRepository } from "./UserProfilRepository"; import { UserProfileDto } from "./UserProfileDto"; import { reflection } from "first-di"; @reflection export class UserProfilService { private readonly userProfilRepository: UserProfilRepository; constructor(userProfilRepository: UserProfilRepository) { this.userProfilRepository = userProfilRepository; } public async getUserProfile(): Promise<UserProfileDto> { return await this.userProfilRepository.getUserProfile(); } /** * ... множество других методов для работы с профилем пользователя */ }
TariffService. Сервис для переиспользования логики между компонентами для работы с тарифамиimport { TariffRepository } from "./TariffRepository"; import { TariffDto } from "./TariffDto"; import { UserProfileDto } from "./UserProfileDto"; import { reflection } from "first-di"; @reflection export class TariffService { private readonly tarifRepository: TariffRepository; constructor(tarifRepository: TariffRepository) { this.tarifRepository = tarifRepository; } public async getTariffs(): Promise<TariffDto[]> { return await this.tarifRepository.requestTariffs(); } public async findBestTariff(userProfile: UserProfileDto): Promise<TariffDto | void> { const tariffs = await this.tarifRepository.requestTariffs(); return tariffs.find((tarif: TariffDto) => { const age = userProfile.getAge(); return age && tarif.ageFrom <= age && age < tarif.ageTo; }); } /** * ... множество других методов для работы с тарифами */ }
UserProfilRepository. Репозиторий для получение профиля с сервера, его проверки и валидацииimport { UserProfileDto } from "./UserProfileDto"; import { reflection } from "first-di"; @reflection export class UserProfilRepository { public async getUserProfile(): Promise<UserProfileDto> { const response = await fetch("./api/user-profile"); const object = await response.json(); return new UserProfileDto().fromJSON(object); } /** * ... множество других методов для рабоыт с профилем пользователя */ }
MockUserProfilRepository. Репозиторий для тестирования логики и верстки на тестовых данныхimport { UserProfileDto } from "./UserProfileDto"; import { reflection } from "first-di"; @reflection // заставляет typescript генерировать рефлексию export class MockUserProfilRepository { // репозиторий для тестов public async getUserProfile(): Promise<UserProfileDto> { const profile = new UserProfileDto(); profile.firstName = "Констанция"; profile.lastName = "Константинопольская"; profile.birthdate = new Date(Date.now() - 1.5e12); return Promise.resolve(profile); // возвращаем тестовые данные } /** * ... множество других методов для рабоыт с профилем пользователя */ }
TariffRepository. Репозиторий для получение тарифов с сервера, их проверки и валидацииimport { TariffDto } from "./TariffDto"; import { reflection } from "first-di"; @reflection // заставляет typescript генерировать рефлексию export class TariffRepository { public async requestTariffs(): Promise<TariffDto[]> { const response = await fetch("./api/tariffs"); const objects: object[] = await response.json(); return objects.map((object: object) => { return new TariffDto().fromJSON(object); }); } /** * ... множество других методов для работы с хранилищем тарифов */ }
UserProfileDto. Модель с логикой и описанием данных для переноса данных между слоямиimport { Serializable, jsonProperty } from "ts-serializable"; export class UserProfileDto extends Serializable { @jsonProperty(String, null) public firstName: string | null = null; @jsonProperty(String, null) public lastName: string | null = null; @jsonProperty(Date, null) public birthdate: Date | null = null; public getAge(): number | null { if (this.birthdate) { const ageDifMs = Date.now() - this.birthdate.getTime(); const ageDate = new Date(ageDifMs); return Math.abs(ageDate.getUTCFullYear() - 1970); } return null; } public getFullname(): string | null { return [ this.firstName ?? "", this.lastName ?? "" ] .join(" ") .trim() || null; } }
TariffDto. Модель с логикой и описанием данных для переноса данных между слоямиimport { Serializable, jsonProperty } from "ts-serializable"; export class TariffDto extends Serializable { @jsonProperty(Number, null) public ageFrom: number = 0; @jsonProperty(Number, null) public ageTo: number = 0; @jsonProperty(Number, null) public price: number = 0; }
В итоге получили модульное масштабируемое приложение с очень маленьким количеством бойлерплейта (1 базовый компонент, 3 строчки на внедрение зависимостей на класс) и очень низкими накладными расходами (фактически только на внедрение зависимостей, все остальное логика). Так же мы не завязаны на какую либо библиотеку представления. Когда умер Angular 1 многие начали переписывать приложения на React. Когда кончились разработчики для Angular 2, многие компании стали страдать из-за скорости разработки. Когда умрет React в очередной раз придется переписывать решения завязанные на его фреймворк и экосистему. Но с Чистой Архитектурой про завязыванием на фреймворк можно забыть.
В чем преимущество относительно Redux?
Для того что бы понять в чем разница, давайте посмотрим как ведется себя Redux при росте приложения.
Как видно из схемы с ростом Redux приложения масштабируется вертикально, Store и количество Reducers также увеличивается и превращается в бутылочное горлышко. А количество накладных расходов на пересоздание Store и поиск нужного Reducer начинает превышать полезную нагрузку.
Проверить соотношение накладных расходов к полезной нагрузке на приложении среднего размера можно простым тестом.
let create = new Function([
"return {",
...new Array(100).fill(1).map((val, ind) => `param${ind}:${ind},`),
"}"
].join(""));
let obj1 = create();
console.time("store recreation time");
let obj2 = {
...obj1,
param100: 100 ** 2
}
console.timeEnd("store recreation time");
console.time("clear logic");
let val = 100 ** 2;
console.timeEnd("clear logic");
console.log(obj2, val);
// store recreation time: 0.041015625ms
// clear logic: 0.0048828125ms
На пересоздания Store в 100 свойств ушло в 8 раз больше времени чем на саму логику. При 1000 элементов это уже 50 раз больше. Кроме того одно действие пользователя может породить целую цепочку экшенов, вызов которых тяжело отлавливать и отлаживать. Можно конечно возразить что 0,04 мс на пересоздание Store это очень мало и тормозить не будет. Но 0,04 мс это на процессоре Core i7 и на один экшен. С учетом более слабых мобильных процессоров и тем что одно действие пользователя может породить десятки экшенов, все это приводит к тому что расчеты не вписываются в 16 мс и создается ощущение что приложение тормозит.
Давайте сравним с тем как растет приложение на Чистой Архитектуре:
Как видно за счет разделение логики и обязанностей между слоями приложение масштабируется горизонтально. Если какому либо компоненту понадобиться обработать данные, он обратиться к соответствующему сервису, не трогая не связанную с его задачей сервисы. После получения данных произойдет перерисовка только одного компонента. Цепочка обработки данных очень короткая и очевидная, и максимум за 4 перехода по коду можно найти нужную логику и отладить ее. Кроме того, если провести аналогию с кирпичной стеной, то мы можем изъять любой кирпичик из этой стены и заменить его на другой никак не повлияв на устойчивость этой стены.
Бонус 1: Расширение функционала компонентов фреймворка
Бонусом получили возможность дополнять или изменять поведения компонентов библиотеки представления. Например реакт при ошибке в представлении не отрисовывает все приложение, если сделать небольшую доработку:
export class BaseComponent<P, S> extends React.Component<P, S> {
...
public render(): React.ReactElement<object> {
try {
if (this.view) {
return this.view(this, this.props);
} else {
return React.createElement("div", {}, "Представление не определено");
}
} catch (e) {
return React.createElement(
"div",
{ style: { color: "red" } },
`В этом компоненте произошла ошибка: ${e}`
);
}
}
}
То теперь реакт будет не отрисовывать только тот компонент в котором произошла ошибка. Таким же нехитрым способом можно добавить, мониторинг, аналитику и прочие ништяки.
Бонус 2: Валидация данных
Модели помимо описания данных и переноса данных между слоями обладают большим запасом возможностей. Например если подключить библиотеку class-validator, то простым навешиванием декораторов можно валидировать данные в этих моделях, в т.ч. при небольшой доработке можно валидировать веб формы.
Бонус 3: Создание сущностей
Кроме того если вам понадобилась работа с локальной базой данных, вы можете подключить библиотеку typeorm и ваши модели превратятся в сущности, по которым будет генерироваться и работать база данных.
Спасибо за внимание
Если вам понравилась статья или подход ставьте лайки и не бойтесь экспериментировать. Если вы адепт Redux и вам не нравится инакомыслие, то объясните пожалуйста в комментариях как вы масштабируете, тестируете и валидируете данные в вашем приложении.
Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Какой подход вы использовали до прочтения этой статьи
54.42% Flux, Redux, Vuex или другой стейт менеджер117
21.86% MobX, RxJs или другой observable без ЧА47
28.84% Чистая Архитектура62
6.05% Другой, поясню в комментариях…13
Проголосовали 215 пользователей. Воздержались 68 пользователей.
Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Понравилась ли вам Чистая Архитектура, планируете ли использовать такой подход?
37.37% Да108
29.07% Возможно84
13.49% Нет39
17.65% Я размещаю логику на бэкенде51
2.42% Я верстаю, у меня нету логики7
Проголосовали 289 пользователей. Воздержались 55 пользователей.