写这篇文档之前,我只想说四个字:膜拜大佬!!!!
本文会分为四个部分介绍
- 使用koa
- 源码结构
- 洋葱模型
- 错误处理
- 委托模式
使用koa
先来个小demo
const Koa = require("koa");
const app = new Koa();
app.use(async (ctx, next) => {
ctx.body = "hello,doing";
});
app.listen(3000);
于是我们就能在终端上看到hello,doing了
这里只介绍了koa的简单使用,更详细的可以看koa官方文档,接下来开始介绍koa源码结构
源码结构
首先先把代码拷贝到本地
git clone https://github.com/koajs/koa.git
看下koa源码的目录结构:
package.json中 "main": "lib/application.js"
可以得知入口为application.js文件中,我们先从lib文件开始学习
application.js
直接上源码(简化版):
先看构造函数constructor
constructor
module.exports = class Application extends Emitter {
constructor(options) {
super();
options = options || {}; //配置
this.proxy = options.proxy || false; //是否proxy模式
this.subdomainOffset = options.subdomainOffset || 2; //domain要忽略的偏移量
this.proxyIpHeader = options.proxyIpHeader || 'X-Forwarded-For'; //proxy自定义头部
this.maxIpsCount = options.maxIpsCount || 0; //代理服务器数量
this.env = options.env || process.env.NODE_ENV || 'development'; //环境变量
if (options.keys) this.keys = options.keys; // 自定义cookie 密钥
this.middleware = []; //中间件数组
this.context = Object.create(context);
this.request = Object.create(request);
this.response = Object.create(response);
if (util.inspect.custom) { //自定义检查,这里的作用是get app时,去执行this.inspect 。感兴趣可见http://nodejs.cn/api/util.html#util_util_inspect_custom
this[util.inspect.custom] = this.inspect;
}
if (options.asyncLocalStorage) { //支持asyncLocalStorage
const { AsyncLocalStorage } = require('async_hooks')
assert(AsyncLocalStorage, 'Requires node 12.17.0 or higher to enable asyncLocalStorage')
this.ctxStorage = new AsyncLocalStorage()
}
}
...
这边重点关注高亮的四行代码,其中contex request response分别对应lib文件下的其他子文件
this.middleware = []; //中间件数组
this.context = Object.create(context);
this.request = Object.create(request);
this.response = Object.create(response);
首先是一个中间件数组,用于存放use声明的中间件。
然后分别用Object.create拷贝的context、request、response。
这里用Object.create是因为我们在同一个应用中可能会有多个new Koa的app,为了防止这些app相互污染,用寄生组合的方法让其引用不指向同一个地址。
ok,接下来介绍application.js下的其他函数。
listen
/**
* Shorthand for:
*
* http.createServer(app.callback()).listen(...)
*
* @param {Mixed} ...
* @return {import('http').Server}
* @api public
*/
listen (...args) {
debug('listen')
const server = http.createServer(this.callback())
return server.listen(...args)
}
在demo中,我们就使用了listen这个函数,这个函数封装了http模块提供的http.createServer和listen方法,并传入了callback函数
callback () {
// compose为洋葱模型的核心,后续介绍
const fn = this.compose(this.middleware)
// koa的错误处理,后续介绍
// 注意这行代码,后面要考哈哈哈哈,this.on这里是继承Emitter类的监听器喔~
if (!this.listenerCount('error')) this.on('error', this.onerror)
// koa的委托
const handleRequest = (req, res) => {
// 将http的req和res封装在一个ctx中
const ctx = this.createContext(req, res)
// 分支持不支持asyncLocalStorage来讨论
if (!this.ctxStorage) {
// 注意这里不是返回handleRequest自己,而是koa实例上的handleRequest
return this.handleRequest(ctx, fn)
}
return this.ctxStorage.run(ctx, async () => {
return await this.handleRequest(ctx, fn)
})
}
return handleRequest
}
这里用到了createContext和handleRequest,我们接着往下介绍
/**
* Initialize a new context.
*
* @api private
*/
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
}
注意这里用Object.create又包了一层,在constructor已经包了一层,为什么这里又包了一层呢?
/** @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)
constructor中包一层是因为同一个应用中可能会有多个new Koa的app,为了防止这些app相互污染
而这里包一层是为了让每次http请求都生成一个唯一的context,相互之间隔离。同样的,Object.create(this.request|response)也是同理。
从createContext的源码中,可以看到,把实例this,http的req和res,同时挂在了ctx,request,response上,这里我们其实就是做了让response、this.request、context,可以共享实例app、res、req这些属性,并且可以互相访问。
接下来再看handleRequest:
/**
* Handle request in callback.
*
* @api private
*/
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)
}
onFinished:处理res为stream时情况
respond:ctx返回不同情况的处理
- method为head时加上content-length字段、
- body为空时去除content-length等字段,返回相应状态码、
- body为Stream时使用pipe等
/**
* Response helper.
*/
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) {
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)
// body: json
body = JSON.stringify(body)
if (!res.headersSent) {
ctx.length = Buffer.byteLength(body)
}
res.end(body)
}
use
use函数可以理解就是注册中间件函数的行为,把中间件放入middleware数组中
/**
* Use the given middleware `fn`.
*
* Old-style middleware will be converted.
*
* @param {(context: Context) => Promise<any | void>} fn
* @return {Application} self
* @api public
*/
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
}
onerror
onerror详细会在后续错误处理章节中介绍
/**
* Default error handler.
*
* @param {Error} err
* @api private
*/
onerror (err) {
// 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) throw new TypeError(util.format('non-error thrown: %j', err))
if (err.status === 404 || err.expose) return
if (this.silent) return
const msg = err.stack || err.toString()
console.error(`\n${msg.replace(/^/gm, ' ')}\n`)
}
其他
/**
* Return JSON representation.
* We only bother showing settings.
*
* @return {Object}
* @api public
*/
toJSON () {
return only(this, [
'subdomainOffset',
'proxy',
'env'
])
}
/**
* Inspect implementation.
*
* @return {Object}
* @api public
*/
inspect () {
return this.toJSON()
}
/**
* return current context from async local storage
*/
get currentContext () {
if (this.ctxStorage) return this.ctxStorage.getStore()
}
/**
* Help TS users comply to CommonJS, ESM, bundler mismatch.
* @see https://github.com/koajs/koa/issues/1513
*/
static get default () {
return Application
}
createAsyncCtxStorageMiddleware () {
const app = this
return async function asyncCtxStorage (ctx, next) {
await app.ctxStorage.run(ctx, async () => {
return await next()
})
}
}
context.js
request.js
response.js
洋葱模型
我第一次看洋葱模型的源码,还是我秋招的时候,我真的被那50行代码惊呆了,再次膜拜辣个男人。我心想要是面试官让我手写洋葱模型的源码,我这不嘎嘎乱杀。总之手写洋葱模型在当时还是菜鸡的我来说是面试杀手锏的存在,可惜我面试没被问到哈哈哈,没机会秀一下。(虽然秋招也才刚刚结束三个月,还是菜🐔
话不多说了,贴上我当时的学习笔记
写个demo看看,每一个中间件都有两次处理时机,类似冒泡捕获
const Koa = require('koa');
const app = new Koa();
// 中间件1
app.use((ctx, next) => {
console.log(1);
next();
console.log(2);
});
// 中间件 2
app.use((ctx, next) => {
console.log(3);
next();
console.log(4);
});
app.listen(8000, '0.0.0.0', () => {
console.log(`Server is starting`);
});
/*
1
3
4
2
模拟一下洋葱模型的实现
const middleware = []
let mw1 = async function (ctx, next) {
console.log("next前,第一个中间件")
await next()
console.log("next后,第一个中间件")
}
let mw2 = async function (ctx, next) {
console.log("next前,第二个中间件")
await next()
console.log("next后,第二个中间件")
}
let mw3 = async function (ctx, next) {
console.log("第三个中间件,没有next了")
}
function use(mw) {
middleware.push(mw);
}
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!')
}
return function (context, next) {
// last called middleware #
let index = -1
return dispatch(0)
function dispatch(i){
// 一个函数中多次调用报错
// await next()
// await next()
if (i <= index) return Promise.reject(new Error('next() called multiple times'))
index = i
// 取出数组里的 fn1, fn2, fn3...
let fn = middleware[i]
// 最后一个 相等,next 为 undefined
if (i === middleware.length) fn = next
// 直接返回 Promise.resolve()
if (!fn) return Promise.resolve()
try {
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)))
} catch (err) {
return Promise.reject(err)
}
}
}
}
use(mw1);
use(mw2);
use(mw3);
const fn = compose(middleware);
fn();
简单来说,compose 函数主要做了两件事情。
- 接收一个参数,校验参数是数组,且校验数组中的每一项是函数。
- 返回一个函数,这个函数接收两个参数,分别是context和next,这个函数最后返回Promise。
简化一下compose,你会发现其实就是下面这种结构
const [fn1, fn2, fn3] = stack;
const fnMiddleware = function(context){
return Promise.resolve(
fn1(context, function next(){
return Promise.resolve(
fn2(context, function next(){
return Promise.resolve(
fn3(context, function next(){
return Promise.resolve();
})
)
})
)
})
);
};
也就是说compose返回的是一个Promise,从中间件数组中取出第一个函数,传入context和第一个next函数来执行。 第一个next函数里也是返回的是一个Promise,从中间件数组中取出第二个函数,传入context和第二个next函数来执行。 第二个next函数里也是返回的是一个Promise,从中间件数组中取出第三个函数,传入context和第三个next函数来执行。 第三个... 以此类推。最后一个中间件中有调用next函数,则返回Promise.resolve。如果没有,则不执行next函数。 这样就把所有中间件串联起来了。这就是洋葱模型。
这里在解释下同一个中间件调用两次next导致出错,为什么i <= index就代表同一个中间件调用了多次next。看下图
在第 5 步中, 传入的 i 值为 1, 因为还是在第一个中间件函数内部, 但是 compose 内部的 index 已经是 3 了, 所以 i < 3, 所以报错了, 可知在一个中间件函数内部不允许多次调用 next 函数.
错误处理
koa的错误捕获
一共有三种错误捕获处理方式
ctx.onerror中间件中的错误捕获app.on('error', (err) => {})最外层实例事件监听形式app.onerror = (err) => {}重写onerror自定义形式
先看ctx.onerror
onerror(err) {
if (null == err) return;
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函数中)
//app.on('error',onerror) ,转交给application的onerror处理。
//这里可以做到emit、on来发布订阅错误 是因为application继承了Emitter模块。
this.app.emit('error', err, this);
if (headerSent) {//已经发送了一个响应头,return掉
return;
}
const { res } = this;
if (typeof res.getHeaderNames === 'function') {//HeaderNames为function,删除所有Header
res.getHeaderNames().forEach(name => res.removeHeader(name));
} else {
res._headers = {}; // Node < 7.7
}
//下面的就是对这个错误的ctx进行修改。如header设置成err.headers、statusCode设置成err.status ,msg设置为如err.message等等
this.set(err.headers);
this.type = 'text';
let statusCode = err.status || err.statusCode;
// ENOENT support
if ('ENOENT' === err.code) statusCode = 404;
// default to 500
if ('number' !== typeof statusCode || !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);
},
ok,从源码中可以看到ctx.onerror捕获到错误后,会把错误抛给app
this.app.emit('error', err, this);
而app中刚好监听了
//application.js
if (!this.listenerCount('error')) this.on('error', this.onerror)
//application.js
onerror (err) {
// 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) throw new TypeError(util.format('non-error thrown: %j', err))
if (err.status === 404 || err.expose) return
if (this.silent) return
const msg = err.stack || err.toString()
console.error(`\n${msg.replace(/^/gm, ' ')}\n`)
}
ps:这里app继承了Emitter类,所以有emit和on监听器
顶层中间件捕获错误
在开发koa应用时,我们如何利用koa的机制优雅的捕获与处理错误?
顶层中间件catchError
声明一个顶层中间件用于捕获错误
app.use(async (ctx, next) => {
try {
await next();
} catch (error) {
// 响应用户
ctx.status = error.status || 500;
ctx.body = error.message || "服务端错误";
}
});
ps:try catch的捕获只能捕获try内部发生的错误,所以next必须await
测试一下
// 模拟错误
router.get("/error", function (ctx, next) {
// 同步错误可以直接捕获
throw new Error("同步错误");
});
router.get("/error2", async function (ctx, next) {
// 新建异步错误
await Promise.reject(new Error("异步错误"));
});
用postman访问http://127.0.0.1:3000/error,可以发现均能捕获到错误
在开发过程中,我们可能会遇到各种各样的错误,如何根据不同的错误做出不同的处理呢?
我们对上面代码进行改造
// src/middlewares/catchError.js
const catchError = async (ctx, next) => {
try {
await next()
} catch(err) {
if (err.errorCode) {
// 已知的错误,自己主动抛出的 HttpException类 错误
ctx.status = err.status || 500
ctx.body = {
code: err.code,
message: err.message,
errorCode: err.errorCode,
request: `${ctx.method} ${ctx.path}`,
}
} else {
// 未知的错误,触发 koa app.on('error') 错误监听事件,可以打印出详细的错误堆栈 log
ctx.app.emit('error', err, ctx)
}
}
});
对错误类进行划分
// src/constant/http-exception.js
class HttpException extends Error {
// message为异常信息,errorCode为错误码(开发人员内部约定),code为HTTP状态码
constructor(message = '服务器异常', errorCode = 10000, code = 400) {
super()
this.errorCode = errorCode || 10000
this.code = code || 400
this.message = message || '服务器异常'
}
}
class ParameterException extends HttpException {
constructor(message, errorCode) {
super()
this.errorCode = errorCode || 10000
this.code = 400
this.message = message || '参数错误'
}
}
class NotFound extends HttpException {
constructor(message, errorCode) {
super()
this.errorCode = errorCode || 10001
this.code = 404
this.message = message || '资源未找到'
}
}
class AuthFailed extends HttpException {
constructor(message, errorCode) {
super()
this.errorCode = errorCode || 10002
this.message = message || '授权失败'
this.code = 401
}
}
class Forbidden extends HttpException {
constructor(message, errorCode) {
super()
this.errorCode = errorCode || 10003
this.message = message || '禁止访问'
this.code = 403
}
}
module.exports = {
HttpException,
ParameterException,
NotFound,
AuthFailed,
Forbidden,
}
error监听器回调errorHandler
上面我们说到对于已知的错误我们会抛出HttpException,而未知的错误我们会emit出来触发应用级错误监听app.on。这里我们声明一个errorHandler函数,用于app监听到错误时执行该回调
// src/utils/errorHandler.js
const path = require('path');
const fs = require('fs');
const escapeHtml = require('escape-html');
const isDev = env === 'development';
const templatePath = isDev
? path.join(__dirname, 'templates/dev_error.html')
: path.join(__dirname, 'templates/prod_error.html');
const defaultTemplate = fs.readFileSync(templatePath, 'utf8');
export default function errorHandler(err, ctx) {
console.log('onerror', err)
// 未知异常状态,默认使用 500
ctx.status = err.status || 500
// 获取客户端请求接受类型
// ctx.accepts 是 request.accepts 的别名,即客户端可接受的内容类型。
// 和其他协商 API 一样, 如果没有提供类型(没有传参数),则返回 所有 客户端可接受的类型。[ '*/*' ]
// 如果提供了,就返回最佳匹配,即第一个匹配上的。
// console.log(ctx.accepts())
switch (ctx.accepts('json', 'html', 'text')) {
case 'json':
// ctx.type 是 response.type 的别名, 用于设置响应头 Content-Type
ctx.type = 'application/json'
ctx.body = { code: ctx.status, message: err.message }
break
case 'html':
ctx.type = 'text/html'
ctx.body = defaultTemplate
.replace('{{status}}', escapeHtml(err.status))
.replace('{{stack}}', escapeHtml(err.stack));
break
case 'text':
ctx.type = 'text/plain'
ctx.body = err.message
break
default:
ctx.throw(406, 'json, html, or text only')
}
}
完整代码
根据以上分析,优雅的koa错误捕获应该包含下面几个重点
- 声明顶层错误捕获中间件,已知错误直接抛出处理,未知错误emit让app全局监听处理
- 声明全局监听器
app.on,并写一个错误回调函数
const path = require('path')
const Koa = require('koa')
const serve = require('koa-static')
const logger = require('koa-logger')
const koaBody = require('koa-body')
const config = require('config')
const mongoose = require('mongoose')
const catchError = require('@/middlewares/catchError')
import errorHandler from '@/utils/errHandler.js'
import adminRouter from '@/routes/admin'
import indexRouter from '@/routes/index'
// 全局定义一些异常类型,方便针对性抛出
const errors = require('@/constant/http-exception')
global.errs = errors
const app = new Koa()
// 顶层中间件
app.use(catchError)
mongoose
.connect(
`mongodb://${config.get('db.user')}:${config.get('db.pwd')}@${config.get(
'db.host'
)}:${config.get('db.port')}/${config.get('db.name')}`
)
.catch(err => {
console.log(err)
throw new errs.HttpException('数据库连接失败')
})
// 静态资源服务中间件
app.use(serve(path.join(process.cwd(), 'public')))
// 记录日志中间件
app.use(logger())
// 处理 post 请求参数的中间件
app.use(koaBody())
// 注册管理后台路由中间件
app.use(adminRouter.routes()).use(adminRouter.allowedMethods())
// 注册前台路由中间件
app.use(indexRouter.routes())
// 错误监听器
app.on('error', errorHandler)
app.listen(3003, err => {
if (err) throw err
console.log('runing at 3003')
})
委托模式
context.js的下部分代码
delegate(proto, 'response') //proto其实就是contex的prototype
.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');
其中用到了delegate,主要用到了method access和getter
declare class Delegate {
constructor(proto: object, target: string);
method(name: string): Delegate;
access(name: string): Delegate;
getter(name: string): Delegate;
setter(name: string): Delegate;
fluent(name: string): Delegate;
}
declare function Delegate(proto: object, target: string): Delegate;
export = Delegate;
来看method的源码,作用很明显,target.name包装一层函数赋值给proto.name,也就是将target上的函数也能让proto去调用。
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;
};
再来看getter,通过__defineGetter__劫持proto的 get,转而去访问 target。(目前官方建议使用Object.defineProroty或Proxy进行劫持)
Delegator.prototype.getter = function(name){
var proto = this.proto;
var target = this.target;
this.getters.push(name);
proto.__defineGetter__(name, function(){
return this[target][name];
});
return this;
};
接着看access
Delegator.prototype.access = function(name){
return this.getter(name).setter(name);
};
其实就是将原生req、res做了一层封装,委托给request和response,再委托给contex。我们在需要时通过contex调用request.js而间接调用了req。(res同理) 打个比方,我们在访问ctx.header时,ctx会将其委托给reques.header,而request又会将其委托给req.headers,最终我们拿到了header值。
参考文章:
www.ruanyifeng.com/blog/2017/0…