0%

Koa2入门笔记

  1. 概述
  2. 安装
  3. Hello World
  4. 中间件
    1. 概述
    2. 级联
  5. 路由
    1. koa-route
    2. koa-router
      1. router.prefix() 设置路由前缀/级联路由
      2. router.use() 设置路由中间件
      3. router.redirect() 重定向方法
  6. 挂载实例或中间件
  7. 参数解析
    1. 安装koa-body
    2. 使用koa-body
    3. koa-body的可选配置项
  8. 静态页面
  9. 其他中间件
    1. 日志
    2. 用户身份验证
      1. 基本验证:Basic Auth
      2. jwt验证:JSON Web Token
      3. passport.js验证
    3. 用户权限控制
      1. ACL
        1. 典型应用
      2. RBAC
      3. ABAC
        1. 典型应用
      4. koa-authz:基于Casbin实现的ACL/RBAC/ABAC控制
    4. 会话管理:session
      1. koa-session的安装和使用
      2. koa-session的配置项
    5. 文件上传:multer

参考资料

Node.js 框架对比之 Express VS Koa

Koa2中文文档_Koa2教程

github | koajs/koa/guide

https://koajs.com

概述

  • Koa.js是基于Node.js的web应用服务端框架,相较于同一团队开发的Express.js来说它体积更小,功能更简洁,同时也尽可能减少了Node.js中存在的大量的回调。

  • 目前通过npm直接安装的Koa.js都是2.0版本以上,如果不是需要升级npm到最新版本;如果需要用1.x版本的可以在安装时指定版本号:npm i koa@1.7.0 -S

  • 使用try...catch结构处理异常,相较于Express.js中的基于回调函数的组合业务逻辑,异常捕获更加轻松自然。

安装

初始化一个npm目录后执行安装:

1
2
3
4
node -v   # v12.14.0
npm -v # 6.13.4
npm init -y
npm i koa -S

安装的版本是:

1
2
3
"dependencies": {
"koa": "^2.13.1"
}

Hello World

在目录下创建app.js,在其中写入:

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

app.use(async(ctx, next) => {
ctx.body = 'Hello Koa.js v2';
await next();
});

/**
* 监听
*/
const port = 8888;
app.listen(port, '0.0.0.0');
console.log(`app listen on: http://127.0.0.1:${port}`);

这样就搭建好了一个服务器,在命令行输入node app,打开浏览器对应的地址即可访问。

其实最后监听那边写成Express.js中的回调风格也是可以的(如下所示),虽然也不是不可以,但是这里是Koa2的教程,所以还是要尽可能地减少使用回调嘛,除了app.use那边不得不用之外,其他地方最好都不要用。

1
2
3
4
const server = app.listen(8888, '0.0.0.0', () => {
const port = server.address().port;
console.log(`app listen on: http://127.0.0.1:${port}`);
});

中间件

概述

中间件是Koa.js的核心概念,一个koa实例的最重要的构成就是中间件,这些中间件以函数的形式存放在一个数组中,根据http请求以类似于堆栈的方式执行。

Koa.js的中间件的最大的特点在于其控制流,根据官网的说法,当存在多个中间件时,调用的顺序是从下游(downstream)开始,循着控制流回到上游(upstream),这一方式称作级联(Cascading),将在下一小节论述。

由于Koa.js本身功能简洁,诸如路由、静态目录、请求内容解析等功能全都由中间件来实现,其官方提供了一个收录列表,记录了其生态中的中间件列表,若有实际应用需求可以首先来这边寻找。

koa中间件官方收录:

https://github.com/koajs/koa/wiki#middleware

级联

中间件级联也可以理解为嵌套,从一次请求开始,数据流会依次流过不同的中间件,最后返回到客户端。在这个流动的过程中,我们可以根据请求做出一定的响应、添加解析功能、进行拦截、添加分流等。而Koa.js提供了一个接口,允许开发者自由控制数据流。

控制的关键就在于next函数,下面是一个官方给出的例子,用于统计一次请求的响应时间,并记录到响应头中、打印到控制台。

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
const Koa = require('koa');
const app = new Koa();

// logger

app.use(async (ctx, next) => {
await next();
const rt = ctx.response.get('X-Response-Time');
console.log(`${ctx.method} ${ctx.url} - ${rt}`);
});

// x-response-time

app.use(async (ctx, next) => {
const start = Date.now();
await next();
const ms = Date.now() - start;
ctx.set('X-Response-Time', `${ms}ms`);
});

// response

app.use(async ctx => {
ctx.body = 'Hello World';
});

app.listen(3000);

当服务器收到请求时,首先来到logger中间件,当执行到await next()语句时,就将当前函数的内容挂起,执行之后的中间件;来到x-response-time中间件时,记录了一下请求发生的时间,接着执行到await next(),挂起等待,进入下一个中间件;在执行完最后一个中间件response之后,发现后续没有中间件了,那么就按照先进后出的顺序,向上回溯,先来到x-response-timeawait next()之后,执行记录响应时间并写到http响应头的操作,执行完之后,再回溯到logger,从http响应头中拿到记录的响应时间,打印到控制台。再往上已没有中间件,该请求就被发送回客户端,一次请求的处理流程就走完了。

中间件级联中的控制流的核心在于await next()语句,由它作为数据下游(downstream)和上游(upstream)的界线。当请求发生时,会根据所写的中间件的顺序,执行await next()语句之前的语句,当最后一个中间件执行完毕后,下游结束,并按照进来的顺序,反向回溯,执行中间件中写在await next()之后的语句。就差不多是下面这个意思:

image-20210708215037915

如果参与级联的中间件不包含next()语句,则认为它就是级联的终点,例如下面语句的执行结果是:请求响应内容只有一个Hello,

1
2
3
4
5
6
7
8
9
// response 1
app.use(ctx => {
ctx.body = 'Hello,';
});

// response 2
app.use(ctx => {
ctx.body += 'World!';
});

要让响应内容为Hello,World!,需要改写成:

1
2
3
4
5
6
7
8
9
10
// response 1
app.use(async (ctx, next) => {
ctx.body = 'Hello,';
await next();
});

// response 2
app.use(ctx => {
ctx.body += 'World!';
});

尝试了一下,好像加不加async/await都可以发挥next()的作用:

1
2
3
4
5
6
7
8
9
10
11
// response 1
app.use((ctx, next) => {
ctx.body = 'Hello,';
next();
ctx.body += '12345';
});

// response 2
app.use(ctx => {
ctx.body += 'World!';
});

响应内容为:Hello,World!12345

路由

koajs开发者提供了两个路由中间件:koa-routekoa-router,前者号称超级简易(uber simple),后者则包含了路由的完整特性(full-featured)。

koa-route

由于不打算使用这个,仅在这边贴几行代码说明安装和示例:

1
npm install koa-route
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
28
29
const _ = require('koa-route');
const Koa = require('koa');
const app = new Koa();

const db = {
tobi: { name: 'tobi', species: 'ferret' },
loki: { name: 'loki', species: 'ferret' },
jane: { name: 'jane', species: 'ferret' }
};

const pets = {
list: (ctx) => {
const names = Object.keys(db);
ctx.body = 'pets: ' + names.join(', ');
},

show: (ctx, name) => {
const pet = db[name];
if (!pet) return ctx.throw('cannot find that pet', 404);
ctx.body = pet.name + ' is a ' + pet.species;
}
};

app.use(_.get('/pets', pets.list));
app.use(_.get('/pets/:name', pets.show));

app.listen(3000);
console.log('listening on port 3000');

koa-router

API

安装:

1
npm i @koa/router -S

Adm.js:在独立文件中创建路由、实例化并导出

1
2
3
4
5
6
7
8
const Router = require('@koa/router');
const router = new Router();

router.get('/', ctx => {
ctx.body = 'admin home';
});

module.exports = router;

app.js:在主程序中,设置前缀(嵌套路由)、添加到服务器应用中。

1
2
3
4
5
/**
* 各类路由
*/
const AdmRouter = require('./routes/adm');
app.use(AdmRouter.prefix('/adm').routes());

通过以上的路由配置,打开浏览器访问http://localhost:8888/adm,即可看到admin home的内容。

router.prefix() 设置路由前缀/级联路由

可以为一个路由设置前缀,它的返回值是加完前缀的路由,有什么用呢,举个例子。

Express.js中,添加路由前缀的方法是:

1
app.use('/adm', AdmRouter);

但是Koa.js中为了做到简洁,不允许这样的用法,app.use只能接受一个参数,要设置前缀就需要通过router自带的方法:

1
app.use(AdmRouter.prefix('/adm').routes());

router.use() 设置路由中间件

根据路由匹配,添加处理的中间件。可以指定1个或多个路由,也可以不指定;至少指定一个中间件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// session middleware will run before authorize
router
.use(session())
.use(authorize());

// or
router.use(session(), authorize());

// use middleware only with given path
router.use('/users', userAuth());

// or with an array of paths
router.use(['/users', '/admin'], userAuth());

app.use(router.routes());

router.redirect() 重定向方法

重定向

1
router.redirect('/login', '/home');

相等于:

1
2
3
4
router.all('/login', ctx => {
ctx.redirect('/home');
ctx.status = 301;
});

挂载实例或中间件

koajs/mount:主要被设计来挂载其他Koa实例,但也可以挂载中间件。

挂载在这里的意思是将某些中间件或者将Koa实例作为中间件对应到特定的路由上,类似于koa-routerprefix()功能。

安装:

1
npm i koa-mount -S

koa-mount可以挂载Koa实例,也可以挂载中间件。

挂载实例的示例代码如下:

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
const mount = require('koa-mount');
const Koa = require('koa');

// 实例a:hello
const a = new Koa();
a.use(async function (ctx, next){
await next();
ctx.body = 'Hello';
});

// 实例b:world
const b = new Koa();
b.use(async function (ctx, next){
await next();
ctx.body = 'World';
});

// app
const app = new Koa();

app.use(mount('/hello', a));
app.use(mount('/world', b));

app.listen(3000);
console.log('listening on port 3000');

以上的a和b都是Koa的实例,通过koa-mount挂载到实例app上,并在app上执行监听,当服务器收到不同的请求时,得到的响应分别是:

1
2
3
4
5
6
7
8
$ GET /
Not Found

$ GET /hello
Hello

$ GET /world
World

koa-mount还可以挂载中间件,例如挂载一个路由,或者挂载一些自定义的中间件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const mount = require('koa-mount');
const Koa = require('koa');

async function hello(ctx, next){
await next();
ctx.body = 'Hello';
}

async function world(ctx, next){
await next();
ctx.body = 'World';
}

const app = new Koa();

app.use(mount('/hello', hello));
app.use(mount('/world', world));

app.listen(3000);
console.log('listening on port 3000');

挂载函数的第一个参数是路由地址,可以不写,默认挂载到全路径下。在挂载中间件时,app.use(ctx => {})等价于app.use(mount(ctx => {}))

参数解析

koajs/koa-body

Koa.js团队提供了一个用于参数解析的中间件koajs/koa-body,支持multiparturlencodedjson请求体的解析。

该中间件还提供了与expressjs/multer一至的功能(解析multipart/form-data,主要用于文件上传)。

也有另一个基于cojs/co-body的参数解析中间件koajs/bodyparser,不过已经一年多没更新了,因此这里我们用的是前者。

安装koa-body

1
npm i koa-body -S

使用koa-body

使用koa-body有两种方式,一种是直接设置在Koa的根实例上,对每一个请求都解析一边参数,这样好处是一次配置永久省心,但缺点是会造成不必要的计算浪费,以及不够灵活。另一种方式是设置在koa-router的路由上,这样足够灵活且不会造成浪费,因此推荐使用第二种。

设置在Koa根实例上:对所有请求都解析参数

1
2
3
4
5
6
7
8
9
10
11
12
13
const Koa = require('koa');
const app = new Koa();

/**
* 参数解析
*/
const KoaBody = require('koa-body');
app.use(KoaBody());

/**
* 监听
*/
app.listen(8888, '0.0.0.0');

设置在路由中间件中:仅对特定路由生效

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const Router = require('@koa/router');
const router = new Router();
const KoaBody = require('koa-body');

router.post('/login', KoaBody(), ctx => {
const { account, password } = ctx.request.body;
if (account == 'test' && password == '123456') {
ctx.body = {
status: 200,
msg: 'login succeed!'
};
} else {
ctx.body = {
status: 1,
msg: 'login failed!'
};
}
});

module.exports = router;

需要注意的是,koa-body不允许嵌套,即不允许层级调用,否则请求会没有响应(卡死),就像下面这句:

1
2
3
router.post('/login', KoaBody(), KoaBody(), ctx => {
ctx.body = 'succeed!';
});

客户端发送请求后迟迟没有收到响应,检查发现第三个中间件函数根本就没有执行。

koa-body的可选配置项

KoaBody()初始化时默认开启了text/urlencoded/json的解析,不开启multipart的解析。相关的配置可以通过传入一个选项进行配置,可选的配置项如下所示:

  • patchNode {Boolean} Patch request body to Node’s ctx.req, default false
  • patchKoa {Boolean} Patch request body to Koa’s ctx.request, default true
  • jsonLimit {String|Integer} The byte (if integer) limit of the JSON body, default 1mb
  • formLimit {String|Integer} The byte (if integer) limit of the form body, default 56kb
  • textLimit {String|Integer} The byte (if integer) limit of the text body, default 56kb
  • encoding {String} Sets encoding for incoming form fields, default utf-8
  • multipart {Boolean} Parse multipart bodies, default false
  • urlencoded {Boolean} Parse urlencoded bodies, default true
  • text {Boolean} Parse text bodies, such as XML, default true
  • json {Boolean} Parse JSON bodies, default true
  • jsonStrict {Boolean} Toggles co-body strict mode; if set to true - only parses arrays or objects, default true
  • includeUnparsed {Boolean} Toggles co-body returnRawBody option; if set to true, for form encoded and JSON requests the raw, unparsed request body will be attached to ctx.request.body using a Symbol (see details), default false
  • formidable {Object} Options to pass to the formidable multipart parser
  • onError {Function} Custom error handle, if throw an error, you can customize the response - onError(error, context), default will throw
  • strict {Boolean} DEPRECATED If enabled, don’t parse GET, HEAD, DELETE requests, default true
  • parsedMethods {String[]} Declares the HTTP methods where bodies will be parsed, default ['POST', 'PUT', 'PATCH']. Replaces strict option.

静态页面

koajs/static

安装:

1
npm i koa-static -S

使用:

1
2
3
4
5
6
/**
* 静态目录
*/
const path = require('path');
const KoaStatic = require('koa-static');
app.use(KoaStatic(path.join(__dirname, 'public')));

其他中间件

日志

koajs/logger

安装:

1
npm i koa-logger -S

使用:

1
2
3
4
5
/**
* 日志
*/
const KoaLogger = require('koa-logger');
app.use(KoaLogger());

当有请求发送时,终端会输出一些请求信息:

1
2
<-- POST /adm/login
--> POST /adm/login 200 14ms 34b

用户身份验证

基本验证:Basic Auth

koajs/basic-auth

基本验证是什么?HTTP Authorization 之 Basic Auth

通过写在HTTP头的用户名:密码的base64编码后的字符串进行验证的一种手段。安全性极低。

jwt验证:JSON Web Token

koajs/jwt

JSON Web Token是什么?基于 Token 的身份验证:JSON Web Token

通过加密、签名、指定JSON格式的方式指定一串密钥,当用户第一次登录时得到对应的口令(Token),之后每次请求都发送这一口令,表示用户已知晓服务端的口令,也就是值得信任。不需要涉及到数据库,能减少运算量和存储量(不需要额外存储session),但一旦对方拿到这个加密后的口令,比如登录一次之后,或者被抓包,那么都可以模拟这个口令发送请求。

而且由于口令不与用户关联(也可以关联,但是那就不是JWT的特点),具有通用性,容易泄露,因此要求经常更新。

有一定的加密手段,但一旦被破解或有内鬼泄露,那该加密手段就失效了。

passport.js验证

https://github.com/rkusa/koa-passport

http://www.passportjs.org/

基于password.js,提供了一串从session中获取用户信息、查询数据库进行验证的接口。

用户权限控制

ACL, RBAC, ABAC 是什么?

ACL/RBAC/ABAC都是用户权限控制的策略,区别在于指定的方式不同,具体可以参考以下三小节的内容(以下内容转载自:使用casbin完成验证授权),ACL对每个用户指定权限,RBAC对一类用户指定,ABAC对具有某种属性的用户指定。用户权限控制首先需要给出权限列表,然后给用户指定列表中的权限,当一个用户想要执行某个权限下的操作时,首先将用户名、请求的权限输入系统进行查询,有权限返回true,无权限返回false

ACL

ACLAccess Control List的缩写,称为访问控制列表. 定义了谁可以对某个数据进行何种操作. 关键数据模型有: 用户, 权限.

ACL规则简单, 也带来一些问题: 资源的权限需要在用户间切换的成本极大; 用户数或资源的数量增长, 都会加剧规则维护成本;

典型应用

  1. 文件系统

文件系统的文件或文件夹定义某个账号(user)或某个群组(group)对文件(夹)的读(read)/写(write)/执行(execute)权限.

  1. 网络访问

防火墙: 服务器限制不允许指定机器访问其指定端口, 或允许特定指定服务器访问其指定几个端口.

RBAC

RBACRole-based access control的缩写, 称为 基于角色的访问控制. 核心数据模型有: 用户, 角色, 权限.

用户具有角色, 而角色具有权限, 从而表达用户具有权限.

由于有角色作为中间纽带, 当新增用户时, 只需要为用户赋予角色, 用户即获得角色所包含的所有权限.

RBAC存在多个扩展版本, RBAC0RBAC1RBAC2RBAC3。这些版本的详细说明可以参数这里。我们在实际项目中经常使用的是RBAC1,即带有角色继承概念的RBAC模型。

ABAC

ABACAttribute-based access control的缩写, 称为基于属性的访问控制.

权限和资源当时的状态(属性)有关, 属性的值可以用于正向判断(符合某种条件则通过), 也可以用于反向判断(符合某种条件则拒绝):

典型应用

  1. 论坛的评论权限, 当帖子是锁定状态时, 则不再允许继续评论;
  2. Github 私有仓库不允许其他人访问;
  3. 发帖者可以编辑/删除评论(如果是RBAC, 会为发帖者定义一个角色, 但是每个帖子都要新增一条用户/发帖角色的记录);
  4. 微信聊天消息超过2分钟则不再允许撤回;
  5. 12306 只有实名认证后的账号才能购票;
  6. 已过期的付费账号将不再允许使用付费功能;

koa-authz:基于Casbin实现的ACL/RBAC/ABAC控制

casbin/node-casbin

node-casbin/koa-authz

Casbin 官网首页

Casbin Model 语法说明

Casbin Tutorials

koa-authz是基于Node-Casbin实现的权限控制中间件,主要控制这么一件事:xx用户是否具有对xx资源进行xx操作的权限。在koa-authz的描述语言里,就是{subject, object, action},其中:

  1. subject:已经登录了的用户名,xx用户;
  2. object:网络资源的URL,如“dataset1/item1”,xx资源;
  3. action:HTTP方法(GET/POST/PUT/DELETE…)或其他语义化方法(如 read-file, write-blog),xx操作。

具体的使用参考node-casbin/koa-authzCasbin Tutorials

会话管理:session

koa-session的安装和使用

koajs/session

安装

1
npm i koa-session -S

基本使用。其中app.keys为必须字段,用于为cookies加密,除非在koa-session的配置项中把加密的关掉。

1
2
3
4
5
6
/**
* session
*/
app.keys = ['secret'];
const KoaSession = require('koa-session');
app.use(KoaSession(app));
1
2
3
4
5
router.get('/', ctx => {
let n = ctx.session.views || 0;
ctx.session.views = ++n;
ctx.body = `${n} views`;
})

以上代码实现的是通过session存储某一网页的访问次数。(session存放在浏览器的cookie中)

当浏览器向客户端发送请求时,会附带上cookie,添加了koa-session中间件后,可以通过ctx.session拿到存储在浏览器cookie中的session信息(这里称作cookie-based sessions),当对session其进行修改后,响应这个请求时会把修改后的session发送到浏览器,更新存放在浏览器的cookie数据。

koa-session的配置项

可以通过如下形式为koa-session传入配置项:

1
2
const SessionConfig = {};
app.use(KoaSession(SessionConfig, app));

可选配置项有:

  • key: (string) 保存在cookie中的键名,默认为koa.sess
  • maxAge: (number || ‘session’),当为一个数字时,表示以毫秒计算的最大session有效时间。默认为86400000,1天;当为'session'字符串时,关闭会话或浏览器时,session会过期。
  • autoCommit: (boolean) 是否自动提交headers,默认为true
  • overwrite: (boolean) 是否重写cookie,默认为true
  • httpOnly: (boolean) 是否开启httpOnly,默认为true,带有httpOnly的Cookie不能通过非http方式来访问。
  • signed: (boolean) 是否使用app.keys进行签名,默认为true,会在cookie中存放一个key.sig的签名字段,主要目的是防止cookie被篡改。(测试了一下,如果不设置签名,将cookie用base64解码后改成另一个值,还能生效;如果设置了签名,如果发生了修改,那么修改的字段和之前的字段都无效了,会重新设置cookie。如果有人同时复制了数据和签名,那么还是可以篡改数据的,只是不能篡改成他想要的样子,签名的原理和app.keys的签名原理一至,底层的实现依赖crypto-utils/keygrippillarjs/cookies)。
  • rolling: (boolean) 强制在每个响应上设置会话标识符 cookie。到期重置为原来的maxAge,重置到期倒计时,默认为false
  • renew: (boolean) 当session将要过期时是否更新它,默认为false
  • secure: (boolean) 是否启用安全cookie,默认为false
  • sameSite: (string) session cookie的sameSite选项,默认为None,将允许声明Cookie是否仅限于第一方或同一站点上下文。接受三个值:Lax:Cookies允许与顶级导航一起发送,并将与第三方网站发起的GET请求一起发送,这是浏览器的默认值;Strict:Cookies只会在第一方上下文中发送,不会与第三方网站发起的请求一起发送;None:Cookies将在所有上下文中发送,即允许跨域发送。使用None时,需要设置Secure

options中还包括一些其他的自定义配置项,如encode/decodeexternalKeystoreContextStoregenid/prefix等,详细的功能和配置方法可以查看koajs/session/index.js

文件上传:multer

koajs/multer

安装:

1
npm i @koa/multer multer -S