Koa 的基本使用与部分源码探究

之前我们已经对 Express 进行了学习,今天要介绍的 Koa 同样出自于 Express 团队之手,但其定位,按照官方文档的说发,是下一代的 web 服务器框架 :

next generation web framework for node.js

相比于内置了许多功能的 express,koa 更加简洁,想实现某些功能,需要另外安装一些库。下面就开始介绍如何使用 koa 来快速搭建服务器。

快速搭建

首先是安装 koa:

npm i koa
复制代码

之后在文件中导入,并使用 new 的方式创建 app,而不是像 express 那样直接调用 express():

// src\main.js 代码片段 1.1
const Koa = require('koa')
const app = new Koa()
复制代码

使用 app.listen() 启动服务器,传入要监听的端口号,这与 express 一样:

// src\main.js 代码片段 1.2
app.listen(4396, () => {
  console.log('服务器启动成功')
})
复制代码

常量配置与 .env 文件的使用

为了方便修改代码片段 1.2 中的端口号,我们其实可以在项目根目录下新建个 .env 文件,然后在其中配置:

# .env
SERVER_PORT = 4396
复制代码

然后可以新建个 src\config\server.config.js 文件将常量统一管理:

const dotenv = require('dotenv')
dotenv.config()
const SERVER_PORT = process.env.SERVER_PORT
module.exports = {
  SERVER_PORT
}
复制代码

dotenv 需要先进行安装 :

npm i dotenv
复制代码

然后调用 dotenv.config() 后,就可以通过 process.env 拿到项目根目录下 .env 文件中定义的 SERVER_PORT 了。最后,在 main.js 中就可以使用 server.config.js 导出的 SERVER_PORT 来替换原本代码片段 1.2 中的 4396

// src\main.js
const { SERVER_PORT } = require('./config/server.config')
app.listen(SERVER_PORT, () => {
  console.log('服务器启动成功')
})
复制代码

部分源码探究

new Koa()

如果在去代码片段 1.1 的 const app = new Koa() 处打上断点,通过调试去查看 koa 的源码,可以看到实际上是执行了 new Application(),也就是去执行了 Application 类的构造方法 :

// node_modules\koa\lib\application.js
module.exports = class Application extends Emitter {
  constructor(options) {
    // ...
    this.middleware = [];
    this.context = Object.create(context); // 上下文对象
    this.request = Object.create(request); // 请求对象
    this.response = Object.create(response); // 响应对象
  }
}
复制代码

这里提前说明下,middleware 用于收集传入 app.use() 的中间件函数;context 为中间函数的第 1 个参数 ctx,具体后文还会介绍。

app.listen()

查看 listen 方法,可以看到其本质上还是使用了 node 的 http 模块创建的服务:

// node_modules\koa\lib\application.js
const http = require('http');
listen(...args) {
  debug('listen');
  const server = http.createServer(this.callback());
  return server.listen(...args);
}
复制代码

当请求发生时,就会调用传入 http.createServer() 的回调,也就是 this.callback() 的返回值,所以我们得去看看 callback 方法:

// node_modules\koa\lib\application.js
callback() {
  const fn = compose(this.middleware);
  if (!this.listenerCount('error')) this.on('error', this.onerror);
  const handleRequest = (req, res) => {
    const ctx = this.createContext(req, res);
    return this.handleRequest(ctx, fn);
  };
  return handleRequest;
}
复制代码

可以看到,当请求发生时,实际上执行的是 handleRequest,在其内部会创建 ctx,并且还会去调用 app 的 handleRequest 方法,我们会在后文探究其具体定义。

中间件

koa 中也有中间件,并且不像 express 那样可以使用什么 app.post() 等方法,而是只能使用 app.use() 的方式来传入中间件函数,中间件函数会被传入 2 个参数,ctxnext,express 中的 reqres 在 koa 中都被上下文对象 ctx 替代。响应数据的方式则是通过给 ctx.body 赋值,如果想使用 end 方法,则需要通过 ctx.res.end('Hello Juejin')

app.use((ctx, next) => {
  ctx.body = 'Hello Juejin'
})
复制代码

ctx

通过前面对源码的查看,我们知道每次请求时,都会通过 const ctx = this.createContext(req, res) 创建一个 ctx 对象,可以通过 ctx.requestctx.response 来获取 koa 封装的请求对象和响应对象,比如打印一个 GET 请求的 ctx.request 结果如下:

{
  method: 'GET',
  url: '/search?name=Jay&age=20',
  header: {
    'user-agent': 'Apifox/1.0.0 (https://www.apifox.cn)',
    accept: '* / *',
    host: 'localhost:4396',
    'accept-encoding': 'gzip, deflate, br',
    connection: 'keep-alive',
    'content-type': 'multipart/form-data; boundary=--------------------------092047209466522747437726',
    'content-length': '904762'
  }
}
复制代码

可以看到里面有请求的 method 、url 和 header 信息,但其实直接打印 ctx.request.path,也能获取到值为 /search,并且大部分在 ctx.requestctx.response 属性,都能直接在 ctx 上获取到,比如可以获取 ctx.pathctx.method。 而通过 ctx.reqctx.res 则可以分别获取 node 的请求和响应对象,也就是使用 http.createServer((req, res) => {}) 来创建服务时,传入的回调里的 reqres, 其中包含的属性相对较多,这也是为何前面说想使用 end 方法返回数据需要通过 ctx.res 调用的原因。

next

同 express 一样,next 用于调用栈中的下一个中间件函数。比如有如下例子,我们将响应 ctx.body = 'hello' 写在于第 1 个中间件函数内,并放置于 next() 之后:

// 代码片段 2.1
app.use((ctx, next) => {
  console.log(1)
  next()
  console.log(5)
  ctx.body = 'hello'
})
app.use((ctx, next) => {
  console.log(2)
  next()
  console.log(4)
})
app.use((ctx, next) => {
  console.log(3)
})
复制代码

当接收到请求时,打印的顺序会是 1 -2 - 3 - 4 - 5,这种执行顺序就是所谓的“洋葱模型”: image.png

异步代码执行顺序(洋葱模型)

如果其中有异步代码,比如最后一个中间件函数内有一个定时器:

// 代码片段 2.2
app.use((ctx, next) => {
  console.log(1)
  next()
  console.log(5)
  ctx.body = 'hello'
})
app.use((ctx, next) => {
  console.log(2)
  next()
  console.log(4)
})
app.use((ctx, next) => {
  setTimeout(() => {
    console.log(3)
  }, 1000)
})
复制代码

那么接收到请求后的打印顺序将是 1 - 2 - 4 - 5 - 3,4 和 5 的打印并不会等待异步代码执行完毕。如果仍旧想让打印顺序为 1 -2 - 3 - 4 - 5,也就是想符合洋葱模型,可以借助 async/await 改成下面这样:

// 代码片段 2.3
app.use(async (ctx, next) => {
  console.log(1)
  await next()
  console.log(5)
  ctx.body = 'hello'
})
app.use(async (ctx, next) => {
  console.log(2)
  await next()
  console.log(4)
})
app.use(async (ctx, next) => {
  await new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log(3)
      resolve()
    }, 1000)
  })
})
复制代码

app.use() 源码探究

其原因可通过直接在代码片段 2.3 的第 1 个 app.use 处打上断点,去查看 koa 的源码来一探究竟:

// node_modules\koa\lib\application.js
use(fn) {
  if (typeof fn !== 'function') throw new TypeError('middleware must be a function!');
  if (isGeneratorFunction(fn)) {
    deprecate('Support for generators will be removed in v3. ' +
              'See the documentation for examples of how to convert old middleware ' +
              'https://github.com/koajs/koa/blob/master/docs/migration.md');
    fn = convert(fn);
  }
  debug('use %s', fn._name || fn.name || '-');
  this.middleware.push(fn);
  return this;
}
复制代码

可以看到在 use 方法中,主要是在第 11 行执行的 this.middleware.push(fn),将我们传入 app.use() 的回调函数 fn,也就是

async (ctx, next) => {
  console.log(1)
  await next()
  console.log(5)
  ctx.body = 'hello'
}
复制代码

加入到 middleware 数组中,之后会依次执行我们定义的下一个 app.use(),最终将第 2、 3 个回调函数也加入到 middleware 中:

image.png

那么加入到 middleware 中的这些回调什么时候执行呢?前文在查看源码中的 callback 方法时提到,当每次请求发生时,会执行的其实是 handleRequest,并且会返回 this.handleRequest(ctx, fn),也就是 app 的 handleRequest

// 代码片段 3.1
// node_modules\koa\lib\application.js
handleRequest(ctx, fnMiddleware) {
  const res = ctx.res;
  res.statusCode = 404;
  const onerror = err => ctx.onerror(err);
  const handleResponse = () => respond(ctx);
  onFinished(res, onerror);
  return fnMiddleware(ctx).then(handleResponse).catch(onerror);
}
复制代码

fnMiddleware 就是前面 callback 里定义的 fn,其值为 compose(this.middleware) 的返回值,也是一个函数,这个函数的返回值为 dispatch 的执行,执行的返回值为 Promise.resolve()Promise.reject()

// node_modules\koa-compose\index.js
function compose (middleware) {
  // ...
  return function (context, next) {
    // last called middleware #
    let index = -1
    return dispatch(0)
    function dispatch (i) {
      if (i <= index) return Promise.reject(new Error('next() called multiple times'))
      index = i
      let fn = middleware[i]
      if (i === middleware.length) fn = next
      if (!fn) return Promise.resolve()
      try {
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
      } catch (err) {
        return Promise.reject(err)
      }
    }
  }
}
复制代码

可以看到,当请求发生时,传入第 1 个 app.use() 的回调是在执行 dispatch(0) 时,从 middleware 内获取赋值给 fn,然后通过 Promise.resolve(fn(context, dispatch.bind(null, i + 1))) 执行的,fn()在执行时传入了 contextdispatch.bind(null, i + 1),所以这个 dispatch 其实就是传入 app.use() 的回调的第 2 个参数 next。 当我们在第 1 个 app.use 的中间件函数中执行完 console.log(1),调用了 await next(),即执行了 await dispatch(1), 就会从 middleware 中取出第 2 个回调执行,打印了 console.log(2),然后也调用了 await next(),于是就会执行 await dispatch(2),取出我们定义的最后一个中间件函数执行:

async (ctx, next) => {
  await new Promise(resolve => {
    setTimeout(() => {
      console.log(3)
      resolve()
    }, 1000)
  })
}
复制代码

因为使用了 async/await 与 promise,所以会等待 console.log(3) 执行完,即dispatch(2) 的返回值Promise.resolve() 内的 fn() 执行完毕,第 2 个中间件回调的 await next() 有了结果,继续执行 console.log(4)。然后 dispatch(1) 执行完毕,第 1 个中间件回调的 await next() 也有了结果,最后执行 console.log(5)ctx.body = 'hello3'。至此,赋值为第 1 个中间函数的 fn() 执行完毕,Promise.resolve(fn()) 返回,作为 dispatch(0) 的执行结果返回,作为代码片段 3.1 的 fnMiddleware(ctx) 的执行结果,然后才会执行之后的 then() 方法里的回调 handleResponse,最终通过 res.end(body) 发送响应数据:

// node_modules\koa\lib\application.js
function respond(ctx) {
  // ...
  if ('string' === typeof body) return res.end(body);
}
复制代码

通过对源码的探究,我们明白了如何让 koa 的中间件函数在执行异步操作时也能符合洋葱模型,而这点正是 koa 与 express 的区别之一,express 的中间件函数在执行同步代码时也符合洋葱模型,但在执行异步代码的时候则不符合。

路由的安装与使用

如果想让中间件针对某个特定的请求方法或路径,比如请求方法为 GET,路径为 '/article/list' 的请求,在 koa 中我们需要自己安装第三方的路由库,比如由官方团队提供的 @koa/router

npm install @koa/router
复制代码

安装完成后我们可以在项目新建 src\router\article.router.js 文件,导入 @koa/router 并通过 new 得到路由对象 articleRouter,下例中我们还传入了配置对象让路径的前缀 prefix'/article'

// src\router\article.router.js
const KoaRouter = require('@koa/router')
const articleRouter = new KoaRouter({ prefix: '/article' })
复制代码

之后就能像在 express 那样定义匹配方法和路径的中间件了(定义接口):

// src\router\article.router.js
articleRouter.get('/list', (ctx, next) => {
  ctx.body = 'Hello Juejin'
})
module.exports = articleRouter
复制代码

在 src\main.js 我们还需要使 KoaRouter 生效,其方法为向 app.use() 传入 articleRouter.routes()

// src\main.js
const Koa = require('koa')
const articleRouter = require('./router/articleRouter')
const { SERVER_PORT } = require('./config/server.config')

const app = new Koa()
app.use(articleRouter.routes())
app.use(articleRouter.allowedMethods())

app.listen(SERVER_PORT, () => {
  console.log('服务器启动成功')
})
复制代码

我们还可以通过执行 app.use(articleRouter.allowedMethods()),在请求方法不匹配我们的定义时,返回稍为具体的错误信息,比如当请求方法为 POST 时,就会返回 'Method Not Allowed' 而不是默认的 'Not Found':

image.png

请求参数的获取

接下来看看 koa 中如何获取请求携带的参数。

query 参数

假设现有 url 为 localhost:4396/article?name=Jay 的 GET 请求,我们可以直接从 ctxctx.request 中获取 query 对象:

// src\router\article.router.js
articleRouter.get('/', (ctx, next) => {
  console.log(ctx.request.query) // [Object: null prototype] { name: 'Jay' }
  console.log(ctx.query) // [Object: null prototype] { name: 'Jay' }
  ctx.body = 'Hello Juejin'
})
复制代码

在实际项目中,如果在中间件函数中处理的逻辑比较复杂,可以把它抽离到 controller 文件中:

// src\controller\article.controller.js
class ArticleController {
  create(ctx, next) {
    console.log(ctx.request.query)
  	console.log(ctx.query)
  	ctx.body = 'Hello Juejin'
  }
}

module.exports = new ArticleController()
复制代码

在 src\router\article.router.js 中仅需专注于处理路径和处理函数 controller 的映射关系:

// src\router\article.router.js
const { create } = require('../controller/article.controller')
articleRouter.get('/', create)
复制代码

如果还需要做一些额外的处理,比如用户的身份验证等,可以另外定义中间件,将它们定义在 middlewar 目录下。

params 参数

假设现有 url 为 localhost:4396/article/123 的 GET 请求,其中 123 是 id 值,为 params 参数,我们也可以直接从 ctxctx.request 中获取 params 对象:

articleRouter.get('/:id', (ctx, next) => {
  console.log(ctx.request.params.id) // 123
  console.log(ctx.params.id) // 123
  ctx.body = 'Hello Juejin'
})
复制代码

body 内的参数

对于放在 body 里携带的 json 和 x-www-form-urlencoded 类型的参数,我们可以使用 koa 团队出品的 koa-bodyparser 来帮助解析。首先是安装:

npm i koa-bodyparser
复制代码

然后进行导入并使用:

const bodyParser = require('koa-bodyparser')
app.use(bodyParser())
复制代码

可以看到对 app 的处理越来越多,我们可以将 app 相关的代码抽离到一个单独的文件 src\app\index.js

// src\app\index.js
const koa = require('koa')
const bodyParser = require('koa-bodyparser')
const articleRouter = require('./router/articleRouter')

const app = new koa()

app.use(bodyParser())
app.use(articleRouter.routes())
app.use(articleRouter.allowedMethods())

module.exports = app
复制代码

然后在 src\main.js 中只是引入 app 然后启动服务器:

const app = require('./app')
const { SERVER_PORT } = require('./config/server.config')

app.listen(SERVER_PORT, () => console.log('服务器启动成功'))
复制代码

现在,body 里携带的参数就会被解析,然后存放到 ctx.request.body 内了:

json 类型

比如 body 内参数为 json 格式:
image.png

则直接通过 ctx.request.body 获取:

articleRouter.post('/', (ctx, next) => {
  console.log(ctx.request.body) // { name: 'Jay', content: '寻找周杰伦' }
  ctx.body = 'Hello Juejin'
})
复制代码

注意,这里就不能通过 ctx.body 获取了,因为 ctx.body 是用来返回数据的,默认值为 undefined

x-www-form-urlencoded 类型

同 json 类型的数据一样,也会被解析放到 ctx.request.body 中。

form-data 类型与文件上传

一般情况下只有涉及到文件上传我们才会使用 form-data 类型的参数,对其的解析需要使用另一个也是 koa 团队出品的 @koa/multer。使用方式与 express 解析 form-data 时用到的 multer 基本一样,另外这里也需要安装上 multer:

npm install --save @koa/multer multer
复制代码

当请求上传多文件时:

image.png

解析方式如下:

const multer = require('@koa/multer')
const upload = multer({ dest: 'uploads' })

articleRouter.post('/upload', upload.array('files'), (ctx, next) => {
  console.log(ctx.request.body) // [Object: null prototype] { name: 'Jay' }
  ctx.body = 'Hello Juejin'
})
复制代码

上传的多文件信息可以通过 ctx.request.files 获取。至于上传单文件或仅上传文本字段的处理,同 express 对 multer 使用一致,不再赘述。需要注意的是,即使是在 router\articleRouter.js 文件定义的 const upload = multer({ dest: 'uploads' })dest 的值也只需要直接写 'uploads',而不是 '../uploads',就可以将上传得到的文件存放在项目根目录下的 uploads 文件夹内。

部署静态资源

为了方便客户端查看上传的文件,我们可以将 uploads 目录设置为静态资源,不同于 express 可以直接使用内置的 express.static() 中间件函数,koa 需要安装 koa-static 来实现,它也是 koa 团队出品的:

npm install koa-static
复制代码

使用起来就和 express.static() 差不多了:

const serve = require('koa-static')
app.use(serve('./uploads'))
复制代码

将要设置为静态资源的目录传给 serve() 再传给 app.use() 即可。

响应数据的格式

koa 里使用给 ctx.body 赋值的形式向客户端返回数据,赋值的类型比较灵活,除了可以是字符串、对象、数组、null 外,还可以是 bufferstream

articleRouter.get('/', (ctx, next) => {
  ctx.body = Buffer.from('Hello Juejin')
})
复制代码

返回流时,为了客户端正确地展示数据,最好通过 ctx.type 设置下数据的类型,比如返回的是 png 图片:

articleRouter.get('/', (ctx, next) => {
  ctx.type = 'image/png'
  ctx.body = fs.createReadStream('./uploads/5fe9f46825383d74531ffc6b45aee423')
})
复制代码

默认情况下,当返回 null 时,状态码为 204,返回其它格式的数据时状态码为 200。如果想指定状态码,可以通过 ctx.statusctx.response.status 设置:

ctx.status = 201
ctx.response.status = 201
复制代码

错误处理

在 koa 中,无法像在 express 里那样将错误信息传给 next(),然后统一在 app.use((err, req, res, next) => {}) 处理。而是使用发射错误事件的方式 —— koa 会将 app 对象加到 ctx 对象上,而 app 对象前面通过查看源码也看到了,它是 Application 类的实例对象,并且继承自 Emitter

// node_modules\koa\lib\application.js
const Emitter = require('events');
复制代码

所以当需要返回错误信息时,就可以通过 ctx.app 发射一个 err 事件,并携带上错误信息和 ctx

articleRouter.get('/:id', (ctx, next) => {
  if (ctx.params.id !== '123') {
    ctx.app.emit('err', 1000, ctx)
  } else {
    ctx.body = 'Hello Juejin'
  }
})
复制代码

传入 ctx 是为了在统一处理错误,也就是监听 err 事件的地方(比如新建 src\utils\process-errors.js),最终能够通过 ctx.body 将信息响应给客户端:

// src\utils\process-errors.js
const app = require('../app')
app.on('err', (code, ctx) => {
  let msg = ''
  switch (code) {
    case 1000:
      msg = 'id 不存在'
      break
  	// 其它情况
    default:
      break
  }
  ctx.body = {
    code,
    msg
  }
})
复制代码

其中错误码 1000 也可以进一步优化,抽取为常量。

感谢.gif 点赞.png

本文正在参加「金石计划」

猜你喜欢

转载自juejin.im/post/7217279252314947644