【Koa】搞定Koa

1,504 阅读9分钟

前言

koa致力于成为一个更小、更富有表现力、更健壮的、更轻量的web开发框架。因为它所有功能都通过插件实现,这种插拔式的架构设计模式,很符合unix哲学。

一个简单的服务,如下:

const Koa = require('koa') 
let app = new Koa() 
app.use((ctx, next) => { 
    console.log(ctx) 
}) 
app.listen(4000)

然后在浏览器端打开http://127.0.0.1:4000即可访问

若没有指定返回body,koa默认处理成了Not Found

本文内容:

  • 中间件原理(结合代码)
    • 原理
    • 中间件实现思路
    • 理解上述洋葱模型
  • 阅读源码
    • app.listen()
    • ctx挂载内容
      • context.js
      • request.js
      • response.js
      • 挂载ctx
    • next构建的洋葱模型
      • app.use((ctx, next) =< { ... })
      • 中间件含异步代码如何保证正确执行
      • 返回报文
      • 解决多次调用next导致混乱问题
    • 基于事件驱动去处理异常
  • 常用的Koa中间件
  • koa2, koa1 和 express区别

一、中间件原理(结合代码)

原理

  • 中间件执行就像穿越洋葱一样,最早use的中间件,就放在最外层。处理顺序横穿洋葱,从左到右,左边接收一个request,右边输出返回response;
  • 一般的中间件都会执行两次,调用next之前为第一次,调用next时把控制传递给下游的下一个中间件。当下游不再有中间件或者没有执行next函数时,就将依次恢复上游中间件的行为,让上游中间件执行next之后的代码;

如下代码:

const Koa = require('koa')
const app = new Koa()
app.use((ctx, next) => {
    console.log(1)
    next()
    console.log(3)
})
app.use((ctx) => {
    console.log(2)
})
app.listen(9001)

# 执行结果是1=>2=>3

中间件实现思路

# 注意其中的compose函数,这个函数是实现中间件洋葱模型的关键
// 场景模拟
// 异步 promise 模拟
const delay = async () => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log('delay 2000ms')
      resolve();
    }, 2000);
  });
}
// 中间间模拟
const fn1 = async (ctx, next) => {
  console.log(1);
  await next();
  console.log(2);
}
const fn2 = async (ctx, next) => {
  console.log(3);
  await delay();
  await next();
  console.log(4);
}
const fn3 = async (ctx, next) => {
  console.log(5);
}

const middlewares = [fn1, fn2, fn3];

// compose 实现洋葱模型
const compose = (middlewares, ctx) => {
  const dispatch = (i) => {
    let fn = middlewares[i];
    if(!fn){ return Promise.resolve() }
    return Promise.resolve(fn(ctx, () => {
      return dispatch(i+1);
    }));
  }
  return dispatch(0);
}

compose(middlewares, 1);

理解上述洋葱模型

const fn1 = async (ctx, next) => { 
    console.log(1); 
    
    const fn2 = async (ctx, next) => { 
        console.log(3); 
        await delay(); 

        const fn3 = async (ctx, next) => { 
            console.log(5); 
        }

        console.log(4); 
    }
    
    console.log(2); 
}

# 1 3 5 4 2

看完这个,大概了解koa的中间件原理了吧。 接下来,咱们一起看下源码。

二、阅读源码

image.png

核心文件四个

  • application.js:简单封装http.createServer()并整合context.js
    application.js是koa的入口文件,它向外导出了创建class实例的构造函数,
    它继承了events,这样就会赋予框架事件监听和事件触发的能力。
    application还暴露了一些常用的api,比如toJSON、listenuse等等。
    
    listen的实现原理其实就是对http.createServer进行了一个封装,
    重点是这个函数中传入的callback,
    它里面包含了中间件的合并,上下文的处理,对res的特殊处理。
    
    use是收集中间件,将多个中间件放入一个缓存队列中,
    然后通过koa-compose这个插件进行递归组合调用这一些列的中间件。
    
  • context.js:代理并整合request.jsresponse.js
  • request.js:基于原生req封装的更好用
  • response.js:基于原生res封装的更好用

image.png

koa是用ES6实现的,主要是两个核心方法app.listen()app.use((ctx, next) => { ... })

1. app.listen()

application.js中实现 app.listen()

handleRequest()

# application.js

    const http = require('http')
    class Koa {
      constructor () {
        // ...
      }  
        // 处理用户请求
      handleRequest (req, res) {
        // req & res nodejs native
        // ...
      }  
      listen (...args) {
        let server = http.createServer(this.handleRequest.bind(this))
        server.listen(...args)
      }  
    }
    module.exports = Koa

2. ctx挂载内容

ctx = {}
ctx.request = {}
ctx.response = {}
ctx.req = ctx.request.req = req
ctx.res = ctx.response.res = res
ctx.xxx = ctx.request.xxx
ctx.yyy = ctx.response.yyy

我们需要以上几个对象,最终都代理到ctx对象上。

创建context.js/request.js/response.js三个文件


2.1 request.js内容

# request.js

const url = require('url')
let request = {}
module.exports = request

request.js中,使用ES5提供的属性访问器实现封装

# request.js

const url = require('url')
let request = {
  get url () {
    return this.req.url // 此时的this为调用的对象 ctx.request
  },
  get path () {
    let { pathname } = url.parse(this.req.url)
    return pathname
  },
  get query () {
    let { query } = url.parse(this.req.url, true)
    return query
  }
  // ...更多待完善
}
module.exports = request

以上实现了封装request并代理到ctx

ctx.request常用属性及方法

# request 对象
		- .header:头信息对象,别名:headers
		- .header=:设置头信息,别名:headrs=
		- .method:请求方法
		- .method=:设置请求方法
		- .length:请求正文内容长度
		- .url:请求URL(字符串)
		- .url=:设置请求URL,不包含协议与主机部分
		- .orginalURL:原始URL,不包含协议与主机部分
		- .href:原始完整URL,包含协议、主机、请求串
		- .path:URL路径部分
		- .path=:设置URL路径
		- .querystring:URL中的querystring
		- .querystring=:设置URL中的querystring
		- .search:URL中的search,带 ? 的querystring
		- .search=:设置URL中的search
                - .host:请求头中的host
		- .hostname:请求头中的hostname
		- .URL:解析过的URL对象
		- .type:请求头中 content-type
		- .charset:请求头中的charset
		- .query:解析过的querystring对象
		- .query=:设置querystring对象值
		- .fresh:判断缓存设置时候有效,true表示有效
		- .stale:与fresh相反
		- protocol:请求使用的协议
		- .secure:是否是安全协议,protocol=='https'
		- .ip:请求客户端 IP
		- .ips:请求客户端所有 IP(比如使用了代理等)
		- .subdomains:子域名数组
		- .is(types...):判断提交内容的MIME类型
		- .socket:request.socket对象
		- .get(field):获取请求头信息的通用方法


2.2 response.js内容

# response.js

let response = {}
module.exports = response

response.js中,使用ES5提供的属性访问器实现封装

# response.js

let response = {
  set body (val) {
    this._body = val
  },
  get body () {
    return this._body // 此时的this为调用的对象 ctx.response
  }
  // ...更多待完善
}
module.exports = response

以上实现了封装response并代理到ctx

ctx.response常用属性或者方法

# response 对象
		- .header:响应头对象
		- .headers:header的别名
		- .socket:response.socket对象
		- .status:响应状态码
		- .status=:设置响应状态码
		- .message:响应状态码描述文本
		- .message=:设置响应状态码描述文本
                - .body:响应内容
		- .body=:设置响应内容,如果status没有设置,Koa会默认设置status为:200 或者 204,同时 Koa 会根据返回的数据类型自动设置 content-type
			- string:text/html 或 text/plain
			- buffer/Stream:application/octet-stream
			- object:application/json
                - .length:响应内容长度
		- .length=:设置响应内容长度
		- .get(field):获取指定头信息
		- .get(fields):批量设置头信息
		- .set(field):设置指定头信息
		- .append(field, value):追加头信息
		- .remove(field):移除头信息
                - .type:获取 content-type
		- .type=:设置 content-type
		- .is(types...):判断 content-type
                - .redirect(url):重定向,默认重定向状态码为:302,可以通过status进行设置
		- .attachment([filename]):设置下载文件头,filename为下载文件的名称






2.3 context.js内容

# context.js 初始化

let context = {}

module.exports = context

context.js中,使用__defineGetter__ / __defineSetter__实现代理,他是Object.defineProperty()方法的变种,可以单独设置get/set,不会覆盖设置。

# context.js

let context = {}
// 定义获取器
function defineGetter (key, property) {
  context.__defineGetter__ (property, function () {
    return this[key][property]
  })
}
// 定义设置器
function defineSetter (key, property) {
  context.__defineSetter__ (property, function (val) {
    this[key][property] = val
  })
}

// 🌰
// 代理 request
defineGetter('request', 'path')
defineGetter('request', 'url')
defineGetter('request', 'query')
// 代理 response
defineGetter('response', 'body')
defineSetter('response', 'body')
module.exports = context

2.4 application.js 挂载ctx

application.js中引入上面三个文件并放到实例上

const context = require('./context')
const request = require('./request')
const response = require('./response')
class Koa extends Emitter{
  constructor () {
    super()
    // Object.create 切断原型链, 深拷贝配置
    this.context = Object.create(context)
    this.request = Object.create(request)
    this.response = Object.create(response)
  }
}

然后处理用户请求并在ctx上代理request / response createContext()

  # application.js
  
  // 创建上下文
  createContext (req, res) {
    let ctx = this.context
    
    // 请求
    ctx.request = this.request
    ctx.req = ctx.request.req = req
    
    // 响应
    ctx.response = this.response
    ctx.res = ctx.response.res = res
    
    // 用户数据存储
    // ctx.state
    // 虽然通过context.n = 1 的形式可以存储用户数据,但不推荐这样,应ctx.state.n = n;
    
    // 当前程序实例 Application对象
    // ctx.app   
    
    // ctx.throw([status][, msg][, properties])
    // 抛出一个错误,Koa 会进行处理比如返回对应的http响应信息,默认返回状态码500
    // 在app.on('error',(err, ctx) => {  })事件中可以捕获到该异常,
    // err 对象参数中也会保存throw时的msg和properties ,
    // 处理函数中还可以通过ctx进行重定向

    return ctx 
  }
  
  // server connection 回调
  handleRequest (req, res) {
    let ctx = this.createContext(req, res)
    return ctx
  }

哎嘿,有个req还有个request,两个一样吗?

console.log('native req ----') // node原生的req
console.log(ctx.req.url) 
console.log(ctx.request.req.url)
console.log('koa request ----') // koa封装了request
console.log(ctx.url) 
console.log(ctx.request.url)

每一次请求都会包装一个context对象


3. next构建的洋葱模型

接下来实现koa中第二个方法app.use((ctx, next) =< { ... })

app.use((ctx, next) =< { ... })

use中存放着一个个中间件,如cookie、session、static...等等一堆处理函数,并且以洋葱式的形式执行。

 # application.js
 
 constructor () {
    // ...
    // 存放中间件数组
    this.middlewares = []
  }
  // 使用中间件
  use (fn) {
    this.middlewares.push(fn)
  }

当处理用户请求时,期望执行所注册的一堆中间件
compose、dispatch

  # application.js

  // 组合中间件
  compose (middlewares, ctx) {
    function dispatch (index) {
      // 迭代终止条件 取完中间件
      // 然后返回成功的promise
      if (index === middlewares.length) return Promise.resolve()
      let middleware = middlewares[index]
      // 让第一个函数执行完,如果有异步的话,需要看看有没有await
      // 必须返回一个promise
      return Promise.resolve(middleware(ctx, () => dispatch(index + 1)))
    }
    return dispatch(0)
  }
  
  // 处理用户请求
  handleRequest (req, res) {
  
    let ctx = this.createContext(req, res)
    
    this.compose(this.middlewares, ctx)
    
    return ctx
  }

以上的dispatch迭代函数在很多地方都有运用,比如递归删除目录,也是koa的核心。

中间件含异步代码如何保证正确执行

返回的promise主要是为了处理中间件中含有异步代码的情况

返回报文

在所有中间件执行完毕后,需要渲染页面。

  # application.js

  // 处理用户请求
  handleRequest (req, res) {
    let ctx = this.createContext(req, res)
    
    res.statusCode = 404 // 默认404 当设置body再做修改
    
    let ret = this.compose(this.middlewares, ctx)
    
    ret.then(_ => {
      if (!ctx.body) { // 没设置body
        res.end(`Not Found`)
      } else if (ctx.body instanceof Stream) { // 流
        res.setHeader('Content-Type', 'text/html;charset=utf-8')
        
        ctx.body.pipe(res)
      } else if (typeof ctx.body === 'object') { // 对象
        res.setHeader('Content-Type', 'text/josn;charset=utf-8')
        
        res.end(JSON.stringify(ctx.body))
      } else { // 字符串
        res.setHeader('Content-Type', 'text/html;charset=utf-8')
        
        res.end(ctx.body)
      }
    })
    return ctx
  }

需要考虑多种情况做兼容。

解决多次调用next导致混乱问题

通过以上代码进行以下测试

执行结果:

   1 => 3 =>logger => 4 
     => 3 =>logger => 4  => 2

并不满足我们的预期, 理论上,在一个中间件函数内部不允许多次调用 next 函数

因为执行过程如下

在第 2 步中, 传入的 i 值为 1, 因为还是在第一个中间件函数内部, 但是 compose 内部的 index 已经是 2 了, 所以 i < 2, 所以报错了, 可知在一个中间件函数内部不允许多次调用 next 函数。

解决方法就是使用flag作为洋葱模型的记录已经运行的函数中间件的下标, 如果一个中间件里面运行两次 next, 那么 index 是会比 flag 小的。

  # application.js

  /**
   * 组合中间件
   * @param {Array<Function>} middlewares 
   * @param {context} ctx 
   */ 
  compose (middlewares, ctx) {
    let flag = -1
    function dispatch (index) {
      // 3)flag记录已经运行的中间件下标
      // 3.1)若一个中间件调用两次next那么index会小于flag
      // if (index <= flag) return Promise.reject(new Error('next() called multiple times'))
      flag = index
      // 2)迭代终止条件:取完中间件
      // 2.1)然后返回成功的promise
      if (index === middlewares.length) return Promise.resolve()
      // 1)让第一个函数执行完,如果有异步的话,需要看看有没有await
      // 1.1)必须返回一个promise
      let middleware = middlewares[index]
      return Promise.resolve(middleware(ctx, () => dispatch(index + 1)))
    }
    return dispatch(0)
  }

4. 基于事件驱动去处理异常

如何处理在中间件中出现的异常呢?

Node是以事件驱动的,所以我们只需继承events模块即可

# application.js

const Emitter = require('events')
class Koa extends Emitter{
  // ...
  // 处理用户请求
  handleRequest (req, res) {
    // ...
    let ret = this.compose(this.middlewares, ctx)
    ret.then(_ => {
      // ...
    })
    .catch(err => { // 处理程序异常
      this.emit('error', err)
    })
    return ctx
  }  
}

然后在上面做捕获异常,使用时如下就好

const Koa = require('./src/index')

let app = new Koa()

app.on('error', err => {
  console.log(err)
})

常用的Koa中间件

# 中间件
		- koa-static-cache:静态文件代理服务
		- koa-router:路由
		- koa-swig:模板引擎
		- koa-bodyparser:body解析
		- koa-multer:formData解析

koa-static-cache

image.png

koa-router

image.png

image.png

koa-bodyparser body解析、数据提交

image.png

opts
		- enableTypes: 允许解析的类型,['json', 'form']
		- encoding:编码,默认 utf-8
		- formLimit:urlencode 编码类型数据的最大size,默认 56kb
		- jsonLimit:json 格式数据最大size,默认 1mb
		- textLimit:文本格式数据最大size,默认 1mb
		- strict:是否是严格默认,json只接受数组和对象

koa-multer formData解析、文件上传

image.png

image.png

image.png

image.png

image.png

image.png

koa2, koa1 和 express区别

  • koa1: 依赖 co库并采用 generator 函数,在函数内使用 yield 语句
  • koa2: 增加了箭头函数,移除了 co 依赖,使用 Promise, 因此可以结合 async await使用,es6 语法,执行时间比 koa1 更快
  • koa和express区别
    • express是大而全,koa是小而精
      • koa是原生不绑定任何中间件的裸框架,需要什么加什么,扩展性非常好,组装几个中间件就可以和express匹敌
    • api对比
      • koa模板引擎和路由方面没有express提供的api丰富,koa将req,res都挂载到了ctx上,通过ctx既可以访问到req,也可以访问到res
      • 虽然koa比express少集成了很多功能,但对应功能只需要require中间件即可,反而更灵活
    • 中间件加载和执行机制
      • 中间件模式区别的核心是next的实现
      • koa请求与响应是洋葱进出模型,使用最新async代码,没有回调函数,代码运行非常清晰。当koa处理中间件遇到await next()的时候会暂停当前中间件进而处理下一个中间件,最后再回过头来继续处理剩下的任务(逻辑就是回调函数),递归存在栈溢出的问题,可能会把js引擎卡死,koa采用了尾调用的方式进行了性能优化
      • express是直线型,只进不出,express本身是不支持洋葱模型的数据流入流出能力的,需要引入其他的插件
      • app.use 就是往中间件数组中塞入新的中间件
        • express中间件的执行则依靠私有方法 app.handle 进行处理
        • koa通过 compose() 这个方法,就能将我们传入的中间件数组转换并级联执行,最后 callback() 返回this.handleRequest()的执行结果。