Koa浅析

113 阅读5分钟

Koa:3.0.0-alpha.1

围绕主要以下几点介绍

  1. 洋葱模型
  2. context
  3. 错误处理
  4. 委托模式

先来写一个简单的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去调用

总结

  1. 洋葱模型的原理主要是,函数内部返回一个dispatch函数,dispatch调用并传入0,fn就从middleware的第一个中间件取值,然后返回被Promise包裹的、fn的执行结果。fn在调用时传入两个参数,一个context上下文;一个是dispatch.bind(null, i + 1)也就是next,调用next,就可以把函数的执行权交给下一个中间件,待其执行完,在回过头继续执行自身。
  2. 在每次请求时会创建一个全局唯一的上下文,保证隔离。并且context内聚了req、res等,通过一个context可以访问到。
  3. 使用委托模式使得自身对象能够调用nodejs原生对象上的方法,使用时直接在自身对象调用,不需要深入到原生对象上