Архитектура приложения Angular. Используем NgModules

https://medium.com/@cyrilletuzi/architecture-in-angular-projects-242606567e40
  • Перевод
  • Tutorial

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


Об NgModules можно прочитать здесь.


image


Один год назад я уже публиковал статью об NgModules, где рассматриваются технические тонкости, когда импортировать модули, пространство имен и т.д. Рекомендуется для ознакомления (прим. перев.: статья по содержанию аналогична той, на которую я ссылаюсь вначале).


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


Я начал детально изучать мануал по NgModules, который разросся аж до 12 страниц подробного описания с FAQ. Но после внимательного прочтения вопросов возникло больше, чем ответов. Например, где лучше реализовать сервис? Внятного ответа на этот вопрос получить не получилось. Более того, некоторые решения противоречат друг другу в контексте мануала.


После переваривания всего раздела про NgModules я решил реализовать свое решение по архитектуре Angular приложений, основанное на следующем:


  • структура: простая для малых приложений, масштабируемость для больших проектов;
  • юзабилити: возможность использования решений в других проектах;
  • оптимизация (в том числе с lazy load);
  • тестируемость.

Angular Modules


Что такое модули Angular?


На самом деле, главная цель модуля — группирование компонентов и/или сервисов, связанных друг с другом. И, в общем-то, больше ничего. Для примера, представим блок новостей на главной странице. Если грубо, то визуальная часть — это компонент, а механизм получения данных из базы данных — это сервис.


Для тех, кто знаком с Java, то модули Angular это пакеты (packages), а в C#/PHP — пространство имен.


Остается только один вопрос — как правильно группировать функционал приложения?


Типы модулей Angular


Их всего 3:


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

Как только вы создали стартовое приложение через ng new projectname
то, как минимум, вы создали модуль страницы. В данном случае одной — главной.


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


Модули страниц


Модули страниц обладают маршрутизацией и предназначены для того, чтобы логически разделить области вашего приложения. Модули страниц загружаются один раз в главном модуле (который обычно называется AppModule) или через lazy load.


Для примера, на странице авторизации, выхода и регистрации нужен модуль AccountLogin; HeroesModule для страницы списка героев, страницы героя и т.д. (прим. перев.: здесь имеется ввиду учебный проект, который описывается в официальной документации).


Модули страниц могут содержать в себе:


  • /shared: сервисы и интерфейсы;
  • /pages: компоненты с маршрутами;
  • /components: компоненты для визуализации данных.

Общедоступные сервисы для страниц


Для отображения данных на странице, сначала нужно эти данные откуда-то взять. Для этого и нужны сервисы


@Injectable()
export class SomeService {

  constructor(protected http: HttpClient) {}

  getData() {
    return this.http.get<SomeData>('/path/to/api');
  }

}

Впоследствии, некоторым страницам нужны будут схожие данные, а значит — сервисы одного типа. В таком случае необходимо сделать один сервис и общедоступным во всем приложении, а не в конкретном модуле.


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


Прим. перевод.


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


Давайте вернемся к модулю AccountManager, который был озвучен ранее в качестве примера. Сервис данного модуля, AccountService, должен быть "тонким" и отвечать, по необходимости, "да" или "нет", в зависимости от ролевой модели пользователя. Статус пользователя (онлайн или нет) не может быть реализован в данном сервисе, т.к. необходимость данного модуля может отсутствовать в некоторых частях приложения. Поэтому статус пользователя необходимо вынести в глобальный сервис, который будет доступен во всем приложении (см. ниже).


Модули-страницы: маршрутизация


Компонент страницы отвечает за представление информации из базы данных, которая извлекается сервисом.


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


@Component({
  template: `<app-presentation *ngIf="data" [data]="data"></app-presentation>`
})
export class PageComponent {

  data: SomeData;

  constructor(protected someService: SomeService) {}

  ngOnInit() {
    this.someService.getData().subscribe((data) => {
      this.data = data;
    });
  }

}

Каждый компонент имеет свой маршрут.


Компоненты для визуализации данных


Компоненты для представления данных извлекают информацию при помощи декоратора @Input и отображают в своем шаблоне


@Component({
  selector: 'app-presentation',
  template: `<h1>{{data.title}}</h1>`
})
export class PresentationComponent {

  @Input() data: SomeData;

}

Это MVx?


Кто знаком с паттерном модель-контроллер-представление задастся вопросом — это оно самое? Если следовать теории, то нет. Однако, если Вам проще представить архитектуру Angular при помощи MVx, то:


services сравнимы с Models,
presentation components похожи на View,
page components будут Controllers \ Presenters \ ViewModels (выберете то, что вы используете).


Несмотря на то, что это не совсем MVx (или совсем не MVx), цели в данном подходе одинаковы — разделение ответственности в решении задач. Почему это важно? Вот почему:


  • "тонкие" компоненты (презентации) можно использовать в других проектах,
  • оптимизация стратегии обнаружения компонентов,
  • тестируемость "тонких" компонентов (если вы не разделяете логику приложения, то забудьте о тестировании, это будет сущий ад).

Суммируя


Пример модуля страницы


@NgModule({
  imports: [CommonModule, MatCardModule, PagesRoutingModule],
  declarations: [PageComponent, PresentationComponent],
  providers: [SomeService]
})
export class PagesModule {}

где сервис инкапсулирован в данном модуле.


Модули глобальных сервисов


Модули глобальных сервисов предоставляют доступ к своему сервису в любом месте Вашего приложения. Так как такие сервисы имеют глобальную область видимости, эти модули загружаются только один раз в корневой модуль (AppModule) и доступны везде, в т.ч. при реализации lazy load.


Вы определенно использовали хотя бы один такой сервис. Например: HttpModule. Но вскоре Вам понадобится свой сервис, похожий на HttpModule. Для примера — AuthModule, который хранит текущий статус пользователя и его токен, и необходим на протяжении всего приложения, всей сессии пользователя.


Юзабилити


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


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


export { SomeService } from './some.service';
export { SomeModule } from './some.module';

Должен ли я делать CoreModule


Нет необходимости. Официальна документация предлагает реализовывать все глобальные сервисы в CoreModule. Вы, безусловно, можете сгруппировать их в /core/modules, однако уделите внимание разделению ответственности и не "сливайте" все в один CoreModule. Иначе Вы не сможете использовать реализованный функционал в других проектах.


Суммарно


Пример глобального модуля для сервиса


@NgModule({
  providers: [SomeService]
})
export class SomeModule {}

UI компоненты и как получать данные


UI компоненты (например виджеты) — "тонкие" и отвечают только за визуализацию полученных данных, как было рассмотрено выше в "модулях страниц". Компонент получает данные при помощи декоратора @Input (иногда из <ng-content>, а иногда и другие решения).


Component({
  selector: 'ui-carousel'
})
export class CarouselComponent {

  @Input() delay = 5000;

}

Вы не должны целиком полагаться на сервис. Почему? Потому что сервисы имеют свою специфику в зависимости от предложения. Например, может поменяться URL у API. Представление данных — дело компонентов внутри страниц модулей. UI компоненты получают данные, предоставленные кем-то, но не ими.


Открытые (public) и скрытые (private) компоненты


Для того, чтобы сделать компонент доступным (public) нужно экспортировать его в модуле. Однако, импортировать все не нужно. Вложенные компоненты должны\могут оставаться скрытыми (private), если в них нет необходимости в другом месте приложения.


Директивы и пайпы


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


Скрытые (private) сервисы


Для работы с данными исключительно внутри UI компонента можно реализовать сервис только внутри компонента, а не NgModule и сделать его закрытым для всего, кроме его компонента. В таком случае это будет выглядеть так


@Component({
  selector: 'some-ui',
  providers: [LocalService]
})
export class SomeUiComponent {}

Общедоступные (public) сервисы


Представим ситуацию, когда Вы хотите открыть доступ к сервису, который реализовали в UI компоненте. Такое следует максимально избегать, но реализовать возможно.


Открываем доступ к сервису в NgModule и получаем проблему многократной загрузки модуля, а с ним и сервиса, т.к. в модуле мы реализуем компонент.


Для решения данной проблемы необходимо реализовать модуль таким образом


xport function SOME_SERVICE_FACTORY(parentService: SomeService) {
  return parentService || new SomeService();
}

@NgModule({
  providers: [{
    provide: SomeService,
    deps: [[new Optional(), new SkipSelf(), SomeService]],
    useFactory: SOME_SERVICE_FACTORY
  }]
})
export class UiModule {}

Кстати, так реализовано (по крайней мере было) в Angular CDK.


Юзабельность


Для использования UI компонентов в виде модулей, необходимо экспортировать компоненты\пайпы\директивы и тд, открыть им доступ создав точку доступа


export { SomeUiComponent }  from './some-ui/some-ui.component';
export { UiModule } from './ui.module';

Нужно ли делать SharedModule?


Нужно ли сливать все весь пользовательский интерфейс (UI компоненты) в SharedModule Определенно нет. Хотя документация предлагает данное решение, но каждый модуль, реализованный в SharedModule будет реализован на уровне проекта, на не интерфейса.


Нет проблем в ипортировании зависимостей при создании проекта, особенно при помощи автоматизации этого процесса в VS Code (или других IDE).


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


Суммарно


Пример UI модуля


@NgModule({
  imports: [CommonModule],
  declarations: [PublicComponent, PrivateComponent],
  exports: [PublicComponent]
})
export class UiModule {}

Что в итоге?


Если Вы будете проектировать Ваше приложение с учетом описанного выше, то:
Вы будете иметь хорошо структурированную архитектуру, будь то в малых или больших приложениях, с или без lazy load.
Вы можете упаковать глобальные модули или UI компоненты в библиотеки и использовать их в других проектах.
Вы будете тестировать приложения без агонии.


Пример структуры проекта


app/
|- app.module.ts
|- app-routing.module.ts
|- core/
   |- auth/
      |- auth.module.ts
      |- auth.service.ts
      |- index.ts
   |- othermoduleofglobalservice/
|- ui/
   |- carousel/
      |- carousel.module.ts
      |- index.ts
      |- carousel/
         |- carousel.component.ts
         |- carousel.component.css
    |- othermoduleofreusablecomponents/
|- heroes/
   |- heroes.module.ts
   |- heroes-routing.module.ts
   |- shared/
      |- heroes.service.ts
      |- hero.ts
   |- pages/
      |- heroes/
         |- heroes.component.ts
         |- heroes.component.css
      |- hero/
         |- hero.component.ts
         |- hero.component.css
   |- components/
      |- heroes-list/
         |- heroes-list.component.ts
         |- heroes-list.component.css
      |- hero-details/
         |- hero-details.component.ts
         |- hero-details.component.css
|- othermoduleofpages/

Если у Вас есть комментарии по данной архитектуре, то, пожалуйста, оставьте свои коментарии.


Telegram русскоязычного Angular сообщества.

  • +12
  • 10,6k
  • 4
Поделиться публикацией
Комментарии 4
    0
    Спасибо за перевод, в данной статье есть дельные советы.

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

    > As services have generally a global scope…
    > Сервисы имеют тоже пространство имен, что и модули…

    И тут вступает определенная сложность самой модульной системы Angular: если сервис провайдится в любом модуле на любом уровне (кроме LazyLoad), то сревис будет доступен глобально и при многократном импорте — будет заменятся.

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

    Внутри каждого Lazy-модуля такое же локальное поведение, плюс видны все сервисы из AppModule.

    Отличный материал по этой теме: blog.angularindepth.com/avoiding-common-confusions-with-modules-in-angular-ada070e6891f

    Автор в оригинале несколько раз делает упор на этой особенности, к сожалению в переводе эта информация слишком упрощена.
      0
      Да, когда писал смотрел куда в другую сторону, поправил.

      Я, честно говоря, не вижу в данном подходе какой-то сложности. По-моему это типичное решение аля синглтон. Мы имеем один экземпляр сервиса в приложении и в случае обращения нему — обращаемся к этому экземпляру или создаем новый, если он не был создан ранее. Если, конечно, мы не создаем сервис внутри директивы\компонента и т.д.

      П.С. Прозвучит как оправдание, но это мой первый опыт перевода.
        0
        Тут дело не в создании экземпляров, а в «инкапсуляции» сервисов, интуитивно кажется, что они ведут себя также, как и директивы/компоненты, но это не так — они попадают в глобальный скоуп.

        > П.С. Прозвучит как оправдание, но это мой первый опыт перевода.

        Главное — не останавливаться :)
          0
          Главное — не останавливаться :)

          Ни в коем разе :)

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

    Самое читаемое