@levinzhang
2023-01-21T17:30:43.000000Z
字数 12855
阅读 311
Angular引入了独立组件的概念,以简化应用的开发,与此同时,它还引入了独立API,以改善库开发人员的编程体验。本文分析了Angular中基于该模式开发的现有API,并总结整理了相应的模式。
本文最初发表于ANGULARarchitects网站,经原作者Manfred Steyer授权,由InfoQ中文站翻译分享。
与独立组件(Standalone Component)一起,Angular团队来引入了独立API(Standalone API)。它们允许以一种更加轻量级的方式实现库。目前,提供独立API的样例是HttpClient
和Router
。另外,NGRX也是该理念的早期采用者。
在本文中,我会介绍几种编写自定义独立API的模式,这些模式的灵感来源于上述的几个库。对于每个模式,我都会讨论如下话题:模式背后的意图、描述、实现样例、在上述库中出现的样例场景以及实现细节的变种。
大多数模式对库作者会特别有用。对库的消费者来说,它们有助于改善DX。但是,对于应用来说,这就有点大材小用了。
本文的源码请参考该地址。
为了阐述这些推断出来的模式,我会使用一个简单的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来接收LoggerConfig
、LogFormatter
和包含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)中需要的所有内容都会放在这里。
提供者工厂是一个函数,它会为给定的库返回一个包含提供者的数组。这个数组会被转换为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
实例中。
这个函数允许消费库的应用在引导过程中像使用其他库(如HttpClient
或Router
)那样设置logger:
bootstrapApplication(AppComponent, {
providers: [
provideHttpClient(),
provideRouter(APP_ROUTES),
[...]
// Setting up the Logger:
provideLogger(loggerConfig),
]
}
Router
和HttpClient
的提供者工厂有第二个可选参数,以提供额外的特性(参见下文的特性模式)。LogFormatter
)。HttpClient
能够通过with
函数(参见下文的特性模式)获取函数化拦截器的数组。这些函数也会被注册为服务。提供者工厂会接收一个包含特性对象的可选数组。每个特性对象都有一个叫做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);
}
}
由于特性是可选的,DefaultLogAppender
将optional: true
传入到了inject
中。否则,如果特性不可用的话,我们会遇到异常。除此之外,DefaultLogAppender
还需要对null
值进行检查。
Router
使用了它,比如用来配置预加载或激活调试跟踪。HttpClient
使用了它,比如提供拦截器、配置JSONP和配置/禁用XSRF令牌的处理。Router
和HttpClient
都将可能的特性组合成了一个联合类型(如export type AllowedFeatures = ThisFeature | ThatFeature
)。这能够帮助IDE提示内置特性。Injector
,并使用它来查找配置了哪些特性。这是对使用optional: true
的一种命令式替换。kind
和providers
属性上添加了ɵ
前缀,因此将它们声明成了内部属性。配置提供者工厂能够扩展现存服务的行为。它们可以提供额外的服务,并使用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
令牌并指向某个函数的多个提供者。它能够获取注入的LogAppender
和LoggerService
。然后,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
特性使用该模式订阅Router
的events
Observable。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
允许使用函数来实现守卫和解析器。