Продвинутая регистрация multi-сервисов в Angular
- суббота, 23 ноября 2024 г. в 00:00:05
Внедрение нескольких сервисов с помощью одного токена — достаточно удобная механика в фреймворке 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.
Надеюсь статья была для вас полезной и данный подход найдёт место в ваших проектах!