likes
comments
collection
share

利用typescript把express服务改写成你用不起的样子

作者站长头像
站长
· 阅读数 14

最近学习了typescript的很多知识,于是就想来用一用,于是就用typscript改写了下原来利用express写的后台服务,改完之后真香。

这里主要用到了typescript类、装饰器和元数据进行改写,先来看看利用改写之后的express服务长什么样子。

改写后的样子

express主要有三个比较重要的文件:

  1. 入口文件index.ts:负责起一个http服务,用来监听用户发起的http请求;
  2. 路由文件router.ts:对不同的请求进行处理,但是,具体的处理逻辑放在controller文件中;
  3. 处理逻辑的文件controller.ts:具体处理逻辑数据;

入口文件index.ts

import express, { Request, Response, NextFunction } from 'express'
import cookieSession from 'cookie-session'
// 必须引入,让装饰器执行
import './controller/LoginController'
import { router } from './router'

const app = express()

// 处理请求体的application/json数据
app.use(express.json())
// 处理form表单数据
app.use(express.urlencoded({ extended: false }))

// 处理cookie-session
app.use(
  cookieSession({
    name: 'session',
    // 用来生成sessionid的秘钥
    keys: ['pk2#42'],
    maxAge: 48 * 60 * 60 * 1000
  })
)

app.use(router)

app.listen('7001', () => {
  console.log('listen at 7001')
})

路由文件router.ts

import { Router } from 'express'

export const router = Router()

这里的路由文件并没有处理任何逻辑,实例化之后直接导出,这与之前的样子区别很大。

原来是长这样的,它在路由文件中耦合了处理逻辑部分。

router.post(
  '/login',
  (req: RequestWithBody, res: Response, next: NextFunction) => {
    const { password } = req.body
    const isLogin = req.session?.isLogin
    if (isLogin) {
      res.end('already login')
    } else {
      if (password === '123' && req.session) {
        req.session.isLogin = true
        req.session.userId = '1234567890'
        res.json(getResponseResult(true))
      } else {
        res.end('login error!')
      }
    }
  }
)

处理逻辑的文件LoginController.ts

import 'reflect-metadata'
import { Request, Response } from 'express'
import { controller, get, post } from '../decorator'
import { getResponseResult } from '../utils/resultModel'

@controller('/')
export class LoginController {
  constructor() {}

  @post('/login')
  login(req: Request, res: Response): void {
    const { password } = req.body
    const isLogin = !!req.session?.isLogin
    if (isLogin) {
      res.end('already login')
    } else {
      if (password === '123' && req.session) {
        req.session.isLogin = true
        res.json(getResponseResult(true))
      } else {
        res.end('login error!')
      }
    }
  }

  @get('/logout')
  logout(req: Request, res: Response): void {
    if (req.session) {
      req.session.isLogin = undefined
    }
    res.json(getResponseResult(true))
  }
}

现在提供了一个LoginController类来处理登录相关的所有逻辑。包括一个登录接口/login和一个登出接口/logout

但是,代码里面并没有和router绑定的逻辑,传统的express的代码,通常是通过router.getrouter.post来处理路由和对应的逻辑,如下代码:

import { Router, Request, Response, NextFunction } from 'express'
router.post('/login', (req: Request, res: Response, next: NextFunction) => {
    ...
})
router.get('/logout', checkLogin, (req, res, next) => {
  ...
})

那它是到底怎么实现路由逻辑的呢?

答案是通过装饰器和元数据来实现的。下面,我们就来一步一步的来改写成你用不起的样子吧。

方法的装饰器:绑定请求方法和请求路径

上面的代码精简了不必要的逻辑之后,代码如下:

@controller('/')
export class LoginController {
  @post('/login')
  login(req: Request, res: Response): void {}

  @get('/logout')
  logout(req: Request, res: Response): void {}
}

它包含三个装饰器,分别是get,post,controller,我们首先看看get、post的逻辑。

enum Methods {
  get = 'get',
  post = 'post'
}
function getRequestDecorator(type: Methods) {
  return function (path: string) {
    // target就是类的原型对象
    return function (target: LoginController, key: string) {
      Reflect.defineMetadata('path', path, target, key)
      Reflect.defineMetadata('method', type, target, key)
    }
  }
}
export const get = getRequestDecorator(Methods.get)
export const post = getRequestDecorator(Methods.post)

这段代码很简单,就是定义了两个getpost两个装饰器,在装饰器里面通过元数据Reflect.defineMetadataLoginController的方法loginlogout上添加了pathmethod两个元数据,例如,login方法上的元数据为:

{
   path: '/login',
   method: 'post'
}

类的装饰器:获取绑定的元数据

装饰器controller用来修饰类LoginController,这里需要知道,方法的装饰器是先于类的装饰器之前执行,所以,能在类的装饰器上获取到在方法的装饰器上定义的元数据。

export function controller(root: string) {
  // target就是类的构造函数,通过target.prototype获取类的原型
  return function (target: new (...args: any[]) => any) {
    for (let key in target.prototype) {
      // 获取路由
      const path: string = Reflect.getMetadata('path', target.prototype, key)
      // 获取请求方法
      const method: Methods=Reflect.getMetadata('method',target.prototype,key)
      // 获取对应的处理函数
      const handle = target.prototype[key]
      // 获取中间件
      const middleware: RequestHandler = Reflect.getMetadata(
        'middleware',target.prototype,key)
      // 拼接路由
      if (path && method) {
        let fullpath = ''
        if (root === '/') {
          if (path === '/') {
            fullpath = '/'
          } else {
            fullpath = path
          }
        } else {
          fullpath = `${root}${path}`
        }
        // 绑定router
        if (middleware) {
          router[method](fullpath, middleware, handle)
        } else {
          router[method](fullpath, handle)
        }
      }
    }
  }
}

首先,遍历类LoginController原型target.prototype上的方法,即loginlogout,从它们身上获取上面定义的元数据pathmethod

const path: string = Reflect.getMetadata('path', target.prototype, key)
const method: Methods = Reflect.getMetadata('method', target.prototype, key)

然后,获取路由对应的处理函数handler

const handle = target.prototype[key]

接着,获取元数据中间件middleware,中间件的元数据定义如下:

// 定义中间件
export function use(middleware: RequestHandler) {
  return function (target: any, key: string) {
    Reflect.defineMetadata('middleware', middleware, target, key)
  }
}
// 获取中间件
const middleware: RequestHandler = Reflect.getMetadata('middleware', target.prototype, key)

最后,把获取的pathmethodmiddleware绑定到路由上,这样就完成了路由的处理逻辑。

import { router } from '../router'
if (middleware) {
  router[method](fullpath, middleware, handle)
} else {
  router[method](fullpath, handle)
}

为什么要用装饰器重构呢?

在回答这个问题之前,看看经过这样改造后,新增两个接口怎么写?

import 'reflect-metadata'
...

// 中间件:验证用户是否登录
const checkLogin = (req: Request, res: Response, next: NextFunction): void => {
  const isLogin = !!req.session?.isLogin
  if (isLogin) {
    next()
  } else {
    res.json(getResponseResult(null, 'please login'))
  }
}

@controller('/api')
export class CrowllerController {
  // 注册路径及方法
  @get('/getData')
  // 注册中间件
  @use(checkLogin)
  getData(req: Request, res: Response): void {
    ...
  }

  @get('/showData')
  @use(checkLogin)
  showData(req: Response, res: Response): void {
    ...
  }
}

可以看到,当新增接口时,就可以新建一个文件,然后创建一个新的类CrowllerController,所有的逻辑都可以写在类里。

有没有发现,这有点像eggjsController类了。

这样写的最大好处是,整个逻辑非常清晰明了。

同时,通过装饰器可以把各个功能都单独提出来,和业务逻辑实现解耦,如下图所示:

利用typescript把express服务改写成你用不起的样子

把各个业务功能看出一条线,这条线在执行过程中会被日志,安全,鉴权等功能切一刀,这种开发模式就是 面向切面编程(Aspect Oriented Programming,简称AOP)。

在不使用装饰器的情况下,实现日志功能的时候,需要把日志功能嵌入到业务功能里面,这样就不符合软件开发原则了,如下代码都混在一块了。

class Foo {
    fn1() {
        // 打印日志
        log()
        console.log('业务功能1')
    }
    fn2() {
        // 打印日志
        log()
        console.log('业务功能2')
    }
}

所以,需要使用装饰器把日志功能单独抽离出来:

function log(target: any, key: string, descriptor: PropertyDescriptor) {
    const oldValue = descriptor.value // fn1 函数

    // 重新定义 fn1 函数
    descriptor.value = function () {
        console.log(`记录日志...`)
        return oldValue.apply(this, arguments)
    }
}

class Foo {
    @log // 不影响业务功能的代码,只是加了一个 log 的“切面”
    fn1() {
        console.log('业务功能1')
    }
}

const f = new Foo()
f.fn1()

这样就实现了业务功能和日志功能的分离解耦。

可以看到,AOPOOP 并不冲突,它们相辅相成

大名鼎鼎的nestjs就是采用这种编程方式。

总结

typescript 不仅仅提供了类型提示,它还扩展了很多 JavaScript 在语法层面没有实现的功能,这些功能在编写高质量的代码过程中是非常好用的。上面通过改写express服务就能很好的体现了typescript的好处。

在开发中,其实不用typescript也能很好的完成项目,但是,当你所引用的第三方库都是用typescript写的,你阅读的源码也是typescript,这个时候不是你愿不愿意用的问题,而且大势所逼。

所以,用起来吧。