В этом посте я попытаюсь формализовать и систематизировать своё собственное понимание, какой должна быть структура SPA-приложений. Это очень субъективное изложение, отражающее мой собственный опыт. Оно относится к определённому классу веб-приложений (SPA, PWA) и не претендует на универсальность.

Какие веб-приложения не относятся к рассматриваемому мной классу:

  • headless-приложения (у которых нет UI)

  • микросервисы и микрофронтенды

  • высоконагруженные приложения

  • статические страницы с использованием внешних библиотек

  • SSR сайты

В контексте данной статьи SPA-приложение - это классическое клиент-серверное приложение, где клиент существует в браузере (как правило, в пределах одной страницы) и взаимодействует с сервером посредством HTTP-запросов. Приложение разрабатывается в виде набора npm-пакетов в стиле “модульный монолит”. Серверная часть реализована на движке Node.js.

Непреодолимые ограничения

SPA - это прежде всего браузерное приложение. Все браузерные приложения “живут” в браузере и общаются с внешним миром через набор протоколов (http(s)://, ftp://, ws(s)://, data://, file://, …). Чтобы приложение попало в браузер, оно должно быть загружено из внешнего источника по одному из трёх протоколов (http, https, file) в виде базового HTML-файла, в котором содержится код всего приложения либо описываются ресурсы, которые браузер должен будет загрузить дополнительно.

Загрузка веб-приложения в браузер
Загрузка веб-приложения в браузер

Поэтому, как бы мы ни крутились, в браузерном приложении должен быть хотя бы один HTML-файл, который и является точкой входа. Лично я придерживаюсь традиции, по которой этот файл носит имя ./index.html.

CDN

Существует множество CDN, которые распространяют различные статические ресурсы:

  • cdnjs.cloudflare.com

  • cdn.jsdelivr.net

  • fonts.googleapis.com

Если наше приложение в точке входа загружает нужные ему ресурсы через CDN, то мы вынуждены придерживаться тех правил, которые нам диктует соответствующий CDN. Исторически сложилось так, что для повышения производительности отдельные ресурсы объединяли в файлы (бандлы, спрайты), и конечный разработчик приложения (интегратор) уже имел дело с ними.

Загрузка бандлов через CDN
Загрузка бандлов через CDN

Другими словами, если в приложении используются сторонние ресурсы, загружаемые через CDN, то разработчик (интегратор) не имеет возможности повлиять ни на размещение файлов, ни на их наименование. Что, в принципе, является нормальной ситуацией для внешних ресурсов. Если же через CDN распространяются файлы самого проекта, то тут разработчик волен размещать их по своему усмотрению.

NPM-пакеты

До появления Node.js в JS-разработке вопрос пакетов остро не стоял. В браузер можно было загрузить любой бандл, доступный через интернет. Централизованные реестры по учёту существующих JS-библиотек, можно сказать, отсутствовали. NPM изменил правила игры, и теперь хорошим тоном является публикация свободных библиотек в виде npm-пакетов в реестре. Таким образом, самым верхним уровнем группировки кода в веб-приложении является npm-пакет.

Приложение состоит из npm-пакетов
Приложение состоит из npm-пакетов

Данную группировку можно видеть у CDN jsDelivr:

Группировка по npm-пакетам
Группировка по npm-пакетам

Код самого веб-приложения также является npm-пакетом (содержит ./package.json в котором прописаны соответствующие пакеты-зависимости):

./project/
    ./index.html        // точка входа
    ./LICENCE           // лицензия
    ./package.json      // дескриптор npm-пакета
    ./README.md         // описание пакета
    ./RELEASE.md        // история изменений

Static assets

Всю информацию, касающуюся веб-приложения, можно разделить на три большие группы:

  • данные

  • код

  • статические ресурсы (static assets)

В принципе, код тоже в массе своей является статическим ресурсом, и даже данные иногда (например, начальная конфигурация приложения), но термин "static assets" закрепился за файлами стилей, медиа-файлами, шрифтами, шаблонами и т.п. Статические ресурсы в проекте помещают в каталоги с названиями ./public/, ./static/, ./assets/. Я в своих проектах размещаю статические ресурсы в каталоге ./web/:

./project/
    ./src/              // исходный JS-код
    ./web/              // статические ресурсы
        ./index.html    // точка входа
    ./LICENCE           // лицензия
    ./package.json      // дескриптор npm-пакета
    ./README.md         // описание пакета
    ./RELEASE.md        // история изменений

Сборка

Сборка дистрибутива (или дистрибутивов - esm, umd, min, prod, dev) является общепринятой практикой при разработке публичных библиотек или при использовании TypeScript. Для сборки, как правило, используют имена каталогов ./build/ или ./dist/. С учётом конфигурационных файлов для сборщиков наша структура приобретает вот такой вид:

./project/
    ./dist/             // результаты сборки
    ./src/              // исходный JS-код
    ./web/              // статические ресурсы
    ./LICENCE           // лицензия
    ./package.json      // дескриптор npm-пакета
    ./README.md         // описание пакета
    ./RELEASE.md        // история изменений
    ./rollup.config.js  // Rollup-конфигурация
    ./tsconfig.json     // TS-конфигурация 

Дополнительное окружение

При разработке в стиле “модульный монолит”, как правило, есть основной npm-пакет - само веб-приложение, и набор npm-пакетов, являющихся плагинами к нему (а зачастую, параллельно, и к другим приложениям). Есть некоторая разница между npm-пакетом, содержащим код приложения, и npm-пакетом, являющимся плагином. Пакет-приложение подразумевает запуск приложения в виде веб-сервера (для загрузки кода в браузер и обработки запросов к бэку) или в виде консольной команды (например, для выполнения сервисных функций). Пакет-плагин самостоятельно не используется и входит в состав других приложений в виде зависимости.

Поэтому есть, например, такие каталоги, как ./doc/ и ./test/, которые могут относиться как к приложениям, так и к плагинам, а есть такие, которые скорее будут относиться только к приложениям - ./bin/, ./cfg/, ./var/.

На этом этапе можно отобразить структуру каталогов таким образом:

./project/
    ./bin/              // уровень приложения, исполняемые скрипты
    ./cfg/              // уровень приложения, локальная конфигурация (подключение к БД и т.п.)
    ./dist/             // уровень приложения, результаты сборки
    ./doc/              // уровень плагина, документация
    ./etc/              // уровень плагина, дополнительная информация (например, DDL таблиц плагина для формирования общей БД)
    ./log/              // уровень приложения, логирование работы приложения
    ./src/              // уровень плагина, исходный JS-код
    ./test/             // уровень плагина, тесты
    ./var/              // уровень приложения, временные результаты работы приложения (например, выгрузка данных по-умолчанию)
    ./web/              // статические ресурсы
    ...

Front & Back

Раз уж JavaScript можно применять для создания кода, работающего и в браузере (фронт), и на сервере (бэк), а в качестве “модуля” в “монолите” выбран npm-пакет, то есть смысл разделять исходный JS-код на браузерный и node’овский на уровне каталогов. Хотя бы просто потому, что он работает в разном окружении, которое предоставляет коду различный функционал (Web API - в браузере и node-модули на сервере). Так как возможен ещё JS-код, который может работать и в браузере, и на сервере, то в каталоге ./src/ появляются три соответствующих области:

./src/
    ./Back/
    ./Front/
    ./Shared/

Разделение “монолита” на “модули” должно происходить таким образом, чтобы код в отдельном npm-пакете (плагине, модуле) был сильно связанным (high cohesion), а сами пакеты обладали слабым зацеплением друг с другом (loose coupling).

Распределённый характер веб-приложения, где множество клиентов (браузеров) используют единый источник данных (БД на сервере), определяет "сильную связь" между полем на форме в браузере и колонкой в БД для хранения данных этого поля. Поэтому вполне рационально объединять код формы и код операции сохранения данных этой формы в одном npm-пакете, несмотря на то, что первый код работает в одном окружении (Web API), а второй - в другом (nodejs). В конце концов, и то, и другое (и третье - ./Shared/) - это обычный JS.

Разумеется, что эти соображения неприменимы, если фронт написан на JS, а бэк - “на другом хорошем ЯП” (Java, PHP, C#, Go, Python, …). Но если всё приложение целиком написано на JS, то исходный код из каталога ./src/ можно разбить на три группы, по месту его использования:

Области использования JS-кода
Области использования JS-кода

Каталог ./web/

Каталог для размещения статики может быть в каждом плагине (npm-пакете). Так как приложение само является npm-пакетом, то при сборке все зависимости попадают в каталог ./node_modules/, а статические ресурсы каждого плагина становятся доступными относительно корня приложения по пути ./node_modules/@vendor/plugin/web/. Далее делом техники является создание обработчика для веб-сервера (express, fastify, koa, ...), который может выдавать статику соответствующего плагина из его web-подкаталога.

Если же npm-пакет опубликован в реестре npmjs, то можно обойтись и без обработчика - все файлы пакета, включая содержимое web-каталога, становятся доступными через CDN (например, jsDelivr - https://cdn.jsdelivr.net/npm/@vendor/plugin@latest/web/…).

Группировка ресурсов в каталоге ./web/, как правило, идёт по типу ресурса:

./web/
    ./css/
    ./font/
    ./img/
    ./js/
    ./media/
    ./favicon.ico
    ./index.html
    ./manifest.json
    ./styles.css
    ./sw.json

Разумеется, что статика используется в основном на фронте, но раздача статики, в силу особенностей веб-приложений (см. “Непреодолимые ограничения”) идёт с сервера (или с CDN).

Каталог ./test/

Есть масса различных типов тестов (юнит-тесты, функциональные, интеграционные и т.д.) и при определенном размере приложения (или даже скорее, при определённом размере команды разработчиков) они все становятся нужными. Я, как правило, разрабатываю свои приложения в 1-2 лица, поэтому основным типом “тестов” у меня является девелоперское окружение для разработки какого-либо класса. Так как в своих приложениях я использую IoC (Constructor Dependency Injection), то вместо того, чтобы поднимать всё приложение для проверки очередной порции правок, я делаю тестовый скрипт, который конструирует экземпляр нужного мне класса и реализует вызов его методов в тестовом окружении (часть зависимостей может быть замокирована или быть реальными - например, соединение с девелоперской версией БД).

В общем, я для себя вижу смысл в трёх тестовых подкаталогах:

./test/
    ./dev/      // уровня плагина, тестовое окружение для разработки отдельных объектов (запускаются вручную)
    ./e2e/      // уровня приложения, для автоматизированной проверки отдельных функций всего приложения в сборе
    ./unit/     // уровня плагина, для автоматизированной проверки реализации отдельных объектов кода со сложной логикой

Каталог ./src/Shared/

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

./src/Shared/
    ./Api/      // содержит описание интерфейсов, которые используются в работе данного плагина и которые должны быть имплементированы в других плагинах или в приложении.
    ./Di/       // имплементация интерфейсов, заданных в других плагинах (замена интерфейсов их имплементациями происходит на уровне контейнера объектов при внедрении зависимостей)
    ./Dto/      // описание структур данных, используемых данным плагином или приложением.
    ./Enum/     //описание кодификаторов, используемых данным плагином или приложением.
    ./Util/     //утилиты.
    ./Web/      // описание структур данных, которые используются для общения фронта и бэка.
        ./Api/      // синхронных POST-запросов от фронта к бэку и ответов бэка на них.
        ./Event/    // сообщений, передаваемых по SSE-каналу.
        ./Rtc/      // сообщений, передаваемых по WebRTC-каналу.
        ./Socket/   // сообщений, передаваемых через WebSocket’ы.

Каталог ./src/Front/

Этот каталог содержит файлы, которые используются только на фронте и завязаны на Web API, предоставляемый браузером. В чём-то он повторяет структуру Shared-каталога, но также добавляет и свои собственные типы, специфичные именно для фронта:

./src/Front/
    ./Api/      // содержит описание интерфейсов, которые используются в работе данного плагина и которые должны быть имплементированы в других плагинах или в приложении.
    ./Convert/  // содержит код для конвертации Shared DTO во Front DTO и обратно. Этот слой кода позволяет уменьшить зацепление (coupling) между структурами данных на фронте и на бэке.
    ./Di/       // имплементация интерфейсов, заданных в других плагинах (замена интерфейсов их имплементациями происходит на уровне контейнера объектов при внедрении зависимостей)
    ./Dto/      // описание структур данных, используемых данным плагином или приложением.
    ./Enum/     //описание кодификаторов, используемых данным плагином или приложением.
    ./Ext/      // содержит код для оборачивания внешних библиотек (например, UMD-модулей, подключаемых в index.html).
    ./Mod/      // модели, в которых реализована логика обработки данных (DTO).
    ./Store/    // код для сохранения данных в различных хранилищах браузера.
        ./IDb/      // IndexedDB
        ./Local/    // localStorage
        ./Mem/      // in-memory cache
        ./Session/  // sessionStorage
    ./Ui/       // код, относящийся к построению пользовательского интерфейса.
        ./Layout/   // компоненты разметки (навигаторы, панели и т.п.).
        ./Lib/      // библиотека общих компонентов, разделяемых другими компонентами (субформы, диалоги, композиционные контролы и т.п.).
        ./Route/    // компоненты для построения интерфейсов маршрутов в приложении (“страниц” в SPA).
        ./Widget/   // компоненты-одиночки, которые могут быть использованы другими компонентами или моделями (например, индикатор выполнения сетевого запроса: он нужен в одном экземпляре на фронт-приложение, но может включаться/выключаться из различных его частей - SSE, WS, RTC, REST).
     ./Util/    // утилиты.
    ./Web/      // обработчики сообщений, поступающих на фронт из сети:
        ./Event/    // SSE сообщения.
        ./Rtc/      // сообщения WebRTC.
        ./Socket/   // сообщения через web socket’ы.

Каталог ./src/Back/

Этот каталог содержит JS-код, который выполняется на сервере в среде nodejs и обеспечивает работу различных экземпляров фронтальной части приложения в браузерах пользователей. Структура каталогов перекликается со структурами в каталогах ./Front/ & ./Shared/, но есть и свои особенности:

./src/Back/
    ./Act/      // действия (actions), отдельные операции над данными, выполненные в функциональном стиле (const {out1, out2, …} = act({in1, in2, …})).
    ./Api/      // содержит описание интерфейсов, которые используются в работе данного плагина и которые должны быть имплементированы в других плагинах или в приложении.
    ./Cli/      // сервисные команды для их выполнения через консоль (например, запуск/останов бэкэнд приложения в режиме веб-сервера).
    ./Convert/  // содержит код для конвертации Shared DTO в Back DTO и обратно. Этот слой кода позволяет уменьшить зацепление (coupling) между структурами данных на фронте и на бэке.
    ./Di/       // имплементация интерфейсов, заданных в других плагинах (замена интерфейсов их имплементациями происходит на уровне контейнера объектов при внедрении зависимостей)
    ./Dto/      // описание структур данных, используемых данным плагином или приложением.
    ./Enum/     //описание кодификаторов, используемых данным плагином или приложением.
    ./Mod/      // модели, в которых реализована логика обработки данных (DTO).
    ./Plugin/   // код для подключения плагина к приложению (структуры локальных конфигурационных данных, дескрипторы конфигурации функционала плагина).
    ./Store/    // код для сохранения данных в различных хранилищах на стороне сервера.
        ./Mem/      // in-memory cache
        ./RDb/      // основная БД (реляционная)
     ./Util/    // утилиты.
    ./Web/      // обработчики сообщений, поступающих на бэк из сети:
        ./Api/      // синхронные запросы через HTTP POST.
        ./Event/    // SSE сообщения.
        ./Handler/  // подключение дополнительных обработчиков для web-запросов (например, files upload processing).
        ./Socket/   // сообщения через web socket’ы.

Заключение

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

  • У монолитной архитектуры есть определённые преимущества перед раздельной разработкой. Например, это облегчает поиск использования элементов кода при его рефакторинге (Find Usages).

  • Модульный подход имеет преимущества перед полностью монолитной архитектурой. Он, как минимум, предоставляет возможность переиспользования модулей в разных приложениях.

  • Модульный подход на уровне npm-пакетов позволяет разделить код приложения по его функциональному назначению (например, аутентификация, контакты пользователей, оформление заказов, складской учёт и т.д.), описав их интеграцию друг с другом в головном npm-пакете веб-приложения. При удачном разбиении можно повысить переиспользуемость пакетов в разных приложениях.

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

  • Правила структурирования статических ресурсов в пакетах могут базироваться на традициях разработки статических сайтов (img, css, font и т.д.). Особенно учитывая, что в SPA HTML-фрагменты пользовательского интерфейса зачастую интегрированы в JS-код компонентов.

  • Правила структурирования статических ресурсов в пакетах могут базироваться на традициях разработки статических сайтов (img, css, font, …). Особенно, учитывая, что в SPA HTML-фрагменты пользовательского интерфейса зачастую интегрированы в JS-код компонентов.

  • Исходный JS-код изначально делится на три части по области его применения (Front, Back, Shared).

  • Дальнейшее деление исходного JS-кода производится относительно типа элемента кода (с учётом области применения и без учёта реализуемой бизнес-функции: DTO, утилита, действие).

  • Деление кода по реализуемым бизнес-функциям происходит уже внутри каждого типа (./Dto/User.js, ./Dto/Sale.js, ./Dto/Address.js).

Таким образом, декомпозиция и структурирование кода идёт по спирали:

  • по бизнес-функциям на уровне пакетов

  • по типам кода внутри отдельного пакета

  • опять по бизнес-функциям внутри отдельного типа кода

  • опять по типам кода внутри реализации отдельной бизнес-функции (AZ-order).

Я изложил свой подход к структурированию программного кода при разработке SPA/PWA с целью формализовать и упорядочить мои текущие практики. Если джентльменам на Хабре есть что сказать по этому поводу, то я с интересом ознакомлюсь с их мнениями.

Комментарии (5)


  1. ris58h
    25.07.2024 15:15

    Не имеет отношения к хабу "Ненормальное программирование".


    1. flancer Автор
      25.07.2024 15:15

      Спасибо, коллега! Я тоже думаю, что писать фронт и бэк на одном языке (причём без транспиляции), а также использовать DI в JS - это норм (y) Но некоторые тут уверены в обратном. Я просто перестраховался :)


  1. leha_gorbunov
    25.07.2024 15:15

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

    Это же SPA , Single Page App. Там же весь фронт однотипный и сам себя должен рисовать без разработчика, а из набора настроек. И бэк тоже однотипный весь. Разработчик нужен только для написания прикладной бизнес логики, а весь этот обвес(front+back) я в блокноте написал без всяких внешних пакетов и прочей ерунды.


    1. flancer Автор
      25.07.2024 15:15
      +2

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


  1. supercat1337
    25.07.2024 15:15

    Спасибо, что делитесь!

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

    Суть в том, что есть главный сервис, который подключает к себе остальные сервисы и связывает их. Главный содержит логику взаимодействия остальных сервисов. К остальным сервисам относится: ui, взаимодействие с серверами (работа с api), сохранение и извлечение данных и так далее. Что это даёт? То, что всякие хелперы, dto и прочие ресурсы кладутся не в общую папку хелперов и dto всего проекта, а в папки сервисов, которые нуждаются в них.

    Становится проще тестировать каждый такой сервис по отдельности, особенно это важно для работы с api сервера. У нас, например, код юнитов для работы с api сервера работает и в браузере, и может запускаться в nodejs. За счёт этого нам очень просто тестировать код. А ещё, например, если захотим сменить библиотеку рендеринга, то это будет проще сделать, так как остальной функционал уже отделен от ui.

    Впоследствии, если код сервиса написан довольно универсально, то он переиспользуется или легко копипастится и редактируется в новом проекте. Это ещё один плюс.

    В этом подходе есть недостатки, связанные с сложностью, так как надо привыкнуть, что взаимодействие сервисов происходит только через главный сервис. То есть если в ui нужно отправить запрос на обновление данных на сервер, то ui работает не напрямую с сервисом, взаимодействующим с сервером, а через главный сервис - путем обмена сообщениями (event bus). В общем, сервисы строятся так, чтобы никто не знал о существовании друг друга. Таким образом, главный сервис будет из себя представлять большую такую событийную шину, через которую будет все наглядно кто как с кем работает.

    Надеюсь, что доступно донес мысль.