Koa学习指南

全篇共 6995 字。按500字/分钟阅读,预计用时 14.0 分钟。总访问 252 次,日访问 2 次。

Koa 是一个新(相对Express算新的)的 web 框架,由 Express 幕后的原班人马打造。既然Koa继Express后打造,必定有其优势所在。我的博客后端基于Express框架开发。我觉得做任何事都需要一个契机,Koa应该就是重构后端代码的契机吧。

安装Koa。正如文档在简介中想向读者传达的那样,ES6+带来了Async/Await,让Koa和Express框架的基石——中间件思想——如虎添翼。从Node各版本支持的特性上可以看到,自Node v7.6.0起,便已经开始完全支持Async/Await了。

看看我博客后端代码目前使用的模块。项目根目录下的package.json文件中的依赖模块如下:

{
  "dependencies": {
    "compression": "^1.7.4",
    "express": "^4.17.1",
    "express-session": "^1.17.0",
    "express-socket.io-session": "^1.3.5",
    "highlight.js": "^9.17.1",
    "js-md5": "^0.7.3",
    "jsonfile": "^5.0.0",
    "markdown-it": "^10.0.0",
    "multer": "^1.4.2",
    "mysql": "^2.17.1",
    "pug": "^2.0.4",
    "socket.io": "^2.3.0",
    "xml2js": "^0.4.23"
  }
}

使用npm install命令安装最新版本的koa。

npm i koa

安装成功。此时在项目根目录下的package.json文件中,依赖项里增加了koa模块:

{
  "dependencies": {
    ...
    "koa": "^2.11.0",
    ...
  }
}

Koa和大多数为了彰显自己有多好用的工具一样,在他们文档比较靠前的地方给性急的开发者丢出了快速上手的示例代码。看看下面这单调乏味的几行代码,我真要扪心自问还有必要学吗。

const Koa = require('koa');
const app = new Koa();

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

app.listen(3000);

按照我过往总结的一套认识计算机世界的一贯思想——输出。输出就完事了。一个一个来。从官方给出的示例代码的第一行中 const Koa = require('koa') 这条赋值语句可以发现,变量Koa 的首字母是大写。按照管用规范,作为构造函数使用的函数名称应该以大写字母开头,所以Koa这个变引用的是一个构造函数,或者干脆说是个类,虽然有人说JS没有真正的类,真 · 人红是非多。

继续走“输出就完事了”的路线,往下看:

const Koa = require('koa');
console.dir(Koa);
// 输出:[Function: Application] { HttpError: [Function: HttpError] }

不出所料,Koa 果然是函数,而且Koa团队给这个构造函数起了个无聊但实际的名字——Application。我猜HttpError应该就是类属性而不是实例属性,因为我没研究过这种输出格式中每一部分代表什么,不过后面就能一一印证我的全部猜想了,现有猜想,通过实践印证猜想和反过来充实猜想。从 Application 这个构造函数的名字就知道,使用 new 关键字实例化它,就会创建了一个 application —— Application的一个实例。变量名的选择当然是在能清晰传达含义的基础上越简洁越好了——所以变量名是 app 而不是 application。

那么继续沿着“输出就完事了”的路线向前走一步:

const Koa = require('koa');
const app = new Koa();

console.dir(app);
// 终端输出:
Application {
  _events: [Object: null prototype] {},
  _eventsCount: 0,
  _maxListeners: undefined,
  proxy: false,
  subdomainOffset: 2,
  proxyIpHeader: 'X-Forwarded-For',
  maxIpsCount: 0,
  env: 'development',
  middleware: [],
  context: {},
  request: {},
  response: {},
  [Symbol(nodejs.util.inspect.custom)]: [Function: inspect]
}

console.log(app);
// 终端输出:
{ subdomainOffset: 2, proxy: false, env: 'development' }

这次输出了许多,但是不必惊慌。显然,这个对象就是 Application 的实例。使用 console.dir() 方法可以输出更全的对象属性。关于 console.log()console.dir() 区别的解释看这篇文章。后者只能传入一个对象作为唯一的实参,前者则可以传入任意数量任何类型的实参。前者的目的是让输出的内容被人读起来感觉更加友好,后者的唯一目的是全面而详实。

一些事件背后的真相你知道吗,我们看到的是被“修饰”过的所谓真相吗,我学过这种被营造出来的氛围在新闻传播里叫“拟态环境”,媒体只报道希望让我们看到的,避重就轻地。我们在社交时何尝不是避重就轻,把好的呈现在社交平台上,这是真实的你吗。道听途说以讹传讹有多可怕,追根溯源,真相需要被实践找回。回归正题。

Node 的其中一个模块 util 中的 inspect 函数提供检查对象的功能,试试看是不是比 console 输出的更全面。将 showHidden 设置为 true 能把不可枚举的属性输出出来。设置 depth 属性控制展开层级,我发现设置到 4 刚刚好全部展开。开始 colors 属性让终端输出的内容不至于一个颜色,不过我把代码复制到博客里,使用的就是 highlight.js 的配色了:

const util = require('util');
const Koa = require('koa');

console.log(util.inspect(Koa,{
  showHidden: true,
  depth: 4,
  colors: true,
}));

// 终端输出:
[Function: Application] {
  [length]: 1,
  [prototype]: Application {
    [constructor]: [Circular],
    [listen]: [Function: listen] { [length]: 0, [name]: 'listen' },
    [toJSON]: [Function: toJSON] { [length]: 0, [name]: 'toJSON' },
    [inspect]: [Function: inspect] { [length]: 0, [name]: 'inspect' },
    [use]: [Function: use] { [length]: 1, [name]: 'use' },
    [callback]: [Function: callback] { [length]: 0, [name]: 'callback' },
    [handleRequest]: [Function: handleRequest] { [length]: 2, [name]: 'handleRequest' },
    [createContext]: [Function: createContext] { [length]: 2, [name]: 'createContext' },
    [onerror]: [Function: onerror] { [length]: 1, [name]: 'onerror' }
  },
  [name]: 'Application',
  HttpError: [Function: HttpError] {
    [length]: 0,
    [name]: 'HttpError',
    [prototype]: [HttpError] { [constructor]: [Circular] },
    [super_]: [Function: Error] {
      [length]: 1,
      [name]: 'Error',
      [prototype]: Error {
        [constructor]: [Circular],
        [name]: 'Error',
        [message]: '',
        [toString]: [Function: toString] { [length]: 0, [name]: 'toString' }
      },
      [captureStackTrace]: [Function: captureStackTrace] {
        [length]: 2,
        [name]: 'captureStackTrace'
      },
      stackTraceLimit: 10,
      prepareStackTrace: undefined
    }
  }
}

使用 Application 构造函数实例化后的 app 拥有从原型链上继承来的属性,从上方代码看到 prototype 这个单词就能猜到这就是 Application.prototype。所以实例化后的 app 简介的才可以调用譬如 app.listen() app.use() app.callback() app.createContext() 等方法。单独把这部分原型拿出来:

[prototype]: Application {
  [constructor]: [Circular],
  [listen]: [Function: listen] { [length]: 0, [name]: 'listen' },
  [toJSON]: [Function: toJSON] { [length]: 0, [name]: 'toJSON' },
  [inspect]: [Function: inspect] { [length]: 0, [name]: 'inspect' },
  [use]: [Function: use] { [length]: 1, [name]: 'use' },
  [callback]: [Function: callback] { [length]: 0, [name]: 'callback' },
  [handleRequest]: [Function: handleRequest] { [length]: 2, [name]: 'handleRequest' },
  [createContext]: [Function: createContext] { [length]: 2, [name]: 'createContext' },
  [onerror]: [Function: onerror] { [length]: 1, [name]: 'onerror' }
},

从这里开始,“输出你就完事了”这句至理名言已经完成了他的使命。不得不承认,虽然“输出”能在猜想和印证阶段帮助我们,但是玩好Koa的游戏规则,就写在Koa的官方文档里,而官方文档里后续的部分,无非都是在解释上面输出的内容。

在Koa里,我们需要设置的三个 app 属性如下,这也正是上方 console.log(app) 函数调用后输出在终端的三个属性:

  • app.env
  • app.proxy
  • app.subdomainOffset

可以在实例化 Koa 时通过传入带有这三个属性的对象来设置这三个属性,也可以在创建完 Koa 对象后动态的设置这三个属性。经过实践,console.dir() 输出的全部属性都可以在实例化对象时被设置。在创建对象时设置属性和动态设置属性的区别在于,前者会被内部逻辑过滤掉那些本不存在于对象中的属性,而后者则可以像为普通对象添加新属性那样操作。

比如 Koa 实例对象中并不存在 name 属性,但如果在实例化应用时传入了包含 name 属性的对象。最后输出发现,name 属性被过滤掉了。

const app = new Koa({
  name: 'airglass'
})
console.dir(app); // 不存在 name 属性
console.log(app); // 依然只有三个属性

Koa 中的中间件用法和 Express 相似,但又不完全相同。在 Express 中为 app 添加中间件使用 app.use() 方法,这是 Koa 和 Express 相同的地方。Express 中使用 use 方法时,第一个参数接收的是字符串,字符串表示正则匹配的路由地址,第二个参数接收的是函数,函数会被传入两个参数,一个代表客户端的请求 req,一个代表服务器端的响应 res。

在 Koa 中使用注册中间件的 use 方法所传递的参数数量和 Express 不同,不是两个,而是一个,只需传递一个总是返回 Promise 实例的函数,该函数会被传入两个参数,一个叫做 ctx 代表包含客户端请求和服务器端响应以及整个 app 属性的上下文,和链条上的下一个注册的中间件。看看示例代码:

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);

把上面的代码复制到在任何代码编辑器内新建的脚本文档中,保存,在终端使用 node 执行该文件中的代码。打开浏览器,输入 localhost:3000,看到网页内显示 Hello World。终端输出了访问的地址和服务器端响应向客户端发送完毕所用的毫秒数。

Koa搭建简易Web示例服务

上面的示例代码已经把 Koa 框架最核心的东西展示出来了。核心的内容主要有两点:

  1. 级联中间件。
  2. Async/Await特性锦上添花。

我相信,闭上了眼睛,反而能看到更多。《小王子》里说,本质的东西用眼睛是看不见的。如果捡现成,看似聪明,其实失去了更多。

看了上面的示例代码,试着猜一猜 Koa 和 Express 的中间件实现原理。从示例代码的运行规律发现,Koa 和 Express 对中间件的注册顺序非常敏感,这就好像串糖葫芦,每使用 app.use() 注册一个中间件,就像往竹签子上串了一枚冰糖山楂。用户每一次访问主机提供web服务的端口,Koa每次都会从第一个中间件开始执行,如果第一个中间件内没有调用 next() 方法执行下一个中间件,则后面所有的中间件都不会执行。这很像糖葫芦要一个一个吃。

注册完中间件后再输出 app,发现 middleware 从一个空数组变成了拥有三个异步执行的函数,使用全等运算符发现这三个函数按中间件注册的先后顺序一一对应到三个中间件。

Application {
  ...
  middleware: [ [AsyncFunction], [AsyncFunction], [AsyncFunction] ]
  ...
}

阅读 Koajs 的源代码,发现在 lib/application.js 模块里定义了 Application 类,在它的实例方法 use 里发现了向 middleware 数组 push 中间件函数的操作:

use(fn) {
  if (typeof fn !== 'function') throw new TypeError('middleware must be a function!');
  ...
  this.middleware.push(fn);
  return this;
}

关于Async/Await语法的使用可以参考ECMAScript+最新版的文档。

接下来完整阅读Koa APIKoajs官方文档。在重构博客后端代码的过程中遇到问题,解决问题,再来更新这篇文章。我猜可能会遇到的问题也就是原来在 Express 框架中用到模块在 Koa 中是否依然能用。比如解析POST表单的客户端请求,还有Pug模版引擎,以及socket.io如何融合 Koa 等问题。

不过上面我猜想的问题都已经不是问题,Koa很灵活很小巧,他可以和 Express 配合使用,所以我完全不用担心 Koa 的加入会阻碍什么,反而 Koa 的介入能“激活和更新换代”一些老旧的东西。