Koa入门教程[2]-常用中间件

中间件执行流程

中间件的执行流程,可以用下面这张图片来生动的说明(图片使用了 Koa 2 的 async 语法):

image.png

对于 Koa 1 来说也类似,只是 async 函数换作 generator 函数,await 换作 yield 关键字。

对于前端程序员,可以把 yield 之前的代码认为是捕获阶段,yield 之后的认为的冒泡阶段,从而理解多个中间件之间代码的执行流程。

路由中间件

路由一般属于业务代码,我们一般放在其他基础中间件之后来注册。路由的基本原理就是判断 url path, 然后决定是否执行某个中间件逻辑。

简单实现可以类似这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const Koa = require("koa");
const app = new Koa();

app.use(function* (next) {
if (this.path === "/home") {
this.body = "首页";
} else {
yield next;
}
console.log("这里会执行哦");
});
app.use(function* (next) {
if (this.path === "/admin") {
this.body = "管理端";
}
});
app.listen(3000);

可以看到,对于不符合本中间件的请求 path, 就直接丢弃,并去执行下一个中间件。如果所有中间件都匹配不到,会返回 404(Koa 默认行为).

上面代码有一个问题,就是 “console.log” 会一直执行,要解决这个也很简单。因为对于路由中间件来说,所有逻辑都是匹配 path 的 if 判断内部的,所以对于这个不匹配的 else 代码,可以直接当做该 generator 的结束。可以在 yield 前面加 return 或这样修改:

1
2
3
4
app.use(function* (next) {
if (this.path !== "/") return yield next;
this.body = "we are at home!";
});

koa-router

为了应对更复杂的路由功能,我们需要引入第三方的 koa-router 路由模块。不过 Koa1 需要使用 4.x 版本的。

Issue: You are using koa@1.x but koa-views@5.x needs koa@2 or above. If you are still at v1 please consider using koa-views@4.x. Note however, there are no updates supporting v1

1
npm i koa-router@4 -d

koa-router 暴露一个 Router 类,像 Vue.js 一样,只需创建一个 router 实例,就可以注册对应的路由规则。

1
2
3
4
5
6
7
8
9
10
11
12
var app = require("koa")();
var Router = require("koa-router");

var myRouter = new Router();

myRouter.get("/", function* (next) {
this.response.body = "Hello World!";
});

app.use(myRouter.routes());

app.listen(3000);

从用法中显然能看出来,routers 方法返回的应该就是一个 generator 中间件函数,只是内部由 koa-router 进行了路由规则的处理和逻辑执行。开发者只需关注如何向 koa-router 对象上注册 处理中间件

koa-router 像多数路由一样支持很多 http 方法和匹配规则:

1
2
3
4
5
router.get()
router.post()
router.put()
router.del()
router.patch()

视图渲染中间件

Koa 有很多的中间件 比如 koa-view

该中间件支持多种模板引擎。

cookie 的获取和设置是 Koa 内置的 context 集成的能力,不需要中间件的参与。

1
2
this.cookies.get("cookieName");
this.cookies;

koa-router

为了兼容 Koa1,我们需要安装一个老一点的 koa-router

1
npm i koa-router@5.x

koa-logger

用于打印请求日志和耗时

1
npm i koa-logger@1 // 1.x 支持 Koa1

session

1
2
// 为了兼容 Koa1
npm i koa-session@3.x

该版本的 koa-session 只需要执行其导出函数并传入一个 app 对象即可使用

1
2
3
4
5
const session = require("koa-session");
app.use(session(app));
app.use(function* (next) {
console.log(this.session.xxx);
});

koa-compress

默认的 Koa 应用,我们观察下浏览器的 Response 响应的话,会发现虽然请求时浏览器携带了 Accept-Encoding: gzip, 但实际上响应里面并没有 Content-Encoding: gzip, 也就是说并没有压缩。

安装 `koa-compress@1.x` 之后,就可以让 Koa 默认开启对响应内容的压缩了:

1
app.use(require("koa-compress")());

大一点的文件才有效果哦,太小的话还比不上 ‘Content-Encoding’ 头所占的字节的话,就有点得不偿失了。

koa-csrf

1
2
# 为了兼容 Koa1 请安装 2.x 版本
npm i koa-csrf@2.x

该模块的导出对象是一个函数,函数会创建一个中间件,你需要将他注册到 Koa 的 app 里面。使用方式如下:

1
2
app.use(session(app)); // koa-csrf 的机制要依赖session能力
app.use(csrf()); // 这是koa1的用法

koa-csrf 原理:

解决跨站请求伪造攻击,需要在客户端请求时携带一个秘密的 token,这个 token 要确保只有服务器端知道,而且用后即焚. 其思路是,一个用户在访问页面时,服务端先把这个 csrf-token 放置到页面中,然后页面再次发起 POST 请求时,页面需要带上这个 token,由服务端来校验是不是服务器颁发的 token.

回到 koa-csrf 这个模块,在每次请求周期中, koa-csrf 都会在它的中间件内生成一个秘钥 secret, 然后基于 secret 生成一个 csrf-token; 并把这个 csrf-token 挂在 ctx 上,把 secret 挂在 session 上(因为 secret 作为一个秘钥基于 session 可以针对一个独立用户,没必要每次都变). 我们把 koa-csrf 中生成 token 过程的源码捡出来如下:

1
2
3
4
5
6
// 创建一个秘钥并放在session里,每次生成和校验csrf时都用这个token
var secret = this.session.secret || (this.session.secret = tokens.secretSync());
// 摘录tokens.secretSync的实现如下:
Tokens.prototype.secretSync = function secretSync() {
return uid.sync(this.secretLength); // 其实就是使用 uid-safe 模块生成一个固定长度的随机uniqueId
};

有了 secret 秘钥了,再来看下 csrf-token 咋生成的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 基于上一步的秘钥secret来生成csrf-token 放在ctx对象上
this._csrf = tokens.create(secret);
// csrf-token的生成过程如下:
Tokens.prototype.create = function create(secret) {
if (!secret || typeof secret !== "string") {
throw new TypeError("argument secret is required");
}
// 重点在这里。其中rndm模块仅仅就是用来生成n位数的随机字符串;而_tokenize函数就是用来生成csrf-token的,其实现我摘录在下面
return this._tokenize(secret, rndm(this.saltLength));
};

// tokenize实现
Tokens.prototype._tokenize = function tokenize(secret, salt) {
// csrf-token 的格式为: salt随机字符串 + hash(salt+secret)
return salt + "-" + hash(salt + "-" + secret);
};

至此,csrf-token 就生成了。接下来,你需要在 GET 请求的页面上,把 this.csrf 渲染到页面中。然后前端再次请求后端的 POST 接口时,需要带上那个 token。这样,POST 请求到达服务器时 koa-csrf 中间件就会在请求到来时优先进行校验。

校验规则已经显而易见了:

  1. 从前端的 query 或 body 或 cookie 中取出 _csrf 这个变量(csrf-token)
  2. 按照 csrf-token 的规则,取出 横线 前面的字符串作为 salt随机串,取后面的作为 待校验的哈希[fehash]
  3. 从服务端的 session 中(this.session.secret)拿出秘钥 secret
  4. 使用与当初一模一样的 tokenize 函数算一下这个哈希:
1
var result = hash("前端传来的salt" + "服务端秘钥secret");
  1. 比对本次算出来的 result 与 前端传来的 待校验 fehash 值 是否一致。不一致则说明是伪造的请求。koa-csrf 中间件会直接跑错

那么,会不会存在黑客在中间网络窃取到某次请求的 token 后,再利用这个 token 来实施 CSRF 呢? 这个实际上是无法避免的,既然黑客能窃取到 http 报文(说明请求被中间人劫持或站点被 XSS 注入),那黑客完全可以窃取到 cookie 等信息,相当于完全模拟了用户,这种情况下任何防范都没有作用了;只能说如果发现 IP 变了那就要求用户重新登录且切换  secret。

更多中间件

几乎所有的网络应用所需的功能都有中间件提供。可以在官方 wiki 中看到中间件列表

中间件编写最佳实践

带参数的中间件

对于编写公共中间件的场景来说,更多的需要用户能自定义中间件中一些配置。此时需要支持用户对中间件进行配置。要实现可配置的中间件也简单,只需要写一个包装函数,返回一个 generator 的函数即可。例如我们的日志中间件,可以允许用户自定义日志格式,则可以这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 可自定义日志格式的中间件
const mylogger = function (format) {
format = format || "{{method}} {{url}} - {{time}}";
return function* (next) {
const start = Date.now();
yield next;
const ms = Date.now() - start;
console.log(
format
.replace("{{method}}", this.method)
.replace("{{url}}", this.url)
.replace("{{time}}", ms)
);
};
};
// 使用该中间件
app.use(mylogger("{{time}} - {{method}} : {{url}}"));

合并多个中间件

有时可能需要将多个中间件合并为一个。对于 Generator 来说,可以使用 .call(this. next) 的方式将他们合并。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const Koa = require("koa");
const app = new Koa();

function* a(next) {
console.log("come a");
yield next;
console.log("end a");
}

function* b(next) {
console.log("come b");
yield next;
console.log("end b");
}

function* all(next) {
console.log("come all");
yield a.call(this, b.call(this, next));
console.log("end all");
}

app.use(all);
app.listen(3000);

执行上述代码,控制台会输出:

1
2
3
4
5
6
come all
come a
come b
end b
end a
end all

你一定比较疑惑为什么多个 generator 函数通过 call(this, next) 是怎么做到如此合并执行的? 其实本质上 Koa 的运作也是基于合并 middlware 来执行的。这里大概是这样的:

  1. 首先我们需要知道 a、b、all 都是 generator 函数;而 next 是 generator 对象
  2. a.call(this, b.call(this, next)) 相当于先执行 b.call(this, next) 创建 b 的 generator 对象。然后该对象会作为 next 参数去执行 a 函数,进而创建出 a 的 generator 对象
  3. 因此 a.call(this, b.call(this, next)) 的返回值是 a 函数的 generator 对象;且 a 函数中的 next 表示的是 b 函数的 generator 对象。 这样执行这个表达式的返回值,便相当于执行:
1
2
3
4
5
function *a(next) {
console.log('come a')
yield (b中间件 的 generator 对象);
console.log('end a')
}
  1. 如此,all 这个 generator 函数就把各个中间件联合起来执行了。

上述过程类似于 koa-compse 模块的合并能力, 这里贴一个 compose 模块的实现(引用自阮一峰的 Koa 教程):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function compose(middleware) {
return function* (next) {
if (!next) next = noop();

var i = middleware.length;

while (i--) {
next = middleware[i].call(this, next);
}

yield* next;
};
}

function* noop() {}

以上就是中间件合并的原理了,合并后会返回一个新的 generator 函数。而 Koa 是如何使用 co 库把合并后的 generator 中间件函数运行起来的呢? 这个就有点复杂了,更详细的 middleware 合并 和 Koa 原理可以参考: qianlongo github

总结

本章节介绍了几个常用中间件,如 koa-router 和 koa-view,并对中间件的合并和传参进行了简单介绍。基本上 Koa 的所有使用方式都已经介绍完毕,后面就是赤裸裸的实践了