前言
起因来源于最近在开发一个小网站的时候,突发奇想,想要使用node来自己开发后台接口,毕竟每一个前端都有一个全栈的梦想😂。我所选定的技术栈是koa2+ts+typeorm。由于受到spring的一些影响(并不熟悉),我想使用注解的方式来控制路由,通过依赖注入的方式控制service.首先声明的是:笔者并不是一个后台开发人员,该操作并没有考虑任何后台的优化的因素,只是纯粹的想要学习一下控制反转这种思想和ts装饰器封装方法,所以该文章适合于对控制反转和aop切面编程感兴趣的同学。
概述
本文包括两个方面,第一是控制反转与依赖注入,第二是装饰器的使用。笔者也是在多处学习并加以实践得出的本文。这里挂上所借鉴的链接。
- 初识JavaScript依赖注入 · Issue #26 · Aaaaaaaty/blog (github.com)
- 有趣的装饰器:使用 Reflect Metadata 实践依赖注入 - 掘金 (juejin.cn)
- 如何基于 TypeScript 实现控制反转 - 知乎 (zhihu.com)
- 都2020年了,你还不会JavaScript 装饰器? - 掘金 (juejin.cn)
- 有趣的装饰器:使用 Reflect Metadata 实践依赖注入 - 掘金 (juejin.cn)
这是完成后的开发模式。
可以看到我们通过@InjectTable
装饰器,获取了类IndexService
,并在类Index
中使用@Inject
将它注入其中。这就是依赖注入。而@Router('/user)
和@Get('/test')
表示根路由为usr,向子级路由test发起get请求将调用异步方法index
,其中index还有两个参数装饰器,表示获取get请求参数Id并将其转化成number类型。
接下来看具体介绍。
依赖注入与控制反转
介绍
具体的介绍可以查看我上面放的参考链接,简而言之:控制反转是一种设计原则,依赖注入则是这一思想的一种「实现模式」。控制反转的意思就是把类自己的控制权的一部分交由第三方统一控制。举例表明:
class A {
test() {
console.log("这里是test方法");
}
}
class B {
private a:A
constructor() {
this.a = new A
}
log() {
this.a.test()
console.log("这里是log方法");
}
}
const b = new B()
b.log()
这里的clas B中的log方法依赖于class A中的方法test,我们需要在使用之前实列化class A,正常情况下是不会出问题的,对class B的测试也是可以通过的,但是如果A实列化的时候出了错,B的自动化测试就会不通过,需要人为排查。比如这样:
class A {
constructor() {
throw "错误"
}
test() {
console.log("这里是test方法");
}
}
A出现问题,导致使用它的B也会报错,测试不通过。所以我们考虑,在B里面使用A的方式,从原本的在B中实列化改为通过构造函数传入。
class B {
private a:A
constructor(a:A) {
this.a = a
}
log() {
this.a.test()
console.log("这里是log方法");
}
}
const a = new A()
const b = new B(a)
b.log()
这里我们通过在外面实列化A,然后通过构造函数给B注入,这样A所产生的错误就能在外面被发现,而不是测试B的时候让B背黑锅。当然,这里是最简单的方法,因为这个看起来实在是不高级,一切都是我们手动操作,一个类所依赖的东西比较多的时候,我们不可能每次都这样手动注入,所以我们就需要一个容器(IOC),来统一管理这些类。
试想一下,我们需要在代码运行的时候,把所有像A这样的类打上标记,通过标记收集在一个容器中,然后检测哪里需要被注入,我们同样给需要注入的地方打上标记,然后检测这个所有有标记的地方,从容器中实列化,然后注入到对应位置。
依赖注入实现
实现一个IOC容器来同意装载需要注入的依赖,这里使用Map来存储,有两个Map对象,其中一个保存实列化后的类,多次注入不用再次实列化,简而言之就是,这是一个单例。
export class Injector {
private readonly providerMap: Map<any, any> = new Map() // 存储class对象
private readonly instanceMap: Map<any, any> = new Map() // 存储new后的class实列 每一个类型对应一个单例
public setProvider(key: any, value: any): void {
if (!this.providerMap.has(key)) this.providerMap.set(key, value)
}
public getProvider(key: any): any {
return this.providerMap.get(key)
}
public setInstance(key: any, value: any): void {
if (!this.instanceMap.has(key)) this.instanceMap.set(key, value)
}
public getInstance(key: any): any {
if (this.instanceMap.has(key)) return this.instanceMap.get(key)
return null
}
}
export const rootInjector = new Injector();
有了容器,接下来就是收集依赖和注入依赖
// import {rootInjector} from "./Injector";
import "reflect-metadata"
import {Injector, rootInjector} from "./Injector";
// 注解属性存储
export function InjectTable(_constructor: any): any {
rootInjector.setProvider(_constructor, _constructor)
return _constructor
}
export function Inject(_constructor: any, propertyName: string): any {
// 元数据反射 获取当前装饰的元素的类型
const propertyType: any = Reflect.getMetadata('design:type', _constructor, propertyName)
const injector: Injector = rootInjector
let providerInsntance = injector.getInstance(propertyType)
// 维护一个单列
if (!providerInsntance) {
const providerClass = injector.getProvider(propertyType)
providerInsntance = new providerClass();
injector.setInstance(propertyType, providerInsntance)
}
_constructor[propertyName] = providerInsntance
return (_constructor as any)[propertyName];
}
这里有值得注意的一点是,InjectTable和Inject其实都是ts的装饰器,下面装饰器的路由控制那里会讲解基本的装饰器用法,这里就不再赘述。
这里的逻辑是InjectTable装饰器在执行时,会自动把这个函数存入map,key和value都是装饰的类,确保了key的唯一性。
可以看到这里使用Reflect.getMetadata
,原本是没有这个api的所以需要下载一个垫片提供支持,也就是上面导入的reflect-metadata
.
npm install reflect-metadata -S
这个的api的作用是反射,获得需要被注入的那个类的类型。例如下面的代码:
@Inject public indexService: IndexService
也就是获取类型IndexService
。剩下的都是很简单的逻辑了,大家自行阅读即可。这里我们再来回顾一下依赖注入的使用方式。
声明需要被注入的类
在需要的地方注入,并使用
装饰器路由控制
ts装饰器用法介绍
- 装饰器只能给类使用,不能装饰函数
- 在ts中使用装饰器需要在tsconfig中开启
"experimentalDecorators": true
- ts装饰器可以装饰类,类方法以及方法参数。
两种使用方式:
第一种是不传参的装饰器,装饰器参数只有一个,那就是装饰的对象
function test(_constructor:any) {
console.log("test_",_constructor);
return class con extends _constructor {
log() {
console.log("这是装饰器的log");
}
}
}
@test
class Hello {
log() {
console.log("log");
}
}
const hello = new Hello()
hello.log()
返回值可以没有,也可以是被重新装饰后的类,比如这里,我修改了原函数的log方法,执行结果为:
有一点需要注意:如果我们把class Hello的实列化删除,会发生什么?
可以看到装饰器依旧执行了,由此可以得出,装饰器的执行是在类定义的时候,而不是类实列化的时候,所以上文我们才可以通过导入类直接收集依赖。
第二种是接受参数的装饰器
使用方式是增加一个闭包来接受原本的类。和vue的计算属性使用方法一样。
路由控制
首先,我们写下需要的接口。
export type paramType = "body" | "header" | "cookie" | "query"
export type httpMethod = "get" | "post" | "put" | "delete"
export type parseType = "number" | "string" | "boolean"
export interface routerInterface {
baseName: string,
constructor: any
}
export interface controllerInterface {
target: any,
type: httpMethod,
path: string,
method: string,
controller: Function
}
export interface paramInterface {
target: object,
key?: string,
position: paramType,
method: string,
index: number
}
export interface parseInterface{
target: object,
type: parseType,
method: string,
index: number
}
接下来,我们写上需要使用的装饰器。
// 建立扫描存储装饰器信息list
import {paramType,httpMethod,parseType,routerInterface, controllerInterface,paramInterface,parseInterface} from "./decoratorInterface"
export const routerList: routerInterface[] = []
export const controllerList: controllerInterface[] = []
export const parseList: parseInterface[] = []
export const paramList: paramInterface[] = []
// 路由类信息存储
export function Router(baseName: string = ''): Function {
return (constructor: any) => {
routerList.push({
constructor,
baseName
})
}
}
// 路由类里面的请求方法 get/post + 子级路由
function Method(type: httpMethod): Function {
return (path: string) => (target: any, name: string, descriptor: PropertyDescriptor) => {
controllerList.push({
target,
type,
path,
method: name,
controller: descriptor.value
})
}
}
// 格式化参数装饰器
export function Parse(type: parseType):Function {
return (target: any, name: string, index: number) => {
parseList.push({
target,
type,
method: name,
index
})
}
}
// 注入请求参数
function Param(position:paramType):Function {
return (key?: string) => (target: any, name: string, index: number) => {
paramList.push({
target,
key,
position,
method: name,
index
})
}
}
export const Body = Param("body")
export const Header = Param("header")
export const Cookie = Param("cookie")
export const Query = Param("query")
export const Get = Method("get")
export const Post = Method("post")
这里除了路由控制以外,还涉及到了对参数的注入,也就是这里用道理装饰器的三种使用地点,类,方法,参数.我们使用的是数组存储,遍历数组的方式,其实使用上面依赖注入用到的map会更好,大家可以自行尝试。这里是因为运行起来了后,只有初次运行才会有遍历,加上项目不大,所以没有更换(主要是懒得弄)。
注册路由并注入需要的参数。
import Koa, {Context} from "koa";
import {routerList, controllerList, paramList, parseList} from "./decorators";
import {httpMethod,paramInterface, parseInterface} from "./decoratorInterface"
import Router from "koa-router";
// 需要导入对应的class,才能自动执行装饰器,获取依赖
import "../controllers/index"
import "../controllers/ContactController"
export const routers: any[] = []
// 遍历所有添加了路由装饰器的class,并创建对应Router对象
routerList.forEach(item => {
const {baseName, constructor} = item
// 创建基本路由
const router = new Router({
prefix: baseName
})
controllerList
.filter(i => i.target === constructor.prototype)
.forEach(
(controller: {
type: httpMethod,
path: string,
method: string,
controller: Function
}) => {
router[controller.type](controller.path,router.allowedMethods(),async (ctx: any, next: Koa.Next) => {
let args: any[] = []
paramList
.filter((param: paramInterface) =>
param.target === constructor.prototype &&
param.method === controller.method
)
.map((param: paramInterface) => {
const {index, key} = param
switch (param.position) {
case "body":
args[index] = ctx.request.body[key] ?? ctx.request.body
break
case "header":
args[index] = ctx.headers[key] ?? ctx.headers
break
case "cookie":
args[index] = ctx.cookies.get(key) ?? ctx.cookies
break
case "query":
args[index] = ctx.query[key] ?? ctx.query
break
default:
throw "请求的参数不在类型定义中"
}
})
// 当前函数对应的参数格式化
parseList
.filter(
(parse: parseInterface) =>
parse.target === constructor.prototype &&
parse.method === controller.method
)
.map((parse: parseInterface) => {
const {index} = parse
switch (parse.type) {
case "number":
args[index] = Number(args[index])
break
case "string":
args[index] = String(args[index])
break
case "boolean":
args[index] = String(args[index]) === "true"
break
default:
throw "数据格式化类型不匹配"
}
})
const myCon = new constructor()
// ctx.body = await controller.controller(...args)
ctx.body = await myCon[controller.method](...args,ctx,next)??[]
})
}
)
routers.push(router.routes())
})
这里就是通过一系列循环遍历,匹配响应的参数,设置路由。然后把得到的最终路由函数导出,在入口文件集体注册到koa实列上面。
import {routers} from "./decoration/InjectRouter";
...
app.use(compose(routers)) // 将数组路由改造为promise并挂载
至此,所有的环境配置完成,可以达到最上面展示的那样操作。需要注意的是,这里有一个缺陷,因为装饰器是在类定义的时候进行,但是类如果未引用,是不会被定义的,所以需要在循环遍历之前导入我们所使用的类。这样以后,装饰器才会被执行,我们定义的数组才会存储到需要的数据。
算是有点小缺陷吧。
后记
使用开发过程中发现了很多问题,不过不是关于本文的,而是我直接使用tsc编译文件,而没有使用打包器所导致的,如果有朋友想用ts加koa这种简单的工具库开发的话,我建议是不要这样,使用大一点的框架,没这么多恶心事。这里推荐eggJs和nestJs.
- Egg.js 是什么? - 为企业级框架和应用而生 (eggjs.org)
- Nest.js 中文文档 (nestjs.cn)
其中nestJs天然支持ts,并且使用方式就是本文所讲述的,装饰器控制路由+依赖注入的方式,这是nest开发的代码。
@Controller('/users')
export class UserController {
constructor(private userService: UserService) {}
@Get('/')
getUserById(@Query('id') id: string, @Headers('authorization') auth: string) {
return this.userService.getUserBtyId(id, auth);
}
}
怎么样,和我们的很像吧,这种方式和spring也蛮相似的,我们戏称为SpringNode
😂。我的建议是,可以学习一下这种开发思想和实现原理,真正要做项目的话,建议还是使用框架,毕竟大框架生态肯定更好。