使用typescript实现依赖注入框架

2,043 阅读7分钟

首先思考一个问题:我们为什么需要依赖注入(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字段的注入实现非常简单,实现以下几步就行了:

  1. 拿到对象需要注入的字段及其类型
  2. 根据类型判断并创建需要注入的对象
  3. 递归注入上一步生成的对象,并注入上下文
  4. 将生成的对象传给成员变量
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.RequestHapi.ResponseToolkit),是为了支持更加特殊的需求。

对于直接传入路由的函数,我对其的定位是“不需要复杂输入的简单逻辑”,所以只会把路径参数的指根据顺序传进去。

注入数据时需要考虑参数类型,我这里定了几个规则:

  • 如果类型是 stringnumberboolean,那么需要将数据转为对应的基础类型
  • 如果类型是一些特定类型,比如Hapi.Request,那么由对应框架的bind来判断注入
  • 如果类型是 Object(可能是object、interface等),那么将数据原样返回
  • 如果类型是 Function(class),分为以下的情况
    • 先new对应的类,如果注入的数据不是基础类型,并且对应的class的构建函数没有参数,那么将注入数据Object.assign给新建对象
    • 如果对应的class的构建函数有参数,或注入的数据是基础类型,那么将注入数据传入class的构建函数

返回类型和异常处理都是目前是由Hapi.js自己处理的,还没研究过express这些库的处理方式,不过应当遵循下面的规则:

  • 返回类型应当支持所有能JSON序列化的值和Promise
  • 抛出异常应当可以直接throw,并有一个统一处理方法

项目结构

因为对于依赖注入的API来说controllersservicesrepositories都是一样的,所以项目结构其实可以由自己的项目情况决定,不过建议分为以下几个层面:

  • 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解决上面的局限问题