10

深入源码:手写一个koa

 3 years ago
source link: https://zhuanlan.zhihu.com/p/357093261
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

深入源码:手写一个koa

Enjoy what you are doing!

前言:了解koa

koa是使用Node.js进行服务端开发的常用框架,它帮用户封装了原生Node.jsreqres,使用户可以更方便的调用API来实现对应的功能。

koa的核心没有捆绑任何其它的中间件,这让它的代码体积更小、性能更高。

下面是koa最简单的Hello World例子:

const Koa = require('koa');
// 创建Koa实例
const app = new Koa();

// 添加中间件来处理请求
app.use(async ctx => {
  // ctx.body可以设置传递客户端的响应
  ctx.body = 'Hello World';
});

// 监听端口
app.listen(3000);

更多关于koa的介绍可以查阅它的官方代码仓库

下面我们将会根据上述的Hello World来一步步实现Koa的核心功能。

代码结构分析

从之前的Hello World代码中,我们可以得到如下信息:

  • Koa是一个类,用户可以通过new Koa来创建一个koa实例
  • koa实例上提供了use方法,该方法接收一个回调函数,可以用来处理请求
  • koa实例上还提供了listen方法,用来监听创建服务的端口

接下来我们去看下koa的源代码的目录结构:

主要有以下四个文件,每个文件的功能如下:

  • application: Koa类,用来创建koa实例
  • context: 导出一个对象,会提供一些api,也会代理requestresponse上的一些属性或方法,方便用户直接通过ctx来调用
  • request: 导出一个对象,封装了Node.js原生req的方法
  • response: 导出一个对象,封装了Node.js原生res的方法

用户在使用时会通过package.json中的main字段来引入Application类创建实例:

首先我们来实现Application文件中的相关逻辑,让它可以支持如下代码的运行:

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

// 使用Node.js原生的处理请求的方法
app.use((req, res) => {
  res.end('Hello World')
});

// 监听端口
app.listen(3000);

Application中的代码如下:

const http = require('http');

function Application () {
  this.middlewares = [];
}

Application.prototype.handleRequest = function (req, res) {
  this.middlewares.forEach(m => m(req, res));
  res.end();
};

Application.prototype.listen = function (...args) {
  const server = http.createServer(this.handleRequest.bind(this));
  return server.listen(...args);
};

Application.prototype.use = function (cb) {
  this.middlewares.push(cb);
};

module.exports = Application;

Applicationlisten方法会通过Node.jshttp模块来创建一个服务器,并且会最终调用server.listen通过...args来将所有参数传入。这样Application 的实例在调用listen时便需要传入和server.listen相同的参数。

handleRequest方法中会执行所有app.use中传入的回调函数,也就是koa中的中间件。

实现context,reponse,request

为了方便用户使用,koahandleRequest中传入的req,res进行了封装,最终为用户提供了ctx对象。

由于对象是引用类型,为了防止对象引用之间相互修改,每个应用Application在实例化的时候都需要创建一个单独的context,request,response

function Application () {
  this.middlewares = [];
  // 使用Object.create通过原型链来进行取值,改值的时候只会修改自身属性
  this.context = Object.create(context);
  this.request = Object.create(request);
  this.response = Object.create(response);
}

在处理请求时,每次请求也都会有单独的context,response,request,并且它们和Node.js原生req,res的关系如下:

Application.prototype.createContext = function (req, res) {
  // 这里访问属性时会通过2层原型链来查找
  const ctx = Object.create(this.context); // 通过原型来继承context上的方法
  const request = Object.create(this.request);
  const response = Object.create(this.response);
  ctx.request = request;
  ctx.response = response;
  ctx.response.req = ctx.request.req = ctx.req = req;
  ctx.response.res = ctx.request.res = ctx.res = res;
  return ctx;
};

这样用户可以通过ctx.req,ctx.request.req,ctx.response.reqctx.res,ctx.request.res,ctx.response.res来调用Node.js原生的req,res

handleRequest中会通过createContext来创建ctx对象,作为回调函数参数传递给用户:

// req,res的功能比较弱,还要单独封装一个ctx变量来做整合,并且为用户提供一些便捷的api
Application.prototype.handleRequest = function (req, res) {
  const ctx = this.createContext(req, res);
  // 这里会是异步函数
  this.middlewares.forEach(m => m(ctx));
  res.end();
};

下面我们实现几个context,request,response中常用api,理解它们的代码思路:

  • ctx.request.path
  • ctx.response.body
  • ctx.path
  • ctx.body
const Koa = require('koa');
const app = new Koa();

app.use(async ctx => {
  console.log(ctx.request.path)
  console.log(ctx.path)
  ctx.body = 'hello'
  ctx.response.body += 'world'
});

app.listen(3000);

request.js中添加如下代码来让它支持path属性:

const url = require('url');
module.exports = {
  get path () {
    // 用户通过ctx.request.path来调用,所以this是ctx.request,可以通过ctx.request.req来获取Node.js原生的req对象
    const { pathname } = url.parse(this.req.url);
    return pathname;
  }
};

response.js中添加如下代码来支持body属性:

module.exports = {
  set body (val) { // ctx.response.body, this => ctx.response
    if (val == null) {return;}
    // 设置body后将状态码设置为200
    this.res.statusCode = 200;
    this._body = val;
  },
  get body () { // 返回的是ctx.response上的_body,并不是当前对象中定义的_body
    return this._body;
  }
};

之后在context.js中会分别代理request,response上对应的属性和方法:

const context = {};
module.exports = context;

// 相当于使用Object.defineProperty设置get和set方法
function defineGetter (target, key) {
  context.__defineGetter__(key, function () {
    return this[target][key];
  });
}

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

defineGetter('request', 'path');

defineGetter('response', 'body');
defineSetter('response', 'body');

context.js通过__defineGetter____defineSetter__实现了对requestresponse上属性的代理,这样用户便可以直接通过ctx来访问对应的属性和方法,少敲几次键盘。

koa中,对ctx.body的类型也进行了处理,方便用户为客户端返回数据:

Application.prototype.handleRequest = function (req, res) {
  const ctx = this.createContext(req, res);
  // 状态码默认为404,在为ctx.body设置值后设置为200
  res.statusCode = 404;
  // 这里会是异步函数
  this.middlewares.forEach(m => m(ctx));
  // 执行完函数后,手动将ctx.body用res.end进行返回
  if (typeof ctx.body === 'string' || Buffer.isBuffer(ctx.body)) {
    res.end(ctx.body);
  } else if (ctx.body instanceof Stream) { // 流
    // 源码会直接将流进行下载,会设置: content-position响应头
    ctx.body.pipe(res);
  } else if (typeof ctx.body === 'object' && ctx.body !== null) { // 
    res.setHeader('Content-Type', 'application/json;charset=utf8');
    res.end(JSON.stringify(ctx.body));
  } else { // null,undefined
    res.end('Not Found');
  }
};
  • Buffer | String: 通过res.endctx.body返回给客户端
  • Stream: 会将可读流通过pipe方法写入到可写流res中返回给客户端,需要用户来手动指定对应请求头的Content-Type
  • Object: 通过JSON.stringify将对象转换为JSON字符串返回

如果body没有设置值或者设置值为nullundefined将返回客户端Not Found,响应状态码为404

实现中间件逻辑

在上边的代码中,我们已经处理好context,request,response之间的关系,下面我们来实现koa中比较重要的功能:中间件。

koa.use方法中传入的函数便是中间件,它接收俩个参数:ctx,next。需要注意的是,这里的next是一个函数,它的返回值为promise

Application.prototype.compose = function (ctx) {
  let i = 0;

  const dispatch = () => {
    if (i === this.middlewares.length) { // 如果执行完所有的中间件函数
      return Promise.resolve(); // 最终返回value为undefined的promise
    }
    const middleware = this.middlewares[i];
    i++;
    try {
      return Promise.resolve(middleware(ctx, dispatch));
    } catch (e) {
      return Promise.reject(e);
    }
  };
  // 默认先执行第1个,然后通过用户来手动调用next来执行接下来的函数
  return dispatch();
};

Application.prototype.handleRequest = function (req, res) {
  const ctx = this.createContext(req, res);
  res.statusCode = 404;
  // 这里会是异步函数
  // this.middlewares.forEach(m => m(ctx));
  this.compose(ctx).then(() => {
    // 执行完函数后,根据不同类型来手动将ctx.body用res.end进行返回
    // some code...
  })
};

compose函数中定义了dispatch函数,并将dispatch执行后的promise返回。此时dispatch的执行会让用户传入的第一个中间件执行,中间件中的next参数就是这里的dispatch

当用户调用next时,便会调用compose中的dispatch方法,此时会通过i来获取下一个middlewares数组中的中间件并执行,再将dispatch传递给用户,重复这个过程,直到处理完所有的中间件函数。

了解了中间件的实现逻辑后,我们来看下面的一个例子:

const Koa = require('koa');
const app = new Koa();
const PORT = 3000;
const sleep = function (delay) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(); // 执行时才会异步的(微任务)执行.then中的回调
      console.log('sleep');
    }, delay);
  });
};

app.use(async (ctx, next) => {
  console.log(1);
  await next(); // next 是 promise,要等到promise执行then方法中的回调时才会执行之后代码
  console.log(2);
});

app.use(async (ctx, next) => {
  console.log(3);
  await sleep(1000); // promise.then(() => {next(); console.log(4)})
  await next();
  console.log(4);
});

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

app.listen(PORT, () => {
  console.log(`server is listening on port ${PORT}`);
});

当中间中有异步逻辑时,一定要使用await或返回对应的promise。这样Promise.resolve便必须等到所有的promiseresolved或者rejected之后才会继续执行。

koa中间件的执行流程如下:

koa的错误处理是通过继承events来实现的:

const EventEmitter = require('events');

function Application () {
  // 继承属性
  EventEmitter.call(this);
  // omit some code...
}

// 继承原型上的方法
Application.prototype = Object.create(EventEmitter.prototype);
Application.prototype.constructor = Application;

Application.prototype.handleRequest = function (req, res) {
  const ctx = this.createContext(req, res);
  res.statusCode = 404;
  // 这里会是异步函数
  // this.middlewares.forEach(m => m(ctx));
  this.compose(ctx).then(() => {
    // some code
  }).catch((err) => { // 在catch方法中处理错误
    res.statusCode = 500;
    ctx.body = 'Server Internal Error!';
    res.end(ctx.body);
    // emit error 事件,需要用户通过on('error',fn)来进行错误事件的订阅
    this.emit('error', err, ctx);
  });
};

在中间执行过程中如果出现了错误,那么便会返回rejected状态的promise,此时可以通过promisecatch方法来捕获错误,并通过继承自eventsemit方法来通知error事件对应的函数执行:

Promise在执行过程中如果出现错误,会通过try{ // some code }catch(e){ reject(err) }返回一个失败状态的promise

const Koa = require('../lib/application');
const app = new Koa();
const PORT = 3000;
app.use(async (ctx, next) => {
  throw Error('I am an error message');
  console.log(1);
  await next();
});

app.use((ctx, next) => {
  ctx.body = 'Hello Koa';
});

app.on('error', (e, ctx) => {
  console.log('ctx', e);
});
app.listen(PORT, () => {
  console.log(`server is listening on ${PORT}`);
});

用户在使用时需要通过on方法来监听error事件,这样在中间件执行的过程中出现错误时,就会通过emit('error')来通知error绑定的函数执行,进行错误处理。

本文讲解了koa源码中的一些核心逻辑:

  • 提供ctx变量,为用户提供更加简洁的api
  • 通过中间件来将一个请求过程中处理的逻辑进行拆分
  • 通过监听error事件来进行错误处理

在了解这些知识后,我们便能更加熟练的运用koa来实现各种需求,也可以借鉴它的实现思路来自己实现一个类似的工具。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK