# 1. 前情回顾

经过上一节我们把开发环境搭建好之后,执行 npm run dev 将项目跑起来并打开浏览器访问 localhost:3000 ,此时浏览器页面会显示 Not Found

不要惊慌,因为我们在 app.ts 中只是单纯的启动了服务器,代码并没有做其他的事情,也就没有了交互。现在,我们继续修改 app.ts 文件:

import * as Koa from "koa";
const app = new Koa();

// 增加代码
app.use(async (ctx: Koa.Context, next: Koa.Next) => {
  ctx.body = "hello nlrx";
});

app.listen("3000", () => {
  console.log("server is running at http://localhost:3000");
});
1
2
3
4
5
6
7
8
9
10
11

保存文件后,此时服务器会自动重启。接着我们再次打开浏览器访问 localhost:3000 ,这时页面会显示出 hello nlrx

那么我们增加的这三行代码是什么呢?起了什么作用呢?本节我们就来探讨这个问题。

# 2. 中间件概述

# 2.1 什么是中间件

如上所述,我们在 app.ts 中,添加了这样一段代码:

app.use(async (ctx: Koa.Context, next: Koa.Next) => {
  ctx.body = "hello nlrx";
});
1
2
3

它的作用是:每收到一个 http 请求, Koa 都会调用通过 app.use() 注册的 async 函数,同时为该函数传入 ctxnext 两个参数。而这个 async 函数就是我们所说的中间件。

中间件是 Koa 的扩展机制,主要用于抽象 HTTP 请求过程。在单一请求响应过程中加入中间件,可以更好地应对复杂的业务逻辑。如果把一个 HTTP 处理过程比作污水处理,那么中间件就像一层层的过滤网。每个中间件在 HTTP 处理过程中通过改写请求和响应数据、状态,实现了特定的功能。大家都知道 HTTP 是无状态协议,所以 HTTP 请求的过程可以这样理解:请求被发送过来,经过无数中间件拦截,直至被响应为止。如下图所示:

# 2.2 中间件参数

Koa 在调用通过 app.use() 注册的 async 函数时,会为该函数传入 ctxnext 这两个参数。那么这两个参数是什么呢?有什么作用呢?我们分别来看一下。

# 2.2.1 ctx

ctx 参数一般被称为上下文对象,当一个请求从开始被服务器接收,经过若干个中间件拦截处理,到最终被响应出去,这个 ctx 对象会贯穿该请求的整个生命周期过程。每个请求至少会经过 N(N>0) 层中间件的拦截, ctx 在整个中间件流转过程中是一直存在的,唯一共享的就是这个上下文对象。它主要封装了请求的 request 对象与 response 对象,并提供了一些帮助开发者编写业务逻辑的方法,我们可以在 ctx.requestctx.response 中很方便地访问这些方法。

那么这个 ctx 上下文对象里面都有些什么呢?我们可以添加如下代码,打印出 ctx 对象,更直观的看一看对象里面都有哪些内容,如下:

app.use(async (ctx: Koa.Context, next: Koa.Next) => {
  console.log(ctx); // 增加这行代码
  ctx.body = "hello nlrx";
});
1
2
3
4

打印出的内容如下:

{
  request: {
    method: 'GET',
    url: '/',
    header: {
      ...
    }
  },
  response: {
    status: 404,
    message: 'Not Found',
    header: [Object: null prototype] {}
  },
  app: {
    subdomainOffset: 2,
    proxy: false,
    env: 'development'
  },
  originalUrl: '/',
  req: '<original node req>',
  res: '<original node res>',
  socket: '<original node socket>'
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

ctx 上常用的对象有 requestresponsereqres 等,其中, requestresponseKoa 内置的对象,是对 HTTP 的实用扩展;而 reqres 是在 http.createServer 回调函数里注入的,即未经加工的原生内置对象。

另外, ctx 还对 Koa 内部的一些常用的属性或者方法做了代理操作,使得我们可以直接通过 ctx 获取。比如, ctx.request.url 可以写成 ctx.url

除此之外, Koa 还约定了一个中间件的存储空间 ctx.state 。中间件在拦截处理过程中可以通过 ctx.state 存储一些数据,比如用户数据,版本信息等。方便中间件之间简单的数据共享和通信。

# 2.2.2 next

next 参数的作用是将请求处理的控制权转交给下一个中间件,而 next() 后面的代码,将会在下一个中间件及后面的中间件(如果有的话)执行结束后再执行。

当一个中间件把自己该完成的工作都完成后,那么此时该中间件要么将请求直接结束并响应出去,如果中间件不想结束请求,那么可以将请求继续转交给下一个中间件继续处理。当需要把请求转交给下一个中间件时,就需要调用 next() ,如此以来,请求的控制权就被转交给下一个中间件了。

# 3. 中间件执行顺序

一个完整的 Koa 应用其实就是各种中间件的组合。那么当多个中间件同时工作时,请求在它们之间的流转过程是怎样的呢?我们不妨做个试验,在 app.ts 中编写如下代码:

import * as Koa from "koa";
const app = new Koa();
app.use(async (ctx: Koa.Context, next: Koa.Next) => {
  console.log("中间件1 doSomething");
  await next();
  console.log("中间件1 end");
});

app.use(async (ctx: Koa.Context, next: Koa.Next) => {
  console.log("中间件2 doSomething");
  await next();
  console.log("中间件2 end");
});

app.use(async (ctx: Koa.Context, next: Koa.Next) => {
  console.log("中间件3 doSomething");
  await next();
  console.log("中间件3 end");
});
app.listen("3000", () => {
  console.log("server is running at http://localhost:3000");
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

保存文件并在浏览器中访问 http://localhost:3000 ,控制台显示内容为:

中间件1 doSomething
中间件2 doSomething
中间件3 doSomething
中间件3 end
中间件2 end
中间件1 end
1
2
3
4
5
6

从结果上可以看到,当一个请求过来的时候,会依次被各个中间件处理,中间件跳转的信号是 next() ,流程是一层层的打开,然后一层层的闭合,像是剥洋葱一样 —— 洋葱模型。

此外,如果一个中间件没有调用 await next() ,会怎样呢?答案是『后面的中间件将不会执行』。

修改 app.ts 如下,我们去掉了第二个中间件里面的 await

import * as Koa from "koa";
const app = new Koa();
app.use(async (ctx: Koa.Context, next: Koa.Next) => {
  console.log("中间件1 doSomething");
  await next();
  console.log("中间件1 end");
});

app.use(async (ctx: Koa.Context, next: Koa.Next) => {
  console.log("中间件2 doSomething");
  // 注意,这里我们删掉了 next
  // await next();
  console.log("中间件2 end");
});

app.use(async (ctx: Koa.Context, next: Koa.Next) => {
  console.log("中间件3 doSomething");
  await next();
  console.log("中间件3 end");
});
app.listen("3000", () => {
  console.log("server is running at http://localhost:3000");
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

重新运行代码后,控制台显示如下:

中间件1 doSomething
中间件2 doSomething
中间件2 end
中间件1 end
1
2
3
4

与我们的预期结果『后面的中间件将不会执行』是一致的。

# 4. 如何编写中间件

开发一个 Koa 应用其实玩的就是中间件,所以编写中间件自然必不可少。那么如何编写一个中间件呢?

中间件的作用就是对请求或响应的拦截,一个完整的中间件大致可分为三部分:

  1. 对请求的拦截逻辑
  2. next
  3. 对响应的拦截逻辑
app.use(async (ctx: Koa.Context, next: Koa.Next) => {
  console.log("对请求的拦截逻辑");
  await next();
  console.log("对响应的拦截逻辑");
});
1
2
3
4
5

三个部分可自由搭配。

当准备编写中间件时,请默默的问自己这样两个问题:

  • 我要拦截什么?是请求还是响应?

    如果是对请求拦截,那么需要 12

    如果是对响应拦截,那么需要 23

    如果请求和响应都要拦截,那么需要 1 , 23

  • 在什么阶段拦截?

    中间件所处的位置决定了中间件在什么阶段被执行,所以,请先考虑清楚你所编写的中间件需要在什么阶段进行拦截。代码是从上往下执行,中间件是从外向里再向外执行,那么在代码层面,写的越靠上的中间件就越先接到请求,同时也越后接到响应。

举个例子,当我想编写一个统计每个请求从请求进来到响应出去所耗费的时长这样一个中间件时,我该怎么做呢?

问题 1:我要拦截什么?是请求还是响应?

答:请求和响应都要拦截,当请求进来时记录一下当前时间,当请求出去时记录一下当前时间,两个时间差就是请求从请求进来到响应出去所耗费的时长。

问题 2:在什么阶段拦截?

答:我希望当请求一进来时就拦截下来记录时间,当请求最后被响应出去时再记录时间。所以这个中间件应该处于所有中间件的外层,这样才能确保请求一进来就先经过这个中间件,请求最终响应出去也最后经过这个中间件。

ok,搞明白这两个问题后,就可以着手编写中间件了,代码如下:

import * as Koa from "koa";
const app = new Koa();

app.use(async (ctx: Koa.Context, next: Koa.Next) => {
  let stime = new Date().getTime(); // 当请求进来时记录一下当前时间
  console.log("请求进来了");
  await next();
  let etime = new Date().getTime(); // 当请求出去时记录一下当前时间
  console.log("请求出去了");
  console.log(`耗时:${etime - stime}ms`);
});

app.use(async (ctx: Koa.Context, next: Koa.Next) => {
  console.log("中间件1 doSomething");
  await next();
  console.log("中间件1 end");
});

app.use(async (ctx: Koa.Context, next: Koa.Next) => {
  console.log("中间件2 doSomething");
  await next();
  console.log("中间件2 end");
});

app.listen("3000", () => {
  console.log("server is running at http://localhost:3000");
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

打印日志如下:

请求进来了
中间件1 doSomething
中间件2 doSomething
中间件2 end
中间件1 end
请求出去了
耗时:1ms
1
2
3
4
5
6
7

注意:一定要把该中间放在最上面,这样才能确保请求一进来就先经过这个中间件,请求最终响应出去也最后经过这个中间件。