Koa 是由 Express 背后的团队设计的一个新的 Web 框架,不过比 Express 更精简,更现代。具体来说:
一、中间件机制
Koa 中通过异步函数,实现中间件控制权传递到"下游"后,依然能够流回"上游",而非 Express 那样仅仅实现了控制权传递,无法追踪,实现了真正意义上的中间件级联调用。
二、上下文对象 context
中间件调用时接收的是上下文对象 context,而非像 Express 中间件那样接收原生 req
、res
对象;另外,context 对象上提供了一些快捷属性和方法,帮助开发者完成相关操作,使用更便捷。
下面,我们先来学习一下 Koa 的常用使用。
基本使用
先来看一下 Koa 的"hello world"程序。
javascript
const Koa = require('koa')
const app = new Koa()
app.use(async ctx => {
ctx.body = 'Hello World'
});
app.listen(3000)
跟 Express 相比,写起来更加简单了。与 Express 有 2 点不一样。
- Koa 应用是采用类实例方式创建的(
new Koa()
),而非 Express 的工厂函数方式创建的(express()
) - 中间件依然是采用
app.use()
方法收集,不过中间件接收的第 1 个参数变成了上下文对象ctx
,而非原生req
、res
对象,原生req
、res
对象可以通过ctx.req
、ctx.res
获取
Koa 禁止通过直接修改 ctx.res
的方式返回响应,包括:
res.statusCode
res.writeHead()
res.write()
res.end()
而必须使用 Koa 自己包装的 Response/Request 对象(通过 ctx.response
、ctx.request
获取)或是借助 ctx.body
、ctx.status
、ctx.message
、ctx.type
、ctx.set()
等这些快捷方式设置,快捷方式本质上是对 ctx.response
, ctx.request
的高层抽象,通常会使用快捷方式进行设置 。context
上的快捷方式分布可以查看这里(Request Alias)和这里(Response Alias)。
还有,跟 Express 一样,app.listen(3000)
实际上是 http.createServer(app.callback()).listen(3000)
的语法糖。
javascript
const Koa = require('koa')
const app = new Koa()
app.listen(3000);
// 等同于
// http.createServer(app.callback()).listen(3000);
当你还需要同时启动 https 服务时,要这样写。
javascript
const http = require('http')
const https = require('https')
const Koa = require('koa')
const app = new Koa()
http.createServer(app.callback()).listen(3000)
https.createServer(app.callback()).listen(3001)
当然,你可以同时注册多个中间件。
javascript
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);
与 Express 类似,中间件系统也是通过 next()
参数连接的。不过不同的是,next()
函数调用后会始终返回一个 Promise 对象,你可以通过 async 函数 + await next()
的方式,在后续中间件执行完成后,做一些操作,这是 Express 中间件系统无法实现的。
拿上面的案例来说:
首先,我们在第 2 个中间件通过 await next()
方式等待响应体设置后(ctx.body = 'Hello World'
),通过 ctx.set()
设置了响应头 X-Response-Time
。
同时,我们在第 1 个中间件中通过 await next()
方式监听第 2 个中间件完成执行,获取并打印响应头 X-Response-Time
的值。
app
上还提供了一个 .context
属性,它是传递给中间件 ctx
参数的原型对象。你可以通过编辑 app.context
向 ctx
添加其他属性,并对所有中间件可见。
javascript
app.context.db = db();
app.use(async ctx => {
console.log(ctx.db)
});
最后,Koa 没有专门的错误中间件。它会通过 app
上的 error
事件,捕获错误。
javascript
app.on('error', err => {
log.error('server error', err)
});
要说明但是,Koa 实例本身也是 EventEmitter
对象,可以触发(.emit(eventName)
)和监听事件(.on(eventName)
)。
以上我们就介绍完了 Koa 的基本使用。下面来看如何实现它。
代码实现
依据 Koa v2.15.0 版本代码。
查看 Koa 仓库源码,一共 4 个文件。
application.js
存放 Koa 和核心实现逻辑(包括 app.use()
,app.callback()
, app.listen()
),context.js
中存储上下文对象中的一些方法和属性,request.js
、response.js
则分别是 Koa 基于 req
、res
封装的对象。
我们的实现不会存放在不同的文件中,而是放在一个文件 koa.js
里面,也不会引入任何外部依赖。
Koa 类及实例属性
首先,Koa 是一个类,并且继承了 EventEmitter
。
javascript
const EventEmitter = require('events')
module.exports = class Application extends EventEmitter {
// TODO
}
初始化 app
实例上,有 3 个属性,context
、request
和 response
。
javascript
const context = {}
const response = {}
const request = {}
module.exports = class Application extends EventEmitter {
constructor() {
super()
this.context = Object.create(context)
this.request = Object.create(request)
this.response = Object.create(response)
}
}
.context
、.request
和 .response
属性分别基于 3 个原型对象创建,内容会随着我们的实现慢慢填充。
app.use()
Koa 我们先来实现 app.use()
方法,它是用来收集存储中间件的。
javascript
module.exports = class Application extends EventEmitter {
constructor() {
super()
this.middleware = []
// ...
}
use(fn) {
this.middleware.push(fn)
return this
}
}
实现比较简单,就是把传入进来中间件存储在内部的 middleware
数组属性上,为了实现链接调用,返回了 this
。
app.listen()
再来实现 app.listen()
方法。
javascript
module.exports = class Application extends EventEmitter {
// ...
listen(...args) {
return require('node:http').createServer(/* ? */).listen(...args)
}
}
通过"基本使用"一节的介绍,我们知道 /* ? */
中的 ?
对应的是 this.callback()
方法。
app.callback()
.callback()
包含 Koa 业务处理的核心逻辑,处理中间件数组的链式调用,并能正常处理返回。
javascript
module.exports = class Application extends EventEmitter {
// ...
listen(...args) {
return require('node:http').createServer(this.callback()).listen(...args)
}
callback() {
return (req、res) => {
// TODO
}
}
}
那该如何来实现呢?首先,我们会写一个 compose
函数来正确处理中间件的前端调用顺序。
compose() 函数
compose
函数类似 compose(middleware)(ctx)
,也就是说实际执行中间件逻辑的时候会传入上文对象 ctx
。
javascript
function compose(middleware) {
return function (context) {
// TODO
}
}
另外,内部 dispatch
函数专门用于执行中间件,你只要传入对应中间件的索引值就行。
javascript
function compose(middleware) {
return function (context) {
function dispatch(index) {
const fn = middleware[index]
// TODO
}
return dispatch(0)
}
}
在调用当前中间件时,除了会传递上下文对象,还会传递下一个中间件的触发函数 dispatch(index + 1)
作为 next()
参数,用于将控制权传递给下一个中间件。
diff
function compose(middleware) {
return function (context) {
function dispatch(index) {
const fn = middleware[index]
+ return fn(context, dispatch.bind(null, index + 1))
}
return dispatch(0)
}
}
如果不调用 next()
,那么执行流程会在当前中间件结束后就返回了。
同时,为了确保每个中间件是异步函数,返回一个 Promise
,还要额外加一个 Promise.resolve()
包装。
diff
function compose(middleware) {
return function (context) {
function dispatch(index) {
const fn = middleware[index]
- return fn(context, dispatch.bind(null, index + 1))
+ return Promise.resolve(fn(context, dispatch.bind(null, index + 1)))
}
return dispatch(0)
}
}
如果 fn()
调用抛错,还要能获取异常。
diff
function compose(middleware) {
return function (context) {
function dispatch(index) {
const fn = middleware[index]
+ try {
return Promise.resolve(fn(context, dispatch.bind(null, index + 1)))
+ } catch (err) {
+ return Promise.reject(err)
+ }
}
return dispatch(0)
}
}
当然,中间件都执行完了的时候,fn
是 undefined
,也要处理。
diff
function compose(middleware) {
return function (context) {
function dispatch(index) {
const fn = middleware[index]
+ if (!fn) {
+ return Promise.resolve()
+ }
try {
return Promise.resolve(fn(context, dispatch.bind(null, index + 1)))
} catch (err) {
return Promise.reject(err)
}
}
return dispatch(0)
}
}
写完 compose
,让我们重新回到 callback()
,继续写逻辑。
重回 app.callback()
app.callback()
中,我们首先使用 compose()
处理中间件数组,让这些中间件实现串行调用。
javascript
module.exports = class Application extends EventEmitter {
// ...
callback() {
return (req、res) => {
const fnMiddleware = compose(this.middleware)
const ctx = this.createContext(req、res)
return fnMiddleware(ctx).then(/* ? */).catch(/* ? */)
}
}
}
接下来调用 fnMiddleware
传入上下文对象,传入的 ctx
需要我们基于 req
、res
创建,创建的逻辑封装在 app.createContext
中,我们来看一下。
app.createContext()
app.createContext()
逻辑如下所示。
javascript
module.exports = class Application extends EventEmitter {
// ...
createContext(req、res) {
const context = Object.create(this.context)
const request = Object.create(this.request)
const response = Object.create(this.response)
context.request = request
context.response = response
context.req = request.req = response.req = req
context.res = request.res = response.res = res
context.app = request.app = response.app = this
return context
}
}
每次请求,我们都会重新创建包括 request
、response
、context
在内的 3 个对象,同时:
context
会部署request
、resopnse
属性,用于设置快捷属性。另外context
、request
、response
会部署res
、req
、app
属性,方便对原生对象进行代理访问,并能与应用实例进行交互
理解了 createContext()
,我们再来看 callback()
。
javascript
module.exports = class Application extends EventEmitter {
// ...
callback() {
return (req、res) => {
const fnMiddleware = compose(this.middleware)
const ctx = this.createContext(req、res)
return fnMiddleware(ctx).then(/* ? */).catch(/* ? */)
}
}
}
因为 Koa 禁止我们直接使用 ctx.res
对象设置响应信息,那么在所有中间件处理结束后,在 .then(/* ? */)
里就需要我们处理请求响应。
respond()
我们把响应逻辑抽象在 respond()
中,并传入 ctx
。
javascript
return fnMiddleware(ctx).then(() => respond(ctx)).catch(/* ? */)
respond
方法中,为避免复杂,我们只处理字符串类型响应和 JSON 对象响应。
javascript
function respond(ctx) {
let body = ctx.body
// body: string
if (typeof body === 'string') {
return ctx.res.end(body)
}
// body: json
body = JSON.stringify(body)
return ctx.res.end(body)
}
如你所见,内部通过 ctx.body
获取请求体数据(等同于 ctx.reposnse.body
,这块实现稍后说明),再对 body
值的类型进行检查,最后通过 ctx.res.end()
返回响应、终止请求。
ctx.onerror()
在 .catch(/* ? */)
里我们处理异常,这块逻辑(onerror
)定义在原型对象 context
中。
javascript
return fnMiddleware(ctx).then(() => respond(ctx)).catch((err) => ctx.onerror(err))
javascript
const context = {
onerror(err) {
this.app.emit('error', err, this)
let statusCode = err.status || err.statusCode || 500
const msg = err.message ?? require('node:http').STATUS_CODES[statusCode]
this.res.end(msg)
}
}
const request = {}
const response = {}
module.exports = class Application extends EventEmitter {
// ...
}
首先,在 app
实例上触发一个 error
事件,供外部监听。其次,使用异常消息作为响应数据返回。
接下来,再讲一讲 ctx.body
的实现。
ctx.body
ctx.body
其实是对 ctx.reposnse.body
属性的代理,我们写一个简单的实现。
javascript
const context = {
// ...
set body(val) {
this.response.body = val
},
get body() {
return this.response.body
}
}
而 response.body
属性实现如下。
javascript
const response = {
set body(val) {
this._body = val
// set the content-type only if not yet set
const setType = !this.has('Content-Type');
// string
if (typeof val === 'string') {
if (setType) this.type = /^\s*</.test(val) ? 'text/html' : 'text/plain'
return
}
// json
this.type = 'application/json'
},
get body() {
return this._body
},
set(field, val) {
this.res.setHeader(field, val)
},
remove(field) {
this.res.removeHeader(field)
},
has(field) {
return this.res.hasHeader(field)
},
set type(type) {
if (type) {
this.set('Content-Type', type)
} else {
this.remove('Content-Type')
}
}
}
观察可知:
- 我们将设置给
response.body
的值存储在了内部的._body
上 - 在设置
response.body
的时候,我们根据response.body
值类型,调整了Content-Type
响应头字段的值 - 继续在
response
上抽象了一连串针对底层res
对象的操作
以上,我们就差不多完成了所有 Koa 核心逻辑的编写。
整体代码
现在来回顾一下整体代码。
javascript
const EventEmitter = require('events')
const context = {
onerror(err) {
this.app.emit('error', err, this)
let statusCode = err.status || err.statusCode || 500
const msg = err.message ?? require('node:http').STATUS_CODES[statusCode]
this.res.end(msg)
},
set body(val) {
this.response.body = val
},
get body() {
return this.response.body
}
}
const response = {
set body(val) {
this._body = val
// set the content-type only if not yet set
const setType = !this.has('Content-Type');
// string
if (typeof val === 'string') {
if (setType) this.type = /^\s*</.test(val) ? 'text/html' : 'text/plain'
return
}
// json
this.type = 'application/json'
},
get body() {
return this._body
},
set(field, val) {
this.res.setHeader(field, val)
},
remove(field) {
this.res.removeHeader(field)
},
has(field) {
return this.res.hasHeader(field)
},
set type(type) {
if (type) {
this.set('Content-Type', type)
} else {
this.remove('Content-Type')
}
}
}
const request = {}
module.exports = class Application extends EventEmitter {
constructor() {
super()
this.middleware = []
this.context = Object.create(context)
this.request = Object.create(request)
this.response = Object.create(response)
}
use(fn) {
this.middleware.push(fn)
return this
}
listen(...args) {
return require('node:http').createServer(this.callback()).listen(...args)
}
callback() {
return (req、res) => {
const fnMiddleware = compose(this.middleware)
const ctx = this.createContext(req、res)
return fnMiddleware(ctx).then(() => respond(ctx)).catch((err) => ctx.onerror(err))
}
}
createContext(req、res) {
const context = Object.create(this.context)
const request = Object.create(this.request)
const response = Object.create(this.response)
context.request = request
context.response = response
context.req = request.req = response.req = req
context.res = request.res = response.res = res
context.app = request.app = response.app = this
return context
}
}
function compose(middleware) {
return function (context) {
function dispatch(index) {
const fn = middleware[index]
if (!fn) {
return Promise.resolve()
}
try {
return Promise.resolve(fn(context, dispatch.bind(null, index + 1)))
} catch (err) {
return Promise.reject(err)
}
}
return dispatch(0)
}
}
function respond(ctx) {
let body = ctx.body
// body: string
if (typeof body === 'string') {
return ctx.res.end(body)
}
// body: json
body = JSON.stringify(body)
return ctx.res.end(body)
}
连上注释加空格,一个 128 行代码,不过移除原型对象部分的(context、request、response)代码,核心逻辑也只有 80 行代码不到而已。
总结
本来我们讲述了轻量级 Web 框架 Koa 的基本使用及实现过程。细心的你可能会发现 Koa 核心库并未内路由支持,这部分实现被放在了 koa-router 包中。
相比较于 Express 的中间件机制,Koa 利用异步函数实现了可回流的中间件调用机制,而不是简单的控制权传递;另一个优势的地方在于基于原生 req
、res
对象进行了一层操作封装,也就是 Koa request、response 对象,让使用更加简单。
当然,本文只是对 Koa 框架核心功能的一个简单实现。其他像 options 支持、Koa Context/Request/Response 对象完整封装,大家有兴趣的话,可以参考源码进行学习。
本文就写到这里,感谢你的阅读,再见!