Введение

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

Существует 4 наиболее распространённых связей между модулем и компонентом:

Идеальная архитектура должна соответствовать принципам SOLID, DRY, KISS, SOLJ, PATTET . А самое главное, что удаление/изменение модуля должно быть простым.

ВАЖНО: Строгое описание помогает улучшить качество кода! Теория без практики мертва, а практика без теории слепа.

Рисунок 1 - Монолитное приложение

Основные цели

Хорошая архитектура проекта может значительно упростить разработку, улучшить производительность, повысить сопровождаемость и расширяемость проекта.

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

Преждевременная оптимизация — корень всех (или большинства) проблем в программировании. — Дональд Кнут, «Computer Programming as an Art» (1974)

Вывод: систему дороже изменять, поэтому нужно заранее думать о том, как вы будете изменять ее в будущем.

Изначально выстроив хорошую и понятную архитектуру, в результате получаем следующие преимущества:

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

Структура исходного кода

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

Все наши пакеты, независимо от папки, в которой они хранятся, имеют подпапку src, и, что необязательно, папку bin. В папках src пакетов, хранящихся в директориях app и lib, могут иметься некоторые из следующих подпапок:

components

React-приложения создаются с помощью компонентов.

├── components
│   ├── common
│   │   └── button
│   │       ├── button.tsx
│   │       ├── button.stories.tsx
│   │       ├── button.spec.tsx
│   │       └── index.ts
│   └── signup-form
│       ├── signup-form.tsx
│       ├── signup-form.spec.tsx
│       └── index.ts

У нас есть папка components, содержащая все компоненты, которые используются в приложении более одного раза, поэтому мы собираемся исключить все специфические компоненты из этой папки.

Содержит папки компонентов с их кодом, переводы, модульные тесты, снимки, истории (если это применимо к конкретному компоненту).

pages

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

Мы можем использовать каталог views (или pages, если хотите).

views
├── home.tsx
├── guestbook.tsx
└── newsletter
   ├── index.ts
   ├── newsletter.tsx
   └── components
       └── newsletter-form
           ├── newsletter-form.tsx
           ├── newsletter-form.spec.tsx
           └── index.ts

Еще один совет - сохранять согласованное имя между страницей и маршрутом, примерно так:

<Route path="/bookings">
 <Route index element={<Bookings />} />
 <Route path="create" element={<CreateBooking />} />
 <Route path=":id" element={<ViewBooking />} />
 <Route path=":id/edit" element={<EditBooking />} />
 <Route path=":id/delete" element={<DeleteBooking />} />
</Route>
layout

Layout вообще не являются страницами, они больше похожи на компоненты, поэтому с ними можно обращаться так же, но лучше помещать их в папку layouts, так понятнее, что в этом приложении есть n лэйаутов.

layouts
├── main.tsx
└── auth.tsx
hook, store, context

Это довольно просто, и обычно, почти все разработчики придерживаются чего-то подобного:

hooks
├── use-users.ts
└── use-click-outside.ts
contexts
├── workbench.tsx
└── authentication.tsx
helpers

Сколько раз вы создавали красивую функцию formatCurrency, не зная, куда ее положить? Папка helpers придет вам на помощь.

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

helpers
├── format-currency.ts
├── uc-first.ts
└── pluck.ts
const

Существует много проектов, которые содержат константы в папке utils или helpers, но лучше помещать их в отдельный файл, давая пользователю хороший обзор того, что используется в качестве константы в приложении. Чаще всего в эту папку помещаются только глобальные константы, поэтому не стоит помещать сюда константу QUERY_LIMIT, если она используется только в одной функции для очень специфического случая.

constants
└── index.ts

Кроме того, можно хранить все константы в одном файле. Нет смысла разбивать каждую константу на отдельные файлы.

// @/constants/index.ts
export const COMPLANY_EMAIL = "example@example.com";

И использоваться они будут так:

import { COMPLANY_EMAIL } from "@/constants";
style

Просто поместите глобальные стили в папку styles, и все готово.

styles
├── index.css
├── colors.css
└── typography.css

Если вы используете emotionstyled-components или просто CSS Modules, поместите их в папку конкретного компонента, чтобы все было оптимально упаковано.

Конфигурационные файлы

Есть ли у вашего приложения файлы конфигурации, такие как Dockerfiles, Fargate Task Definitions, webpack и т.д.? Папка config должна быть идеальным местом для них. Помещение их в соответствующую директорию позволяет избежать загрязнения корневого каталога не относящимися к делу файлами.

API

99% приложений react имеют хотя бы один вызов API к внешнему источнику данных (вашему бэкенду или какому-то публичному сервису), обычно эти операции выполняются в нескольких строках кода без особых сложностей, и именно поэтому, оптимальная их организация игнорируется. Рассмотрим этот код:

axios
 .get("https://api.service.com/bookings")
 .then((res) => setBookings(res.data))
 .catch((err) => setError(err.message));

Довольно просто, верно? Теперь представьте, что у вас есть эти 3 строки, распределенные по 10 компонентам, потому что вы часто используете именно этот адрес сервера.

Рассмотрите возможность использования каталога api, который, прежде всего, содержит экземпляр клиента, используемого для вызовов, например, fetch или axios, а также файлы, содержащие декларации вызовов fetch.

api
├── client.ts
├── users.ts
└── bookings.ts

И пример файла users.ts:

export type User = {
 id: string;
 firstName: string;
 lastName: string;
 email: string;
};
 
export const fetchUsers = () => {
 return client.get<User[]>("/users", {
   baseURL: "https://api.service.com/v3/",
 });
};
Резюме

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

Паттерны проектирования

Классическая архитектура [Без архитектуры]

Структура кода:

  • pages
  • components
  • api
  • helpers
  • store
  • hooks
  • assets

Важно: Структура папок не задаёт её архитектуру. Поток данных от pages - components однонаправленный.

Чёткие связи между компонентами отсутствуют. Возможны кольцевые зависимости. UI-компоненты смешиваются с компонентами и бизнес-логикой.

Классическая архитектура применяется в том случае, когда нужно писать какое-то небольшое приложение, где задумываться об архитектуре не надо и необходимо быстро дать результат.

Модульная простая архитектура [монолитное приложение]

Структура кода:

  • pages
    • AdminPage
    • ProfilePage
    • SettingsPage

ВАЖНО: Главное отличие от modules в том, что pages максимально тонкая.

  • modules (какие-то модули кода, которые могут быть размещены на странице)
    • InfiniteUsersList
    • ArticleComments
    • RegistationForm
    • RegionsGeoTree
    • OrderFeedbackForm

ВАЖНО: Из модуля мы можем достать только то, что задаст разработчик, чтобы избежать анархических связей в приложении. Реализуем с помощью PUBLIC API . Реализация инкапсуляции в ООП.

  • components (какие-то модальные формы, которые могут быть размещены в модуле)
    • UserCard
    • ProductItem
    • Commet
    • DocumentsItem
    • RatingCard
  • UI-kit (какие-то элементы, которые могут содержаться в компоненте)
    • spinner
    • button
    • input
    • modal
    • tooltip
    • select

Реализация однонаправленного потока данных#UDF pages->modules->components->ui-kit Из минусов: что делать, если модуль нужно использовать в другом модуле? Неявные связи всё равно образуются.

Идеально подходит для команды фронтов из 4-6 человек. Не подходит для проектов со сложной бизнес-логикой и большим количеством бизнес-сущностей и бизнес-фичей.

Atomic design [монолитное приложение]

Структура кода:

  • atoms (переиспользуемые ui-kit)
  • molecules (компонент, который состоит из atoms)
  • organisms (модули, которые состоят из molecules)
  • templates (шаблоны задают layot)
  • pages (используя шаблоны, реализуем страницы)

Микрофронтенд + монорепа + модульная

Всё вышеперечисленное хранится в одном большом монорепозитории.

Инструменты для реализации:

  • pnpm / yarn workspaces
  • nx
  • lerna
  • bit
  • single-spa
  • webpack module federations

Микрофронтенд используется:

  1. Большой монолитный проект, который можно разделить на разные модули
  2. Несколько фронтовых команд, которые занимаются разработкой проекта
  3. Сборка, тесты, деплой занимают много времени
  4. В большой монолитный проект сложнее внедрять новые подходы и технологии, нежели в маленькие. Новые проекты можно писать на новых фреймворках / библиотеках + менять архитектурные подходы

Недостатки:

  1. Сложная инфраструктура
  2. Нужна команда, которая будет заниматься поддержкой и развитием монорепозитория
  3. Размазывается зона ответственности

Как обеспечить обязательное применение руководства по стилю?

Мы стремились к единообразию структуры файлов и папок нашего проекта. Того же самого мы хотели добиться и для кода.

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

Настройка линтера в монорепозитории выполняется так же, как и в любом другом репозитории. И это хорошо, так как это позволяет проверить весь репозиторий за один запуск линтера. Если вы не знакомы с линтерами — рекомендую взглянуть на ESLint и Stylelint. Мы пользуемся именно ими.

Как поддерживать качество кода на высоком уровне?

Линтеры, тесты, контроль типов — всё это благотворно сказывается на качестве кода. Но программист легко может забыть запустить соответствующие проверки перед включением кода в ветку master. Лучше всего сделать так, чтобы подобные проверки запускались бы автоматически. Некоторые предпочитают делать это при каждом коммите, пользуясь хуками Git, что не позволяет сделать коммит до тех пор, пока код не пройдёт все проверки. Но мы считаем, что при таком подходе система слишком сильно вмешивается в работу программиста. Ведь, например, работа над некоей веткой может занять несколько дней, и все эти дни она не будет признана подходящей для отправки в репозиторий. Поэтому мы проверяем коммиты, используя систему непрерывной интеграции. Проверкам подвергается только код веток, которые связаны с merge-запросами. Это позволяет нам избежать запуска проверок, которые гарантированно не будут пройдены, так как мы чаще всего делаем запросы на включение результатов своей работы в основной код проекта тогда, когда уверены в том, что эти результаты способны пройти все проверки.

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

URM диаграмма проекта

Красные стрелки — поток течения данных (но не зависимостей, диаграмма зависимостей отображена на круговой диаграмме выше). Изображение в виде прямоугольной диаграммы позволяет лучше понять, как движется поток данных внутри приложения. Идею описания в виде такой диаграммы я увидел в ЭТОЙ статье.

Исходники для увеличения — GitHub.

Сборщик приложений

За раздачу статики отвечает веб-сервер (#Nginx ). Соответственно, Frontend и Backend являются отдельными веб-приложениями. Перед тем, как передать Nginx информацию мы пропускаем через сборщик (#rollup#webpack#vite) наш JS, HTML, CSS - файлы для подготовки к production , а на выходе мы получаем наш bundle.js , который раздаётся веб-сервером.

В то время, как взаимодействие с Backend происходит посредством ЗАПРОС-ОТВЕТ на самом Frontend. Backend инфраструктура также расширяется.

Для автоматизации процессов: сборка приложения, прогон по тестам и прочее используются ci/cd pipeline - в качестве инструментов может использоваться (gitlab ci/cd, jenkins и др.). При этом, приложение упаковывается в docker-контейнер, в котором настроена вся необходимая среда для правильного развёртывания приложения на рабочем компьютере.

Ядро нашего Backend приложения может разрастаться, формируя собой большой монолит. Мы можем создать раcпределённую систему, которая позволит разграничить ответственность ядра приложения по микросервисам.

При декомпозиции сервисов необходимо создать единую точку входа API Gateway

Далее возникает необходимость в хранении больших объёмов данных - для этого зачастую используется S3 .


Назад