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

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

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

Ниже представлены свойства, которые имеет идеальная, на мой взгляд, структура приложения:

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

  • Масштабируемая. В любом момент может появиться необходимость добавить в приложение новую функцию или новый раздел. Это не должно быть проблемой. При этом производительность не должна проседать с ростом приложения.

Принципы, которые необходимо соблюдать, чтобы достичь такой структуры приложения:

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

  • Lazy loading для каждого раздела. Мы должны загружать модуль раздела только тогда, когда пользователь переходит в него. При таком подходе при масштабировании приложения его производительность не будет страдать.

  • Provide in root только тогда, когда это действительно необходимо. Не раз встречал подход, когда тимлид говорит, что все сервисы должны быть внедрены в корневой модуль. Не знаю для чего это делать, может быть для того, чтобы избежать ошибки появление нескольких экземпляров одного и того же сервиса. Я считаю, если сервис используется только в одном модуле, то он должен быть внедрен в этот модуль и находиться рядом с ним в одной папке. Такие сервисы не должны жить все время работы приложения.

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

  • Общие модули. Когда мы имеем пайп/директиву/компонент, который используется только в нескольких разделах приложения (не во всех), то нет необходимости внедрять его в корневой модуль и загружать при старте приложения. Необходимо создать отдельный независимый модуль и импортировать его только в те разделы, где он используется. Таким образом, этот пайп/директива/компонент будет загружаться по требованию вместе с соответствующими разделами приложения.

  • Небольшая вложенность папок и однообразность структуры модулей. Чем меньше вложенность папок в проекте, тем удобнее с ним работать – мелочь, а приятно. Также однообразная структура модулей позволяет поддерживать порядок независимо от размера проекта.

Итак, перейдем собственно к самой структуре. Я представил ее на другом ресурсе для вашего удобства, чтобы вы имели возможность развернуть/свернуть каждую ветку проекта. Она выглядит так:

https://dynalist.io/d/iZZJgMzUewPX9Thji4xebcGa

Рассмотрим подробнее одну из веток – "system" модуль:

system
-- core
---- services
---- interfaces
---- store
-- transactions
---- core
------ services
------ interfaces
---- transactions.component.html
---- transactions.component.scss
---- transactions.component.ts
---- transactions.module.ts
---- transactions-routing.module.ts
-- header
---- header.component.html
---- header.component.scss
---- header.component.ts
-- system.component.html
-- system.component.scss
-- system.component.ts
-- system.module.ts
-- system-routing.module.ts

Я предлагаю в каждом модуле создавать папку "core". В нее мы будем складывать все сервисы, интерфейсы, константы и прочие сущности, которые относятся к модулю. Соответственно, все эти сущности должны быть внедрены именно в этот модуль.

Файлы модуля ("system.module.ts", "system-routing.module.ts") и файлы главного компонента модуля ("system.component.html", "system.component.scss", "system.component.ts") лежат в корне. Все остальные компоненты которые относятся к модулю (например, "header" компонент) мы также будем складывать в корень, но уже в папках. Еще в корень мы будем складывать все модули внутренних разделов (например, "transactions" модуль).

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

И, напоследок, небольшой лафхак как сделать красивые короткие импорты, которые отображают принадлежность сущности к модулю. Попытаемся избавиться от таких конструкций:

import { TransactionsService } "../../../../../core/services/transactions.service";
import { UsersService } "../../../../../core/services/users.service";

В каждой папке, которые находятся в "core" создаем файл "index.ts" и экспортируем все содержимое папки в нем:

core
-- services
---- transactions.service.ts
---- users.service.ts
---- index.ts

// содержимое файла index.ts
export * from './transactions.service';
export * from './users.service';

После направляемся в файл "tsconfig.json", ищем раздел "paths" и указываем в нем алиасы для каждого из модулей первого уровня (обычно этого достаточно, потому что зачастую только модули первого уровня являются зависимостями для остальных модулей):

"paths": {
  "@environments/*": ["src/environments/*"],
  "@app/*": ["src/app/*"],
  "@auth/*": ["src/app/auth/*"],
  "@system/*": ["src/app/system/*"],
  "@form/*": ["src/app/shared/form/*"]
}

Кроме прочего, я создаю алиасы для каждого из общих модулей из папки "shared", а также для переменных окружения. Теперь мы имеем вот такие красивые импорты:

import { TransactionsService, UsersService } from '@app/core/services';
import { TransactionDto, UserDto } from '@app/core/interfaces';
import { AuthService } from '@auth/core/services';
import { Size, Color } from '@form/core/enums';
import { FieldOptions, ButtonOptions } from '@form/core/interfaces';

Описанная мной структура хорошо показала себя на практике. До сих пор в проектах, где я ее использовал, мне не приходилось ее менять в процессе роста проекта. Она проста и масштабируема. Над проектом с такой структурой комфортно работать командой. Все наглядно и всегда порядок.

Спасибо всем, кто прочитал статью! Буду рад любой обратной связи. Успехов в разработке приложений на Angular.

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


  1. ojgenn
    08.09.2022 13:21
    +2

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

    не очень понятно, зачем делать не рутовые. какой профит

    Я предлагаю в каждом модуле создавать папку "core". 

    такое себе. сервисы, интерфейсы и т.д. могут вполне лежать и рядом с соответствующим компонентом, если конечно там не вагон файлов на один компонент. нет необходимости к каждому компоненту создавать core, share, helpers etc


    1. akrnv25 Автор
      08.09.2022 14:21

      не очень понятно, зачем делать не рутовые. какой проф

      Если сервис является зависимостью только для одного lazy модуля, то зачем его создавать на старте приложения. Идея в том, чтобы соотносить все сущности с модулями, не валить все в кучу. Чем больше проект, тем больше куча.

      нет необходимости к каждому компоненту создавать core

      Я не предлагал создавать папку "core" для каждого компонента. Посмотрите, она есть только в модулях.

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

      Это нормально работает, когда в одиночку трудишься над проектом, свой порядок в голове и все прекрасно. Проблемы начинаются, когда несколько человек трудятся над проектом. Сегодня интерфейс используется только в одном компоненте и лежит с ним в одной папке. А завтра этот интерфейс уже используется в десяти компонентах и двух сервисах. И тут есть 2 варианта:

      • разработчик забьет на это и оставит его там, где он есть;

      • перенесет в корень проекта, в котором уже сам черт ногу сломит, потому что там под сотню интерфейсов таких же;

      • создаст очередную папку "share" или "helpers" или еще что-то.

      Если нет общего стандарта, то каждый делает так, как считает правильно. При этом у каждого своя правда. Так и появляется хаос.

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


      1. movl
        08.09.2022 16:15
        +1

        Если сервис является зависимостью только для одного lazy модуля, то зачем его создавать на старте приложения

        Если я ничего не путаю, то инстанс все же создается по требованию за счет DI, а не на старте приложения. То есть для ленивых модулей, сервис может загружаться так же лениво. Если сервис нигде не используется, он не будет включен в сборку за счет встряхивания дерева.

        Ангуляровский подход по умолчанию предполагает, что сервисы являются одиночками, в рамках всего приложения. За счет как раз указания опции providedIn: 'root', вместо прямого прописывания провайдера в соответствующий модуль, становятся доступны все эти средства для оптимального распределения кода по нужным чанкам бандла.

        Не корневые сервисы, зачастую поставляются с модулем, который позволяет устанавливать конфигурацию на уровне DI (вот эти все модули с .forRoot, .forChild), что требуется крайне редко.

        В общем если нужен просто сервис-одиночка, без всяких хитростей с DI, без множественных инстансов и без жесткого ограничения на конкретный модуль, то предпочтительный вариант - это использовать @Injectable({ providedIn: 'root' }). Кроме того, если необходим новый инстанс сервиса на уровне каких-то конкретных компонентов, то лучше их прокидывать через провайдеры самих компонентов.

        Про жесткое ограничение на конкретный модуль, я имел ввиду, что если какой то сервис попадает в тот же самый index.ts в виде экспорта, то лучше чтобы он был корневым. Это гораздо лучше позволит оптимизировать конечный бандл, чем подключение этого сервиса через еще одну прослойку в виде модуля, в котором он прописан.


        1. Xuxicheta
          09.09.2022 11:48
          +1

          Всё верно, а еще с подключением сервисов в модули можно напутать и не заметить что имеешь дело с несколькими разными инстансами.

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


      1. ojgenn
        09.09.2022 12:22
        +1

        простейший пример. вот у нас есть компонент А. у него есть сервис В. и вот мы запровайдили сервис, работает все ок. и тут выясняется, что А будет еще в другом модуле. а потом еще в одном. и вот сначала мы плодим провайдинг сервиса. а потом приходим к providedIn: root. и да. коллеги уже расписали бессмысленность провайдинга с точки зрения DI и тришейкинга


        1. akrnv25 Автор
          09.09.2022 13:47

          Да, прочитал комментарии выше по этому поводу. Пересмотрю свой подход. Спасибо за обратную связь, открыли мне глаза в вопросе провайдинга сервисов!


  1. miscusi
    08.09.2022 13:21
    +1

    да это стандартная структура

    для каждого модуля есть свой набор ангуляровских сущностей, причем некоторые сервисы могут быть внедрены на уровень компонента, а не модуля (зависит от задач). Некоторые общие сервисы уносятся в core модуль, чтобы разгрузить app модуль, а все общее делается за счет standalone компонентов в папке shared (либо по старинке один компонент - один модуль)

    единственное что не понимаю смысл в алиасах и реэкспорт в index файлах. Сейчас еще кто-то вручную прописывает все импорты? Обычно в редакторах и ide этот блок скрыт, на него даже не смотришь, а импортирование автоматическое


    1. akrnv25 Автор
      08.09.2022 14:35

      да это стандартная структура

      По факту да. Но далеко не везде она есть, поэтому решил написать. Часто в начале все складывают как придется, не хотят на это время тратить. А когда проект вырос, то уже сложно привести все в порядок. Уже не дерево, а лабиринт. Начинают на ходу переносить файлы, менять структуру зависимостей и т.д. Это все очень неприятно. Плюс в гите отследить изменения в файле если его 3 раза перенесли и 2 раза переименовали тоже тот еще квест.

      единственное что не понимаю смысл в алиасах и реэкспорт в index файлах

      Я и не говорю о каком-то супер смысле этого. Просто намного приятнее смотреть на алиасы, чем на забор из слэшей и точек. Когда мы импортируем что-то из node_modules, то не пишем же полный относительный путь. Так почему не делать также со своими модулями. Но и есть небольшой плюс у этого все же – мы видим из какого модуля приходит зависимость, по слэшам и точкам это сложно понять.


  1. movl
    08.09.2022 14:05
    +1

    Как мне кажется, для импортов в рамках приложения, лучше использовать один скоуп: @app-name/auth, @app-name/system и т. д. Как минимум, чтобы можно было визуально понимать, что импорт относится именно к приложению, а не к какой-то библиотеке.

    Служебные директории core, при таком подходе, очень легко могут превратится в очень большие свалки в каких-то общих модулях (app, shared и т. д.), но зависит конечно от предполагаемого объема приложения. То есть неглубокая вложенность - это конечно хорошо, но когда приходится работать с сотней подобных сервисов или интерфейсов:

    import { TransactionsService, UsersService } from '@app/core/services';
    import { TransactionDto, UserDto } from '@app/core/interfaces';
    

    то такая плоская структура не всегда может быть удобной. Хочется иметь какое-то разделение подобных сервисов и интерфейсов на домены, но при этом не сильно увеличивая связность различных модулей. То есть для решения этой проблемы может понадобится введение дополнительных абстракций в структуру приложения, по типу: entities/transactions/transactions.service.ts, entities/transactions/transactions.interface.ts. В таком случае вложенность увеличивается не сильно, но нет такой проблемы, что сначала в одной директории среди большого множества файлов ищешь сервис, а потом в соседней директории, среди такого же множества файлов ищешь интерфейс данных, с которыми работает данный сервис. Иногда же бывает, что помимо сервиса и интерфейса данных, еще имеются какие-нибудь сторы и прочие инструменты, требуемые в рамках работы с одними и теми же данными. Впрочем редакторы сильно упрощают навигацию по коду, но все равно довольно сложно удерживать в голове: какие инструменты доступны для работы с теми же транзакциями, а какие нет. Разделение на основе семантики, а не по функциональному признаку, может значительно упростить восприятие отдельных частей проекта.

    Кстати для контроля зависимостей между различными узлами приложения можно использовать NX. Там есть утилиты для линтера, для которых задаются правила: что, откуда и куда может импортироваться. Да и помимо этого, для ангуляра там достаточно удобный туллинг, именно в плане организации структуры репозитория.


    1. akrnv25 Автор
      08.09.2022 15:24

      Как мне кажется, для импортов в рамках приложения, лучше использовать один скоуп: @app-name/system@app-name/auth@app-name/system и т. д.

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

      Служебные директории core, при таком подходе, очень легко могут превратится в очень большие свалки в каких-то общих модулях (app, shared и т. д.), но зависит конечно от предполагаемого объема приложения.

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

      Разделение на основе семантики, а не по функциональному признаку, может значительно упростить восприятие отдельных частей проекта.

      Я думаю, что разделение по функциональному признаку требует больше внимания и контроля, появляются дополнительные вопросы:

      • если сущность относится к нескольким функциям, то класть в какую-то общую папку или в папку первой, второй или третьей функции;

      • если сущность единственная для какой-то функции, то все равно делать папку или складывать в какую-то общую папку.

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

      Кстати для контроля зависимостей между различными узлами приложения можно использовать NX.

      Да, хорошая вещь. В плане структуры вообще отличная. Особенно полезна при тестировании. Тесты в большом приложении могут очень долго бежать, а в случае с NX можно каждый кусочек отдельно прогнать. NX дает классную структуру, но непростую, на первых порах может быть сложно. Особенно с обновлением зависимостей проекта/ов и самого NX.