Koa:3.0.0-alpha.1
围绕主要以下几点介绍
- 洋葱模型
- context
- 错误处理
- 委托模式
先来写一个简单的koa demo。
const Koa = require("koa");
const app = new Koa();
app.use(async (ctx, next) => {
ctx.body = "Hello, world";
});
app.listen(3000);
可以看到,app.js里引入了koa,然后用new来实例化一个app,之后我们使用了app.use传入一个async函数,也就是kao中间件,最后调用app.listen方法,koa应用就跑起来了。
koa 入口
我们先来看koa源码,入口文件为application.js。这里暴露了一个Application类,继承于Emitter。
constructor (options) {
super()
options = options || {}
// 是否为proxy模式
this.proxy = options.proxy || false
this.subdomainOffset = options.subdomainOffset || 2
// proxy自定义头部
this.proxyIpHeader = options.proxyIpHeader || 'X-Forwarded-For'
// 代理服务器的最大数量
this.maxIpsCount = options.maxIpsCount || 0
// 环境变量
this.env = options.env || process.env.NODE_ENV || 'development'
this.compose = options.compose || compose
// 自定义cookie 密钥
if (options.keys) this.keys = options.keys
// 中间件数组
this.middleware = []
// 这里使用object.create的原因主要是防止app污染 koa可以new 多个实例
this.context = Object.create(context)
this.request = Object.create(request)
this.response = Object.create(response)
// util.inspect.custom support for node 6+
/* istanbul ignore else */
if (util.inspect.custom) {
this[util.inspect.custom] = this.inspect
}
// 内聚了用于管理异步上下文的工具
if (options.asyncLocalStorage) {
const { AsyncLocalStorage } = require('async_hooks')
assert(AsyncLocalStorage, 'Requires node 12.17.0 or higher to enable asyncLocalStorage')
this.ctxStorage = new AsyncLocalStorage()
}
}
app.use
use (fn) {
if (typeof fn !== 'function') throw new TypeError('middleware must be a function!')
debug('use %s', fn._name || fn.name || '-')
this.middleware.push(fn)
return this
}
这里主要是把中间件放置到middleware数组中。
app.listen
listen (...args) {
debug('listen')
// this.callback()返回的是一个 (req,res)=> any 的函数
const server = http.createServer(this.callback())
return server.listen(...args)
}
这个方法是封装了http模块提供的createServer和listen方法,然后将this.callback()传入,我们看一下这个callback做了什么
callback () {
// compose就是koa中间件洋葱模型的核心了
const fn = this.compose(this.middleware)
if (!this.listenerCount('error')) this.on('error', this.onerror)
const handleRequest = (req, res) => {
// 将req, res包装成一个ctx返回
const ctx = this.createContext(req, res)
if (!this.ctxStorage) {
return this.handleRequest(ctx, fn)
}
// 如果启用了异步管理上下文 才会用到 本次不涉及到
return this.ctxStorage.run(ctx, async () => {
return await this.handleRequest(ctx, fn)
})
}
return handleRequest
}
不难看出,这里的核心逻辑主要是compose的洋葱模型,createContext创建一个统一上下文,handleRequest处理中间件的调用以及结果的返回。
compose createContext handleRequest
compose
function compose (middleware) {
if (!Array.isArray(middleware)) throw new TypeError('Middleware stack must be an array!')
for (const fn of middleware) {
if (typeof fn !== 'function') throw new TypeError('Middleware must be composed of functions!')
}
/**
* @param {Object} context
* @return {Promise}
* @api public
*/
return function (context, next) {
// last called middleware #
// 这个是为了防止多次调用next
let index = -1
return dispatch(0)
function dispatch (i) {
// 当在同一个中间件多次调用next的时候 报错
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)
}
}
}
}
compose是一个高阶函数,函数内部返回一个dispatch函数,dispatch调用并传入0,fn就从middleware的第一个中间件取值,然后返回被Promise包裹的、fn的执行结果。fn在调用时传入两个参数,一个context上下文;一个是dispatch.bind(null, i + 1)也就是next,调用next,就可以把函数的执行权交给下一个中间件,待其执行完,在回过头继续执行自身,就是所谓的洋葱模型。
createContext
createContext (req, res) {
/** @type {Context} */
const context = Object.create(this.context)
/** @type {KoaRequest} */
const request = context.request = Object.create(this.request)
/** @type {KoaResponse} */
const response = context.response = Object.create(this.response)
context.app = request.app = response.app = this
context.req = request.req = response.req = req
context.res = request.res = response.res = res
request.ctx = response.ctx = context
request.response = response
response.request = request
context.originalUrl = request.originalUrl = req.url
context.state = {}
return context
}
这里的作用主要是创建一个全局唯一的上下文,每次请求都生成一个context,保证隔离。并且context内聚了req、res等,通过一个context可以访问到。
handleRequest
handleRequest (ctx, fnMiddleware) {
const res = ctx.res
res.statusCode = 404
const onerror = err => ctx.onerror(err)
const handleResponse = () => respond(ctx)
// 在一个http请求后执行回掉 感兴趣可以看看on-finished这个包
onFinished(res, onerror)
return fnMiddleware(ctx).then(handleResponse).catch(onerror)
}
首先将状态码赋予404,然后调用传入的中间件函数成功调用handleResponse,发生错误则调用onerror。先来看看handleResponse中的respond函数。
function respond (ctx) {
// allow bypassing koa
if (ctx.respond === false) return
if (!ctx.writable) return
const res = ctx.res
let body = ctx.body
const code = ctx.status
// ignore body
if (statuses.empty[code]) {
// strip headers
ctx.body = null
return res.end()
}
if (ctx.method === 'HEAD') {
if (!res.headersSent && !ctx.response.has('Content-Length')) {
const { length } = ctx.response
if (Number.isInteger(length)) ctx.length = length
}
return res.end()
}
// status body
if (body === null || body === undefined) {
if (ctx.response._explicitNullBody) {
ctx.response.remove('Content-Type')
ctx.response.remove('Transfer-Encoding')
ctx.length = 0
return res.end()
}
if (ctx.req.httpVersionMajor >= 2) {
body = String(code)
} else {
body = ctx.message || String(code)
}
if (!res.headersSent) {
ctx.type = 'text'
ctx.length = Buffer.byteLength(body)
}
return res.end(body)
}
// responses
if (Buffer.isBuffer(body)) return res.end(body)
if (typeof body === 'string') return res.end(body)
if (body instanceof Stream) return body.pipe(res)
if (body instanceof Blob) return Stream.Readable.from(body.stream()).pipe(res)
if (body instanceof ReadableStream) return Stream.Readable.from(body).pipe(res)
if (body instanceof Response) return Stream.Readable.from(body?.body).pipe(res)
// body: json
body = JSON.stringify(body)
if (!res.headersSent) {
ctx.length = Buffer.byteLength(body)
}
res.end(body)
}
主要是针对ctx.body返回不同情况的处理,如method为head时加上content-length字段、body为空时去除content-length等字段,返回相应状态码、body为Stream时使用pipe等。
再来看看ctx.onerror。他是上述createContext函数中创建的context
const context = Object.create(this.context)
this.context是context.js里的文件内容。我们这里主要看onerror的定义
onerror (err) {
// don't do anything if there is no error.
// this allows you to pass `this.onerror`
// to node-style callbacks.
if (err == null) return
// When dealing with cross-globals a normal `instanceof` check doesn't work properly.
// See https://github.com/koajs/koa/issues/1466
// We can probably remove it once jest fixes https://github.com/facebook/jest/issues/2549.
// 是否是原生错误
const isNativeError =
Object.prototype.toString.call(err) === '[object Error]' ||
err instanceof Error
if (!isNativeError) err = new Error(util.format('non-error thrown: %j', err))
let headerSent = false
if (this.headerSent || !this.writable) {
headerSent = err.headerSent = true
}
// emit 这个错误
// 对应的是 在 application.callback 中
// if (!this.listenerCount('error')) this.on('error', this.onerror)
this.app.emit('error', err, this)
// nothing we can do here other
// than delegate to the app-level
// handler and log.
if (headerSent) {
return
}
const { res } = this
// first unset all headers
/* istanbul ignore else */
if (typeof res.getHeaderNames === 'function') {
res.getHeaderNames().forEach(name => res.removeHeader(name))
} else {
res._headers = {} // Node < 7.7
}
// then set those
this.set(err.headers)
// force text/plain
this.type = 'text'
let statusCode = err.status || err.statusCode
// ENOENT support
if (err.code === 'ENOENT') statusCode = 404
// default to 500
if (typeof statusCode !== 'number' || !statuses[statusCode]) statusCode = 500
// respond
const code = statuses[statusCode]
const msg = err.expose ? err.message : code
this.status = err.status = statusCode
this.length = Buffer.byteLength(msg)
res.end(msg)
},
这里主要是把error响应了。我们主要来看看this.set(err.headers),是对这个错误的ctx进行修改。如header设置成err.headers、statusCode设置成err.status等等。这里的set方法是利用了委托模式 delegates。
delegate(proto, 'response')
.method('attachment')
.method('redirect')
.method('remove')
.method('vary')
.method('has')
.method('set')
.method('append')
.method('flushHeaders')
.access('status')
.access('message')
.access('body')
.access('length')
.access('type')
.access('lastModified')
.access('etag')
.getter('headerSent')
.getter('writable')
Delegator.prototype.method = function(name){
var proto = this.proto;
var target = this.target;
this.methods.push(name);
proto[name] = function(){
return this[target][name].apply(this[target], arguments);
};
return this;
};
target.name包装一层函数赋值给proto.name,也就是将target上的函数也能让proto去调用。
总结
- 洋葱模型的原理主要是,函数内部返回一个dispatch函数,dispatch调用并传入0,fn就从middleware的第一个中间件取值,然后返回被Promise包裹的、fn的执行结果。fn在调用时传入两个参数,一个context上下文;一个是dispatch.bind(null, i + 1)也就是next,调用next,就可以把函数的执行权交给下一个中间件,待其执行完,在回过头继续执行自身。
- 在每次请求时会创建一个全局唯一的上下文,保证隔离。并且context内聚了req、res等,通过一个context可以访问到。
- 使用委托模式使得自身对象能够调用nodejs原生对象上的方法,使用时直接在自身对象调用,不需要深入到原生对象上