首先思考一个问题:我们为什么需要依赖注入(Dependency injection下面简称DI)?
之前用java的spring、php的laravel和angular时发现它们的模式非常相似,框架会把请求处理、线程管理、错误处理等都封装好,你只需要实现对应的横向和纵向切面,然后让框架来管理和调用你的代码,这就是设计模式中有名的控制反转(简称IOC)。
而DI是IOC的一种比较通用的实现方式,举个例子我们的web服务中有controller(接口层)和service(业务逻辑层),我们需要在controller中调用service的代码,但是service一般会有上下文(context)(比如使用了当前的请求对象、数据库连接、全局参数等)。如果我们每次在调用service时都要手动给它这么多参数实在太麻烦了,而且代码会很耦合。此时DI就能解决这个问题了,我们只需要声明需要的对象,框架就能自动创建好带有上下文对象。那么下面我们来看看怎么用ts实现一个简单的依赖注入框架。
下文写的时候我还没有使用过nodejs写过复杂的后端服务,所以造了个简单的轮子来梳理项目代码,使用的hapijs社区也不太活跃,所以本文仅适合作为参考和学习使用。要使用nodejs开发大型应用的话建议使用nest.js或者eggjs。
核心API
先看看实现的API长什么样
import * as Knex from 'knex';
import { autowired, impl, context } from '../injection';
class MyController {
@autowired userRepository: IUserRepository;
getUsers() {
return this.userRepository.getAllExistUsers();
}
}
// 这里用抽象类来表示接口(下面会通称为“接口”)
abstract class IUserRepository {
abstract getAllExistUsers(): PromiseLike<IUser[]>;
}
@impl(IUserRepository)
class UserRepositoryImpl extends IUserRepository {
@context('knex') knex: Knex;
getAllExistUsers() {
return this.knex('users').select().where('deleted', false);
}
}
这里的API设计稍微参考了下spring,还有一些妥协设计(比如为什么要用abstract class
而不用interface
、为什么 @impl
需要传入对应接口),这些下面会解释。
API实现原理
这里虽然实现了3个decorator
,但是这些decorator
的作用其实和java里的annotation
一样 —— 定义metadata,所以实现上很简单,基本上都是一句话就能讲清楚里面的逻辑:
@autowired
(需要自动注入的变量):把当前的property key('userRepository'
)以及对应的type(IUserRepository
)存到当前类的metadata
中,方便后面注入的时候传入。@impl
(实现某个接口的类):将当前的接口和类保存到一个全局Map<接口, 实现>。@context
(需要注入当前应用上下文的变量):将当前key('knex'
)与需要注入的context key('knex'
)保存到当前类的metadata
下面是autowired的实现
export const metaKey = Symbol('autowiredKeys');
interface IAutowiredKey {
// 字段名
key: string;
// 对应类型,通过metadata返回的类型必定是Object与其子类
type: Function;
}
export default function autowired(target: any, propertyKey: string) {
const autowiredKeys = getAutowiredKeys(target);
// 得到当前装饰成员变量的类型
const type = Reflect.getMetadata('design:type', target, propertyKey);
autowiredKeys.push({ key: propertyKey, type });
// 将变量保存到当前类的metadata里
Reflect.defineMetadata(metaKey, autowiredKeys, target);
}
/**
* 拿到在当前类上定义的需要自动注入的key和type
*/
export function getAutowiredKeys(target: any): IAutowiredKey[] {
return Reflect.getMetadata(metaKey, target) || [];
}
Typescript metadata
typescript可以通过metadata
拿到3种类型信息
- 对象上的成员变量类型
- 函数的参数类型
- 函数的返回类型
但是又有非常大的限制,可以看一下这一节文章,简单来说就是拿不到 interface
的类型,而 abstract class
可以,所以使用中需要用 abstract class
来代替 interface
。
另外关于 @impl
为什么要传入对应接口,主要是因为如果不传入接口的话,在注入@autowired
变量时,我必须要遍历被@impl
装饰的类来判断其是否是该变量类型的本身或者子类。
这里可能会出现一个问题,如果@autowired
的变量类型是interface
啥的话,由于上面提到的限制我只能拿到 Object
这个类型,由于所有类都是其子类,所以就会注入错误的类型了。
注入
关于@autowired
字段的注入实现非常简单,实现以下几步就行了:
- 拿到对象需要注入的字段及其类型
- 根据类型判断并创建需要注入的对象
- 递归注入上一步生成的对象,并注入上下文
- 将生成的对象传给成员变量
const implMap: Map<any, any> = new Map();
export default function impl<T, C extends T>(p1: T) {
return function (ctor: C) {
implMap.set(p1, ctor);
}
}
export function injectAutowired(target: any, context: { [key: string]: any }) {
const needAutowiredKeys = getAutowiredKeys(target);
needAutowiredKeys.forEach(({ key, type }: { key: string, type: any }) => {
const ctor = implMap.get(type);
let inst = null;
if (ctor && typeof ctor === 'function') {
inst = new ctor(context);
} else {
// type must be Object
inst = new type(context);
}
injectAutowired(inst, context);
injectContext(inst, context);
target[key] = inst;
});
}
路由设计
路由层参考laravel框架,因为我个人认为将路由放在一个地方同一管理比spring那种分散到Controller
上定义要方便索引(api -> controller
)。
提供的API如下
import { Route } from '../injection';
const route = new Route();
// 设置放置controllers的目录,默认是 ${work directory}/controllers
route.setControllersRoot('server/controllers');
// 指定Controller的method作为handler
route.post('/apples/{id}', 'SampleController@updateApple');
route.get('/users', 'SampleController@getUsers');
// 直接传入函数作为hanlder
route.match(['get', 'post'], '/healthz', () => 'ok');
// prefix
route.prefix('admin').group((r) => {
r.post('users/{id}/ban', 'AdminController@banUser');
})
export default route;
这里除了将 Controller
引入并绑定到对应的 path
上外,还要检测对应的方法是否存在,这样就能将错误放在程序启动时而不是运行时抛出了。
接口层的IO
目前设计的API如下
// controller内
class MyController {
getUsers(@param id: number, @query detail: boolean = false, @payload body: Object) {
return {
users: []
};
}
getUser(@query('name') userName: string, request: Hapi.Request, h: Hapi.ResponseToolkit) {
return {
users: []
};
}
}
// 直接传入路由的函数
route.get('welcome/{name}', (name: string) => {
return {
name,
message: `welcome ${name}`
}
});
这里有3个decorator
,分别代表 路径参数(@param
)、查询参数(@query
)、和请求体(@payload
),作用同样是设置metadata
。另外有一些框架特定类型的参数(Hapi.Request
和Hapi.ResponseToolkit
),是为了支持更加特殊的需求。
对于直接传入路由的函数,我对其的定位是“不需要复杂输入的简单逻辑”,所以只会把路径参数的指根据顺序传进去。
注入数据时需要考虑参数类型,我这里定了几个规则:
- 如果类型是
string
、number
、boolean
,那么需要将数据转为对应的基础类型 - 如果类型是一些特定类型,比如
Hapi.Request
,那么由对应框架的bind来判断注入 - 如果类型是
Object
(可能是object、interface等),那么将数据原样返回 - 如果类型是
Function
(class),分为以下的情况- 先new对应的类,如果注入的数据不是基础类型,并且对应的class的构建函数没有参数,那么将注入数据
Object.assign
给新建对象 - 如果对应的class的构建函数有参数,或注入的数据是基础类型,那么将注入数据传入class的构建函数
- 先new对应的类,如果注入的数据不是基础类型,并且对应的class的构建函数没有参数,那么将注入数据
返回类型和异常处理都是目前是由Hapi.js
自己处理的,还没研究过express
这些库的处理方式,不过应当遵循下面的规则:
- 返回类型应当支持所有能JSON序列化的值和
Promise
。 - 抛出异常应当可以直接
throw
,并有一个统一处理方法
项目结构
因为对于依赖注入的API来说controllers
、services
和repositories
都是一样的,所以项目结构其实可以由自己的项目情况决定,不过建议分为以下几个层面:
controllers
: 负责接口IO处理,表单验证,流程控制services
: 负责业务模块逻辑repositories
: DAO层,负责与数据库打交道models
: 数据模型routes.ts
: 定义路由app.ts
: 项目的启动、配置
绑定Hapi.js
目前在项目里用到的service端实现是hapi.js
,所以讲讲injection与hapi.js
的bind需要实现的功能:
- 根据路由配置生成hapi的路由配置
- 在handler里注入所有的接口依赖、上下文依赖以及方法的参数依赖
import { injectAutowired, injectContext, callHanlderWithInjection } from '../injection';
// 生成Hapi route handler的函数
function createControllerHandler<T extends IClassType>(Controller: T, methodName: string, context: { [key: string]: any }) {
return (request: Hapi.Request, h: Hapi.ResponseToolkit, err?: Error): Hapi.Lifecycle.ReturnValue => {
// 将请求对象绑定到当前上下文
const contextInLifecycle = Object.assign({ request }, context);
const c: any = new Controller(contextInLifecycle);
injectAutowired(c, contextInLifecycle);
injectContext(c, contextInLifecycle);
return c[methodName](request, h, err);
};
}
Todo
- 路由层的权限控制
- 更加通用的参数验证
- 更加通用的错误处理
- 更加通用的Request与Resposne结构
- DAO层使用ORM
- 实现Laravel里的Facades模式?
- 利用typescript的compiler解决上面的局限问题