Многослойная архитектура FrontEnd-приложений на основании SOLID, часть 2
Итак, в предыдущем посте мы многое разложили по полочкам и разобрали проблемы кодовой базы. Осталось есть ощущение, будто что-то еще не так. Хочется чего-то более элегантного.
В этом посте подойдем к проблеме пошире и начнем с архитектуры. Вот для примера довольно стандартная архитектура.
Большинство нормально структурированных приложений придерживается ее высокоуровнево, но на деле она вас не особо ограничивает. Есть много сходств со стандартной MVC-архитектурой:
есть presenter-layer, который делает UI,
есть business-layer, который организует данные,
есть слой connection, который выступает в роли контроллера, то есть дает возможность изменять UI и прокидывать данные.
Здесь еще появляется data-layer, который позволяет решить взаимодействие с бэкендом, вынести его на тонкий легко поддерживаемый слой и забыть. Дальше вы работаете только внутри компонента.
Вы можете вынести в отдельный слой подготовку запроса, хардкод-данные, URL и даже обработку ответа. Этот слой можно поместить в корень проекта, назвать, например, requests, и описать там все методы, связанные с каждой фичей. Это поможет сильно упростить взаимодействия с бэком. Как минимум, они все будут в одном месте. Если бэк что-то поменяет, мы всегда работаем с тем форматом, которые сами себе замапили. В этом слое используются Data Mappers, которые я не показал, однако в последней строчке authRequest можно было вызвать функцию, которая преобразовала бы под нужный фронтенду формат. В дальнейшем бы при любом изменении бэка мы бы все обрабатывали здесь.
Посмотрим шире.
Очень часто через request-layer решается вопрос пендинга. Например, вам нужно дергать проверку оплаты — прошла ли она или нужно ждать еще. Вместо того, чтобы делать один запрос, можно сделать цепочку promise, повторные запросы и работать с ними, будто у вас один метод. Так же в приложениях, где пишутся атомарные бэкенды на каждую фичу, можно делать единый метод. Со стороны это будет вызов одной функции, возвращающей promise, а на деле она может склеивать десять вызовов, получать результаты и возвращать уже готовые данные. Все это вносится на request-layer, и сложность приложения сильно уменьшается.
Presenter-layer
Presenter-layer тоже можно разложить по частям. Application — самый верхний уровень приложения. При объявлении первой страницы любого проекта чаще всего вы решаете одни и те же задачи — адаптивности, роутинга, Hot Reload, наполнения начальными данными, запроса данных и показа ошибки. Эти задачи типовые, по факту их можно свести к двадцати самым популярным видам. Чаще всего при создании нового проекта вы начинаете все копировать, тем самым нарушая принцип DRY.
Давайте посмотрим, как можно этого избежать.
В данном случае используется паттерн HOC. Например, в withRedixStore передается локально объявленная для проекта функция, а сам withRedixStore предоставляет вам собранный для вашей архитектуры готовый store.
Вся архитектура будет зависеть от того, какие данные мы получаем от connection layer. Здесь нужно дергать минимальное количество данных, чаще всего достаточно данных для роутинга. Например, вы получаете от него URL и view для компонента.
Connection-layer
Есть множество подходов, но мне кажется, что нужно коннектить максимально низкие узлы, то есть каждый конкретный элемент должен коннектиться к store и получать атомарные данные. React-Redux или любая библиотека, которая стоит между store и приложением, должна с точки зрения архитектуры изолировать store от приложения.
То есть приложение ничего не должно знать о структуре store и наоборот. В этом случае, если вы вносите изменения в формат хранилища данных, менять надо будет только селекторы страниц store. Если же вы вносите изменения в UI, нужно дергать другие методы, но нет кастинга параметров.
В корне разделения на слои лежит Declarative layer, в который входит Page, View и Macro. Уникальность этой архитектуры состоит в том, что мы искусственно вводим слой UI, который следует конкретным строгим правилам. В этом случае мы решаем все проблемы, перечисленные ранее. Если посмотреть на Connection layer и связки с ним, можно увидеть две большие точки: Connection и Declarative со стрелкой «Флаги», а также Connection и Markup со стрелкой «Данные». Markup это и есть те грязные коннекторы, которые получают много простых полей.
Если говорить про слои Page, View и Macro, то флаги — это переменные, определяющие только формат отрисовки view.
Рассмотрим примеры Page-layer и View-layer.
Примерно так выглядят внешние слои для наших компонентов интернет-магазина. Плюс в том, что мы видим всего 15–20 строчек кода, но даже по ним можно понять, что за страница перед нами. Таким образом, если вам нужно внести куда-то изменения или добавить элемент, вы сразу понимаете, где это сделать. Если обратить внимание на isMobile и isAuthorized, то мы увидим те самые флаги, о которых шла речь ранее.
Вот более сложный пример View-layer. Здесь больше флагов, в целом подход такой же — можно прочитать компонент и понять, что он делает. Можно заметить, что компонент уже становится перегруженным. Тут есть code smell — isPartnerView используется практически на каждой строчке. В какой-то момент может возникнуть ситуация, что вы использовали флаг, а потом поняли, что из-за него меняется слишком много UI. Плюс данного подхода в том, что вам не составит труда разбить этот view на две.
Да, здесь будет копирование нескольких компонентов, но view созданы для того, чтобы их копировать в случае, если они слишком отличаются. Здесь вся логика из PartnerView для этого компонента и вся логика, где этого view нет.
Вернемся же к схеме.
В Declarative layer есть еще и Macro. Когда у вас появляются сложные компоненты, которые вы не хотите разбивать на конкретные элементы на верхнем уровне, тогда используются Macro. Это неразлучные друг с другом кусочки, которые складываются в единый компонент. Бывают примеры по типу аккумулятора, когда у вас есть каретка, заполненная часть, контейнер аккумулятора. Мы условно представляем какую-то заполняющуюся батарейку, в этом случае не имеет смысла говорить о каретке в рамках view или страницы — нужно создавать Macro для этого аккумулятора, и все заполненные и незаполненные части выносятся туда.
В Markup layer помещаются бизнес-задачи. К Design UI-kit компании чаще всего приходят сами. При создании страницы по UI мы стараемся выносить переиспользуемые компоненты на отдельный слой. Store для него использовать нельзя, чтобы можно было в нужный момент вынести его в отдельную библиотеку и делитесь ей с другими командами.
Куда делся весь хлам
Мы вычистили наш чердак из прошлой части статьи от хлама, но куда же делась бизнес-логика, условия и проверки? К сожалению, полностью избавиться от этого у вас не получится. Даже в примере селектора от самого Redux будет куча проверок, маппингов, переносов и так далее. Это тоже можно решать, но мы не нашли для себя какой-то достаточно несложный способ, чтобы это сильно упростить.
Тем не менее, когда логика заперта внутри store, а не в компонентах, вы получаете большой плюс — селекторы работают по довольно простой схеме. У вас есть state и выделенное состояние из него. Всегда можно разделить это на несколько элементов, максимальная сложность будет ограничена. Даже если у вас гигантское приложение, сложность селектора будет на каком-то предсказуемом уровне, который вас чаще всего устраивает. Если же надо что-то разделять, то вы уже не лезете в UI, а просто исправляете на месте. Например, можно разделить селектор на два. Если проблема с архитектурой store — переписываем селектор так, чтобы новый формат вас устраивал. Есть проблема и с action. Здесь можно следовать рекомендациям React-Redux. У них есть специальный формат Query, который позволяет работать со списками объектов entities по идентификаторам. Если не получается entity — используете Async Thunk, иначе используете стандартные action, которые друг друга вызывают.
Что в итоге
Этот анализ показал, что самой большой проблемой является нарушение SRP. Я всех призываю понять, где оно нарушается и как его исправлять. Нужно учитывать, что SRP имеет не одно, а как минимум три чтения. Например, ваш компонент должен иметь одну ответственность, но со стороны общей базы какая-то задача должна решаться одним компонентом. То есть, если вы копируете несколько элементов, вы тоже нарушаете SRP.
А с другой стороны, многие пытаются разделить все настолько, что получается слишком атомарно. Чаще всего даже в книгах приводится такой пример: если ваш компонент отвечает за запись и чтение, скорее всего, это одна и та же функция. Здесь нужно тоже приходить к тому, что каждое разделение на новые компоненты — это создание абстракции, что, в свою очередь, является усложнением.
Отмечу, что проблема качества кода должна решаться и сверху, и снизу. Есть атомарные правила, как писать какие-то компоненты. Например, в ваших компонентах не должно быть больше двух базовых Hooks. Это работа снизу вверх, то есть работа с качеством конкретного кода. Но этого недостаточно, нужно смотреть еще и архитектурно. Таким образом, следовать единому принципу написания кодовой базы архитектурно так же важно, как и следовать правилам атомарного кода.
Третье правило — правило пионера, оно поможет сохранить проект в хорошем состоянии. Если вы полезли в какой-то сложный компонент, и вам просто нужно было добавить свою фичу, посмотрите на недостатки этого кода, отредактируйте так, чтобы после вас стало чище, чем было до вас.
Последнее. Работа с техдолгом — непрерывный процесс. Люди, которые считают, что все можно вынести в техдолг, а потом когда-нибудь сесть и закрыть все за месяц, неправы. Техдолг с первого дня накладывает большой отпечаток, нужно в рамках каждой задачи исправлять свой код, чтобы у вас все было красиво.