首先思考一个问题:我们为什么需要依赖注入(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解决上面的局限问题