对于express VS koa 中间件机制的理解

1,369 阅读7分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第5天,点击查看活动详情

express 中间件的使用

中间件的注册

1. 参数只有一个

中间件就是一个函数(其实函数就是一个中间件),最后通过next执行下一个中间件。

app.use((req, res, next) => { 
    console.log('处理 /api 路由') 
    next() 
})

所有的请求都会执行这个没有路由的中间件。

2. 参数有两个

第一个是路由,第二个是中间件,只有命中了路由才会执行这个中间件。

// 如果访问的是/api/user,也会执行这个中间件,因为命中了父路由 
app.use('/api', (req, res, next) => { 
    console.log('处理 /api 路由') 
    next() 
}) 

// 也可以用这种方式来注册,如果是get、或者是post请求,并且命中了路由,就去执行里面的中间件 
app.get('/api', (req, res, next) => { 
    console.log('get /api 路由') 
    next() 
}) 

app.post('/api', (req, res, next) => { 
    console.log('post /api 路由') 
    next() 
})

app.use app.get app.post都可以注册中间件

中间件的使用

// 模拟登录验证
function loginCheck(req, res, next) {
  setTimeout(() => {
    // 如果模拟登陆失败,执行以下逻辑  
    if () {
        console.log('模拟登陆失败')
        res.json({
          errno: -1,
          msg: '登录失败'
        })
    } else {
        // 如果模拟登陆成功,执行以下逻辑
        console.log('模拟登陆成功')
        next()
    }    
  })
}
app.get('/api/get-cookie', loginCheck, (req, res, next) => {
  console.log('get /api/get-cookie')
  res.json({
    errno: 0,
    data: req.cookie
  })
})

异常处理的中间件

// catch 404 and forward to error handler
app.use(function (req, res, next) {
  next(createError(404))
})

// error handler
app.use(function (err, req, res, next) {
  // set locals, only providing error in development
  res.locals.message = err.message
  res.locals.error = req.app.get('env') === 'dev' ? err : {}

  // render the error page
  res.status(err.status || 500)
  res.render('error')
})

使用时需要注意两点:

  • 参数是四个,第一个参数是err,否则会视为普通的中间件
  • 异常处理中间件需要在请求之后

express中间件机制

首先需要明确的一点是: Express 为线型模型,而 Koa 则为洋葱型模型

下面将使用 Express 实现一个简单的 demo 来进行中间件机制的讲解:

var express = require('express');

var app = express();
app.use(function (req, res, next) {
    console.log('第一个中间件start');
    setTimeout(() => {
        next();
    }, 1000)
    console.log('第一个中间件end');
});
app.use(function (req, res, next) {
    console.log('第二个中间件start');
    setTimeout(() => {
        next();
    }, 1000)
    console.log('第二个中间件end');
});
app.use('/foo', function (req, res, next) {
    console.log('接口逻辑start');
    next();
    console.log('接口逻辑end');
});
app.listen(4000);

此时的输出比较符合我们对 Express 线性的理解,其输出为:

第一个中间件start 
第一个中间件end 
第二个中间件start 
第二个中间件end 
接口逻辑start 
接口逻辑end

但是,如果我们取消掉中间件内部的异步处理直接调用 next():

app.use(function (req, res, next) { 
    console.log('第一个中间件start'); 
    next() 
    console.log('第一个中间件end'); 
});

输出结果为:

第一个中间件start 
第二个中间件start 
接口逻辑start 
接口逻辑end 
第二个中间件end 
第一个中间件end

这种结果不是和 Koa 的输出很相似吗?是的,但是它和剥洋葱模型还是不一样的,其实这种输出的结果是由于代码的同步运行导致的,并不是说 Express 不是线性的模型

当我们的中间件内没有进行异步操作时,其实我们的代码最后是以下面这种方式运行的:

app.use(function middleware1(req, res, next) {
  console.log('第一个中间件start')(
    // next()
    function (req, res, next) {
      console.log('第二个中间件start')(
        // next()
        function (req, res, next) {
          console.log('接口逻辑start')(
            // next()
            function handler(req, res, next) {
              // do something
            }
          )()
          console.log('接口逻辑end')
        }
      )()
      console.log('第二个中间件end')
    }
  )()
  console.log('第一个中间件end')
})

我们可以模拟手写一个express中间件的机制,来看看是如何导致上面的执行顺序的。

const http = require('http')
const slice = Array.prototype.slice

class LikeExpress {
  constructor() {
    // 存放中间件
    this.routes = {
      all: [], // app.use(...)
      get: [], // app.get(...)
      post: [] // app.post(...)
    }
  }

  register(path) {
    const info = {}
    // 如果第一个参数是路由
    if (typeof path === 'string') {
      info.path = path
      // 从第二个参数开始,转化为数组,存入stack
      info.stack = slice.call(arguments, 1)
    } else {
      // 如果第一个参数传的不是路径,那么就默认为是根目录
      info.path = '/'
      // 从第1个参数开始,转化为数组,存入stack
      info.stack = slice.call(arguments, 0)
    }
    return info
  }

  use() {
    const info = this.register.apply(this, arguments)
    this.routes.all.push(info)
  }

  get() {
    const info = this.register.apply(this, arguments)
    this.routes.get.push(info)
  }

  post() {
    const info = this.register.apply(this, arguments)
    this.routes.post.push(info)
  }

  match(method, url) {
    let stack = []
    if (url === '/favicon.ico') {
      return stack
    }
    // 获取routes
    let curRoutes = []
    // all 注册的方法都要
    curRoutes = curRoutes.concat(this.routes.all)
    curRoutes = curRoutes.concat(this.routes[method])
    curRoutes.forEach(item => {
      if (url.indexof(item.path) === 0) {
        stack = stack.concat(item.stack)
      }
    })
    return stack
  }

  // 核心的 next  机制
  handle(req, res, stack) {
    const next = () => {
      // 拿到第一个中间件
      const middleware = stack.shift()
      if (middleware) {
        middleware(req, res, next)
      }
    }
    next()
  }

  callback() {
    return (req, res) => {
      // express 框架有个json方法
      res.json = (data) => {
        res.setHeader('Content-type', 'application/json')
        res.end(
          JSON.stringify(data)
        )
      }
      // 通过url 和 method来命中那些中间件
      const url = req.url
      const method = req.method.toLowerCase()
      const resultList = this.match(method, url)
      this.handle(req, res, resultList)
    }
  }

  listen(...args) {
    const server = http.createServer(this.callback())
    server.listen(...args)
  }
}

// 工厂函数
module.exports = () => {
  return new LikeExpress()
}
  1. 注册中间件registry
  2. 根据路由匹配中间件match,把符合条件的中间件挑选出出来
  3. 把挑选出来的中间件执行handle
// 核心的 next  机制
handle(req, res, stack) {
    const next = () => {
      // 拿到第一个中间件
      const middleware = stack.shift()
      if (middleware) {
        middleware(req, res, next)
      }
    }
    next()
}

koa 中间件

koa2 中间件是一个async函数(express的中间件是一个普通函数),它返回一个promise,所以可以跟在await后面,如图所示:

image.png

从这个图可以看出,从一个中间件开始,最后也是从第一个中间件结束,这样一层套一层,就形成了一个洋葱圈的模型。所以 koa 的错误中间件一般是放在第一个

这个洋葱圈模式会导致和express的响应机制不同:

  • Express: 我们直接操作的是 res 对象,直接 res.send() 之后就立即响应了,后面的中间件不会执行了。

  • Koa2: 数据的响应是通过 ctx.body 进行设置,注意这里仅是设置并没有立即响应,后面的中间件还是可以执行的,即所有的中间件结束之后做了响应。这样有什么好处呢?就是所有的中间件都能对ctx 进行一些操作。

koa2 使用ctx (上下文对象)封装了 req 和 res,以及一些常用的功能。

下面手写一个koa2中间件机制:

const http = require('http')

// 组合中间件
function compose(middlewareList) {
  return function(ctx) {
    function dispatch(i) {
      const fn = middlewareList[i]
      try {
        return Promise.resolve(
          // fn 因为是个async函数本来返回一个promise,但是外面为什么还要包一层Promise.resolve呢,是因为为了防止用户传的中间件没有用async开头,那就不能用await next()
          fn(ctx, dispatch.bind(null, i + 1))
        )
      } catch (err) {
        return Promise.reject(err)
      }
    }
    dispatch(0)
  }
}

class LikeKoa2 {
  constructor() {
    this.middlewareList = []
  }

  use(fn) {
    this.middlewareList.push(fn)
    return this
  }
  // 把req, res封装到ctx里面  
  createContext(req, res) {
    const ctx = {
      req,
      res
    }
    ctx.query = req.query
    return ctx
  }

  callback() {
    const fn = compose(this.middlewareList)
    return (req, res) => {
      const ctx = this.createContext(req, res)
      fn(ctx)
    }
  }

  listen(...args) {
    const server = http.createServer(this.callback())
    server.listen(...args)
  }
}

下面通过一个例子来对中间件执行这一过程进行分析:

// 中间件 fn1 和 fn2
async function fn1(ctx, next) {
  console.log('first: start')
  await next()
  console.log('first: end')
}
async function fn2(ctx, next) {
  console.log('second: start')
  await next()
  console.log('second: end')
}

打印结果:

first: start 
second: start
second: end
first: end

根据上面的描述我们就理解了express的中间件是线型模型,而 Koa 则为洋葱型模型。

两者的不同

可以看到,Koa2的中间件机制和express没啥区别,都是回调函数的嵌套,遇到next或者 await next就中断本中间件的代码执行,跳转到对应的下一个中间件执行期内的代码,一直到最后一个中间件,然后逆序回退到倒数第二个中间件await next 或者next下部分的代码执行,完成后继续回退,一直回退到第一个中间件await next或者next下部分的代码执行完成,中间件全部执行结束。

仔细看一下koa除了调用next的时候前面加了一个await好像和express没有任何区别,都是函数嵌套,都是洋葱模型。但是koa是在哪里响应的用户请求呢?koa中好型并没有cxt.send这样的函数,只有cxt.body,但是调用cxt.body并不是直接结束请求返回响应,和express的res.send有着本质上的不同。

所以,最关键的不是这些中间的执行顺序,而是响应的时机,Express 使用 res.end() 是立即返回(尽管响应结束了,但是后面的代码仍然会执行),这样想要做出些响应前的操作变得比较麻烦;而 Koa 是在所有中间件中使用 ctx.body 设置响应数据,但是并不立即响应,而是在所有中间件执行结束后,再调用 res.end(ctx.body) 进行响应,这样就为响应前的操作预留了空间,所以是请求与响应都在最外层,中间件处理是一层层进行,所以被理解成洋葱模型。