Внедрение нескольких сервисов с помощью одного токена — достаточно удобная механика в фреймворке 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; }, }, ]; }
Посмотри по шагам что данная функция делает:
-
Регистрирует в DI все наши реализации с помощью токена из
options.serviceToken
, используя значениеmulti: true
-
Регистрирует в DI сервис-менеджер, с помощью его конструктора (потому что ранее использовали
Type<>
) и здесь же регистрирует все реализации в сервис-менеджер -
В конечном итоге в нашем 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/
Добавить комментарий