Angular是由Google推出的前端框架,曾经与React和Vue一起被开发者称为“前端三驾马车”,但从随着技术的迭代发展,它在国内前端技术圈中的存在感变得越来越低,通常只有Java技术栈的后端工程师在考虑转型全栈工程师时才会优先考虑使用。Angular没落的原因并不是因为它不够好,反而是因为它过于优秀,还有点高冷,忽略了国内前端开发者的学习意愿和接受能力,就好像一个学霸,明明成绩已经很好了,但还是不断寻求挑战来实现自我突破,尽管他从不吝啬分享自己的所思所想,但他所接触的领域令广大学渣望尘莫及,而学渣们感兴趣的事物在他看来又有些无聊,最终的结果通常都只能是大家各玩各的。
了解过前端框架发展历史的读者可能会知道在2014年时Angular1.x版本有多火,尽管它并不是第一个将MVC思想引入前端的框架,但的确可以算作第一个真正撼动jQuery江湖地位的黑马,由于在升级Angular2.0版本的过程中强制使用Typescript作为开发语言,使它失去了大量用户,Vue和React也开始趁势崛起,很快便形成“三足鼎立”之势。但Angular似乎并没有回头的意思,而是保持着半年一个大版本的迭代速度将更多的新概念带给前端,从而推动前端领域的技术演进,也推动着前端向正规的软件工程方向逐步靠拢。我常说Angular是一个孤傲的变革者,它喜欢引入和传播思想层面的概念,将那些被公认为正确优雅且有助于工程实践的事物带给前端,它似乎总是在说“这个是好的,那我们就在Angular里实现它吧”,从早期的模块化和双向数据绑定的引入,到后来的组件化、Typescript、Cli、RxJS、DI、AOT等等,一个个特性的引入都引导着开发者从不同的角度去思考,扩展着前端领域的边界,也对团队的整体素养提出更高的要求。如果你看看今天Typescript在前端开发领域的江湖地位,回顾一下早期的Vue和Angular1.x之间的差异性,看看RxJS和React Hooks出现的时间差,就不难明白Angular的思想有多前卫。
“如果一件事情是软件工程师应该懂的,那么你就应该弄懂它”,这在笔者看来是Angular带给前端开发者最有价值的思想,精细化分工对企业而言是非常有利的,但却非常容易限制技术人员本身的视野和职业发展,就好像流水线上从事体力劳动的工人,就算对自己负责的环节再熟悉,也无法仅仅凭此来保障整个零件加工最终的质量。我们应该在协作中对自己的产出负责,但只有摘掉职位头衔带来的思维枷锁,你才能成为一个更全面的软件工程师,它并不是关于技能的,而是关于思维方式的,那些源于内心深处的认知和定位会决定一个人未来所能达到的高度。
无论你是否会在自己的项目中使用Angular,都希望你能够花一点时间了解它的理念,它能够扩展你对于编程的认知,领略软件技术思想层面的美。本章中我们就一起来学习Angular框架中最具特色的技术——DI(依赖注入),了解相关的IOC设计模式、AOP编程思想以及实现层面的装饰器语法,最后再看看如何使用Inversify.js来在自己的代码中实现“依赖注入”。如果你对此感兴趣,可以通过Java的Spring框架进行更深入的研究。
依赖为什么需要注入
依赖注入(Dependency Injection,简称DI)并不算一个复杂的概念,但想要真正理解它背后的原理却不是一件容易的事情,它的上游有更加抽象的IOC设计思想,下游有更加具体的AOP编程思想和装饰器语法,只有搞清楚整个知识脉络中各个术语之间的联系,才能够建立相对完整的认知,从而在适合的场景使用它,核心概念的关系如下图所示:
面向对象的编程是基于“类”和“实例”来运作的,当你希望使用一个类的功能时,通常需要先对它进行实例化,然后才能调用相关的实例方法。由于遵循“单一职责”的设计原则,开发者在实现复杂的功能时并不会将代码都写在一起,而是依赖于多个子模块协作来实现相应的功能,如何将这些模块组合在一起对于面向对象编程而言是非常关键的,这也是设计模式相关的知识需要解决的主要问题,代码结构设计不仅影响团队协作的开发效率,也关系着代码在未来的维护和扩展成本。毕竟在真实的开发中,不同的模块可能由不同的团队来开发维护,如果模块之间耦合度太高,那么偏底层的模块一旦发生变更,整个程序在代码层面的修改可能会非常多,这对于软件可靠性的影响是非常严重的。
在普通的编程模式中,开发者需要引入自己所依赖的类或者相关类的工厂方法(工厂方法是指运行后会得到实例的方法)并手动完成子模块的实例化和绑定,如下所示:
import B from ‘../def/B’;
import createC from ‘../def/C’;
class A{
constructor(paramB, paramC){
this.b = new B(paramB);
this.c = createC(paramC);
}
actionA(){
this.b.actionB();
}
}
从功能实现的角度而言,这样做并没有什么问题,但从代码结构设计的角度来看却存在着一些潜在的风险。首先,在生成A的实例时所接受的构造参数实际上并不是由A自身来消费的,而是将其透传分发给它所依赖的B类和C类,换句话说,A除了需要承担其本身的职责之外,还额外承担了B和C的实例化任务,这与面向对象编程中的SOLID基本设计原则中的“单一职责”原则是相悖的;其次,A类的实例a仅仅依赖于B类实例的actionB方法,如果对actionA方法进行单元测试,理论上只要actionB方法执行正确,那么单元测试就应该能够通过,但在前文的示例代码中,这样的单元测试实际上已经变成了包含B实例化过程、C实例化过程以及actionB方法调用的小范围集成测试,任何一个环节发生异常都会导致单元测试无法通过;最后,对于C模块而言,它对外暴露的工厂方法createC可以对实例化的过程进行控制,例如维护一个全局单例对象,但对于直接导出类定义的B模块而言,每个依赖它的模块都需要自己完成对它的实例化,如果未来B类的构造方法发生了变化,恐怕开发者只能利用IDE全局搜索所有对B类进行实例化的代码然后手动进行修改。
“依赖注入”的模式就是为了解决以上的问题而出现的,在这种编程模式中,我们不再接收构造参数然后手动完成子模块的实例化,而是直接在构造函数中接受一个已经完成实例化的对象,在代码层面的基本实现形式变成了下面的样子:
class A{
constructor(bInstance, cInstance){
this.b = bInstance;
this.c = cInstance;
}
actionA(){
this.b.actionB();
}
}
对于A类而言,它所依赖的b实例和c实例都是在构造时从外部注入进来的,这意味着它不再需要关心子模块实例化的过程,而只需要以形参的方式声明对这个实例的依赖,然后专注于实现自己所负责的功能即可,对子模块实例化的工作交给A类外部的其他模块来完成,这个外部模块通常被称为“IOC容器”,它本质上就是“类注册表+工厂方法”,开发者通过“key-value”的形式将各个类注册到IOC容器中,然后由IOC容器来控制类的实例化过程,当构造函数需要使用其他类的实例时,IOC容器会自动完成对依赖的分析,生成需要的实例并将它们注入到构造函数中,当然需要以单例模式来使用的实例都会保存在缓存中。
另一方面,在“依赖注入”的模式下,上层的A类对下层模块B和C的强制依赖已经消失了,它和你在JavaScript基础中了解到的“鸭式辨形”机制非常类似,只要实际传入的bInstance参数也实现一个actionB方法,且在函数签名(或者说类型声明)上和B类的actionB方法保持一致,对于A模块而言它们就是一样的,这可以极大地降低对A模块进行单元测试的难度,而且方便开发者在开发环境、测试环境和生产环境等不同的场景中对特定的模块提供完全不同的实现,而不是被B模块的具体实现所限制,如果你了解过面向对象编程的SOLID设计原则就会明白,“依赖注入”实际上就是对“依赖倒置原则”的一种体现。
这就是“依赖注入”和“控制反转”的基本知识,依赖的实例由原本手动生成的方式转变为由IOC容器自动分析并以注入的方式提供,原本由上层模块控制的实例化过程被转移给IOC容器来完成,本质上它们都是对面向对象基本设计原则的实现手段,目的就是为了降低模块之间的耦合性,隐藏更多细节。很多时候,设计模式的应用的确会让本来直观清晰的代码变得晦涩难懂,但换来的却是整个软件对于需求不确定性的抵御能力。初级开发者在编程时千万不要只满足于实现眼前的需求,而是应该多思考如何降低需求变动可能给自己造成的影响,甚至直接“控制反转”将细节定制的环节以配置文件的形式提供给产品人员。请时刻记得,软件工程师的任务是设计软件,让软件和可复用的模块去帮助自己实现需求,而不是把自己变成一个擅长搬砖的工具。
IOC容器的实现
基于前文的分析,你可以先自己来思考一下基本的IOC容器应该实现的功能,然后再继续下面的内容。IOC容器的主要职责是接管所有实例化的过程,那么它肯定能够访问到所有的类定义,并且知道每个类的依赖,但类的定义可能编写在多个不同的文件中,IOC容器要如何来完成依赖收集呢?比较容易想到的方法就是为IOC容器实现一个注册方法,开发者在每个类定义完成后调用注册方法将自己的构造函数和依赖模块的名称注册到IOC容器中,IOC容器以闭包的形式维护一个私有的类注册表,其中以键值对的形式记录了每个类的相关信息,例如工厂方法、依赖列表、是否使用单例以及指向单例的指针属性等等,你可以根据实际需要去添加更多的配置信息,这样一来IOC容器就拥有了访问所有类并进行实例化的能力;除了收集信息外,IOC容器还需要实现一个获取所需实例的调用方法,当调用方法执行时,它可以根据传入的键值去找到对应的配置对象,根据配置信息返回正确的实例给调用者。这样一来,IOC容器就可以完成实例化的基本职能。
IOC容器的使用对于模块之间耦合关系的影响是非常明显的,在原来的手动实例化模型中,模块之间的关系时相互耦合的,模块的变动很容易直接导致依赖它的模块发生修改,因为上层模块对底层模块本身产生了依赖;在引入IOC容器后,每个类只需要调用容器的注册方法将自己的信息登记进去,其他模块如果对它有依赖,通过调用IOC容器提供的方法就可以获取到所需要的实例,这样一来,子模块实例化的过程和主模块之间就不再是强依赖关系,子模块发生变化时也不需要再去修改主模块,这样的处理模式对于保障大型应用的稳定性非常有帮助。现在我们再回过头看看那张经典的控制反转示意图,就比较容易理解其背后完成的工作了:
IOC的机制其实和招聘是非常类似的,公司的项目要落地实施,需要项目经理、产品、设计、研发、测试等多个不同岗位的员工协作来完成,对公司而言,更加关注每个岗位需要多少人,低中高不同级别的人员比例大概是多少,从而可以从宏观的角度来评估人力配置是否足以保障项目落地,至于具体招聘到的人是谁,公司并不需要在意;而HR的角色就像是IOC容器,只需要按照公司的标准去市场上搜寻符合条件的候选人,并用面试来检验他是否符合用人要求就可以了。
手动实现IOC容器
下面我们使用Typescript来手动实现一个简单的IOC容器类,你可以先体会一下它的基本用法,因为强类型的特点,它更容易帮助你在抽象层面了解自己所写的代码,另外它的面向对象特性也更加完备,语法特征和Java非常相似,是学习收益率很高的选择。相比于JavaScript的灵活,Typescript的代码增加了非常多的限制,最开始你可能会被类型系统搞的晕头转向,但当你熟悉后,就会慢慢开始享受这种代码层面的约束和自律带来的工程层面的清晰。我们先来编写基本的结构和必要的类型限制:
// IOC成员属性
interface iIOCMember {
factory: Function;
singleton: boolean;
instance?: {}
}
// 定义IOC容器
Class IOC {
private container: Map<PropertyKey, iIOCMember>;
constructor() {
this.container = new Map<string, iIOCMember>();
}
}
在上面的代码中我们定义了2个接口和1个类,IOC容器类中有一个私有的map实例,它的键是PropertyKey类型,这是Typescript中预设的类型,指string | number | symbol的联合类型,也就我们平时用作键的类型,而值的类型是iIOCMember,从接口的定义中可以看到,它需要一个工厂方法、一个标记是否为单例的属性以及指向单例的指针,接下来我们在IOC容器类上添加用于注册构造函数的方法bind:
// 构造函数泛型
interface iClass<T> {
new(...args: any[]): T
}
// 定义IOC容器
class IOC {
private container: Map<PropertyKey, iIOCMember>;
constructor() {
this.container = new Map<string, iIOCMember>();
}
bind<T>(key: string, Fn: iClass<T>) {
const factory = () => new Fn();
this.container.set(key, { factory, singleton: true });
}
}
bind方法的逻辑并不难理解,初学者可能会对iClass接口的声明比较陌生,它是指实现了这个接口的实体在被new时需要返回预设类型T的实例,换句话说就是这里接收的是一个构造函数,new( )作为接口的属性时也被称为“构造器字面量”。但IOC容器是延迟实例化的,想要让构造函数延迟执行,最简单的方式就是定义一个简单的工厂方法(如前文示例中的factory方法所做的那样)并将它保存起来,等需要时在进行实例化。最后我们再来实现一个调用方法use:
use(namespace: string) {
let item = this.container.get(namespace);
if (item !== undefined) {
if (item.singleton && !item.instance) {
item.instance = item.factory();
}
return item.singleton ? item.instance : item.factory();
} else {
throw new Error('未找到构造方法');
}
}
use方法接收一个字符串并根据它从容器中找出对应的值,这里的值就会符合iIOCMember接口定义的结构,为了方便演示,如果没有找到对应的记录就直接报错,如果需要单例且还没有生成过相应的对象,就调用工厂方法来生成单例,最终根据配置信息来判断是返回单例还是创建新的实例。现在我们就可以来使用这个IOC容器了:
class UserService {
constructor() {}
test(name: string) {
console.log(`my name is ${name}`);
}
}
const container = new IOC();
container.bind<UserService>('UserService', UserService);
const userService = container.use('UserService');
userService.test('大史不说话');
使用ts-node直接运行Typescript代码后,就可以在控制台看到打印的信息。前文的IOC容器仅仅实现了最核心的流程,它还不具备依赖管理和加载的功能,希望你可以自己尝试来进行实现,需要做的工作就是在注册信息时提供依赖模块键的列表,然后在实例化时通过递归的方式将依赖模块都映射为对应的实例,当你学习webpack模块加载原理时也会接触到类似的模式,下一小节中我们来看看Angular1.x版本如何完成对依赖的自动分析和注入。
AngularJS中的依赖注入
AngularJS在业内特指Angular2以前的版本(更高的版本中统一称为Angular),它提倡使用模块化的方式来分解代码,将不同层面的逻辑拆分为Controller、Service、Directive、Filter等类型的模块,从而提高整个代码的结构性,其中Controller模块是用来连接页面和数据模型的,通常每个页面会对应一个Controller,典型的代码片段如下所示:
var app = angular.module(“myApp”, []);
//编写页面控制器
app.controller(“mainPageCtrl”,function($scope,userService) {
// 控制器函数操作部分 ,主要进行数据的初始化操作和事件函数的定义
$scope.title = ‘大史住在大前端’;
userService.showUserInfo();
});
// 编写自定义服务
app.service(‘userService’,function(){
this.showUserInfo = function(){
Console.log(‘call the method to show user information’);
}
})
示例代码中先通过module方法定义了一个全局的模块实例,接着在实例上定义了一个控制器模块(Controller)和一个服务模块(Service),$scope对象用于和页面之间产生关联,通过模板语法绑定的变量或事件处理函数都需要挂载在页面的$scope对象上才能够被访问,上面这段简单的代码在运行时,AngularJS就会将页面模板上带有ng-bind=“title”标记的元素内容替换为自定义的内容,并执行userService服务上的showUserInfo方法。
如果你仔细观察上面的代码,很容易就会发现依赖注入的痕迹,Controller在定义时接收了一个字符串key和一个函数,这个函数通过形参userService来接收外部传入的同名服务,用户要做的仅仅是使用AngularJS提供的方法来定义对应的模块,而框架在执行工厂方法来实例化时就会自动找到它依赖的模块实例并将其注入进来,对于Controller而言,它只需要在工厂函数的形参中声明自己依赖的模块就可以了。有了前文中IOC相关知识的铺垫,我们不难想象,app.controller方法的本质其实就是IOC容器中的bind方法,用于将一个工厂方法登记到注册表中,它仅仅是依赖收集的过程,app.service方法也是类似的。这种实现方式被称为“推断注入”,也就是从传入的工厂方法形参的名称中推断出依赖的模块并将其注入,函数体的字符串形式可以调用toString方法得到,接着使用正则就可以提取出形参的字符,也就是依赖模块的名称。“推断注入”属于一种隐式推断的方式,它要求形参的名称和模块注册时使用的键名保持一致,例如前文示例中的userService对应着使用app.service方法所定义的userService服务。这种方式虽然简洁,但代码在利用工具进行压缩混淆时通常会将形参使用的名称修改为更短的名称,这时再用形参的名称去寻找依赖项就会导致错误,于是AngularJS又提供了另外两种依赖注入的实现方式——“内联声明”和“声明注入”,它们的基本语法如下所示:
// 内联注入
app.controller(“mainPageCtrl”,[‘$scope’, ’userService’, function($scope,userService) {
// 控制器函数操作部分 ,主要进行数据的初始化操作和事件函数的定义
$scope.title = ‘大史住在大前端’;
userService.showUserInfo();
}]);
// 声明注入
var mainPageCtrl = function($scope,userService) {
// 控制器函数操作部分 ,主要进行数据的初始化操作和事件函数的定义
$scope.title = ‘大史住在大前端’;
userService.showUserInfo();
};
mainPageCtrl.$inject = [‘$scope’,’userService’];
app.controller(“mainPageCtrl”, mainPageCtrl);
内联注入是在原本传入工厂方法的位置传入一个数组,默认数组的最后一项为工厂方法,而前置项是依赖模块的键名,字符串常量并不像函数定义那样会被压缩混淆工具影响,这样AngularJS的依赖注入系统就能够找到需要的模块了;声明注入的目的也是一样的,只不过它将依赖列表挂载在工厂函数的$inject属性上而已(JavaScript中的函数本质上也是对象类型,可以添加属性),在程序的实现上想要兼容上述的几种不同的依赖声明方式并不困难,只需要判断app.controller方法接收到的第二个参数是数组还是函数,如果是函数的话是否有$inject属性,然后将依赖数组提取出来并遍历加载模块就可以了。
AngularJS的依赖注入模块源代码可以在官方代码仓的src/auto/injector.js中找到,从文件夹的命名就可以看到它是用来实现自动化依赖注入的,其中包含大量官方文档的注释,会对阅读理解源代码的思路有很大帮助,你可以在其中找到annotate方法的定义,就可以看到AngularJS中对于上述几种不同的依赖声明方式的兼容处理,感兴趣的读者可以自行完成其他部分的学习。
AOP和装饰器
面向切面编程(Aspect Oriented Programming,即AOP),是程序设计中非常经典的思想,它通过预编译或动态代理的方式来为已经编写完成的模块添加新的功能,从而避免了对源代码的修改,也让开发者可以更方便地将业务逻辑功能和诸如日志记录、事务处理、性能统计、行为埋点等系统级的功能拆分开来,从而提升代码的复用性和可维护性。真实开发中的项目可能时间跨度很长,参与的人员也可能会不断更换,如果将上述代码都编写在一起,势必会对其它协作者理解主要业务逻辑造成干扰。面向对象编程的关注点是梳理实体关系,它解决的问题是如何将具体的需求划分为相对独立且封装良好的类,让它们有属于自己的行为;而面向切面编程的关注点是剥离通用功能,让很多类共享一个行为,这样当它变化时只需要修改这个行为即可,它可以让开发者在实现类的特性时更加关注其本身的任务,而不是苦恼于将它归属于哪个类。
“面向切面编程”并不是什么颠覆性的技术,它带来的是一种新的代码组织思路。假设你在系统中使用著名的axios库来处理网络请求,后端在用户登录成功后返回一个token,需要你每次发送请求时都将它添加在请求头中以便进行鉴权,常规的思路是编写一个通用的getToken方法,然后在每次发请求时通过自定义headers将其传入(假设自定义头字段为X-Token):
import { getToken } from ‘./utils’;
axios.get(‘/api/report/get_list’,{
headers:{
‘X-Token’:getToken()
}
});
从功能实现角度而言,上面的做法是可行的,但我们不得不在每个需要发送请求的模块中引用公共方法getToken,这样显得非常繁琐,毕竟在不同的请求中添加token信息的动作都是一样的;相比之下,axios提供的interceptors拦截器机制就非常适合用来处理类似的场景,它就是非常典型的“面向切面”实践:
axios.interceptors.request.use(function (config) {
// 在config配置中添加自定义信息
config.headers = {
...config.headers,
‘X-Token’:getToken()
}
return config;
}, function (error) {
// 请求发生错误时的处理函数
return Promise.reject(error);
});
如果你了解过express和koa框架中所使用的中间件模型,就很容易意识到这里的拦截器机制本质上和它们是一样的,用户自定义的处理函数被依次添加进拦截器数组,在请求发送前或者响应返回后的特定“时间切面”上依次执行,这样一来,每个具体的请求就不需要再自行处理向请求头中添加Token之类的非业务逻辑了,功能层面的代码就这样被剥离并隐藏起来,业务逻辑的代码自然就变得更加简洁。
除了利用编程技巧,高级语言也提供了更加简洁的语法来方便开发者实践“面向切面编程”,JavaScript从ES7标准开始支持装饰器语法,但由于当前前端工程中有Babel编译工具的存在,所以对于开发者而言并不需要考虑浏览器对新语法支持度的问题,如果使用Typescript,开发者就可以通过配置tsconfig.json中的参数来启用装饰器(在Spring框架中被称为annotation,也就是注解)语法来实现相关的逻辑,它的本质只是一种语法糖。常见的装饰器包括类装饰器、方法装饰器、属性装饰器、参数装饰器,类定义中几乎所有的部分都可以被装饰器包装。以类装饰器为例,它接收的参数是需要被修饰的类,下面的示例中使用@testable修饰符在已经定义的类的原型对象上增加一个名为_testable的属性:
function testable(target){
target.prototype._testable = false;
}
// 在类名上一行编写装饰器
@testable
Class Person{
constructor(name){
this.name = name;
}
}
从上面的代码中你会发现,即使没有装饰器语法,我们自己在JavaScript中执行testable函数也可以完成对类的扩展,它们的区别在于手动执行包装的语句是命令式风格的,而装饰器语法是声明式风格的,后者通常被认为更适合在面向对象编程中使用,因为它可以保持业务逻辑层代码的简洁,而把无关紧要的细节移交给专门的模块去处理。Angular中提供的装饰器通常都可以接收参数,我们只需要借助高阶函数来实现一个“装饰器工厂”,返回一个装饰器生成函数就可以了:
// Angular中的组件定义
@Component({
selector: ‘hero-detail’,
templateUrl: ‘hero-detail.html’,
styleUrls: [‘style.css’]
})
Class MyComponent{
//......
}
//@Component装饰器的定义大致符合如下的形式
function Component(params){
return function(target){
// target可以访问到params中的内容
target.prototype._meta = params;
}
}
这样组件在被实例化时,就可以获得从装饰器工厂传入的配置信息,这些配置信息通常也被称为类的元信息。其他类型装饰器的基本工作原理也是一样的,只是函数签名中的参数不同,例如方法装饰器被调用时会传入3个参数:
第1个参数装饰静态方法时为构造函数,装饰类方法时为类的原型对象
第2个参数是成员名
第3个参数是成员属性描述符
你可能一下子就发现了,它和JavaScript中的Object.defineProperty的函数签名是一样的,这也意味着方法装饰器和它一样属于抽象度较高但通用性更强的方法。在方法装饰器的函数体中,我们可以从构造函数或原型对象上获取到需要被装饰的方法,接着用代理模式生成一个带有附加功能的新方法,并在恰当的时机执行原方法,最后通过直接赋值或是利用属性描述符中的getter返回包装后的新方法,从而完成对原方法功能的扩展,你可以在Vue2源码中数据劫持的部分学习到类似的应用。下面我们来实现一个方法装饰器,希望在被装饰的方法执行前后在控制台打印出一些调试信息,代码实现大致如下:
function log(target, key, descriptor){
const originMethod = target[key];
const decoratedMethod = ()=>{
console.log(‘方法执行前’);
const result = originMethod();
console.log(‘方法执行后’);
return result;
}
//返回新方法
target[key] = decoratedMethod;
}
你只需要在被装饰的方法上一行写上@log来标记就可以了,当然也可以通过工厂方法将日志的内容以参数的形式传入。其他类型的装饰器本文中不再赘述,它们的工作方式是相似的,下一节中我们来看看Inversify.js是如何使用装饰器语法来实现依赖注入的。
用inversify.js实现依赖注入
Inversify.js提供了更加完备的依赖注入实现,它是使用Typescript编写的。
基本使用
官方网站已经提供了基本的示例代码和使用方式,首先是接口定义:
// file interfaces.ts
export interface Warrior {
fight(): string;
sneak(): string;
}
export interface Weapon {
hit(): string;
}
export interface ThrowableWeapon {
throw(): string;
}
上面的代码中定义并导出了战士、武器和可投掷武器这三个接口,还记得吗?依赖注入是“SOLID”设计原则中依赖倒置原则的一种实践,上层模块和底层模块应该依赖于共同的抽象,当不同的类使用implements关键字来实现接口或者将某个标识符的类型声明为接口时,就需要满足接口声明的结构限制,于是接口就成为了它们“共同的抽象”,而且Typescript中的接口定义只用于类型约束和校验,上线前编译为JavaScript后就消失了。接下来是类型定义:
// file types.ts
const TYPES = {
Warrior: Symbol.for("Warrior"),
Weapon: Symbol.for("Weapon"),
ThrowableWeapon: Symbol.for("ThrowableWeapon")
};
export { TYPES };
和接口声明不同的是,这里的类型定义是一个对象字面量,它编译后并不会消失,Inversify.js在运行时需要使用它来作为模块的标识符,当然也支持使用字符串字面量,就像前文中我们自己实现IOC容器时所做的那样。接下来就是类定义时的声明环节:
import { injectable, inject } from "inversify";
import "reflect-metadata";
import { Weapon, ThrowableWeapon, Warrior } from "./interfaces";
import { TYPES } from "./types";
@injectable()
class Katana implements Weapon {
public hit() {
return "cut!";
}
}
@injectable()
class Shuriken implements ThrowableWeapon {
public throw() {
return "hit!";
}
}
@injectable()
class Ninja implements Warrior {
private _katana: Weapon;
private _shuriken: ThrowableWeapon;
public constructor(
@inject(TYPES.Weapon) katana: Weapon,
@inject(TYPES.ThrowableWeapon) shuriken: ThrowableWeapon
) {
this._katana = katana;
this._shuriken = shuriken;
}
public fight() { return this._katana.hit(); }
public sneak() { return this._shuriken.throw(); }
}
export { Ninja, Katana, Shuriken };
可以看到最核心的两个API是从inversify中引入的injectable和inject这两个装饰器,这也是在大多数依赖注入框架中使用的术语,injectable是可注入的意思,也就是告知依赖注入框架这个类需要被注册到容器中,inject是注入的意思,它是一个装饰器工厂,接受的参数就是前文在types中定义的类型名,如果你觉得这里难以理解,可以将它直接当做字符串来对待,其作用也就是告知框架在为这个变量注入依赖时需要按照哪个key去查找对应的模块,如果将这种语法和AngularJS中的依赖注入进行比较就会发现,它已经不需要开发者手动来维护依赖数组了。最后需要处理的,就是容器配置的部分:
// file inversify.config.ts
import { Container } from "inversify";
import { TYPES } from "./types";
import { Warrior, Weapon, ThrowableWeapon } from "./interfaces";
import { Ninja, Katana, Shuriken } from "./entities";
const myContainer = new Container();
myContainer.bind<Warrior>(TYPES.Warrior).to(Ninja);
myContainer.bind<Weapon>(TYPES.Weapon).to(Katana);
myContainer.bind<ThrowableWeapon>(TYPES.ThrowableWeapon).to(Shuriken);
export { myContainer };
不要受到Typescript复杂性的干扰,这里和前文中自己实现的IOC容器类的使用方式是一样的,只不过我们使用的API是ioc.bind(key, value),而这里的实现是ioc.bind(key).to(value),最后就可以来使用这个IOC容器实例了:
import { myContainer } from "./inversify.config";
import { TYPES } from "./types";
import { Warrior } from "./interfaces";
const ninja = myContainer.get<Warrior>(TYPES.Warrior);
expect(ninja.fight()).eql("cut!"); // true
expect(ninja.sneak()).eql("hit!"); // true
inversify.js提供了get方法来从容器中获取指定的类,这样就可以在代码中使用Container实例来管理项目中的类了,示例代码可以在本章的代码仓库中找到。
源码浅析
本节中我们深入源码层面来进行一些探索,很多读者一提到源码就会望而却步,但Inversify.js代码层面的实现可能比你想象的要简单很多,但想要弄清楚背后的思路和框架的结构,还是需要花费不少时间和精力的。首先是injectable装饰器的定义:
import * as ERRORS_MSGS from "../constants/error_msgs";
import * as METADATA_KEY from "../constants/metadata_keys";
function injectable() {
return function (target) {
if (Reflect.hasOwnMetadata(METADATA_KEY.PARAM_TYPES, target)) {
throw new Error(ERRORS_MSGS.DUPLICATED_INJECTABLE_DECORATOR);
}
var types = Reflect.getMetadata(METADATA_KEY.DESIGN_PARAM_TYPES, target) || [];
Reflect.defineMetadata(METADATA_KEY.PARAM_TYPES, types, target);
return target;
};
}
export { injectable };
Reflect对象是ES6标准中定义的全局对象,用于为原本挂载在Object.prototype对象上的API提供函数化的实现,Reflect.defineMetadata方法并不是标准的API,而是由引入的reflect-metadata库提供的扩展能力,metadata也被称为“元信息”,通常是指需要隐藏在程序内部的与业务逻辑无关的附加信息。如果我们自己来实现,很大概率会将一个名为_metadata的属性直接挂载在对象上,但是在reflect-metadata的帮助下,元信息的键值对与实体对象或对象属性之间以映射的形式存在,从而避免了对目标对象的污染,其用法如下:
// 为类添加元信息
Reflect.defineMetadata(metadataKey, metadataValue, target);
// 为类的属性添加元信息
Reflect.defineMetadata(metadataKey, metadataValue, target, propertyKey);
injectable源码中引入的METADATA_KEY对象实际上只是一些字符串而已。当你把上面代码中的常量标识符都替换为对应的字符串后就非常容易理解了:
function injectable() {
return function (target) {
if (Reflect.hasOwnMetadata(‘inversify:paramtypes’, target)) {
throw new Error(/*...*/);
}
var types = Reflect.getMetadata(‘design:paramtypes’, target) || [];
Reflect.defineMetadata(‘inversify:paramtypes’, types, target);
return target;
};
}
可以看到injectable装饰器所做的事情就是把与target对应的key为“design:paramtypes”的元信息赋值给了key为“inversify:paramtypes”的元信息。再来看看inject装饰器工厂的源码:
function inject(serviceIdentifier) {
return function (target, targetKey, index) {
if (serviceIdentifier === undefined) {
throw new Error(UNDEFINED_INJECT_ANNOTATION(target.name));
}
var metadata = new Metadata(METADATA_KEY.INJECT_TAG, serviceIdentifier);
if (typeof index === "number") {
tagParameter(target, targetKey, index, metadata);
}
else {
tagProperty(target, targetKey, metadata);
}
};
}
export { inject };
inject是一个装饰器工厂,这里的逻辑就是根据传入的标识符(也就是前文中定义的types),实例化一个元信息对象,然后根据形参的类型来调用不同的处理函数,当装饰器作为参数装饰器时,第三个参数index是该参数在函数形参中的顺序索引,是数字类型的,否则将认为该装饰器是作为属性装饰器使用的,tagParameter和tagProperty底层调用的是同一个函数,其核心逻辑是在进行了大量的容错检查后,将新的元信息添加到正确的数组中保存起来。事实上无论是injectable还是inject,它们作为装饰器所承担的任务都是对于元信息的保存,IOC的实例管理能力都是依赖于容器类Container来实现的。
Inversify.js中的Container类将实例化的过程分解为多个自定义的阶段,并增加了多容器管理、多值注入、自定义中间件等诸多扩展机制,源代码本身阅读起来并不困难,但理论化相对较强且英文的术语较多,对于初中级开发者的实用价值非常有限,所以笔者不打算在本文中详细展开分析Container类的实现,社区也有很多非常详细的源码结构分析的文章,足以帮助感兴趣的同学继续深入了解。
停下来
如果你第一次接触依赖注入相关的知识,可能也会和笔者当初一样,觉得这样的理论和写法非常“高级”,迫不及待地想要深入了解,事实上即使花费很多时间去浏览源码,我在实际工作中也几乎从来没有使用过它,但“解耦”的意识却留在了我的意识里。作为软件工程师,我们需要去了解技术背后的原理和思想,以便扩展自己的思维,但对技术的敬畏之心不应该演变成对高级技术的盲目崇拜。“依赖注入”不过是设计模式的一种,模式总会有它适合或不适合的使用场景,常用的设计模式还有很多,经典的设计思想也有很多,只有灵活运用才能让自己在代码结构组织的工作上游刃有余,请不要让执念限制了自己思维的广度。