写点什么

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

作者:Manfred Steyer

  • 2023-02-10
    北京
  • 本文字数:9383 字

    阅读完需:约 31 分钟

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

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


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


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


本文的源码请参考该地址:https://github.com/manfredsteyer/standalone-example-cli.git。


样例


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



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


export enum LogLevel {  DEBUG = 0,  INFO = 1,  ERROR = 2,}
复制代码


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


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


export abstract class LoggerConfig {  abstract level: LogLevel;  abstract formatter: Type<LogFormatter>;  abstract appenders: Type<LogAppender>[];}
复制代码


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


export const defaultConfig: LoggerConfig = {  level: LogLevel.DEBUG,  formatter: DefaultLogFormatter,  appenders: [DefaultLogAppender],};
复制代码


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


export abstract class LogFormatter {  abstract format(level: LogLevel, category: string, msg: string): string;}
复制代码


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


@Injectable()export class DefaultLogFormatter implements LogFormatter {  format(level: LogLevel, category: string, msg: string): string {    const levelString = LogLevel[level].padEnd(5);    return `[${levelString}] ${category.toUpperCase()} ${msg}`;  }}
复制代码


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


export abstract class LogAppender {  abstract append(level: LogLevel, category: string, msg: string): void;}
复制代码


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


@Injectable()export class DefaultLogAppender implements LogAppender {  append(level: LogLevel, category: string, msg: string): void {    console.log(category + ' ' + msg);  }}
复制代码


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


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


export const LOG_APPENDERS = new InjectionToken<LogAppender[]>("LOG_APPENDERS");
复制代码


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


@Injectable()export class LoggerService {  private config = inject(LoggerConfig);  private formatter = inject(LogFormatter);  private appenders = inject(LOG_APPENDERS);
log(level: LogLevel, category: string, msg: string): void { if (level < this.config.level) { return; } const formatted = this.formatter.format(level, category, msg); for (const a of this.appenders) { a.append(level, category, formatted); } } error(category: string, msg: string): void { this.log(LogLevel.ERROR, category, msg); }
info(category: string, msg: string): void { this.log(LogLevel.INFO, category, msg); }
debug(category: string, msg: string): void { this.log(LogLevel.DEBUG, category, msg); }}
复制代码


黄金法则


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


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


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


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


意图


  • 为可重用库提供服务

  • 配置可重用库

  • 替换预定义的实现细节

描述


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


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


样例


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


export function provideLogger(  config: Partial<LoggerConfig>): EnvironmentProviders {  // using default values for missing properties  const merged = { ...defaultConfig, ...config };
return makeEnvironmentProviders([ { provide: LoggerConfig, useValue: merged, }, { provide: LogFormatter, useClass: merged.formatter, }, merged.appenders.map((a) => ({ provide: LOG_APPENDERS, useClass: a, multi: true, })), ]);}
复制代码


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


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


bootstrapApplication(AppComponent, {  providers: [
provideHttpClient(),
provideRouter(APP_ROUTES),
[...]
// Setting up the Logger: provideLogger(loggerConfig), ]}
复制代码


使用场景和变种


  • 在所有经过验证的库中,这是一个常用的模式。

  • RouterHttpClient的提供者工厂有第二个可选参数,以提供额外的特性(参见下文的特性模式)。

  • NGRX 支持为 reducer 提供令牌或具体的对象,而不是传入具体的服务实现(如LogFormatter)。

  • HttpClient能够通过with函数(参见下文的特性模式)获取函数化拦截器的数组。这些函数也会被注册为服务。

模式:特性(Feature)


意图


  • 激活或配置可选的特性

  • 使这些特性支持摇树

  • 通过当前环境作用域提供底层服务

描述


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


样例


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



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


export enum LoggerFeatureKind {    COLOR,    OTHER_FEATURE,    ADDITIONAL_FEATURE}
复制代码


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


export interface LoggerFeature {  kind: LoggerFeatureKind;  providers: Provider[];}
复制代码


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


export function withColor(config?: Partial<ColorConfig>): LoggerFeature {  const internal = { ...defaultColorConfig, ...config };
return { kind: LoggerFeatureKind.COLOR, providers: [ { provide: ColorConfig, useValue: internal, }, { provide: ColorService, useClass: DefaultColorService, }, ], };}
复制代码


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


export function provideLogger(  config: Partial<LoggerConfig>,  ...features: LoggerFeature[]): EnvironmentProviders {  const merged = { ...defaultConfig, ...config };
// Inspecting passed features const colorFeatures = features?.filter((f) => f.kind === LoggerFeatureKind.COLOR)?.length ?? 0;
// Validating passed features if (colorFeatures > 1) { throw new Error("Only one color feature allowed for logger!"); }
return makeEnvironmentProviders([ { provide: LoggerConfig, useValue: merged, }, { provide: LogFormatter, useClass: merged.formatter, }, merged.appenders.map((a) => ({ provide: LOG_APPENDERS, useClass: a, multi: true, })),
// Providing services for the features features?.map((f) => f.providers), ]);}
复制代码


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


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


export class DefaultLogAppender implements LogAppender {  colorService = inject(ColorService, { optional: true });
append(level: LogLevel, category: string, msg: string): void { if (this.colorService) { msg = this.colorService.apply(level, msg); } console.log(msg); }}
复制代码


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


使用场景和变种


  • Router使用了它,比如用来配置预加载或激活调试跟踪。

  • HttpClient使用了它,比如提供拦截器、配置 JSONP 和配置 / 禁用 XSRF 令牌的处理。

  • RouterHttpClient都将可能的特性组合成了一个联合类型(如export type AllowedFeatures = ThisFeature | ThatFeature)。这能够帮助 IDE 提示内置特性。

  • 有些实现注入到了当前Injector,并使用它来查找配置了哪些特性。这是对使用optional: true的一种命令式替换。

  • Angular 的特性实现在kindproviders属性上添加了ɵ前缀,因此将它们声明成了内部属性。


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


  • 配置现存的服务。

  • 提供额外的服务并将它们与现存的服务注册到一起。

  • 在嵌套环境作用域中扩展服务的行为。

描述


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


样例


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


@Injectable()export class LoggerService {
private appenders = inject(LOG_APPENDERS); private formatter = inject(LogFormatter); private config = inject(LoggerConfig); [...]
// Additional LogAppender per log category readonly categories: Record<string, LogAppender> = {};
log(level: LogLevel, category: string, msg: string): void {
if (level < this.config.level) { return; }
const formatted = this.formatter.format(level, category, msg);
// Lookup appender for this very category and use // it, if there is one: const catAppender = this.categories[category];
if (catAppender) { catAppender.append(level, category, formatted); }
// Also, use default appenders: for (const a of this.appenders) { a.append(level, category, formatted); }
}
[...]}
复制代码


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


export function provideCategory(  category: string,  appender: Type<LogAppender>): EnvironmentProviders {  // Internal/ Local token for registering the service  // and retrieving the resolved service instance  // immediately after.  const appenderToken = new InjectionToken<LogAppender>("APPENDER_" + category);
return makeEnvironmentProviders([ { provide: appenderToken, useClass: appender, }, { provide: ENVIRONMENT_INITIALIZER, multi: true, useValue: () => { const appender = inject(appenderToken); const logger = inject(LoggerService);
logger.categories[category] = appender; }, }, ]);}
复制代码


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


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


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


export const FLIGHT_BOOKING_ROUTES: Routes = [  {    path: '',    component: FlightBookingComponent,
// Providers for this route and child routes // Using the providers array sets up a new // environment injector for this part of the // application. providers: [ // Setting up an NGRX feature slice provideState(bookingFeature), provideEffects([BookingEffects]),
// Provide LogAppender for logger category provideCategory('booking', DefaultLogAppender), ], children: [ { path: 'flight-search', component: FlightSearchComponent, }, [...] ], },];
复制代码


使用场景和变种


  • @ngrx/store使用该模式来注册特性切片(slice)。

  • @ngrx/effects使用该模式来装配特性提供的效果。

  • withDebugTracing特性使用该模式订阅Routerevents Observable。

模式:NgModule 桥


意图


  • 当切换到独立 API 时,不能破坏使用NgModules的现有代码。

  • 支持上述类型的应用通过来自提供者工厂的EnvironmentProviders设置应用的部分功能。

描述


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


样例


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


@NgModule({  imports: [/* your imports here */],  exports: [/* your exports here */],  declarations: [/* your delarations here */],  providers: [/* providers, you _always_ want to get, here */],})export class LoggerModule {  static forRoot(config = defaultConfig): ModuleWithProviders<LoggerModule> {    return {      ngModule: LoggerModule,      providers: [        provideLogger(config)      ],    };  }
static forCategory( category: string, appender: Type<LogAppender> ): ModuleWithProviders<LoggerModule> { return { ngModule: LoggerModule, providers: [ provideCategory(category, appender) ], }; }}
复制代码


当使用 NgModules 时,这种方式是很常用的,所以消费者可以利用现有的知识和惯例。


使用场景和变种


  • 我们检查过的库均使用这种模式以保持向后兼容性。

模式:服务链


意图


  • 使服务能够委托给父作用域中的另一个实例。

描述


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


样例


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


export const FLIGHT_BOOKING_ROUTES: Routes = [  {    path: '',    component: FlightBookingComponent,    canActivate: [() => inject(AuthService).isAuthenticated()],    providers: [      // NGRX      provideState(bookingFeature),      provideEffects([BookingEffects]),
// Providing **another** logger for this part of the app: provideLogger( { level: LogLevel.DEBUG, chaining: true, appenders: [DefaultLogAppender], }, withColor({ debug: 42, error: 43, info: 46, }) ),
], children: [ { path: 'flight-search', component: FlightSearchComponent, }, [...] ], },];
复制代码


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


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


@Injectable()export class LoggerService {  private appenders = inject(LOG_APPENDERS);  private formatter = inject(LogFormatter);  private config = inject(LoggerConfig);
private parentLogger = inject(LoggerService, { optional: true, skipSelf: true, }); [...]
log(level: LogLevel, category: string, msg: string): void {
// 1. Do own stuff here [...]
// 2. Delegate to parent if (this.config.chaining && this.parentLogger) { this.parentLogger.log(level, category, msg); } } [...]}
复制代码


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


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


使用场景和变种


  • HttpClient使用这种模式可以在父作用域中触发HttpInterceptor。关于链式HttpInterceptor的更多细节,可以参阅 该文。在这里,链式行为可以通过一个单独的特性来激活。从技术上讲,这个特性注册了另一个拦截器,将调用委托给了父作用域中的服务。

模式:函数式服务


意图


  • 通过使用函数即服务,使库的使用更加轻量级。

  • 通过使用临时函数来减少间接影响。

描述


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


样例


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


bootstrapApplication(AppComponent, {  providers: [    provideLogger(      {        level: LogLevel.DEBUG,        appenders: [DefaultLogAppender],
// Functional CSV-Formatter formatter: (level, cat, msg) => [level, cat, msg].join(";"), }, withColor({ debug: 3, }) ), ],});
复制代码


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


export type LogFormatFn = (  level: LogLevel,  category: string,  msg: string) =>
复制代码


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


export const LOG_FORMATTER = new InjectionToken<LogFormatter | LogFormatFn>(  "LOG_FORMATTER");
复制代码


这个InjectionToken既支持基于类的LogFormatter,也支持函数式的LogFormatter


这可以防止破坏现有的代码。为了支持这两种情况,providerLogger需要以稍微不同的方式处理这两种情况:


export function provideLogger(config: Partial<LoggerConfig>, ...features: LoggerFeature[]): EnvironmentProviders {
const merged = { ...defaultConfig, ...config};
[...]
return makeEnvironmentProviders([ LoggerService, { provide: LoggerConfig, useValue: merged },
// Register LogFormatter // - Functional LogFormatter: useValue // - Class-based LogFormatters: useClass (typeof merged.formatter === 'function' ) ? { provide: LOG_FORMATTER, useValue: merged.formatter } : { provide: LOG_FORMATTER, useClass: merged.formatter },
merged.appenders.map(a => ({ provide: LOG_APPENDERS, useClass: a, multi: true })), [...] ]);}
复制代码


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


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


@Injectable()export class LoggerService {  private appenders = inject(LOG_APPENDERS);  private formatter = inject(LOG_FORMATTER);  private config = inject(LoggerConfig);
[...]
private format(level: LogLevel, category: string, msg: string): string { if (typeof this.formatter === 'function') { return this.formatter(level, category, msg); } else { return this.formatter.format(level, category, msg); } }
log(level: LogLevel, category: string, msg: string): void { if (level < this.config.level) { return; }
const formatted = this.format(level, category, msg);
[...] } [...]}
复制代码


使用场景和变种


  • HttpClient允许使用函数式拦截器。它们是通过一个特性注册的(参见特性模式)。

  • Router允许使用函数来实现守卫和解析器。


原文链接:


https://www.angulararchitects.io/en/aktuelles/patterns-for-custom-standalone-apis-in-angular

相关阅读:

2023 重学 Angular

Angular v15 发布:可以脱离 NgModules 构建组件了

AngularJS 进阶 (二十五)requirejs + angular + angular-route 浅谈 HTML5 单页面架

谈谈企业级前端 Angular 应用的定制化二次开发话题

2023-02-10 13:504940

评论

发布
暂无评论
发现更多内容

Forklift Mac破解版 一款强大的文件管理程序

Rose

Java实现基于ArrayList的生产者/消费者

极客罗杰

SiteSucker for mac 允许用户下载整个网站以供离线浏览

Rose

最适合ZKsync主网和撸空投的4个钱包推荐,bitget钱包

股市老人

直播预告丨华为数字化转型解决方案,助力钢铁行业飞越“寒冬”!

YG科技

FabFilter Total Bundle 2024 Mac音频插件合集 v2024.05.30直装激活版

Rose

Karmada v1.10发布,新增多集群声明式负载重平衡

华为云开发者联盟

开源 云原生 华为云 华为云开发者联盟 企业号2024年6月PK榜

Pixelmator Pro for Mac v3.6.4中文激活版 媲美PS的修图软件

Rose

又来了!涛思数据成为中关村物联网产业联盟高级会员单位

TDengine

数据库 tdengine 时序数据库

云电竞平台测评

Yan-英杰

AIGC在保险场景中的视觉应用

京东科技开发者

游戏部署换新季,华为云618营销季助力游戏企业闯关升级

YG科技

Music Tag Editor Mac版:音频标签管理工具

Rose

专访 Altair 大中华区总经理刘源博士:仿真拥抱人工智能,Altair 打造数字孪生新生态

Altair RapidMiner

人工智能 机器学习 数据分析 altair

一键获取企业“松弛感”

脑极体

云服务

Microsoft Remote Desktop for Mac(微软远程连接软件)v10.9.8直装激活版

Rose

Apache IoTDB 走进东南大学,深入分享项目发展历程与收获

Apache IoTDB

第55期|GPTSecurity周报

云起无垠

【程序设计】做一个发送系统邮件的功能,如何设计数据表?

靠谱的程序员

程序设计

线性稳压器LDO的基础知识

芯动大师

芯片 LDO 电源

HarmonyOS NEXT大揭秘进入倒计时:HDC 2024华为开发者大会精彩亮点抢先看

最新动态

接口测试:Mock 的价值与意义

霍格沃兹测试开发学社

功能强大的数字绘画和照片编辑应用:Artstudio Pro

Rose

电竞直播APP软件开发公司哪家好?现成源码由哪些优势

软件开发-梦幻运营部

多媒体下载和转换工具Allavsoft

Rose

极速查询:StarRocks 存算分离 Compaction 原理 & 调优指南

StarRocks

云计算 大数据 数据仓库 数据湖 湖仓一体

AI大佬都在说下一个爆点是智能体,建议开发者抢占先机!

AI浩

智能体

在Angular中实现自定义独立API的模式_大前端_InfoQ精选文章