AngularInDepth уходит от Medium. Более свежие статьи размещаются на новой платформе inDepth.dev. Спасибо за то, что участвуете в глубоком движении!

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

Эта проблема

Представьте, что у вас есть полнофункциональная панель администрирования или любой другой продуманный веб-сайт. Это веб-приложение может иметь заголовок с данными пользователя, динамическое меню (где элементы меню зависят от ролей пользователя), панель мониторинга (сводка, отчеты, профили и т. Д.) И другие элементы. И однажды вы захотите узнать, какое представление инициировало какой HTTP-запрос? Таким образом, вы столкнетесь с проблемой отслеживания запросов (или «мониторинга», или «аудита»).

Почему это проблема? Потому что, пока вы не решите ее, вы не узнаете:

  • какое представление использует большую часть ресурсов сервера;
  • если запросы пользователей безопасны, безопасны и разрешены;
  • как изменить некоторые из ваших показателей, чтобы улучшить взаимодействие с пользователем и конверсию веб-сайта.

Возможные решения

Наиболее очевидное решение - добавить маркер к каждому запросу в параметрах запроса или в заголовках запроса. Однако очевидное не означает оптимального: в нашем проекте около 2300 HTTP-вызовов GET и ~ 1000 HTTP-вызовов POST (среди прочего), и добавление маркеров к каждому запросу просто невозможно.

Лучшее решение - автоматически добавлять маркер для каждого HTTP-запроса, инициированного клиентом. В приложениях Angular это можно сделать с помощью http-перехватчика. Но перехватчик не может самостоятельно определить, какое представление инициировало конкретный запрос.

В нашей команде мы думали о следующих путях.

Способ Zone.js

Вообще говоря, Zone.js как часть инфраструктуры Angular может использоваться для разделения контекстов разных фрагментов кода. Размещение глобальной переменной Zone.current.name в нужном месте должно помочь нашему перехватчику. Мы попытались обернуть наши представления в разные зоны (форк NgZone и запустить код компонентов), но столкнулись со следующими критическими проблемами:

  • Механизм обнаружения изменений Angular часто начинается с AppRef.tick (он же NgZone.run), а Zone.current.name часто будет angular, что нам не нужно.
  • Все вызовы метода run из зоны, разветвленной из NgZone, будут запускать AppRef.tick, значительно снижая производительность, и если мы запустим за пределами NgZone, обнаружение изменений не будет работать вообще.

Похоже, что Angular может использовать Zone.js для обнаружения изменений только в данный момент. Мы пытались реализовать какое-то решение самостоятельно, но безуспешно. Мы создали проблему для angular github repo с запросом функции, которая позволяла бы компилировать разные части приложения в определенных зонах.

Инжекторный способ

Указание разных поставщиков (также называемых «токенами») для разных модулей-инжекторов - еще один способ «разделить» логику в нашем приложении. Таким образом мы можем идентифицировать родительский модуль каждой сущности (компонент, директива, служба и т. Д.). После этого мы можем использовать эти токены в HttpInterceptors для отслеживания запросов.

Мы написали простой пример. Есть 3 модуля: AppModule (корневой), BusinessModule и AnotherBusinessModule. Бизнес-модули с готовностью загружаются в AppModule. Мы предполагаем, что каждый бизнес-модуль реализует некоторую отдельную бизнес-логику. Каждый из них состоит из одного компонента. Каждый компонент инициирует несколько запросов. Мы хотим пометить эти запросы токенами business и another-business. Кроме того, AppModule помечен токеном root. Есть http-перехватчик, который разрешает токен и добавляет его в запросы.

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

Это фрагмент сгенерированного кода NgFactory для приведенного выше примера. Токены BusinessModule и AnotherBusinessModule игнорируются. В NgFactory зарегистрирован только токен из AppModule.

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

Как известно, модули Angular можно скачивать как долго, так и лениво.

В документации по Angular также говорится:

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

Инжекторы дочерних модулей позволят нам предоставить разные токены для разных NgModules. Эти модули могут содержать разные фрагменты бизнес-логики нашего приложения. С дочерними модулями-инжекторами каждый модуль-инжектор (каждый модуль) содержит свой собственный токен. Каждый запрос, инициированный любым компонентом, объявленным в таких модулях, будет отмечен определенным токеном.

В результате мы хотим получить несколько NgFactories с предоставленными в них разными токенами:

var AppModuleNgFactory = /*@__PURE__*/ /*@__PURE__*/ _angular_core__WEBPACK_IMPORTED_MODULE_0__[
  "ɵcmf"
  ](
  _with_custom_injector_inner_module__WEBPACK_IMPORTED_MODULEZone.current.name_[
    "AppModule"
    ],
  [],
  function(_l) {
    return _angular_core__WEBPACK_IMPORTED_MODULE_0__["ɵmod"]([
      // ...
      _angular_core__WEBPACK_IMPORTED_MODULE_0__["ɵmpd"](
        256,
        _tokens__WEBPACK_IMPORTED_MODULEAnotherBusinessModule_["CUSTOM_INJECTOR_TOKEN"],
        "root",
        []
      )
      // ...
    ]);
  }
);

// ...

var BusinessModuleNgFactory = /*@__PURE__*/ /*@__PURE__*/ _angular_core__WEBPACK_IMPORTED_MODULE_0__[
  "ɵcmf"
  ](
  _with_custom_injector_inner_module__WEBPACK_IMPORTED_MODULEZone.current.name_[
    "BusinessModule"
    ],
  [],
  function(_l) {
    return _angular_core__WEBPACK_IMPORTED_MODULE_0__["ɵmod"]([
      // ...
      _angular_core__WEBPACK_IMPORTED_MODULE_0__["ɵmpd"](
        256,
        _tokens__WEBPACK_IMPORTED_MODULEAnotherBusinessModule_["CUSTOM_INJECTOR_TOKEN"],
        "business",
        []
      )
      // ...
    ]);
  }
);

// ...

var AnotherBusinessModuleNgFactory = /*@__PURE__*/ /*@__PURE__*/ _angular_core__WEBPACK_IMPORTED_MODULE_0__[
  "ɵcmf"
  ](
  _with_custom_injector_inner_module__WEBPACK_IMPORTED_MODULEZone.current.name_[
    "AnotherBusinessModule"
    ],
  [],
  function(_l) {
    return _angular_core__WEBPACK_IMPORTED_MODULE_0__["ɵmod"]([
      // ...
      _angular_core__WEBPACK_IMPORTED_MODULE_0__["ɵmpd"](
        256,
        _tokens__WEBPACK_IMPORTED_MODULEAnotherBusinessModule_["CUSTOM_INJECTOR_TOKEN"],
        "another-business",
        []
      )
      // ...
    ]);
  }
);

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

Что насчет RouterModule?

RouterModule не соответствует нашим требованиям по следующим причинам:

  • нам не нужны ленивые модули, но только с ленивыми модулями RouterModule может создавать инжекторы дочерних модулей;
  • мы хотим решить проблему отслеживания запросов, а не маршрутизации. С RouterModule любую часть нашего приложения, которую мы хотим регистрировать, мы должны обернуть в Route и использовать <router-outlet>
  • RouterModule - не лучшее решение для огромных гибридных (AngularJS + Angular) приложений (1kk + loc)

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

Двигаясь глубже

Короче говоря, когда вы запрашиваете какой-либо URL-адрес, определенный в конфигурации вашего маршрутизатора, Angular Router:

  1. Лениво загружать модуль роута
  2. Скомпилируйте модуль (получите NgFactory модуля)
  3. Создайте модуль с дочерним инжектором
  4. Визуализируйте компонент в роутере-розетке.

Давайте подробнее рассмотрим шаг 3.

Эта статья Craig Spence очень помогает понять, как работает RouterModule.

Он объясняет, как Angular Router работает в AOT сборках, и, что более важно, как Angular Compiler Plugin (ACP) понимает, какие модули angular должны быть скомпилированы как отдельные фабрики ng.

Мы также обнаружили, что параметр ввода Angular Compiler может использоваться для расширения ленивых маршрутов, собираемых Angular Compiler и AngularCompilerPlugin. Этот параметр не описан в доступной документации, но он указан в AngularCompilerPluginOptions интерфейсе и является общедоступным, поэтому мы можем его использовать.

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

Вот наш план:

  1. Как-нибудь скажите Angular создать NgFactories.
  2. Используйте загрузчик systemjs, чтобы получить модуль NgFactory.
  3. Создайте модуль из этой фабрики.
  4. Визуализируйте компонент, используя ComponentFactoryResolver из созданного модуля.

Макс Корецкий в своей статье объясняет, как получить дочерние инжекторные модули без Angular Router и с ленивой загрузкой.

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

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

// parent module
ChildInjectorModule.forModules([
  WithCustomInjectorModule, AnotherWithCustomInjectorModule]
])
// WithCustomInjectorModule module imports
ChildInjectorModule.forChildModule([
  WithCustomInjectorComponent
])
// AnotherModuleWithCustomInjectorModule module imports
ChildInjectorModule.forChildModule([
  AnotherWithCustomInjectorComponent
])

Он очень похож на модуль роутера, не так ли?

Код модуля следующий:

@NgModule({
  imports: [CommonModule],
  declarations: [ChildInjectorComponent],
  entryComponents: [ChildInjectorComponent],
  exports: [ChildInjectorComponent]
})
export class ChildInjectorModule {
  static forModules(modules: IChildInjectorModules): ModuleWithProviders {
    return {
      ngModule: ChildInjectorModule,
      providers: [
        {
          provide: CHILD_INJECTOR_MODULES,
          useValue: modules,
          multi: true
        },
        {
          provide: CHILD_INJECTOR_COMPILED_MODULES,
          useFactory: childInjectorModulesFactory,
          deps: [CHILD_INJECTOR_MODULES, Compiler, Injector]
        }
      ]
    };
  }

  static forChildModule<T>(components: Array<T>): ModuleWithProviders {
    return {
      ngModule: ChildInjectorModule,
      providers: [{ provide: CHILD_INJECTOR_ENTRY_COMPONENTS, useValue: components }]
    };
  }
}

export function childInjectorModulesFactory(
  modulesOfModules: Array<IChildInjectorModules> = [],
  compiler: Compiler,
  injector: Injector
): Array<IChildInjectorCompiledModules<Type<any>, Type<any>>> {
  const modulesOfModulesResult = modulesOfModules.map(modules => {
    const modulesMapResult = modules.map(ngModuleWebpackModule => {
      if (ngModuleWebpackModule.compiled) {
        return ngModuleWebpackModule.compiled;
      }

      const [name, factory]: INgModuleFactoryLoaderResult = NgFactoryResolver.resolve(ngModuleWebpackModule, compiler);
      const module = factory.create(injector);
      const components = module.injector.get(CHILD_INJECTOR_ENTRY_COMPONENTS);

      ngModuleWebpackModule.compiled = { name, module, components };

      return { name, module, components };
    });

    return modulesMapResult;
  });
  return modulesOfModulesResult;
}

Код снова похож на код модуля маршрутизатора, но проще.

Как это работает? Когда мы вызываем статический метод forModules в любом модуле, создается экземпляр модуля с его поставщиками. Он определяет некоторых поставщиков для модуля и объявляет компонент theChildInjectorComponent. ChildInjectorComponent похож на <router-outlet> компонент. Код компонента должен быть вам знаком. Макс упомянул что-то подобное в одной из своих статей (см. Ссылку на его статью выше):

constructor(
  @Inject(CHILD_INJECTOR_COMPILED_MODULES)
  private compiledModules: IChildInjectorCompiledModules<any, T>,
  private vc: ViewContainerRef
) {
}

ngOnInit() {
  // ...
  const compiledModule = (this.compiledModules || []).reduce(
    (res, modules: any) => {
      if (res) {
        return res;
      }
      return modules.find((module: IChildInjectorCompiledModule<any, T>) =>
        module.components.some(component => component === this.component)
      );
    },
    null
  );

  if (!compiledModule) {
    throw new Error(`[ChildInjectorComponent]: can not find compiled module for component ${(this.component as any).name}`);
  }

  const factory: ComponentFactory<T> = compiledModule.module.componentFactoryResolver.resolveComponentFactory(
    this.component
  );
  this.componentRef = this.vc.createComponent(factory);
  const { instance, location } = this.componentRef;

  // ...
}
// ...

Мы используем токен theCHILD_INJECTOR_COMPILED_MODULES для получения скомпилированных модулей. Когда компонент будет создан, будет вызываться childInjectorModulesFactory из нашего модуля. Он вызовет волшебный resolve статический метод NgFactoryResolver.

Что это? Это функция, возвращающая NgModuleFactory. В режимах AOT и JIT он работает по-разному.

static resolve(ngModuleWebpackModule: any, compiler: Compiler) {
  const offlineMode = compiler instanceof Compiler;
  return offlineMode
    // in AOT we just resolve NgFactory
    ? NgFactoryResolver.resolveFactory(ngModuleWebpackModule)
    // in JIT we have to compile NgFactory
    : NgFactoryResolver.resolveAndCompileFactory(ngModuleWebpackModule, compiler);
}

Эта функция проверяет, есть ли у нас компилятор, и если да, мы вызываем метод resolveFactory, в противном случае мы вызываем resolveAndCompileFactory. То же, что и в RouterModule.

Вот JIT реализация. Достаем модуль и компилируем его так, чтобы получилось NgFactory:

resolveAndCompileFactory(ngModuleWebpackModule, compiler) {
  const moduleName = ngModuleWebpackModule.name;
  return [moduleName, compiler.compileModuleSync(ngModuleWebpackModule)];
}

В JIT мы должны получить ссылку на модуль и скомпилировать ее с помощью компилятора. Это даст нам NgModuleFactory модуля.

А вот реализацияAOT:

resolveFactory(ngModuleWebpackModule) {
  const moduleName = Object.keys(ngModuleWebpackModule).find(key => key.endsWith('ModuleNgFactory'));
  return [moduleName.replace('NgFactory', ''), ngModuleWebpackModule[moduleName]];
}

В AOT код другой. Нам не нужно компилировать модуль (и на самом деле мы даже не можем это сделать согласно статье Крейга, поскольку у нас нет Compiler во время выполнения - так работает Angular Compiler и AOT режим). Нам нужно только вернуть из него фабрику модуля. Но как это возможно, если мы использовали forModules со статическим импортом в модули?

Если вы посмотрите, как работает подключаемый модуль Angular Compiler Plugin, вы увидите, что он выполняет некоторые преобразования в файлы машинописного текста. Алексей Зуев написал статью о том, как это работает в Angular.

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

Если вы используете настраиваемую конфигурацию webpack, вам просто нужно добавить этот преобразователь в массив преобразователей ACP. Если вы используете @angular/cli, у вас нет конфигурации wepback, но вы можете получить ее с помощью специального конструктора. Вот как можно добавить трансформатор:

const AngularCompilerPluginInstance = initial.plugins.find(plugin => plugin instanceof AngularCompilerPlugin);
  const defaultsTransformers = AngularCompilerPluginInstance._transformers;  
AngularCompilerPluginInstance._transformers = [ngModulePathTransformer(), ...defaultsTransformers];

В нашем примере мы используем конструктор @ angular-builders / custom-webpack для получения нашей собственной конфигурации webpack. Подробнее о строителях можно прочитать там.

Что происходит в трансформаторе?

Преобразователь обрабатывает только *.module.ngfactory.ts файлов, сгенерированных компилятором Angular. Эти файлы являются скомпилированными источниками NgModules со всеми их зависимостями. Итак, преобразователь находит предоставленное значение токена CHILD_INJECTOR_MODULES. Мы передаем его вызову метода forModules. В нашем случае это массив модулей:

[
  ModuleA, ModuleB
]

На заводе это будет выглядеть следующим образом

import * as iN from './path/to/a/module';
import * as iM from './path/to/b/module';
...
i0.ɵmpd(256, i11.CHILD_INJECTOR_MODULES, [
  [iN.ModuleA, iM.ModuleB],
  ...
 ], [])

Другими словами, в преобразователе мы находим выражения, похожие на iN.ModuleA, и меняем эти выражения на iN. После компиляции и преобразования у нас есть объект со свойством ModuleANgFactory. Это скомпилированный NgFactory какой-то модуль.

После трансформации фабрика будет выглядеть следующим образом:

import * as iN from './path/to/a/module.ngfactory';
import * as iM from './path/to/b/module.ngfactory';
...
i0.ɵmpd(256, i11.CHILD_INJECTOR_MODULES, [
  [iN, iM],
  ...
 ], [])

Почему бы нам просто не изменить iN.Module1 на iN.Module1NgFactory? Если мы это сделаем, проблема появится во время выполнения в методе relsolve в режиме AOT. В производственной сборке есть оптимизации, переменные и имена функций будут искажены, и мы не сможем получить имя модуля из таких имен, поэтому нам нужен весь объект.

После этих манипуляций у нас есть дочерние модули-инжекторы без ленивой загрузки и мы можем внедрять собственные токены в такие модули (пример). Эти токены разрешаются в нашем http-перехватчике и их можно добавлять в каждый запрос в виде заголовка.

Как изменится код вашего приложения?

Поговорим о AppModule и AppComponent. Поскольку вы не импортируете модули с функциями (сейчас вы используете ChildInjectorModule.forModules), вы не можете использовать заявленные компоненты в своих шаблонах. Мы написали эту простую абстракцию ChildInjectorComponent, и теперь мы должны использовать ее в нашем AppComponent. Вот пример:

<app-child-injector module="WithCustomInjectorModule" [inputs]="inputs"></app-child-injector>
<app-child-injector module="AnotherModuleWithCustomInjectorModule"></app-child-injector>

Теперь давайте проверим, все ли работает в JIT. Полный пример с ChildInjectorModule и преобразованиями можно найти здесь.

Клонируем репо и переходим на examples/production-ready-child-modules-injector-example. Установите зависимости с помощью диспетчера пакетовnpm.

Выполните команду ng serve.

После запуска webpack-dev-server перейдите к localhost:4200.

Ты увидишь:

Все работает. Вы все еще помните, почему мы так глубоко копали? :) Мы хотели, чтобы запросы в разных частях нашего приложения помечались автоматически. Если вы откроете консоль браузера, вы увидите вывод http-перехватчика:

А что насчет AOT? Для нас это была самая важная часть.

Выполните ng serve --aot=true в консоли.

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

Больше примеров вы можете найти в нашем репо. В других примерах мы использовали другой подход для получения резолвера NgFactories. Мы использовали механизм псевдонимов webpack, чтобы определить, какая функция (для JIT или AOT) будет вызываться во время выполнения. Такой подход может сэкономить немного битов в ваших финальных пакетах.

Мы также поместили код ChildModuleInjector в пакет npm. Код доступен в этом репозитории на github. Если вы хотите попробовать дочерние модули инжектора без RouterModule и без отложенной загрузки, вы можете просто установить следующие пакеты, и они будут работать в вашем проекте. Основная часть находится в пакете @ easy-two / ngx-child-injector. Установите его с помощью пряжи или npm, чтобы использовать модуль и компонент. Это будет работать в режиме JIT. Если вы хотите, чтобы режим AOT работал, установите @ easy-two / ngx-child-injector-transformer и добавьте трансформатор, как в примере.

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