javascript

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

  • суббота, 23 ноября 2024 г. в 00:00:05
https://habr.com/ru/articles/860586/

Внедрение нескольких сервисов с помощью одного токена — достаточно удобная механика в фреймворке 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.

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