Продвинутая регистрация multi-сервисов в Angular

от автора

Внедрение нескольких сервисов с помощью одного токена — достаточно удобная механика в фреймворке Angular. Однако, можно столкнуться с неприятностью, что во всех местах, где нужно получить данный сервис, придётся как-то выбирать нужный инстанс из массива. Кто-то делает это напрямую, через метод массива find, кто-то регистрирует сервис-менеджер, который умеет возвращать нужный инстанс, однако оба варианта рождают неприятный бойлерплейт. В этой статье разберём подход по удобной и продвинутой регистрации multi-сервисов.

Проблема

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

Типичный пример выглядит как-то так:

1. Есть некоторый интерфейс, который наши сервисы должны реализовать и, непосредственно, сами реализации

interface IExampleService {   key: string;   getData(): Data; }  @Injectable() class ExmapleServiceA implements IExampleService {   public readonly key: string = 'keyA';      public getData(): Data {} }  @Injectable() class ExmapleServiceB implements IExampleService {   public readonly key: string = 'keyB';      public getData(): Data {} }

2. Для удобства работы c несколькими сервисами создадим менеджер, который вытащит их из DI, зарегистрирует их в Map и предоставит удобное апи для взаимодействия с ними

@Injectable() class ExampleServiceManager {   private readonly _serviceMap: Map<string, IExampleService> = new Map();    public get(key: string): IExampleService {     const service: IExampleService | undefined = this._serviceMap.get(key);      if (!service) {       throw new Error(`Не найден сервис по ключу: "${key}"`);     }      return service;   }    public register(service: IExampleService): void {     if (this._serviceMap.has(service.key)) {       throw new Error(`Ключ "${service.key}" уже были зарегистрирован`);     }      this._serviceMap.set(service.key, service);   } }

3. Далее мы хотим зарегистрировать эти сервисы в DI-контейнер

const EXAMPLE_SERVICE_TOKEN: InjectionToken<IExampleService[]>    = new InjectionToken<IExampleService[]>(     'Токен для регистрации сервисов' );  function provideExampleService(): Provider[] {   return [     {       provide: EXAMPLE_SERVICE_TOKEN,       useClass: ExmapleServiceA,       multi: true,     },     {       provide: EXAMPLE_SERVICE_TOKEN,       useClass: ExmapleServiceB,       multi: true,     },     ExampleServiceManager   ] }

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

Но что будет если появится ещё одно, два, пять таких мест, в которых нужно будет зарегистрировать multi-сервисы? Цикл наших действий повторится, что родит кучу бойлерплейт-кода, который практически ничем не отличается от аналогичных мест.

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

Решение

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

interface IKeyed<TKey extends string> {   key: TKey; }

Дженерик позволяет типизировать наши ключи какими-то кастомными значениями, которые могут быть получены из union-type'а или какого-то enum'а.

Также, мы понимаем, что создавать каждый раз сервис менеджер — не вариант, потому что код в этих менеджерах будет отличаться примерно ничем. Значит для сервиса-менеджера нужно создать базовую реализацию, которую будем переиспользовать в дальнейшем.

abstract class BaseServiceManager<   TKey extends string,   TServiceType extends IKeyed<TKey> > {   private readonly _serviceMap: Map<TKey, TServiceType> = new Map();    public get(key: TKey): TServiceType {     const service: TServiceType | undefined = this._serviceMap.get(key);      if (!service) {       throw new Error(`Не найден сервис по ключу: "${key}"`);     }      return service;   }    public register(service: TServiceType): void {     if (this._serviceMap.has(service.key)) {       throw new Error(`Ключ "${service.key}" уже были зарегистрирован`);     }      this._serviceMap.set(service.key, service);   } }

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

  • TKey extends string — тип ключа, может быть типизирован кастомными значениями

  • TServiceType extends IKeyed<TKey> — тип сервиса, он должен реализовать контракт и иметь уникальный ключ для регистрации

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

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

type MultiServiceProviderOptions<   TManagerType extends BaseServiceManager<string, IKeyed<string>>,   TServiceType extends IKeyed<string> > = MultiServiceRegisterStrategy<TServiceType> & {   managerType: Type<TManagerType>;   serviceToken: InjectionToken<TServiceType[]>;   services: Array<Type<TServiceType>>; };

Посмотрим на дженерики

  • TManagerType extends BaseServiceManager<string, IKeyed<string>> — тип нашего сервиса-менеджера (подробнее об этом поговорим немного ниже)

  • TServiceType extends IKeyed<string> — тип сервиса

А теперь пройдёмся по полям опций

  • managerType — тип сервиса-менеджера. Type позволяет извлечь конструктор из переданного типа. Подробнее тут.

  • serviceToken — токен, с помощью которого мы будем регистрировать наши реализации

  • services — список сервисов-реализаций, которые мы хотим использовать с помощью нашего менеджера

Описание опций функции подготовили, теперь давайте реализуем функцию-провайдер.

export function provideMultiService<   TManagerType extends BaseServiceManager<string, IKeyed<string>>,   TServiceType extends IKeyed<string> >(   options: MultiServiceProviderOptions<TManagerType, TServiceType> ): Provider[] {   return [     ...options.services.map((service: Type<TServiceType>) => ({       provide: options.serviceToken,       useClass: service,       multi: true,     })),     {       provide: options.managerType,       useFactory: (): TManagerType => {         const manager: TManagerType = new options.managerType();         const diServices: TServiceType[] = inject(options.serviceToken);          diServices.forEach((service: TServiceType) =>           manager.register(service)         );          return manager;       },     },   ]; }

Посмотри по шагам что данная функция делает:

  1. Регистрирует в DI все наши реализации с помощью токена из options.serviceToken, используя значение multi: true

  2. Регистрирует в DI сервис-менеджер, с помощью его конструктора (потому что ранее использовали Type<>) и здесь же регистрирует все реализации в сервис-менеджер

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

Далее переделаем пример из части про проблему на новый подход и посмотрим как изменится наш код, но сейчас я бы хотел затронуть ещё небольшой нестандартный пример.

Экзотические кейсы

Текущее решение позволяет нам регистрировать сервисы в DI с помощью опции multi, а все наши реализации должны быть помечены декоратором @Injectable() . Однако, иногда возникают более экзотические кейсы, при которых мы хотим зарегистрировать в DI массив сущностей, которые не помечаются декоратором и не используют DI внутри себя, но хранить и конфигурировать эти сущности удобнее через провайдеры.

Например: вариант с инстанциированием сервисов вручную.

const EXAMPLE_ENTITY_TOKEN: InjectionToken<IExmapleEntity[]>    = new InjectionToken<IExmapleEntity[]>(     'Токен для регистрации сущностей' );  {   provide: EXAMPLE_ENTITY_TOKEN,   useValue: [     new ExmapleEntityA(),     new ExmapleEntityB(),     new ExmapleEntityC()   ], }

Текущая реализация не позволит нам зарегистрировать массив сущностей в DI, но при этом не регистрировать каждую сущности отдельно и не помечатать декоратором @Injectable() . Давайте немного её доработаем.

type MultiServiceProviderOptions<   TManagerType extends BaseServiceManager<string, IKeyed<string>>,   TServiceType extends IKeyed<string> > = MultiServiceRegisterStrategy<TServiceType> & {   managerType: Type<TManagerType>;   serviceToken: InjectionToken<TServiceType[]>; };  type MultiServiceRegisterStrategy<TServiceType extends IKeyed<string>> =   | RegisterEach<TServiceType>   | RegisterPack<TServiceType>;  type RegisterEach<TServiceType extends IKeyed<string>> = {   registerEach: true;   services: Array<Type<TServiceType>>; };  type RegisterFlat<TServiceType extends IKeyed<string>> = {   registerEach: false;   services: TServiceType[]; };

Поля managerType и serviceToken остались без изменений, однако теперь мы можем регулировать поведение опций и прокинуть либо массив конструкторов (Type<>), либо массив, непосредственно, инстансов, которые создали сами.

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

function provideMultiService<   TManagerType extends BaseServiceManager<string, IKeyed<string>>,   TServiceType extends IKeyed<string> >(   options: MultiServiceProviderOptions<TManagerType, TServiceType> ): Provider[] {   let serviceProvider: Provider[] = [];    if (options.registerEach) {     serviceProvider = options.services.map((service: Type<TServiceType>) => ({       provide: options.serviceToken,       useClass: service,       multi: true,     }));   } else {     serviceProvider = [       {         provide: options.serviceToken,         useValue: [...options.services],       },     ];   }    return [     ...serviceProvider,     {       provide: options.managerType,       useFactory: (): TManagerType => {         const manager: TManagerType = new options.managerType();         const diServices: TServiceType[] = inject(options.serviceToken);          diServices.forEach((service: TServiceType) =>           manager.register(service)         );          return manager;       },     },   ]; }

Таким образом, теперь мы можем регулировать способ регистрации инстансов сущностей в DI, а также отказываемся от обязательного условия в виде декоратора @Injectable().

Обновлённый пример

Теперь давайте перепишем пример, который был описан в начале статьи.

1. Необходимый инфраструктурный код выглядит следующим образом:

/** Необязательная часть, можно оставить просто строку */ export enum ExampleServiceKey {   A = 'a',   B = 'b',   C = 'c', }  export const EXAMPLE_SERVICE_TOKEN: InjectionToken<IExampleService[]> =   new InjectionToken<IExampleService[]>('Токен для провайда сервисов');  export interface IExampleService extends IKeyed<ExampleServiceKey> {   getData(): Data; }  @Injectable() export class ExampleServiceManager extends BaseServiceManager<   ExampleServiceKey,   IExampleService > {}

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

2. Реализации остаются без изменений:

@Injectable() class ExmapleServiceA implements IExampleService {   public readonly key: ExampleServiceKey = ExampleServiceKey.A;      public getData(): Data {} }  @Injectable() class ExmapleServiceB implements IExampleService {   public readonly key: ExampleServiceKey = ExampleServiceKey.B;      public getData(): Data {} }

3. Чтобы всё это дело зарегистрировать воспользуемся нашей функцией-провайдером:

providers: [   provideMultiService({     managerType: ExampleServiceManager,     serviceToken: EXAMPLE_SERVICE_TOKEN,     registerEach: true,     services: [ExampleServiceA, ExampleServiceB],   }), ],

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

@Component({}) export class App {    private readonly _exampleServiceManager: ExampleServiceManager = inject(     ExampleServiceManager   );    public log(key: ExampleServiceKey): void {     alert(this._exampleServiceManager.get(key).getData());   } }

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

Синтаксический сахар

Чтобы массив providers выглядел чище и не был перегружен лишней информацией можно делать обёртки над функцией-провайдером и использовать их для регистрации:

const provideExampleServices = provideMultiService.bind(null, {   managerType: ExampleServiceManager,   serviceToken: EXAMPLE_SERVICE_TOKEN,   registerEach: true,   services: [ExampleServiceA, ExampleServiceB], });

Заключение

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

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

Исходный код решения можно посмотреть на stackblitz.

Надеюсь статья была для вас полезной и данный подход найдёт место в ваших проектах!


ссылка на оригинал статьи https://habr.com/ru/articles/860586/