Express的两种路由风格

在讨论Express框架的路由风格前,先让我们解决一下 async/await 的问题。

在Express框架下的路由,通常写法如下:

import * as express from 'express'

const router = express.Router()

router.get('/OK', (req, res) => {
    res.send('OK')
})

对于一般的任务,如使用服务的渲染页面,重定向或者读取文件,都不会有问题。因为这些任务都有一个特征:它们都是同步的(或是提供同步的调用接口)

但是在服务器上,很多任务是异步的,而要命的是某些异步方法执行的结果,是另外一些任务的前置条件,例如下面这个例子,先需要获取User对象,才能获取对应的user.token:


router.get('/OK', async (req, res) => {
    const user = await User.find({id: 1})
    const data = await AsyncRedis.get(user.token)
    res.resify(data)
})

如果这个时候 await User.find({id: 1}) 抛出异常,那么整个node进程会退出。也即:由async/await抛出的异常不会被errorHandler(如果有的话)所捕获。

当然,直接在路由中try/catch可以解决这个问题:


router.get('/OK', async (req, res, next) => {
    try {
        const user = await User.find({id: 1})
        const data = await AsyncRedis.get(user.token)
        res.resify(data)
    } catch (e) {
        next(e)
    }
})

在捕获异常后,交给下一个中间件处理。不过,真的有必要在每个路由中重复一遍这个吗…?

将路由初始化之前,试着引入下面的代码,你会发现,在路由里可以自由使用 async/await 了!这是因为下方的代码直接对Router对象进行了修改,相当于提前将try/catch植入到实际的路由事件中… 等等,这种操作是不是很熟悉,是不是有点AOP(面向切面编程)的感觉了?

// typescript
/**
 * Copy from https://github.com/davidbanham/express-async-errors
 *
 */
import { Router } from 'express'
// @ts-ignore
import Layer = require('express/lib/router/layer')

const last = (arr = []) => arr[arr.length - 1]
const noop = Function.prototype

function copyFnProps (oldFn: { [x: string]: any }, newFn: { (...args: any[]): any;[x: string]: any }) {
    Object.keys(oldFn).forEach((key) => {
        newFn[key] = oldFn[key]
    })
    return newFn
}

function wrap (fn: { [x: string]: any; apply?: any; length?: any }) {
    const newFn = function newFn (...args: any[]) {
        // @ts-ignore
        const ret = fn.apply(this, args)
        const next = (args.length === 5 ? args[2] : last(args as any)) || noop
        if (ret && ret.catch) ret.catch((err:Error) => next(err))
        return ret
    }
    Object.defineProperty(newFn, 'length', {
        value: fn.length,
        writable: false,
    })
    return copyFnProps(fn, newFn)
}

function patchRouterParam () {
    const originalParam = Router.prototype.constructor.param
    Router.prototype.constructor.param = function param (name: any,
        fn: { (...args: any[]): any;[x: string]: any; apply?: any; length?: any }
    ) {
        fn = wrap(fn)
        return originalParam.call(this, name, fn)
    }
}

Object.defineProperty(Layer.prototype, 'handle', {
    enumerable: true,
    get () {
        return this.__handle
    },
    set (fn) {
        fn = wrap(fn)
        this.__handle = fn
    },
})

patchRouterParam()

同样的,对于Express的路由来说,既有标准的中间件风格,也有使用装饰器的AOP风格

中间件

一个Http请求经过Express服务器时,就像水流经过处理厂,一开始可能有沉淀装置,然后可能有化学净水装置,再然后是物理净水装置,最后经过二次净化后流出。这些装置就像是Express中的各种组件一样,目标是通过这些组件的组装,对Http请求作出处理和响应。这些组件被称作中间件, 一个典型例子如下:

app.use(function (req, res, next) {
    res.header('Access-Control-Allow-Origin', '*')
    res.header('Access-Control-Allow-Headers', 'Content-Type, X-Refresh-Token, Authorization')
    res.header('Access-Control-Allow-Methods', 'GET,POST,DELETE')
    next()
})

...

const requestInterceptor = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
    ...
    next()
}

app.use(requestInterceptor)
app.use('/', router)
app.use('/test', TestHandler)
app.use(errorHandler)

...

app.use标志着括号里的函数被作为一个中间件,来处理流经的Http请求;而第一个参数是string的则表示,对应的路径的请求,交给后面的中间件处理。每个中间件接受三个参数,分别是:req: Request,res: Response,next: NextFunction

中间件通过调用 next() 参数来传递 Http请求,如果不调用 next(),Http请求将由该中间件响应(Http Response)

如果现在需要一个身份认证的前置逻辑,对于中间件,这样写:

async function Auth(req, res, next) {
    ... // do something
    next()
}

app.use('/', Auth, router)
// 或者
router.get('/OK', Auth, (req, res) => {
    ... // do something
})

注意这里的next(),这是一个中间件不可缺少的调用。使用中间件可以很轻易地实现诸如拦截器,身份验证的逻辑。

装饰器

如果说使用中间件是标准风格的话,那么装饰器风格无疑就很炫酷了。


class APIHandler {
    @GET('/OK')
    ok (req: express.Request, res: express.Response) {
        res.resify.success()
    }
}

它带来了声明式编程的体验,你总是能一眼通过装饰器洞察到它们的实际目的。

使用装饰器的路由原理也不同于使用中间件,装饰器更像是截断了原来的管道(而中间件是在原来的管道前/后添加新的管道),再用新工艺重新封装起来。


import * as express from 'express'

export const router = express.Router()

export function GET (path: string): MethodDecorator {
    return function ( target: any, propertyKey: string | symbol, fn: PropertyDescriptor) {
        router.get(path, fn.value)
        return fn
    }
}

在装饰器的定义函数中,我们没有发现熟悉的next(),甚至整个函数非常抽象,需要一点学习成本。因为@GET('/OK')在程序启动时就运行了,所以在执行router.get(path, descriptor.value)时,路由和Handler被绑定。当Express调用对应路由的handler时,将由这个被定义的ok方法处理。而在代码层面上,我们并不实际调用APIHandler().ok(req, res)。

实际上这个例子并不好,我们只是简单地进行了路由绑定而已。下面一个例子,我们为刚才的路由添加一个身份验证的装饰器:


export function Auth (): MethodDecorator {
    return function (target: any, key: string, fn: PropertyDescriptor) {
        const origin = fn.value
        fn.value = async function (req: Request, res: Response, next: NextFunction) {
            ... // do something
            const user = await UserSerivce.find({id: req.uid})
            res.user = user
            const result = await origin.apply(this, [req, res, next])
            return result
        }
        return fn
    }
}

class APIHandler {
    @GET('/OK')
    @Auth()
    ok (req: express.Request, res: express.Response) {
        res.resify.success()
    }
}

看,在实际执行handler之前,我们偷偷做了点工作(把user对象挂载到 res: Response 对象上)

这不就是中间件的作用吗?只不过这次,我不需要手动调用next()了,定义Auth装饰器时,写法比定义一个中间件更复杂。好处就是带来了声明式这么直观、高效地使用方式。