Koa源码解读

382 阅读5分钟

这是我参与8月更文挑战的第13天,活动详情查看: 8月更文挑战”juejin.cn/post/698796…

介绍

概述

与Express相比,Koa的体量极小。原因在于Express框架是一个大而全的框架,内置了很多的中间件在里面;而Koa框架则没有捆绑任何中间件,本身只有一个简单的中间件的整合逻辑和http请求的处理,所以作为一个功能性的中间件框架来存在。

内容

Koa的整个框架被分成了四部分内容,即为四个文件:

  • application.js
  • context.js
  • request.js
  • response.js

基础示例

首先根据下面的一个基础示例,开始进行分析。

const Koa = require('koa');
const app = new Koa();

app.use((ctx,next)=>{
  ctx.body = 'hello worlds';
})

app.listen(3000)

综合以往的知识,可以分析得出以下几个情况或问题:

  • Koa框架最后被导出的是一个class类,在这个类里有use和listen方法

    • use方法的参数是一个函数
    • 根据http模块的使用规则,isten方法的参数只有一个端口号吗?
  • ctx的由来以及ctx.body的由来?

  • next是什么东西?

以下,就让我们带着问题开始解读Koa的源码。

Application

Application对象

Application.js中导出的Application对象是Koa的核心对象,也即为上文我们所说的Koa框架最后被导出的那个类。并且该对象通过use方法来注册中间件和通过listen方法创建http服务。

代码分析

为节约时间,已省略部分代码。

module.exports = class Application extends Emitter {
  constructor (options) {
    ...
    // 用来收集存放中间件内容
    this.middleware = []
    // 用于封装Request和Respnse对象以及创建上下文
    this.context = Object.create(context)
    this.request = Object.create(request)
    this.response = Object.create(response)
    ...
  }
  // 用于创建http服务,监听端口并启动服务
  listen (...args) {
    ...
    const server = http.createServer(this.callback())
    return server.listen(...args)
  }
  // 用于注册中间件
  use (fn) {
    this.middleware.push(fn)
    return this
  }
}

上下文封装

在Application类的构造函数里,可以看到对于上下文对象和request、response等实例属性的创建,通过Object.create浅拷贝的方式放到实例中。但这一部分的重点不在这一部分,留后再述

listen方法

listen方法的参数为...args,则表示参数不止一个。依照http模块的用法,参数可以为端口号和启动监听事件。如下:

server.lsiten(3000,()=>{
  ....
})

use方法

use方法的主要作用在于注册中间件,将中间件函数存储到middleware数组中,并且在注册中间件之后返回当前实例。

callback解释

callback的由来

依据正常的原生Node写法,this.callback应当是一个正常执行逻辑的回调函数。

const http = require('http');
const server = http.createServer((req,res)=>{
  ...
  // 正常逻辑内容
})

this.callback正是代码中的正常逻辑部分。

而在Koa框架中,逻辑部分变为对中间件的处理逻辑。对于callback方法的描述在下文:

callback () {
    // 处理数组中的中间件,通过compose()将其转换为洋葱模型的格式
    const fn = compose(this.middleware)
    // 定义请求处理回调,也就是原生Node里的逻辑部分
    const handleRequest = (req, res) => {
      // 创建上下文对象,留后再说
      const ctx = this.createContext(req, res)
      // 返回通过handleRequest方法处理过的中间件执行结果
      return this.handleRequest(ctx, fn)
    }
    return handleRequest
  }

compose

在Koa中,compose的功能是由koa-compose所支持的。

function compose(middleware){
  ...
  return function(context,next){
    let index = -1
    return dispatch(0)
    function dispatch(i){
      // 一个中间件里不可多次调用next,如果多次调用next,
      // 就会导致下一个中间件的多次执行,这样就破坏了洋葱模型。
      if (i <= index) {
        return Promise.reject(
          new Error('next() called multiple times')
        )
      }
      index = i
      
      // fn就是当前的中间件
      let fn = middleware[i]
      if (i === middleware.length) {
        fn = next 
        // 最后一个中间件如果也next时进入(一般最后一个中间件是直接
        // 操作ctx.body,并不需要next了)
      }
      if (!fn) {
        return Promise.resolve() // 没有中间件,直接返回成功
      }
      
      try {
        /* 
          * 使用了bind函数返回新的函数,类似下面的代码
          return Promise.resolve(fn(context, function next () {
            return dispatch(i + 1)
          }))
        */
        // dispatch.bind(null, i + 1)就是中间件里的next参数,
        // 调用它就可以进入下一个中间件

        // fn如果返回的是Promise对象,Promise.resolve直接把这个对象返回
        // fn如果返回的是普通对象,Promise.resovle把它Promise化
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
      } catch (err) {
        // 中间件是async的函数,报错不会走这里,
        // 直接在fnMiddleware的catch中捕获
        // 捕获中间件是普通函数时的报错,Promise化,
        // 这样才能走到fnMiddleware的catch方法
        return Promise.reject(err)
      }
    }
    }
  }
}
  • 在dispatch函数的开头,需要判断当前中间件的下标来防止一个中间件多次调用next。因为如果多次调用next,会使得下一个中间件多次执行,破坏了洋葱模型的结构。
  • 经过层层的return,实际上compose最终提供的是一个中间件全部执行后结果的回调。
  • next是进入下一个中间件的钥匙。
Promise.resolve(fn(context, dispatch.bind(null, i + 1)));

在上面的代码中,可以看到dispatch以递归的形式将自身绑定index参数后作为本次中间件的第二个参数传入,相当于调用执行了下一个中间件。以此类推,直到最后一个中间件执行完毕。

同时,在当前中间件执行完毕后,通过返回的Promise.resolve()又会将结果返回到上一个中间件,也就是达到洋葱模型先入后出的效果。

Req和Res封装

在源码的request.js和response.js中,都使用了大量的get和set代理属性。

代理形式

header get\set

get header () {
  return this.req.headers
},
set header (val) {
  this.req.headers = val
},

以此形式的还有很多:

resquest.js

get/set方法get/set方法
get/setheadersget/seturl
getorigingethref
get/setmethodget/setpath
get/setqueryget/setquerystring
get/setsearchgethost
gethostnamegetURL
getfreshgetstale
getidempotentgetsocket
getcharsetgetlength
getprotocolgetsecure
getipsget/setip
getsubdomainsget/setaccept

response.js

get/set方法get/set方法
getsocketgetheader
getheadersget/setstatus
get/setmessageget/setbody
get/setlengthgetheaderSent
get/settypeget/setlastModified
get/setetag

Context

context.js里重要的有三部分:

  • onerror方法的错误处理
  • 对cookies的get和set处理
  • 通过delegate对request和response的代理

onerror的错误处理

因为中间件经过compose处理后的函数返回的依旧是Promise对象,所以在处理过程中的错误可以在catch中捕捉到。

onerror方法的触发在application.js中

handleRequest (ctx, fnMiddleware) {
    const res = ctx.res
    const onerror = err => ctx.onerror(err)
    const handleResponse = () => respond(ctx)
    onFinished(res, onerror)
    return fnMiddleware(ctx).then(handleResponse).catch(onerror)
  }

通过catch捕获到错误之后,执行onerror的错误处理操作

onerror的实现在context.js中

onerror (err) {
  if (err == null) return
  // 用于处理框架层面的错误
  this.app.emit('error', err, this)
    
  const { res } = this
  if (err.code === 'ENOENT') statusCode = 404
  if (typeof statusCode !== 'number' || !statuses[statusCode]) {
    statusCode = 500
  }
  
  const code = statuses[statusCode]
  const msg = err.expose ? err.message : code
  this.status = err.status = statusCode
  this.length = Buffer.byteLength(msg)
  res.end(msg)
}

onerror处理的是中间件里的错误,对于Koa框架本身的错误则是采用对原生Emitter的继承,从而实现error监听。

const Emitter = require('events');

// 继承Emitter
class Application extends Emitter {
  constructor() {
    // 调用super
    super();
    ...
   }
 } 

Koa异常捕获方式

  • 中间件捕获(Promise catch)
  • 框架捕获(Emitter error)
// 捕获全局异常的中间件
app.use(async (ctx, next) => {
  try {
    await next()
  } catch (error) {
    return ctx.body = 'error'
  }
})

// 事件监听
app.on('error', err => {
  console.log('error happends: ', err.stack);
});

cookies处理

主要封存了环境的上下文信息

get cookies () {
  if (!this[COOKIES]) {
    // 创建cookie实例
    this[COOKIES] = new Cookies(this.req, this.res, {
      keys: this.app.keys,
      secure: this.request.secure
    })
  }
  return this[COOKIES]
},

set cookies (_cookies) {
  this[COOKIES] = _cookies
}

delegate数据代理

根据文章《数据代理方法》,对代理方式已经有所了解。

delegate方法的原理就是defineGetter__和__defineSetter方式

delegate(proto, 'response')
  .access('status')
  .access('body')

delegate(proto, 'request')
  .access('url')
  .getter('header')

代理问题比较

  • set和get方法中可以加入自己的逻辑处理
  • delegate只能代理属性,不能额外添加操作
{
  get length() {
    const len = this.get('Content-Length');
    if (len == '') return;
    return ~~len;
  },
}

delegate(proto, 'response')
  .access('length')

context的创建

contxt对象和response、request等对象的联系建立是在application.js中的createContext方法中

createContext (req, res) {
  // 对于每个请求,都会创建一个ctx
  const context = Object.create(this.context)
  // 创建request、response并挂载到context下
  const request = context.request = Object.create(this.request)
  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创建一个全新的对象,通过原型链继承原来的属性。这样可以有效的防止污染原来的对象

总结

Koa的整个流程分为初始化阶段、请求阶段、响应阶段

  • 初始化阶段

new初始化一个实例,use注册中间件到middleware数组,listen 合成中间件fnMiddleware,返回一个callback函数给http.createServer,开启服务器,等待http请求。

  • 请求阶段

每次请求,createContext生成一个新的ctx,传给fnMiddleware,触发中间件的整个流程

  • 响应阶段

整个中间件完成后,调用respond方法,对请求做最后的处理,返回响应给客户端。