Продумать структуру приложения на этапе создания очень важно, но часто в начале пути этому вопросу посвящают мало внимания. Предлагаю обсудить проблемы масштабирования современных веб-приложения с которыми сталкиваются разработчики.
Первое время разработка идет бодро, добавлять новые функции легко и приятно, приложение работает быстро, все просто и понятно. Но с ростом количество модулей, компонентов, сервисов скорость разработки падает, как и скорость работы приложения. Задач на рефакторинг появляется в спринте все больше. Кроме прочего, команда разработчиков может регулярно меняться (одни увольняются, другие приходят), что не добавляет порядка. Со временем может показаться, что проще все снести и написать заново.
Современные приложения предоставляют пользователям широкие возможности взаимодействия. Сложность логики интерфейса постоянно растет. Количество данных, которое мы загружаем на каждой странице, растет. Требования к производительности интерфейса растут. Количество разработчиков, которые работают над приложением, растет. В таких условиях рефакторинг структуры приложения становится проблемой, поэтому очень важно подумать о ней в самом начале.
Ниже представлены свойства, которые имеет идеальная, на мой взгляд, структура приложения:
Простая и понятная. Любой разработчик из команды не должен ни секунды думать, где разместить новый модуль, компонент, сервис, интерфейс или любую другую сущность.
Масштабируемая. В любом момент может появиться необходимость добавить в приложение новую функцию или новый раздел. Это не должно быть проблемой. При этом производительность не должна проседать с ростом приложения.
Принципы, которые необходимо соблюдать, чтобы достичь такой структуры приложения:
Независимые модули. Каждый раздел приложения должен быть представлен в виде отдельного модуля. Это ключевой принцип, он является основной для всех следующих. Он позволяет масштабировать приложение и комфортно работать над ним большому количеству разработчиков одновременно.
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)
miscusi
08.09.2022 13:21+1да это стандартная структура
для каждого модуля есть свой набор ангуляровских сущностей, причем некоторые сервисы могут быть внедрены на уровень компонента, а не модуля (зависит от задач). Некоторые общие сервисы уносятся в core модуль, чтобы разгрузить app модуль, а все общее делается за счет standalone компонентов в папке shared (либо по старинке один компонент - один модуль)
единственное что не понимаю смысл в алиасах и реэкспорт в index файлах. Сейчас еще кто-то вручную прописывает все импорты? Обычно в редакторах и ide этот блок скрыт, на него даже не смотришь, а импортирование автоматическое
akrnv25 Автор
08.09.2022 14:35да это стандартная структура
По факту да. Но далеко не везде она есть, поэтому решил написать. Часто в начале все складывают как придется, не хотят на это время тратить. А когда проект вырос, то уже сложно привести все в порядок. Уже не дерево, а лабиринт. Начинают на ходу переносить файлы, менять структуру зависимостей и т.д. Это все очень неприятно. Плюс в гите отследить изменения в файле если его 3 раза перенесли и 2 раза переименовали тоже тот еще квест.
единственное что не понимаю смысл в алиасах и реэкспорт в index файлах
Я и не говорю о каком-то супер смысле этого. Просто намного приятнее смотреть на алиасы, чем на забор из слэшей и точек. Когда мы импортируем что-то из node_modules, то не пишем же полный относительный путь. Так почему не делать также со своими модулями. Но и есть небольшой плюс у этого все же – мы видим из какого модуля приходит зависимость, по слэшам и точкам это сложно понять.
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. Там есть утилиты для линтера, для которых задаются правила: что, откуда и куда может импортироваться. Да и помимо этого, для ангуляра там достаточно удобный туллинг, именно в плане организации структуры репозитория.
akrnv25 Автор
08.09.2022 15:24Как мне кажется, для импортов в рамках приложения, лучше использовать один скоуп:
@app-name/system@app-name/auth
,@app-name/system
и т. д.Да, отличная идея. Визуально это очень хорошо выделит импорты приложения от библиотек.
Служебные директории core, при таком подходе, очень легко могут превратится в очень большие свалки в каких-то общих модулях (app, shared и т. д.), но зависит конечно от предполагаемого объема приложения.
Согласен, предел масштабирования есть. В какой-то момент захочется добавить дополнительные разделения.
Разделение на основе семантики, а не по функциональному признаку, может значительно упростить восприятие отдельных частей проекта.
Я думаю, что разделение по функциональному признаку требует больше внимания и контроля, появляются дополнительные вопросы:
если сущность относится к нескольким функциям, то класть в какую-то общую папку или в папку первой, второй или третьей функции;
если сущность единственная для какой-то функции, то все равно делать папку или складывать в какую-то общую папку.
Вроде это все и некритично, но такие вопросы ставят в тупик. Хотя действительно удобно, когда связанные вещи рядом. Такой подход более актуален для больших модулей. На мой взгляд оба варианта имеют место быть и тут больше зависит от предпочтений.
Кстати для контроля зависимостей между различными узлами приложения можно использовать NX.
Да, хорошая вещь. В плане структуры вообще отличная. Особенно полезна при тестировании. Тесты в большом приложении могут очень долго бежать, а в случае с NX можно каждый кусочек отдельно прогнать. NX дает классную структуру, но непростую, на первых порах может быть сложно. Особенно с обновлением зависимостей проекта/ов и самого NX.
ojgenn
не очень понятно, зачем делать не рутовые. какой профит
такое себе. сервисы, интерфейсы и т.д. могут вполне лежать и рядом с соответствующим компонентом, если конечно там не вагон файлов на один компонент. нет необходимости к каждому компоненту создавать core, share, helpers etc
akrnv25 Автор
Если сервис является зависимостью только для одного lazy модуля, то зачем его создавать на старте приложения. Идея в том, чтобы соотносить все сущности с модулями, не валить все в кучу. Чем больше проект, тем больше куча.
Я не предлагал создавать папку "core" для каждого компонента. Посмотрите, она есть только в модулях.
Это нормально работает, когда в одиночку трудишься над проектом, свой порядок в голове и все прекрасно. Проблемы начинаются, когда несколько человек трудятся над проектом. Сегодня интерфейс используется только в одном компоненте и лежит с ним в одной папке. А завтра этот интерфейс уже используется в десяти компонентах и двух сервисах. И тут есть 2 варианта:
разработчик забьет на это и оставит его там, где он есть;
перенесет в корень проекта, в котором уже сам черт ногу сломит, потому что там под сотню интерфейсов таких же;
создаст очередную папку "share" или "helpers" или еще что-то.
Если нет общего стандарта, то каждый делает так, как считает правильно. При этом у каждого своя правда. Так и появляется хаос.
И тут дело не в "красоте". Проблема в том, что чтобы понять в хаосе что к чему относится, что от чего зависит и что где используется, нужно открыть каждый файл и выполнить "find usages" – это все очень долго и нужно в голове все держать. А когда приходится разделить какую-то одну штуку на две отдельные штуки, так совсем становится печально.
movl
Если я ничего не путаю, то инстанс все же создается по требованию за счет DI, а не на старте приложения. То есть для ленивых модулей, сервис может загружаться так же лениво. Если сервис нигде не используется, он не будет включен в сборку за счет встряхивания дерева.
Ангуляровский подход по умолчанию предполагает, что сервисы являются одиночками, в рамках всего приложения. За счет как раз указания опции
providedIn: 'root'
, вместо прямого прописывания провайдера в соответствующий модуль, становятся доступны все эти средства для оптимального распределения кода по нужным чанкам бандла.Не корневые сервисы, зачастую поставляются с модулем, который позволяет устанавливать конфигурацию на уровне DI (вот эти все модули с
.forRoot
,.forChild
), что требуется крайне редко.В общем если нужен просто сервис-одиночка, без всяких хитростей с DI, без множественных инстансов и без жесткого ограничения на конкретный модуль, то предпочтительный вариант - это использовать
@Injectable({ providedIn: 'root' })
. Кроме того, если необходим новый инстанс сервиса на уровне каких-то конкретных компонентов, то лучше их прокидывать через провайдеры самих компонентов.Про жесткое ограничение на конкретный модуль, я имел ввиду, что если какой то сервис попадает в тот же самый
index.ts
в виде экспорта, то лучше чтобы он был корневым. Это гораздо лучше позволит оптимизировать конечный бандл, чем подключение этого сервиса через еще одну прослойку в виде модуля, в котором он прописан.Xuxicheta
Всё верно, а еще с подключением сервисов в модули можно напутать и не заметить что имеешь дело с несколькими разными инстансами.
Тем более что при уходе с модуля сервис не уничтожается. У module-scoped сервиса отличается от рутового только тем, что модульному можно скормить контролируемые зависимости.
ojgenn
простейший пример. вот у нас есть компонент А. у него есть сервис В. и вот мы запровайдили сервис, работает все ок. и тут выясняется, что А будет еще в другом модуле. а потом еще в одном. и вот сначала мы плодим провайдинг сервиса. а потом приходим к providedIn: root. и да. коллеги уже расписали бессмысленность провайдинга с точки зрения DI и тришейкинга
akrnv25 Автор
Да, прочитал комментарии выше по этому поводу. Пересмотрю свой подход. Спасибо за обратную связь, открыли мне глаза в вопросе провайдинга сервисов!