TypeScript:从架构分层设计到IOC和AOP

容器按类型取实例、装饰器读出参数类型,这两件事都要求类型活到运行时,而 TypeScript 的类型编译完就没了。IoC 用 token 绕,AOP 用元数据绕,各自的代价写在这里。

预计
10 分钟

下面这个 IoC 容器看上去是类型安全的:resolve 带着类型参数,调用处写 resolve<ILogger>('ILogger'),编辑器也确实提示得出 ILogger 上的方法。但它挡不住任何一种写错。

一个会撒谎的容器
class IOCContainer {
private deps = new Map<string, unknown>();
register<T>(key: string, dep: T): void { this.deps.set(key, dep); }
resolve<T>(key: string): T {
const dep = this.deps.get(key);
if (!dep) throw new Error(`Dependency ${key} not found`);
return dep as T; // 「类型安全」全部落在这一个 as 上
}
}
const c = new IOCContainer();
c.register<ILogger>('ILogger', new ConsoleLogger());
const a = c.resolve<ILogger>('Iogger'); // 键少了一个字母
const b = c.resolve<Date>('ILogger'); // 要的类型根本不对

tsc --strict 对最后两行一个字都不说。第一行会在运行时抛 Dependency Iogger not found;第二行更糟,它把一个 ConsoleLogger 当成 Date 交出去,要等到某处调用 .getTime() 才炸。

原因是 key 的类型是 stringT 由调用方随口指定,两者之间没有任何东西把它们绑在一起。as T 不是检查,是命令编译器闭嘴。

分层、IoC、AOP 这三件事在 TypeScript 里最后都撞上同一堵墙:类型在运行时不存在。容器要按类型找实例,装饰器要读出参数类型,可编译完之后那些类型全没了。下面几节讲的就是三种绕法,以及各自付出了什么。

让 token 自己带上类型

把字符串换成一个带类型参数的对象,键和类型就绑在一处了。Token<T> 里那个 _type 字段只存在于类型层面 —— declare 让它不生成任何运行时代码,它唯一的作用是让 Token<ILogger>Token<Date> 在编译器眼里成为两个不同的类型。

token 携带类型,register 和 resolve 自动对齐
// 幽灵字段:只存在于类型里,编译完就没了,运行时 Token 只是个带名字的对象
class Token<T> {
declare private _type: T;
constructor(readonly name: string) {}
}
const LOGGER = new Token<ILogger>('ILogger');
class Container {
private deps = new Map<Token<unknown>, unknown>();
register<T>(token: Token<T>, dep: T): void {
this.deps.set(token as Token<unknown>, dep);
}
resolve<T>(token: Token<T>): T {
if (!this.deps.has(token as Token<unknown>)) throw new Error(`${token.name} 没注册`);
return this.deps.get(token as Token<unknown>) as T;
}
}

换过来之后,前面那两行错误都变成编译错误:

tsc --strict 的输出
error TS2345: Argument of type 'Date' is not assignable to parameter of type 'ILogger'.
Property 'log' is missing in type 'Date' but required in type 'ILogger'.
error TS2740: Type 'ILogger' is missing the following properties from type 'Date': toDateString, …

调用处也不用再写类型参数了,c.resolve(LOGGER) 直接推出 ILogger。容器内部那两个 as 还在,但它们被关进了 registerresolve 两个方法体里,外面再也构造不出不匹配的组合 —— 这是断言的正确用法:把不安全压缩到一处,而不是散在每个调用点

代价是 token 变成了必须传来传去的值。字符串可以凭记忆写出来,LOGGER 得先 import 进来;跨包共享时两边必须引到同一个 token 对象,否则 Map 认不出。真实框架大多两样都给:Angular 的 InjectionToken、InversifyJS 的 symbol 加接口,都是这个形状。

分层真正的约束是依赖方向

表现层、业务逻辑层、数据访问层、数据库,四层自上而下,箭头只有一个朝向:

四层自上而下:表现层调业务逻辑层,业务逻辑层调数据访问层,数据访问层访问数据库,实线箭头全部朝下;右侧那条从数据库指回表现层的虚线标着「反向依赖:不允许」 四层自上而下:表现层调业务逻辑层,业务逻辑层调数据访问层,数据访问层访问数据库,实线箭头全部朝下;右侧那条从数据库指回表现层的虚线标着「反向依赖:不允许」

这张图真正约束的是一件事:依赖只能往一个方向走。业务层可以认识数据访问层,数据访问层不许反过来认识业务层。类型系统本身不检查这件事,靠的是接口摆在哪一层。

这里唯一值得写成代码的是仓库接口。一个泛型接口覆盖所有实体,业务层只依赖它,不依赖任何具体的数据库客户端:

数据访问层暴露的全部形状
interface Repository<T> {
findById(id: number): Promise<T | null>;
findAll(): Promise<T[]>;
create(item: T): Promise<T>;
update(id: number, item: T): Promise<T>;
delete(id: number): Promise<void>;
}
interface User { id: number; name: string; email: string; }
// 实现里是 SQL 还是 HTTP 调用,业务层看不见也不该看见
class UserRepository implements Repository<User> { /* … */ }

仓库模式的回报是可测试性:业务层的构造函数上写的是 Repository<User>,测试时塞一个数组实现进去就行,不用起数据库。但这句话有个前提 —— 业务层依赖的必须是 Repository<User> 这个接口,不是 UserRepository 这个类。写成后者的话分层只剩下目录结构,换实现照样要改业务代码。

而一旦依赖写成接口,就回到了开头那个问题:接口在运行时不存在,容器拿什么去找实现?这正是下面两节要处理的。

工厂里的 switch 应该由 never 兜底

工厂的常见写法是 switch 加一个 default: throw。问题是联合类型加了一个成员之后,这个 default 会安静地接住它,编译期什么都不报,直到线上抛 Invalid product type

default 里的值赋给 never,漏掉的分支就变成编译错误:

漏掉的分支在编译期就报出来
type ProductType = 'type1' | 'type2' | 'type3'; // 新加了 type3
function createProduct(type: ProductType): Product {
switch (type) {
case 'type1': return new ConcreteProduct1();
case 'type2': return new ConcreteProduct2();
default:
// 所有分支都覆盖到时,type 会被收窄成 never;漏了就不是
const exhaustive: never = type;
throw new Error(`Invalid product type: ${exhaustive}`);
}
}

上面这段的报错是 error TS2322: Type '"type3"' is not assignable to type 'never'.,直接指到漏掉的那个成员。把 const exhaustive: never = type; 删掉换回 throw new Error(...),同一段代码就一声不吭地编译过去 —— 差别只有这一行。

装饰器有两套语义,签名对不上就编译不过

AOP 那部分的代码最容易在这里翻车。TypeScript 现在有两套互不兼容的装饰器:

  • 老的,靠 experimentalDecorators 开启,从 TS 1.5 起就有,方法装饰器拿到 (target, propertyKey, descriptor) 三个参数。
  • 新的,TypeScript 5.0 起在不开 experimentalDecorators 时默认启用,实现的是 TC39 的装饰器提案。方法装饰器只拿到 (originalMethod, context) 两个参数。

这里要说清楚一件常被写错的事:那份提案至今停在 Stage 2.7,没有进入任何一版 ECMAScript。把它叫「ES 装饰器标准」或者「ES2022 的特性」都不对,它现在只是 TypeScript(和 Babel)各自实现的一个提案。

两套不能混。把老写法放进默认配置的 TS 5.x,编译器直接把话说明白了:

老签名 + 新语义
error TS1241: Unable to resolve signature of method decorator when called as an expression.
The runtime will invoke the decorator with 2 arguments, but the decorator expects 3.

反过来也一样 —— 新签名的装饰器配上 experimentalDecorators,报的还是 TS1241。同一个日志切面,两套各写一遍是这样:

老装饰器 · 替换 descriptor.value
function Log() {
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
console.log(`调用 ${propertyKey},参数 ${JSON.stringify(args)}`);
return originalMethod.apply(this, args);
};
return descriptor;
};
}
新装饰器 · 直接返回一个替换函数
function Log<This, Args extends unknown[], Return>(
target: (this: This, ...args: Args) => Return,
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
) {
const name = String(context.name);
return function (this: This, ...args: Args): Return {
console.log(`调用 ${name},参数 ${JSON.stringify(args)}`);
return target.call(this, ...args);
};
}

差别不只是参数个数。老写法改的是属性描述符,新写法根本拿不到描述符,它接过原方法、返回一个新函数,由运行时替你装回去;方法名从 propertyKey: string 变成了 context.name(类型是 string | symbol)。新版还多了 context.addInitializercontext.access 这些老版没有的东西,而老版独有的能力下一节会说。

求值从上往下,应用从下往上

@Log()@Measure() 叠在一起时,执行顺序是拧着的。把两个工厂函数各加一行打印,跑出来是这样:

@Log() @Measure() createUser 的实际输出
求值 Log()
求值 Measure()
应用 Measure
应用 Log

装饰器表达式从上往下求值,得到的装饰器函数从下往上应用。所以最靠近方法的那个先包上,写在最外面的那个反而在最外层 —— 调用 createUser 时先进 Log 的包装,再进 Measure 的,最后才是原方法。

这不是约定,是编译产物里写着的。老装饰器生成的 __decorate 辅助函数里那个循环是倒着走的:for (var i = decorators.length - 1; i >= 0; i--)。想让耗时统计只覆盖业务逻辑、不把日志的开销算进去,就得把 @Measure() 放在更靠近方法的位置。

反射元数据只有老装饰器给得出

容器要自动装配,就得在运行时知道「这个构造函数的第一个参数要什么」。emitDecoratorMetadata 干的就是这件事。给一个类加上装饰器再编译,产物里会多出一行:

tsc --experimentalDecorators --emitDecoratorMetadata 的产物
UserService = __decorate([
Injectable(),
__metadata("design:paramtypes", [UserRepository, Object])
], UserService);

原类的构造函数写的是 constructor(private repo: UserRepository, private logger: ILogger)。看产物:UserRepository 是个类,它作为值真实存在,于是原样留在数组里;ILogger 是接口,编译后什么都不剩,位置上只留下一个 Object

这一个 Object 解释了依赖注入里最常见的那个报错。 容器拿 design:paramtypes[0] 得到 UserRepository 构造函数本身,用它当键去查表就能找到实例;拿到第 1 项却只有 Object,无从判断你想要哪个日志实现,只能报「解析不了这个依赖」。要注入接口,必须另外给一个 token —— 也就是前面那个 Token<T>,或者框架提供的等价物。NestJS 的做法可以对着看:Nest.js 中使用声明文件定义依赖注入的类型那篇拆的就是它怎么读这份元数据。

还有两条边界值得记住,都能自己验证:

  • 没有装饰器就没有元数据。@Injectable() 去掉,即使开着 emitDecoratorMetadata,产物里也一行 design:paramtypes 都不会有。类装饰器在这里的作用不是「标记可注入」,是触发这次 emit。
  • emitDecoratorMetadata 单独开不了。 不配 experimentalDecorators 就直接报 error TS5052: Option 'emitDecoratorMetadata' cannot be specified without specifying option 'experimentalDecorators'.

第二条意味着:想要这套反射,就只能待在老装饰器上。

代价

  • 两套装饰器是全仓库一次性的选择。 experimentalDecorators 是编译器级别的开关,不能一个文件一套。依赖里有任何一个库的装饰器是按老语义写的,整个项目就得留在老语义。
  • 参数装饰器在新语义下不存在。 constructor(@Inject('LOGGER') private logger: ILogger) 这种写法,不开 experimentalDecorators 时报的是 error TS1206: Decorators are not valid here.。构造函数注入正是靠参数装饰器写 token 的,这也是 NestJS 这类框架至今仍在老装饰器上的直接原因。
  • 反射元数据要额外的运行时。 __metadata 只在 Reflect.metadata 存在时才真的写入,所以入口处必须先 import 'reflect-metadata',而且要早于任何被装饰的类被加载。漏了不会报错,只会得到一个空的元数据表。
  • 容器把「缺依赖」从编译期推到了启动期。 手写 new UserService(repo, logger),少一个参数编译就过不去;改成容器解析之后,忘了注册只有跑起来才知道。换来的是装配集中在一处,代价是这一类错误不再由编译器负责。
  • AOP 装饰器会改掉方法的类型。 上面那个新版 Log 保住了签名,是因为返回的函数用了和入参相同的 ArgsReturn。真要在包装里改参数或返回值,调用方看到的类型就得跟着手写,装饰器在这方面帮不上忙。

这篇归在 TypeScript 下。同一主题里最近的另外两篇是 Typescript:Nest.js 中使用声明文件定义依赖注入的类型TypeScript:MobX 中使用映射类型设计实现对象的响应式代理