写点什么

在 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:504931

评论

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

Linux Page Cache调优在Kafka中的应用

vivo互联网技术

大数据 kafka

28岁硕士女程序员想分手!对象专科学历,北京土著,失业3个月找不到工作!遭网友群嘲!

程序员生活志

程序员

一行错误代码:5 亿美元没了。。。项目关闭。。。

程序员生活志

PHP中的错误和异常

书旅

php 异常 常见错误

基于Ambari的大数据平台搭建

数据社

大数据 hadoop ambari

设计模式-技术专题-建造者模式(Builder)

洛神灬殇

Java 设计模式

因为套用这个模板,我成了公司最佳员工

华为云开发者联盟

网站架构 华为云 网站搭建 匀速建站 SEO

PM2 管理node.js开机自启动(非root用户)

不会写诗的王维

node.js

他被称为"中国第一程序员",一人之力单挑微软!真牛!

程序员生活志

90后程序员小姐姐在线征婚!年薪70w!拥有五套房!她却担心自己因为年龄大嫁不出去!

程序员生活志

程序员

更改用户host留下的坑

Simon

MySQL

揭秘MySQL主从数据不一致

Simon

MySQL 主从复制

芯片破壁者(十三):台湾地区半导体的古史新证

脑极体

【数据结构与算法】用动图解说数组、链表、跳表原理与实现

三钻

数组 链表 数据结构与算法 跳表

关于自增id 你可能还不知道

Simon

MySQL MySQL自增ID

IOTA架构下的数据采集

易观大数据

性能优化-技术专题-top和jstack分析高CPU问题

洛神灬殇

JVM

LeetCode题解:66. 加一,倒序遍历+可中途退出,JavaScript,详细注释

Lee Chen

大前端 LeetCode

【API进阶之路】逆袭!用关键词抽取API搞定用户需求洞察

华为云开发者联盟

接口 软件开发 API 华为云 API Explorer平台

Spring-技术专题-Bean的生命周期简介

洛神灬殇

spring

MySQL视图介绍

Simon

MySQL

暴雪员工抗议薪酬不公,部分员工称甚至难以维持生计

程序员生活志

职场

卡丁车的后轴是如何做到差速的?

TGP大跨步

科普 卡丁车 TGP 大跨步 素材

史上最强DIY,手工制作一只会说话的机器狗

华为云开发者联盟

聊天机器人 nlp 华为云 语言识别 语言合成

哥尼斯堡七桥问题

InfoQ_aef2dd810f7f

5. JsonFactory工厂而已,还蛮有料,这是我没想到的

YourBatman

Jackson Fastjson JSON库 JsonFactory

LeetCode题解:11. 盛最多水的容器,双循环暴力法,JavaScript,详细注释

Lee Chen

大前端 LeetCode

MySQL-长事务详解

Simon

MySQL mysql事务

通过波士顿矩阵模型做产品定位

GuOjixIE

数据分析 产品定位 波士顿矩阵模型

如何选择一台打印机

别把虾米不当海鲜

[8.20]leetcode每日一题,

一起搞稽

算法 DFS

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