手撕 koa2

62 阅读13分钟

我们发现 http 基于回调创建服务的方式很不优雅,而在 node 中,有一个第三方模块 koa,它就是基于 http 模块进行了一次封装,和 express 不同的是,它底层全部基于 promise,所以需要我们对 promise 有非常深入的了解。

为什么会有 koa

  1. 原生 req 和 res 功能非常弱,koa 增强了 req 和 res,自己生成了两个对象 request 和 response,并代理到 ctx 上
  2. 有自己的中间件机制,可以通过 next 控制中间件的执行顺序

koa 安装 和使用

npm i koa
// server.js
const Koa = require('koa');

// 通过 new 方式创建一个应用
const app = new Koa(); // 相当于 原生的 const server = createServer(cb);

app.use(ctx => {

})

app.listen(3000, function() {
  console.log(`serve is running on port 3000`);
});

不过我们没有返回任何东西,在浏览器上访问会返回 Not Found

app.use(ctx => {
	ctx.body = 'hello world' 
})

这时候,就能真正的输出结果啦,如果发生错误,还能进行错误捕获

app.use(ctx => {
  ctx.body = `hello world: ${ a }`;
})

app.on('error', function(err) {
  console.log('err', err);
});

此刻访问服务,页面展示 Internal Server Error,并且 node 命令行打印错误

err ReferenceError: a is not defined

到这里,我们已经了解 koa 70% 的功能了,是不是特别简单呢

手动实现 koa

先来仿 node_modules 中的 koa 目录结构

- myKoa
	- lib
		- application.js
		- context.js
		- request.js
		- response.js
	- package.json
- server.js

step1: ctx 代理 & 返回数据

这一步主要处理服务启动,ctx 数据代理,数据返回等逻辑。

修改 server.js

  1. 引用我们自己实现的 myKoa 包
  2. 各种测试打印代码,可以看到我们要处理 ctx & ctx.request 上挂载原生 req, ctx & ctx.response 上挂载 res 等一系列挂载方式,而且支持 ctx.xxx 取自 ctx.request.xxx 的功能。
server.js(包含各种测试代码)
// const Koa = require('koa');
const Koa = require('./myKoa');

// 通过 new 方式创建一个应用
const app = new Koa(); // 相当于 原生的 const server = createServer(cb);

app.use(ctx => {
  // // 原生 req 上取 url
  // console.log(ctx.req.url); // 好长呀
  // console.log(ctx.request.req.url); // 更长了

  // // koa 实现的
  // console.log(ctx.request.url, 'ctx.request.url');
  // console.log(ctx.url); // 我们一般用这种方式,通过代理哦

  // console.log(ctx.path, 'ctx.path');
  // ctx.body = 'hello';
  // ctx.response.body += ' world'
  ctx.body = { a: 1 };
  console.log(ctx.body);
})

app.listen(3000, function() {
  console.log(`serve is running on port 3000`);
});

app.on('error', function(err) {
  console.log('err', err);
});


增加 myKoa/package.json

  1. 声明包入口文件路径
myKoa/package.json
{
  "main": "./lib/application"
}


增加 myKoa/application.js 主入口文件

  1. 构建 Koa 类
  2. 封装回调方法,包含构建 ctx,执行中间件,返回数据给客户端逻辑。
  3. 根据 listen 参数启动服务,传入 handleRequest 回调方法
myKoa/application.js
const EventEmitter = require('events'); // 有 on('error') 肯定用了 events 模块
const http = require('http');
const request = require('./request');
const response = require('./response');
const context = require('./context');

class Koa extends EventEmitter {
  constructor() {
    super();
    this.context = Object.create(context); // 防止多个应用之间公用一个 context
    this.request = Object.create(request); // 防止多个应用之间公用一个 request
    this.response = Object.create(response); // 防止多个应用之间公用一个 response
  }

  use(fn) {
    this.fn = fn; // 保存中间件函数 
  }

  // 构建 ctx
  createContext(req, res) {
    let ctx = Object.create(this.context); // 防止多个请求共享 ctx
    let request = Object.create(this.request); // 防止多个请求共享 request
    let response = Object.create(this.response); // 防止多个请求共享 response

    ctx.request = request;
    ctx.request.req = ctx.req = req;
    ctx.response = response;
    ctx.response.res = ctx.res = res;

    return ctx;
  }

  handleRequest = (req , res) => {
    const ctx = this.createContext(req, res);

    res.statusCode = 404;

    this.fn(ctx); // 中间件执行

    // 如果用户设置了 ctx.body 就传递给客户端
    if (typeof ctx.body == 'object') {
      res.setHeader('Content-type', 'application/json;chartset=utf-8');
      // 对象转字符串
      res.end(JSON.stringify(ctx.body));
    } else if (ctx.body) {
      res.end(ctx.body);
    } else {
      res.end('Not Fount');
    }
  }

  listen(...args) {
    // 就是 node 中的原生 http 模块
    const server = http.createServer(this.handleRequest);

    server.listen(...args);
  }
}

module.exports = Koa;

增加 myKoa/context.js 代理 ctx 内变量访问和赋值

  1. 代理 ctx.属性名 --> ctx.requst.属性名
  2. 代理 ctx.body = xxx --> ctx.response.body = xxx
myKoa/context.js
// 代理 比如 ctx.query
const context = {
}

// 代理 ctx.xxx -> ctx.request.xxx
function defineGetter(target, key) {
  // 给 context 某属性 增加 get 拦截器
  context.__defineGetter__(key, function() {
    return this[target][key];
  });
}

function defineSetter(target, key) {
  context.__defineSetter__(key, function(val) {
    this[target][key] = val;
  });
}

// 代理取值和赋值 
// ctx.query -> ctx.request.query
defineGetter('request', 'query');
defineGetter('request', 'url');
defineGetter('request', 'path');
defineGetter('response', 'body');
defineSetter('response', 'body');

module.exports = context;


增加 myKoa/request.js 代理 req 内需要 url 模块的变量

  1. 代理 ctx.path --> url.parse(this.url).pathname
  2. 代理 ctx.query --> url.parse(this.url).query
myKoa/request.js
const url = require('url');

// ctx.path -> url.parse(this.url).pathname
const request = {
  get url() {
    // 使用 ctx.request.url 时候,执行 ctx.request.url()
    // ctx.request 就是 this
    return this.req.url;
  },
  get path() {
    return url.parse(this.url).pathname;
  },
  get query() {
    return url.parse(this.url).query;
  }
}

module.exports = request;


增加 myKoa/response.js 代理 res.body

  1. ctx.response.body --> ctx.body = xxx
myKoa/response.js
// ctx.response.body = 'world'
const response = {
  _body: undefined,
  get body() {
    return this._body;
  },
  set body(val) {
    this.res.statusCode = 200;
    this._body = val;
  }
}

module.exports = response;


至此,我们其实已经完成了 70% 的源码,那还剩下 30% 在哪里呢,那就是中间件机制啦~

step2: 实现中间件机制

这一步我们主要来实现 koa 中间件的特性.

koa 中间件执行顺序

在实现之前,我们先来看下 koa 的中间件是怎么执行的。

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

app.use(async (ctx, next) => {
  console.log(1);
  next();
  console.log(2);
})

app.use(async (ctx, next) => {
  console.log(3);
  next();
  console.log(4);
})

app.use(async (ctx, next) => {
  console.log(5);
  next();
  console.log(6);
})

app.listen(3000, function() {
  console.log(`serve is running on port 3000`);
});

app.on('error', function(err) {
  console.log('err', err);
});

中间件中遇到 next 就会跳到下个中间件,等下个中间件执行完,才会继续执行剩余部分。

所以输出结果为:1 3 5 6 4 2

koa 会将多个中间件进行组合处理,内部会将这三个函数全部包装成 promise(哪怕没用 async),并且将这三个 promise 串联起来,第一层等待其他层执行完毕再继续执行。

加了 sleep 的中间件执行顺序

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

const sleep = (time) => {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            console.log('sleep')
            resolve();
        }, time);
    })
}

app.use(async (ctx, next) => {
  console.log(1);
  next();
  console.log(2);
})

app.use(async (ctx, next) => {
  console.log(3);
  sleep(1000); // 睡 1 秒
  next();
  console.log(4);
})

app.use(async (ctx, next) => {
  console.log(5);
  next();
  console.log(6);
})

app.listen(3000, function() {
  console.log(`serve is running on port 3000`);
});

app.on('error', function(err) {
  console.log('err', err);
});
  1. 第一个中间件执行,输出 1,遇到 next,把 next 后面的代码包成一个 promise,这里我们称之为 promsie1,promise1 等待第二个中间件执行结果。
  2. 第二个中间件执行,输出 3,遇到 sleep,内部 setTimeout 是个宏任务,在 webapi 线程计时(1s 后入宏任务队列),回到第二个中间件,执行 next,将后面的代码包成一个 promise,我们称之为 promise2,promise2 等待第三个中间件的返回结果,跳到第三个中间件
  3. 第三个中间件执行,输出 5,遇到 next,把后面代码包成一个 promise,我们称之为 promise3,promise3 等待下一个中间件返回结果,发现没有其他中间件了,此时 promise3 入微任务队列,微任务队列为 [promise3]
  4. 同步代码执行完,去清空微任务队列,promise3 执行,输出 6,同时 promise2 入队列,微任务队列为 [promise2]
  5. promise2 执行,输出4, promise1 入微任务队列,微任务队列为 [promise1]
  6. promsie1 执行,输出 2,微任务队列情况
  7. 开始执行宏任务,等待定时器到时间,回调函数作为一个宏任务入队列执行,输出 sleep

所以得到输出结果为:1 3 5 6 4 2 sleep,sleep 函数并没有影响中间件的执行顺序。

加了 await sleep 的中间件执行顺序

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

const sleep = (time) => {
  // 注意这里虽然有 promsie,但并未执行 .then,这里没有微任务
  // 注意,return new Promsie,只有等 new Promise 执行完毕后,sleep 才有返回值
  return new Promise((resolve, reject) => {
      setTimeout(() => {
          console.log('sleep')
          resolve();
      }, time);
  })
}

app.use(async (ctx, next) => {
  ctx.body = 'one';
  console.log(1);
  next();
  ctx.body = 'two';
  console.log(2);
})

app.use(async (ctx, next) => {
  ctx.body = 'three';
  console.log(3);
  await sleep(1000);
  next();
  ctx.body = 'four';
  console.log(4);
})

app.use(async (ctx, next) => {
  ctx.body = 'five';
  console.log(5);
  next();
  ctx.body = 'six';
  console.log(6);
})

app.listen(3000, function() {
  console.log(`serve is running on port 3000`);
});

app.on('error', function(err) {
  console.log('err', err);
});
  1. 第一个中间件执行,输出 1,遇到 next,把 next 后面的代码包成一个 promise,这里我们称之为 promsie1,promise1 等待第二个中间件执行结果。
  2. 第二个中间件执行,输出 3,遇到 await sleep,会把之后的代码包装成一个 promise,我们这里称为 afterSleepPromise,然后第二个中间件同步代码执行完毕,返回 undefined,promsie1 入微任务队列,至此 同步代码执行完毕,微任务队列为 [promise1]
  3. 扫描微任务队列,promise1 执行,输出 2,页面渲染为 two(首个中间件执行完毕,页面立马渲染),微任务队列清空完毕。
  4. 等定时器到时间,setTimeout 回调进入宏任务队列,宏任务执行,输出 sleep,sleep 函数接收到成功态的返回值,afterSleepPromise 入微任务队列,宏任务队列清空完毕,微任务队列为 [afterSleepPromise]
  5. 扫描微任务队列,afterSleepPromise 执行,把第二个中间件 next 后面代码包成 promise2,promise2 等待第三个中间件的返回结果,跳到第三个中间件
  6. 第三个中间件执行,输出 5,遇到 next,把后面代码包成一个 promise,我们称之为 promise3,promise3 等待下一个中间件返回结果,发现没有其他中间件了,此时 promise3 入微任务队列,微任务队列为 [promise3]
  7. 同步代码执行完,去清空微任务队列,promise3 执行,输出 6,同时 promise2 入队列,微任务队列为 [promise2]
  8. promise2 执行,输出4,代码执行完毕。

所以得到输出结果为:1 3 2 (sleep 1s) 5 6 4,sleep 函数影响了中间件的执行顺序,而且在输出 2 时,已经给浏览器返回了 ctx.body,此后再改 ctx.body 已经没有用了。

koa 中间件的使用原则

但是这肯定不是我们希望得到的结果,你总不能让我的中间件内不使用 await 吧,我们肯定希望每个中间件都要被等待执行完成后,才返回数据,所以每个中间件的 next 前需要加个 await 或者 return (一个 promise 返回另一个 promise 也会有等待效果)。

app.use(async (ctx, next) => {
  ctx.body = 'one';
  console.log(1);
  await next();
  ctx.body = 'two';
  console.log(2);
})

app.use(async (ctx, next) => {
  ctx.body = 'three';
  console.log(3);
  await sleep(1000);
  await next();
  ctx.body = 'four';
  console.log(4);
})

app.use(async (ctx, next) => {
  ctx.body = 'five';
  console.log(5);
  await next();
  ctx.body = 'six';
  console.log(6);
})

app.on('error', function(err) {
  console.log('err', err);
});

这样的话,中间件 1 就要等待下一个中间件执行完成,而中间件 2 需要等待 sleep 和 下一个 next,这样就形成了一种 koa 中 next 方法的使用原则,所有的 next 方法调用,前面必须加 await。

输出 1 3 sleep 5 6 4 2

完善 koa,增加中间件

我们需要收集所有的 middleware,然后把他们包成 promise,并用 next 方法控制执行即可。

修改 myKoa/application.js

  1. 收集中间件,维护中间件列表
  2. 创建 compose 方法,该方法会把中间件函数包装成一个 promise,并用 next 方法来控制依次执行,整体串成一个 promise 链,中间件 1 等待其他中间件 promise 返回结果。
  3. 同一个中间件多次调用 next,直接返回一个失败的 promise
  4. 中间件 1 执行完毕后,直接 return 一个成功态的 promise 对象,修改 handleRequest,在成功回调中执行 ctx.body 取值,并返回给页面的操作,失败回调把错误 emit,留给全局的 app.on('err', cn) 监听处理。
myKoa/application.js
const EventEmitter = require('events'); // 有 on('error') 肯定用了 events 模块
const http = require('http');
const request = require('./request');
const response = require('./response');
const context = require('./context');

class Koa extends EventEmitter {
  constructor() {
    super();
    this.context = Object.create(context); // 防止多个应用之间公用一个 context
    this.request = Object.create(request); // 防止多个应用之间公用一个 request
    this.response = Object.create(response); // 防止多个应用之间公用一个 response
    this.middlewares = []; // 中间件队列
  }

  use(middleware) {
    // this.fn = fn; // 保存中间件函数 
    this.middlewares.push(middleware);
  }

  // 构建 ctx
  createContext(req, res) {
    let ctx = Object.create(this.context); // 防止多个请求共享 ctx
    let request = Object.create(this.request); // 防止多个请求共享 request
    let response = Object.create(this.response); // 防止多个请求共享 response

    ctx.request = request;
    ctx.request.req = ctx.req = req;
    ctx.response = response;
    ctx.response.res = ctx.res = res;

    return ctx;
  }

  compose(ctx) {
    let flag = -1; // 用来标识当前中间件第几次调用 next 的一个标识。
    // 我需要将 middlewares 中的所有方法拿出了来,先调用第一个,第一个调用完毕后,会调用 next,再去执行第二个
    // 类似 co 的异步串行,声明一个执行任务的方法,并初始化执行,完毕后调用下一个任务
    const dispatch = i => {
      if (flag > i) {
        // 同一个中间件 多次调用 next,报错
        return Promise.reject('next() called multiple times');
      }

      flag = i;
      // 如果没有中间件,或者执行到最后一个中间件了,返回一个成功的 promise
      if (this.middlewares.length == i) return Promise.resolve();

      // 包成 promise,传入 ctx,并执行下一个
      // () => dispatch(i + 1) 就是我们调用的 next 方法
      // 也就是说,每个中间件本身被包成 promise,而且每个 next 返回一个新的 promise
      // 这样一层层串起来的
      return Promise.resolve(this.middlewares[i](ctx, () => dispatch(i + 1)));
    }

    return dispatch(0)
  }

  handleRequest = (req , res) => {
    const ctx = this.createContext(req, res);

    res.statusCode = 404;

    // this.fn(ctx); // 中间件执行

    // 把中间件组合成一个 promsie
    // promise 执行完之后,再异步返回数据给客户端
    this.compose(ctx).then(() => {
      // 如果用户设置了 ctx.body 就传递给客户端
      if (typeof ctx.body == 'object') {
        res.setHeader('Content-type', 'application/json;chartset=utf-8');
        // 对象转字符串
        res.end(JSON.stringify(ctx.body));
      } else if (ctx.body) {
        res.end(ctx.body);
      } else {
        res.end('Not Fount');
      }
    }).catch(err=>{
      this.emit('error',err)
    })
  }

  listen(...args) {
    // 就是 node 中的原生 http 模块
    const server = http.createServer(this.handleRequest);

    server.listen(...args);
  }
}

module.exports = Koa;


step3: 兼容 ctx.body = 文件流

ctx.body 支持返回一个文件流。

修改 myKoa/application.js

  1. 判断 ctx.body 是流类型,直接 ctx.body.pipe(res)
myKoa/application.js
// ...
const Stream = require('stream');

class Koa extends EventEmitter {
  // ...
  handleRequest = (req , res) => {
    const ctx = this.createContext(req, res);

    res.statusCode = 404;

    this.compose(ctx).then(() => {
      // 这里兼容流
      if (ctx.body instanceof Stream){
        ctx.body.pipe(res);
      } else if (typeof ctx.body == 'object') {
        res.setHeader('Content-type', 'application/json;chartset=utf-8');
        // 对象转字符串
        res.end(JSON.stringify(ctx.body));
      } else if (ctx.body) {
        res.end(ctx.body);
      } else {
        res.end('Not Fount');
      }
    }).catch(err=>{
      this.emit('error',err)
    })
  }
}


源码汇总

到这里,koa 的核心原理我们已经实现啦。

myKoa/package.json
{
  "main": "./lib/application"
}

myKoa/application.js
const EventEmitter = require('events'); // 有 on('error') 肯定用了 events 模块
const http = require('http');
const request = require('./request');
const response = require('./response');
const context = require('./context');
const Stream = require('stream');

class Koa extends EventEmitter {
  constructor() {
    super();
    this.context = Object.create(context); // 防止多个应用之间公用一个 context
    this.request = Object.create(request); // 防止多个应用之间公用一个 request
    this.response = Object.create(response); // 防止多个应用之间公用一个 response
    this.middlewares = []; // 中间件队列
  }

  use(middleware) {
    // this.fn = fn; // 保存中间件函数 
    this.middlewares.push(middleware);
  }

  // 构建 ctx
  createContext(req, res) {
    let ctx = Object.create(this.context); // 防止多个请求共享 ctx
    let request = Object.create(this.request); // 防止多个请求共享 request
    let response = Object.create(this.response); // 防止多个请求共享 response

    ctx.request = request;
    ctx.request.req = ctx.req = req;
    ctx.response = response;
    ctx.response.res = ctx.res = res;

    return ctx;
  }

  compose(ctx) {
    let flag = -1; // 用来标识当前中间件第几次调用 next 的一个标识。
    // 我需要将 middlewares 中的所有方法拿出了来,先调用第一个,第一个调用完毕后,会调用 next,再去执行第二个
    // 类似 co 的异步串行,声明一个执行任务的方法,并初始化执行,完毕后调用下一个任务
    const dispatch = i => {
      if (flag > i) {
        // 同一个中间件 多次调用 next,报错
        return Promise.reject('next() called multiple times');
      }

      flag = i;
      // 如果没有中间件,或者执行到最后一个中间件了,返回一个成功的 promise
      if (this.middlewares.length == i) return Promise.resolve();

      // 包成 promise,传入 ctx,并执行下一个
      // () => dispatch(i + 1) 就是我们调用的 next 方法
      // 也就是说,每个中间件本身被包成 promise,而且每个 next 返回一个新的 promise
      // 这样一层层串起来的
      return Promise.resolve(this.middlewares[i](ctx, () => dispatch(i + 1)));
    }

    return dispatch(0)
  }

  handleRequest = (req , res) => {
    const ctx = this.createContext(req, res);

    res.statusCode = 404;

    // this.fn(ctx); // 中间件执行

    // 把中间件组合成一个 promsie
    // promise 执行完之后,再异步返回数据给客户端
    this.compose(ctx).then(() => {
      // 如果用户设置了 ctx.body 就传递给客户端
      if (ctx.body instanceof Stream){
        ctx.body.pipe(res);
      } else if (typeof ctx.body == 'object') {
        res.setHeader('Content-type', 'application/json;chartset=utf-8');
        // 对象转字符串
        res.end(JSON.stringify(ctx.body));
      } else if (ctx.body) {
        res.end(ctx.body);
      } else {
        res.end('Not Fount');
      }
    }).catch(err=>{
      this.emit('error',err)
    })
  }

  listen(...args) {
    // 就是 node 中的原生 http 模块
    const server = http.createServer(this.handleRequest);

    server.listen(...args);
  }
}

module.exports = Koa;

myKoa/context.js
// 代理 比如 ctx.query
const context = {
}

// 代理 ctx.xxx -> ctx.request.xxx
function defineGetter(target, key) {
  // 给 context 某属性 增加 get 拦截器
  context.__defineGetter__(key, function() {
    return this[target][key];
  });
}

function defineSetter(target, key) {
  context.__defineSetter__(key, function(val) {
    this[target][key] = val;
  });
}

// 代理取值和赋值 
// ctx.query -> ctx.request.query
defineGetter('request', 'query');
defineGetter('request', 'url');
defineGetter('request', 'path');
defineGetter('response', 'body');
defineSetter('response', 'body');

module.exports = context;

myKoa/request.json
const url = require('url');

// ctx.path -> url.parse(this.url).pathname
const request = {
  get url() {
    // 使用 ctx.request.url 时候,执行 ctx.request.url()
    // ctx.request 就是 this
    return this.req.url;
  },
  get path() {
    return url.parse(this.url).pathname;
  },
  get query() {
    return url.parse(this.url).query;
  }
}

module.exports = request;

myKoa/response.js
// ctx.response.body = 'world'
const response = {
  _body: undefined,
  get body() {
    return this._body;
  },
  set body(val) {
    this.res.statusCode = 200;
    this._body = val;
  }
}

module.exports = response;