作者: 涂鸦-Bayek
来涂鸦工作: job.tuya.com/
🤔️:轮子不是圆的吗?为什么是方的。
😣:因为圆能用。
要说依赖注入(DI,Dependency Injection),就不得不提控制反转(IoC,Inversion of Control),它们几乎已经是绑定概念了,那么两者有什么关系和区别呢。
很多文章会把两者混为一谈,但以我的理解,IoC是一种概念性的设计思想,除开Spring、Angular,就如React、Vue这样的框架,我们按照框架的规则写代码,框架拿到我们的代码进行一系列转换然后执行,这也算控制反转。相反的如jQuery、Lodash这样的工具库,我们调用库的方法实现目的,就不是控制反转。 回到DI,DI则是IoC思想的一种具体模式,主要用于降低Class之间的耦合。在Spring、Nest.js、Angular等框架中依赖注入是基本概念。
本文将基于TS实现一个基础的依赖注入过程,主要体现依赖注入的基本原理。
先看如下代码。
import { AuthService } from "./AuthService";
class AuthController {
constructor() {
this.authService = new AuthService();
}
login() {
this.authService.xxx()
}
}
我们在controller引入service类,并且直接实例化,类之间是强依赖的。
而在Java Spring、Nest.js类似框架里,将大概是下面这样子。
/** AuthService.ts **/
@Service() // 这个类要交给IoC容器
class AuthService {
xxx() {
// xxx
}
}
/** AuthController.ts **/
import { AuthService } from "./AuthService";
class AuthController {
@AutoWired() // 容器帮忙创建和注入AuthService
authService: AuthService;
login() {
this.authService.xxx()
}
}
这里没有再手动的new对象,而是给属性打上了一个注解,容器就会实例化对应的类再赋值给这个属性。这样做的好处有:
- 方便协同,如果两个类是两位同学一起写,只需要先定义好interface,引入interface作为类型。对方没有开发好这边也不会报错,Class的依赖也彻底没有了。(但是TS无法实现基于interface的自动注入,下面会实现基于abstract class的注入作为替代方案)
- 方便替换,如果我们有一个重构的V2版本的service,只需要更换一下实现类即可。
- 方便测试,只需要给到IoC容器一个简单的Mock实例,即可进行测试,不需要考虑内部new实例的副作用。
前置知识
装饰器
这里简单说一下,装饰器实质上就是高阶函数的语法糖,可以作用在类、方法、访问符(getter、setter)和方法参数上,可以获取并修改被作用元素的元信息。具体的用法大全与其我把文档搬过来,不如直接去看官方文档咯~
这里主要用到的是类装饰器和属性装饰器。
-
Component是类装饰器,打在要交给容器管理的类上。
-
AutoWired是属性装饰器,打在需要容器帮忙注入实例的属性上。(暂且只实现属性注入)
元信息
我们在装饰器中,可以拿到被作用元素的一些信息,比如类装饰器中可以拿到类的构造函数、构造函数名字(即类名),这些是默认就有的,也可以给其define一些新的信息,这些信息就称为元信息。我们可以通过定义和读取元信息,得到各个部件的依赖关系。
需求分解
- 需要一个IoC容器类来负责创建和注入实例。
- 需要@Component装饰器标准要被容器管理的类。
- 需要@AutoWired装饰器标注需要自动注入的属性。
假设我们要实现一个获取用户信息的功能,采用Web最常用的Controller -- Service -- Repository架构。
容器实现:
我们维护两个列表,一个注册方法,一个get方法。然后就可以先把容器new出来了。
class Container {
/**
* components维护组件列表
* instances维护实例列表
*/
components = new Map<string, any>(); // key -> Constructor
instances = new Map<string, object>(); // key -> Instance
/**
* 注册组件
* @param constructor 被装饰的类的构造函数
* @param alias 该组件的名字,默认取类名
*/
regist(constructor: Function, alias?: string) {
let name = alias;
if (!name) {
name = constructor.name;
}
if (this.components.has(name)) {
console.warn("重复注册Component: " + name);
}
this.components.set(name, constructor);
console.log(this);
}
/**
* 获取实例,实例是懒加载的单例,第一次获取时创建
* @param alias 组件名字
*/
get(alias: string) {
if (this.instances.has(alias)) {
return this.instances.get(alias);
}
const component = this.components.get(alias);
if (!component) {
throw "未注册: " + alias;
}
const ins = new component();
this.instances.set(alias, ins);
console.log(this);
return ins;
}
}
const iocContainer = new Container();
@Component实现
这个非常简单,target就是UserRepo的构造函数,拿到构造函数target和别名alias,注册进容器然后将构造函数原样返回即可。
function Component(alias?: string) {
return function (target: any) {
iocContainer.regist(target, alias || target.name);
return target;
};
}
@Component()
class UserRepo {
getUserById(id: number) {
return { user: "jj.zhang", id };
}
}
然后容器的components会有UserRepo的构造函数。
@AutoWired实现
function AutoWired(alias?: string) {
return function (target: any, propertyName: string) {
let name = alias;
if (!name) {
const classConstructor = Reflect.getMetadata(
"design:type",
target,
propertyName
);
console.log(99, classConstructor, target, propertyName);
name = classConstructor.name;
if (name === "Object") {
// 没有写类型,则尝试将属性名转大写查找实例
name = camelcase(propertyName, { pascalCase: true });
}
}
const instance = iocContainer.get(name || "");
target[propertyName] = instance;
return instance;
};
}
@Component()
class UserService {
@AutoWired()
userRepo!: UserRepo;
getUserById(id: number) {
return this.userRepo.getUserById(id);
}
}
这里用到Reflect.getMetadata("design:type", target, propertyName)
-
target
是UserService的原型对象 -
propertyName
是属性的名字,即"userRepo"
-
design:type
是内置的元数据key
整句表示获取target["userRepo"]
的类型即被装饰属性的构造函数,构造函数的name属性就是我们要的属性类型字符串,也就是UserRepo,然后找容器要一个UserRepo实例,赋值给target["userRepo"]
属性。
如果属性没有声明类型,比如这样:
@AutoWired()
userRepo;
则name属性拿到的是"Object"
,此时我们就将属性名propertyName
转换为pascalCase作为alias去容器取实例。
最后容器的instances就会多出一个UserRepo的实例了。
抽象类注入
Java Spring的接口注入是我觉得很精髓的地方,巧妙运用了多态的特性。
但是在TypeScript,装饰器中拿不到接口相关的信息,所以就无法实现接口注入了。
function Component(target: any) {
// 这里拿不到任何关于TestInterface的信息
}
interface TestInterface {
test(): string;
}
@Component
class A implements TestInterface {
test() {
return 'hello tuya';
}
}
但是可以使用抽象类实现类似的效果:
-
容器新增一个abstracrs列表,维护每个抽象类的所有实现子类。
-
新增一个@Impl装饰器,收集每一对抽象类 & 子类,注册到容器,并写入元信息表明这是一个抽象类。
-
修改@AutoWired注入逻辑,根据第2步写入的元信息判断,如果属性类型是抽象类,就在该抽象类的所有实现类中,查找和属性名匹配的类实例,进行注入。
最后容器就是这个结构,顺藤摸瓜就可以找到实现类了。
但有意思的事,不管是Nest.js还是Midway的Injection库,都没有提供类似的方案,或许这是个伪需求?
毕竟即使在Java项目中,多数interface从生到死也只有一个Implements😅
- 效果(精简版,完整代码比较长就不贴了,想看的点这里)
abstract class IUserService {
}
@Impl(IUserService)
@Component()
class UserService extends IUserService {
}
@Impl(IUserService)
@Component()
class UserServiceV2 extends IUserService {
}
@Component()
class UserController {
// // 注入UserService
// @AutoWired()
// userService!: IUserService;
// // 注入UserServiceV2
// @AutoWired("UserServiceV2")
// userService!: IUserService;
// 注入UserService,因为名称转pascal后就是UserService
@AutoWired()
userService;
}
总结
以上用最基础的手法,实现了依赖注入的基本原理。还有非常多的特性没有实现,比如构造函数注入、异步初始化等。
实际Component可能在出现在任意文件任意位置,而上面的例子中,如果将AutoWired移到Component前面,就找不到依赖了。
原因有二:
- 没有预先扫描所有Component。
- 不应该在装饰器中直接创建实例。
实际成熟框架的装饰器只负责写入元信息,不会实例化,由ApplicationContext启动时进行全局包扫描,获取所有的元信息,构建依赖图,确保所有Component都注册到容器中。时间所限这里不再继续展开,有兴趣的大佬可以移步源码Midway包扫描,injection源码。
来涂鸦工作: job.tuya.com/