[关闭]
@levinzhang 2023-01-21T09:30:43.000000Z 字数 12855 阅读 158

在Angular中实现自定义独立API的模式

摘要

Angular引入了独立组件的概念,以简化应用的开发,与此同时,它还引入了独立API,以改善库开发人员的编程体验。本文分析了Angular中基于该模式开发的现有API,并总结整理了相应的模式。


本文最初发表于ANGULARarchitects网站,经原作者Manfred Steyer授权,由InfoQ中文站翻译分享。

与独立组件(Standalone Component)一起,Angular团队来引入了独立API(Standalone API)。它们允许以一种更加轻量级的方式实现库。目前,提供独立API的样例是HttpClientRouter。另外,NGRX也是该理念的早期采用者。

在本文中,我会介绍几种编写自定义独立API的模式,这些模式的灵感来源于上述的几个库。对于每个模式,我都会讨论如下话题:模式背后的意图、描述、实现样例、在上述库中出现的样例场景以及实现细节的变种。

大多数模式对库作者会特别有用。对库的消费者来说,它们有助于改善DX。但是,对于应用来说,这就有点大材小用了。

本文的源码请参考该地址

样例

为了阐述这些推断出来的模式,我会使用一个简单的Logger库。这个库会尽可能简单,但是它又足以复杂以阐述这些模式。

每条日志消息都有一个LogLevel,它是由枚举定义的:

  1. export enum LogLevel {
  2. DEBUG = 0,
  3. INFO = 1,
  4. ERROR = 2,
  5. }

为了简单起见,我们将Logger库限制为只有三个日志级别。

我们会使用一个抽象LoggerConfig来定义可能的配置选项:

  1. export abstract class LoggerConfig {
  2. abstract level: LogLevel;
  3. abstract formatter: Type<LogFormatter>;
  4. abstract appenders: Type<LogAppender>[];
  5. }

我们有意将其定义为抽象类,因为接口无法作为DI的令牌(token)。该类的一个常量定义了配置选项的默认值:

  1. export const defaultConfig: LoggerConfig = {
  2. level: LogLevel.DEBUG,
  3. formatter: DefaultLogFormatter,
  4. appenders: [DefaultLogAppender],
  5. };

LogFormatter用于在通过LogAppender发布日志消息之前对其进行格式化:

  1. export abstract class LogFormatter {
  2. abstract format(level: LogLevel, category: string, msg: string): string;
  3. }

LoggerConfiguration类似,LogFormatter是一个抽象类,可以用作令牌。Logger库的消费者可以通过提供自己的实现来调整格式。他们也可以使用库提供的默认实现:

  1. @Injectable()
  2. export class DefaultLogFormatter implements LogFormatter {
  3. format(level: LogLevel, category: string, msg: string): string {
  4. const levelString = LogLevel[level].padEnd(5);
  5. return `[${levelString}] ${category.toUpperCase()} ${msg}`;
  6. }
  7. }

LogAppender是另一个可替换的概念,它会负责将日志消息追加到日志中:

  1. export abstract class LogAppender {
  2. abstract append(level: LogLevel, category: string, msg: string): void;
  3. }

默认实现会将日志消息打印至控制台。

  1. @Injectable()
  2. export class DefaultLogAppender implements LogAppender {
  3. append(level: LogLevel, category: string, msg: string): void {
  4. console.log(category + ' ' + msg);
  5. }
  6. }

尽管我们只能有一个LogFormatter,但是这个库支持多个LogAppender。例如,第一个LogAppender可以将消息写到控制台,而第二个可以将消息发送至服务器。

为了实现这一点,各个LogAppender是通过多个提供者(provider)注册的。所以,Injector在一个数组中将它们全部返回。因为数组无法作为DI令牌,所以样例使用了一个InjectionToken来代替:

  1. export const LOG_APPENDERS = new InjectionToken<LogAppender[]>("LOG_APPENDERS");

LoggserService本身会通过DI来接收LoggerConfigLogFormatter和包含LogAppender的数组,并允许为多个LogLevel记录日志信息:

  1. @Injectable()
  2. export class LoggerService {
  3. private config = inject(LoggerConfig);
  4. private formatter = inject(LogFormatter);
  5. private appenders = inject(LOG_APPENDERS);
  6. log(level: LogLevel, category: string, msg: string): void {
  7. if (level < this.config.level) {
  8. return;
  9. }
  10. const formatted = this.formatter.format(level, category, msg);
  11. for (const a of this.appenders) {
  12. a.append(level, category, formatted);
  13. }
  14. }
  15. error(category: string, msg: string): void {
  16. this.log(LogLevel.ERROR, category, msg);
  17. }
  18. info(category: string, msg: string): void {
  19. this.log(LogLevel.INFO, category, msg);
  20. }
  21. debug(category: string, msg: string): void {
  22. this.log(LogLevel.DEBUG, category, msg);
  23. }
  24. }

黄金法则

在开始介绍推断出的模式之前,我想强调一下我所说的提供服务的黄金法则:

只要有可能,就使用@Injectable({providedIn: 'root'})

尤其是在应用的代码中,但是在库中,有些场景也应该使用这种方式,它提供了一些我们想要的特征:很简单、支持摇树(tree-shakable),并且能够与懒加载协作。最后一项特征与其说是Angular的优点,不如说是底层打包器的优点:在懒加载包(bundle)中需要的所有内容都会放在这里。

模式:提供者工厂(Provider Factory)

意图

描述

提供者工厂是一个函数,它会为给定的库返回一个包含提供者的数组。这个数组会被转换为Angular的EnvironmentProviders类型,以确保提供者只能在环境作用域内使用,具体来讲也就是,根作用域以及懒路由配置引入的作用域。

Angular和NGRX将这些函数放在名为provider.ts的文件中。

样例

如下的提供者函数(Provider Function)provideLogger会接收一个LoggerConfiguration,并使用它来创建一些提供者:

  1. export function provideLogger(
  2. config: Partial<LoggerConfig>
  3. ): EnvironmentProviders {
  4. // using default values for missing properties
  5. const merged = { ...defaultConfig, ...config };
  6. return makeEnvironmentProviders([
  7. {
  8. provide: LoggerConfig,
  9. useValue: merged,
  10. },
  11. {
  12. provide: LogFormatter,
  13. useClass: merged.formatter,
  14. },
  15. merged.appenders.map((a) => ({
  16. provide: LOG_APPENDERS,
  17. useClass: a,
  18. multi: true,
  19. })),
  20. ]);
  21. }

缺失的配置会使用默认配置的值。Angular的makeEnvironmentProviders会将Provider数组包装到一个EnvironmentProviders实例中。

这个函数允许消费库的应用在引导过程中像使用其他库(如HttpClientRouter)那样设置logger:

  1. bootstrapApplication(AppComponent, {
  2. providers: [
  3. provideHttpClient(),
  4. provideRouter(APP_ROUTES),
  5. [...]
  6. // Setting up the Logger:
  7. provideLogger(loggerConfig),
  8. ]
  9. }

使用场景和变种

模式:特性(Feature)

意图

描述

提供者工厂会接收一个包含特性对象的可选数组。每个特性对象都有一个叫做kind的标识符和一个providers数组。kind属性允许校验传入特性的组合。比如,可能会存在互斥的特性,如为HttpClient同时提供配置XSRF令牌处理和禁用XSRF令牌处理的特性。

样例

我们的样例使用了一个着色的特性,它允许为不同的LoggerLevel显示不同的颜色:

为了对特性进行分类,我们使用了一个枚举:

  1. export enum LoggerFeatureKind {
  2. COLOR,
  3. OTHER_FEATURE,
  4. ADDITIONAL_FEATURE
  5. }

每个特性都使用LoggerFeature对象来表示:

  1. export interface LoggerFeature {
  2. kind: LoggerFeatureKind;
  3. providers: Provider[];
  4. }

为了提供着色特性,我们引入了一个遵循with Feature命名模式的工厂函数:

  1. export function withColor(config?: Partial<ColorConfig>): LoggerFeature {
  2. const internal = { ...defaultColorConfig, ...config };
  3. return {
  4. kind: LoggerFeatureKind.COLOR,
  5. providers: [
  6. {
  7. provide: ColorConfig,
  8. useValue: internal,
  9. },
  10. {
  11. provide: ColorService,
  12. useClass: DefaultColorService,
  13. },
  14. ],
  15. };
  16. }

提供者工厂通过可选的第二个参数接收多个特性,它们定义为rest数组:

  1. export function provideLogger(
  2. config: Partial<LoggerConfig>,
  3. ...features: LoggerFeature[]
  4. ): EnvironmentProviders {
  5. const merged = { ...defaultConfig, ...config };
  6. // Inspecting passed features
  7. const colorFeatures =
  8. features?.filter((f) => f.kind === LoggerFeatureKind.COLOR)?.length ?? 0;
  9. // Validating passed features
  10. if (colorFeatures > 1) {
  11. throw new Error("Only one color feature allowed for logger!");
  12. }
  13. return makeEnvironmentProviders([
  14. {
  15. provide: LoggerConfig,
  16. useValue: merged,
  17. },
  18. {
  19. provide: LogFormatter,
  20. useClass: merged.formatter,
  21. },
  22. merged.appenders.map((a) => ({
  23. provide: LOG_APPENDERS,
  24. useClass: a,
  25. multi: true,
  26. })),
  27. // Providing services for the features
  28. features?.map((f) => f.providers),
  29. ]);
  30. }

特性中kind属性用来检查和验证传入的特性。如果一切正常的话,特性中发现的提供者会被放到返回的EnvironmentProviders对象中。

DefaultLogAppender能够通过依赖注入获取着色特性提供的ColorService

  1. export class DefaultLogAppender implements LogAppender {
  2. colorService = inject(ColorService, { optional: true });
  3. append(level: LogLevel, category: string, msg: string): void {
  4. if (this.colorService) {
  5. msg = this.colorService.apply(level, msg);
  6. }
  7. console.log(msg);
  8. }
  9. }

由于特性是可选的,DefaultLogAppenderoptional: true传入到了inject中。否则,如果特性不可用的话,我们会遇到异常。除此之外,DefaultLogAppender还需要对null值进行检查。

使用场景和变种

模式:配置提供者工厂(Configuration Provider Factory)

意图

描述

配置提供者工厂能够扩展现存服务的行为。它们可以提供额外的服务,并使用ENVIRONMENT_INITIALIZER来获取所提供的服务以及要扩展的现存服务的实例。

样例

我们假设有一个扩展版本的LoggerService,它允许为每个日志类别定义一个额外的LogAppender

  1. @Injectable()
  2. export class LoggerService {
  3. private appenders = inject(LOG_APPENDERS);
  4. private formatter = inject(LogFormatter);
  5. private config = inject(LoggerConfig);
  6. [...]
  7. // Additional LogAppender per log category
  8. readonly categories: Record<string, LogAppender> = {};
  9. log(level: LogLevel, category: string, msg: string): void {
  10. if (level < this.config.level) {
  11. return;
  12. }
  13. const formatted = this.formatter.format(level, category, msg);
  14. // Lookup appender for this very category and use
  15. // it, if there is one:
  16. const catAppender = this.categories[category];
  17. if (catAppender) {
  18. catAppender.append(level, category, formatted);
  19. }
  20. // Also, use default appenders:
  21. for (const a of this.appenders) {
  22. a.append(level, category, formatted);
  23. }
  24. }
  25. [...]
  26. }

为了给某个类别配置LogAppender,我们可以引入另外一个提供者工厂:

  1. export function provideCategory(
  2. category: string,
  3. appender: Type<LogAppender>
  4. ): EnvironmentProviders {
  5. // Internal/ Local token for registering the service
  6. // and retrieving the resolved service instance
  7. // immediately after.
  8. const appenderToken = new InjectionToken<LogAppender>("APPENDER_" + category);
  9. return makeEnvironmentProviders([
  10. {
  11. provide: appenderToken,
  12. useClass: appender,
  13. },
  14. {
  15. provide: ENVIRONMENT_INITIALIZER,
  16. multi: true,
  17. useValue: () => {
  18. const appender = inject(appenderToken);
  19. const logger = inject(LoggerService);
  20. logger.categories[category] = appender;
  21. },
  22. },
  23. ]);
  24. }

这个工厂为LogAppender类创建了一个提供者。但是,我们并不需要这个类,而是需要它的一个实例。同时,我们还需要Injector解析该示例的依赖。这两者均需要在通过注入检索LogAppender时提供。

确切地讲,这是通过ENVIRONMENT_INITIALIZER实现的,它是绑定到ENVIRONMENT_INITIALIZER令牌并指向某个函数的多个提供者。它能够获取注入的LogAppenderLoggerService。然后,LogAppender会被注册到logger上。

这样,就能扩展甚至来自父作用域的现有LoggerService。例如,如下的样例假设LoggerService在根作用域中,而额外的日志级别是在懒加载路由中设置的:

  1. export const FLIGHT_BOOKING_ROUTES: Routes = [
  2. {
  3. path: '',
  4. component: FlightBookingComponent,
  5. // Providers for this route and child routes
  6. // Using the providers array sets up a new
  7. // environment injector for this part of the
  8. // application.
  9. providers: [
  10. // Setting up an NGRX feature slice
  11. provideState(bookingFeature),
  12. provideEffects([BookingEffects]),
  13. // Provide LogAppender for logger category
  14. provideCategory('booking', DefaultLogAppender),
  15. ],
  16. children: [
  17. {
  18. path: 'flight-search',
  19. component: FlightSearchComponent,
  20. },
  21. [...]
  22. ],
  23. },
  24. ];

使用场景和变种

模式:NgModule桥

意图

描述

NgModule桥是一个通过提供者工厂衍生的NgModule。为了让调用者对服务有更多的控制权,可以使用像forRoot这样的静态方法。这些方法也可以接收一个配置对象。

样例

如下的NgModules允许以传统的方式设置Logger。

  1. @NgModule({
  2. imports: [/* your imports here */],
  3. exports: [/* your exports here */],
  4. declarations: [/* your delarations here */],
  5. providers: [/* providers, you _always_ want to get, here */],
  6. })
  7. export class LoggerModule {
  8. static forRoot(config = defaultConfig): ModuleWithProviders<LoggerModule> {
  9. return {
  10. ngModule: LoggerModule,
  11. providers: [
  12. provideLogger(config)
  13. ],
  14. };
  15. }
  16. static forCategory(
  17. category: string,
  18. appender: Type<LogAppender>
  19. ): ModuleWithProviders<LoggerModule> {
  20. return {
  21. ngModule: LoggerModule,
  22. providers: [
  23. provideCategory(category, appender)
  24. ],
  25. };
  26. }
  27. }

为了避免重复实现提供者工厂,该模块的方法委托给了它们。当使用NgModules时,这种方式是很常用的,所以消费者可以利用现有的知识和惯例。

使用场景和变种

模式:服务链

意图

描述

当同一个服务被放在多个嵌套的环境injector中时,我们通常只能得到当前作用域的服务实例。因此,在嵌套作用域中,对服务的调用无法反映到父作用域中。为了解决这个问题,服务可以在父作用域中查找自己的实例并将调用委托给它。

样例

假设我们为一个懒加载的路由再次提供了日志库:

  1. export const FLIGHT_BOOKING_ROUTES: Routes = [
  2. {
  3. path: '',
  4. component: FlightBookingComponent,
  5. canActivate: [() => inject(AuthService).isAuthenticated()],
  6. providers: [
  7. // NGRX
  8. provideState(bookingFeature),
  9. provideEffects([BookingEffects]),
  10. // Providing **another** logger for this part of the app:
  11. provideLogger(
  12. {
  13. level: LogLevel.DEBUG,
  14. chaining: true,
  15. appenders: [DefaultLogAppender],
  16. },
  17. withColor({
  18. debug: 42,
  19. error: 43,
  20. info: 46,
  21. })
  22. ),
  23. ],
  24. children: [
  25. {
  26. path: 'flight-search',
  27. component: FlightSearchComponent,
  28. },
  29. [...]
  30. ],
  31. },
  32. ];

在这里,我们在懒加载路由及其子路由中的环境injector中设置了另外一套Logger的服务。该服务会屏蔽掉父作用域中对应的服务。因此,当懒加载作用域中的组件调用LoggerService时,父作用域中的服务不会被触发。

为了防止这种情况,我们可以从父作用域中获取LoggerService。更准确地说,这不一定是父作用域,而是提供LoggerService的“最近的祖先作用域”。随后,该服务可以委托给它的父服务。这样,服务就被链结起来了。

  1. @Injectable()
  2. export class LoggerService {
  3. private appenders = inject(LOG_APPENDERS);
  4. private formatter = inject(LogFormatter);
  5. private config = inject(LoggerConfig);
  6. private parentLogger = inject(LoggerService, {
  7. optional: true,
  8. skipSelf: true,
  9. });
  10. [...]
  11. log(level: LogLevel, category: string, msg: string): void {
  12. // 1. Do own stuff here
  13. [...]
  14. // 2. Delegate to parent
  15. if (this.config.chaining && this.parentLogger) {
  16. this.parentLogger.log(level, category, msg);
  17. }
  18. }
  19. [...]
  20. }

当使用inject来获取父LoggerService时,我们需要传递optional: true,避免祖先作用域在没有提供LoggerService时出现异常。传递skipSelf: true能够确保只有祖先作用域会被搜索。否则,Angular会从当前作用域开始进行搜索,因此会返回调用服务本身。

另外,上述的样例允许通过LoggerConfiguration中的新标记chaining启用或停用这种行为。

使用场景和变种

模式:函数式服务

意图

描述

库能够避免强迫消费者按照给定的接口实现基于类的服务,而是允许使用函数。在内部,它们可以使用useValue注册服务。

样例

在本例中,消费者可以直接传入一个函数,作为LogFormatter传递给provideLogger

  1. bootstrapApplication(AppComponent, {
  2. providers: [
  3. provideLogger(
  4. {
  5. level: LogLevel.DEBUG,
  6. appenders: [DefaultLogAppender],
  7. // Functional CSV-Formatter
  8. formatter: (level, cat, msg) => [level, cat, msg].join(";"),
  9. },
  10. withColor({
  11. debug: 3,
  12. })
  13. ),
  14. ],
  15. });

为了允许这样做,Logger需要使用LogFormatFn类型来定义函数的签名:

  1. export type LogFormatFn = (
  2. level: LogLevel,
  3. category: string,
  4. msg: string
  5. ) =>

同时,因为函数不能用作令牌,所以需要引入一个InjectionToken

  1. export const LOG_FORMATTER = new InjectionToken<LogFormatter | LogFormatFn>(
  2. "LOG_FORMATTER"
  3. );

这个InjectionToken既支持基于类的LogFormatter,也支持函数式的LogFormatter。这可以防止破坏现有的代码。为了支持这两种情况,providerLogger需要以稍微不同的方式处理这两种情况:

  1. export function provideLogger(config: Partial<LoggerConfig>, ...features: LoggerFeature[]): EnvironmentProviders {
  2. const merged = { ...defaultConfig, ...config};
  3. [...]
  4. return makeEnvironmentProviders([
  5. LoggerService,
  6. {
  7. provide: LoggerConfig,
  8. useValue: merged
  9. },
  10. // Register LogFormatter
  11. // - Functional LogFormatter: useValue
  12. // - Class-based LogFormatters: useClass
  13. (typeof merged.formatter === 'function' ) ? {
  14. provide: LOG_FORMATTER,
  15. useValue: merged.formatter
  16. } : {
  17. provide: LOG_FORMATTER,
  18. useClass: merged.formatter
  19. },
  20. merged.appenders.map(a => ({
  21. provide: LOG_APPENDERS,
  22. useClass: a,
  23. multi: true
  24. })),
  25. [...]
  26. ]);
  27. }

基于类的服务是用useClass注册的,而对于函数式服务,则需要使用useValue

此外,LogFormatter的消费者需要为函数式和基于类的方式进行调整:

  1. @Injectable()
  2. export class LoggerService {
  3. private appenders = inject(LOG_APPENDERS);
  4. private formatter = inject(LOG_FORMATTER);
  5. private config = inject(LoggerConfig);
  6. [...]
  7. private format(level: LogLevel, category: string, msg: string): string {
  8. if (typeof this.formatter === 'function') {
  9. return this.formatter(level, category, msg);
  10. }
  11. else {
  12. return this.formatter.format(level, category, msg);
  13. }
  14. }
  15. log(level: LogLevel, category: string, msg: string): void {
  16. if (level < this.config.level) {
  17. return;
  18. }
  19. const formatted = this.format(level, category, msg);
  20. [...]
  21. }
  22. [...]
  23. }

使用场景和变种

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注