拜读koa源码--详解

120 阅读8分钟

写这篇文档之前,我只想说四个字:膜拜大佬!!!!

本文会分为四个部分介绍

  1. 使用koa
  2. 源码结构
  3. 洋葱模型
  4. 错误处理
  5. 委托模式

使用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源码的目录结构:

image.png

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返回不同情况的处理

  1. method为head时加上content-length字段、
  2. body为空时去除content-length等字段,返回相应状态码、
  3. 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函数。 这样就把所有中间件串联起来了。这就是洋葱模型。

image.png

这里在解释下同一个中间件调用两次next导致出错,为什么i <= index就代表同一个中间件调用了多次next。看下图

image.png

在第 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错误捕获应该包含下面几个重点

  1. 声明顶层错误捕获中间件,已知错误直接抛出处理,未知错误emit让app全局监听处理
  2. 声明全局监听器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…

koa.bootcss.com/

www.freecodecamp.org/chinese/new…

github.com/demopark/ko…