koa2框架原理解析

简介

 

Koa 是一个新的 web 框架,由 Express 幕后的原班人马打造, 致力于成为 web 应用和 API 开发领域中的一个更小、更富有表现力、更健壮的基石。 通过利用 async 函数,Koa 帮你丢弃回调函数,并有力地增强错误处理。 Koa 并没有捆绑任何中间件, 而是提供了一套优雅的方法,帮助您快速而愉快地编写服务端应用程序。

koa2使用的是async/await执行方式

koa源码结构

上图是koa2的源码目录结构的lib文件夹,lib文件夹下放着四个koa2的核心文件:application.js、context.js、request.js、response.js

application.js 

入口文件,向外导出了创建class实例的构造函数;继承Emitter,这样就具有了事件监听和事件触发的能力。

application还暴露了一些常用的api:

  • use, use是收集中间件,将多个中间件放入一个缓存队列中,然后通过koa-compose这个插件进行递归组合调用这一些列的中间件
  • toJSON
  • inspect
  • listen创建并返回 HTTP 服务器

  • callback返回适用于 http.createServer() 方法的回调函数来处理请求

context.js

koa的应用上下文ctx,其实就一个简单的对象暴露,使用delegate进行代理对象的属性比如我们要访问ctx.repsponse.status但是我们通过delegate,可以直接访问ctx.status访问到它。

request.js

Request 对象是在 node 的 原生请求对象之上的抽象,提供了诸多对 HTTP 服务器开发有用的功能;比如设置headers,设置body等

response.js

Response 对象是在 node 的原生响应对象之上的抽象,提供了诸多对 HTTP 服务器开发有用的功能;比如获取headers,获取body等

原理解析

koa框架需要实现四个大模块:

  • 封装node http server、创建Koa类构造函数
  • 构造request、response、context对象
  • 中间件机制和剥洋葱模型的实现
  • 错误捕获和错误处理

下面就逐一分析和实现

封装node http server和创建Koa类构造函数

我们先来看原生Node实现一个简单Server服务器的代码:

const http = require('http');

const server = http.createServer((req, res) => {
  res.writeHead(200);
  res.end('hello world');
});

server.listen(3000, () => {
  console.log('server start at 3000');
});

以上代码就实现了一个服务器Server

我们需要将上面的node原生代码封装实现成koa的模式:

const Koa = require('koa');
const app = new Koa();
app.listen(3000);

实现koa的第一步就是对以上的这个过程进行封装,为此我们需要创建application.js实现一个Application类的构造函数:

// application.js
const http = require('http');

class Application {
  constructor() {
    this.callbackFn = null;
  }

  use(fn) {
    this.callbackFn = fn;
  }

  callback() {
    return (req, res) => this.callbackFn(req, res)
  }

  listen(...args) {
    const server = http.createServer(this.callback());
    return server.listen(...args);
  }
}

module.exports = Application;

然后创建example1.js,引入application.js,运行服务器实例启动监听代码: 

const Koa = require('./application');
let app = new Koa();
app.use((req, res) => {
    res.writeHead(200);
    res.end('hello world');
});
app.listen(3000, () => {
    console.log('listening on 3000');
});

在浏览器输入localhost:3000即可看到浏览器里显示“hello world”。

现对这第一步总结下:

已完成:

  1. 对http server进行了简单的封装和创建了一个可以生成koa实例的类class;
  2. 实现了app.use用来注册中间件和注册回调函数;
  3. app.listen用来开启服务器实例并传入callback回调函数

缺点:

  • use传入的回调函数,接收的参数依旧是原生的reqres
  • 多次调用use,会覆盖上一个中间件,并不是依次执行多个中间件

构造request、response、context对象 

request.js

koa里面的request.js是对node的原生request、response进行了一个功能的封装,使用了getter和setter属性

新建request.js, 只列举部分封装,其他还包含url、origin、path、method、search等方法,都是对原生的request上用getter和setter进行了封装

// request.js
const qs = require('querystring');

module.exports = {

  get header() {
    return this.req.headers;
  },

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

  get url() {
    return this.req.url;
  },

  set url(val) {
    this.req.url = val;
  },

  get query() {
    const str = this.querystring;
    const c = this._querycache = this._querycache || {};
    return c[str] || (c[str] = qs.parse(str));
  },

  set query(obj) {
    this.querystring = qs.stringify(obj);
  }
};

response.js

koa里面的response.js是对node的原生response进行了一个功能的封装,使用了getter和setter属性

新建response.js, 同样只列举部分封装

// response.js
module.exports = {

  get status() {
    return this.res.statusCode;
  },

  set status(code) {
    this.res.statusCode = code;
  },

  get body() {
    return this._body;
  },

  set body(val) {
    // 源码里不光对val有null判断,还有对val类型(String|Buffer|Object|Stream)的各种判断,这里省略
    this._body = val;
  }
};

以上代码实现了对koa的status的读取和设置,读取的时候返回的是基于原生的response对象的statusCode属性,而body的读取则是对this._body进行读写和操作。这里对body进行操作并没有使用原生的this.res.end,因为在我们编写koa代码的时候,会对body进行多次的读取和修改,所以真正返回浏览器信息的操作是在application.js里进行封装和操作。

context.js

context的作用就是将request、response对象挂载到ctx的上面,让koa实例和代码能方便的使用到request、response对象中的方法

我们知道context.body和context.status分别是代理了context.response.body与context.response.status,但具体是怎么实现的呢?context.js里通过delegates(委托)实现,

delegates 原理就是__defineGetter____defineSetter__(该特性已经从 Web 标准中删除)

举例说明:

var obj = {};
obj.__defineGetter__('name', function() { return 'zxx'; });
console.log(obj.name);    // zxx

新建delegates.js

module.exports = Delegator;

function Delegator(proto, target) {
  if (!(this instanceof Delegator)) return new Delegator(proto, target);
  this.proto = proto;
  this.target = target;
  this.methods = [];
  this.getters = [];
  this.setters = [];
}

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;
};

Delegator.prototype.access = function(name){
  return this.getter(name).setter(name);
};

Delegator.prototype.getter = function(name){
  var proto = this.proto;
  var target = this.target;
  this.getters.push(name);

  proto.__defineGetter__(name, function(){
    return this[target][name];
  });

  return this;
};

Delegator.prototype.setter = function(name){
  var proto = this.proto;
  var target = this.target;
  this.setters.push(name);

  proto.__defineSetter__(name, function(val){
    return this[target][name] = val;
  });

  return this;
};

新建context.js

// context.js

const delegate = require('delegates');

const proto = module.exports = {};

// proto.body = context.response.body
// proto.status = context.response.status
delegate(proto, 'response')
  .access('status')
  .access('body');


// proto.query = proto.request.query
// proto.url = proto.request.url
delegate(proto, 'request')
  .access('query')
  .access('url')
  .getter('header');

因此context.js比较适合使用delegate,仅仅是代理requestresponse的属性和方法。

真正注入原生对象,是在application.js里的createContext方法中注入的!

代码中使用Object.create的方法创建一个全新的对象,通过原型链继承原来的属性。这样可以有效的防止污染原来的对象

// application.js
const http = require('http');
const context = require('./context');
const request = require('./request');
const response = require('./response');

class Application {
  constructor() {
    this.callbackFn = null;
    this.context = Object.create(context);
    this.request = Object.create(request);
    this.response = Object.create(response);
  }

  use(fn) {
    this.callbackFn = fn;
  }

  callback() {
    return (req, res) => this.callbackFn(req, res)
  }

  // 将request、response所有方法挂载到context下
  // 将原生的req和res挂载到了context的子属性上
  createContext(req, res) {       
    let context = Object.create(this.context);
    context.request = Object.create(this.request);
    context.response = Object.create(this.response);
    context.req = context.request.req = req;
    context.res = context.response.res = res; 
    return context;
 }

  listen(...args) {
    const server = http.createServer(this.callback());
    return server.listen(...args);
  }
}

module.exports = Application;

现对这第一步总结下:

已完成:

  1. 封装了request、response、context对象 ;
  2. 通过createContext方法将原生的req和res挂载到了context的属性及子属性上;

缺点:

  • 中间件未实现, 即多次调用use,会覆盖上一个中间件,并不是依次执行多个中间件

中间件机制和洋葱模型的实现

koa的中间件机制是一个剥洋葱式的模型,多个中间件通过use放进一个数组队列然后从外层开始执行,遇到next后进入队列中的下一个中间件,所有中间件执行完后开始回帧,执行队列中之前中间件中未执行的代码部分,这就是剥洋葱模型,koa的中间件机制。

koa2的剥洋葱模型使用了async/await + Promise去实现的

我们先假设koa的中间件机制已经做好了, 那么它能成功运行下面代码的:

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

app.use(async (ctx, next) => {
    ctx.body = 'Hello World';
    console.log(1);
    next();
    console.log(4);
});

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

app.listen(3000);

运行成功后会在终端输出1 2 3 4,那就能验证我们的koa的剥洋葱模型是正确的。

在实现之前,我们先学习了函数式编程中compose的思想

var toUpperCase = function(str) { return str.toUpperCase(); };
var exclaim = function(str) { return str + '!'; };

var shout = compose(exclaim, toUpperCase);

var result = shout('hello world');    
console.log(result); // HELLO WORLD!

通过将exclaim、toUpperCase组合成一个复合函数,调用这个函数会先执行 toUpperCase,然后把返回值传给exclaim继续执行得到最终结果;

compose如何实现:

function compose(fns) {
  let length = fns.length;
  let count = length - 1;
  let result = null;

  return function fn1(...args) {
    result = fns[count].apply(null, args);
    if (count <= 0) {
      return result
    }

    count--;
    return fn1(result);
  }
}


// underscore.js compose实现
function compose(){
    var args = arguments;
    var start = args.length - 1;
    return function(){
        var i = start;
        var result = args[i].apply(this,arguments);
        while(i--) result = args[i].call(this,result);
        return result;
    }  
}



// reduceRight实现
const compose = (...args) => (value) => args.reduceRight((acc, fn) => fn(acc), value)

Koa的中间件机制类似上面的compose,同样是把多个函数包装成一个,但是koa的中间件类似洋葱模型,也就是从A中间件执行到B中间件,B中间件执行完成以后,仍然可以再次回到A中间件。

Koa使用了koa-compose实现了中间件机制 

新建compose.js

// compose.js
module.exports = 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!')
  }

  return function (context, next) {
    // last called middleware #
    let index = -1
    return dispatch(0)
    function dispatch (i) {
      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 {
        // dispatch.bind(null, i + 1)就是中间件里的next参数,调用它就可以进入下一个中间件
        // 递归调用下一个中间件
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
      } catch (err) {
        // 中间件是async的函数,报错不会走这里,直接在fnMiddleware的catch中捕获
        // 捕获中间件是普通函数时的报错,Promise化,这样才能走到fnMiddleware的catch方法
        return Promise.reject(err)
      }
    }
  }
}

总结下洋葱模型原理

洋葱模型原理: 所有的中间件依次执行,每次执行一个中间件,遇到next()就会将控制权传递到下一个中间件,当执行到最后一个中间件的时候,控制权发生反转,开始回头去执行之前所有中间件中剩下未执行的代码; 当最终所有中间件全部执行完后,会返回一个Promise对象,因为我们的compose函数返回的是一个async的函数,async函数执行完后会返回一个Promise,这样我们就能将所有的中间件异步执行同步化,通过then就可以执行响应函数和错误处理函数

修改application.js

引入compose,实现中间件

// application.js
const http = require("http");
const context = require("./context");
const request = require("./request");
const response = require("./response");
const compose = require("./compose");

class Application {
  constructor() {
    // 存储中间件
    this.middleware = [];
    this.context = Object.create(context);
    this.request = Object.create(request);
    this.response = Object.create(response);
  }

  use(fn) {
    this.middleware.push(fn);
    return this;
  }

  callback() {
    // 合成所有中间件
    const fn = compose(this.middleware);

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

    return handleRequest;
  }

  handleRequest(ctx, fnMiddleware) {
    const handleResponse = () => respond(ctx);
    // 执行中间件并把最后的结果交给respond
    return fnMiddleware(ctx).then(handleResponse);
  }

  // 将request、response所有方法挂载到context下
  // 将原生的req和res挂载到了context的子属性上
  createContext(req, res) {
    let context = Object.create(this.context);
    context.app = request.app = response.app = this;
    context.request = Object.create(this.request);
    context.response = Object.create(this.response);
    context.req = context.request.req = req;
    context.res = context.response.res = res;
    return context;
  }

  listen(...args) {
    const server = http.createServer(this.callback());
    return server.listen(...args);
  }
}

module.exports = Application;

function respond(ctx) {
  const res = ctx.res;
  let body = ctx.body;
  if (Buffer.isBuffer(body)) return res.end(body);
  if ("string" === typeof body) return res.end(body);
  if (body instanceof Stream) return body.pipe(res);

  // body: json
  body = JSON.stringify(body);
  if (!res.headersSent) {
    ctx.length = Buffer.byteLength(body);
  }
  res.end(body);
}

改下example.js

const Koa = require("./application");
let app = new Koa();

app.use(async (ctx, next) => {
  console.log("1-start");
  await next();
  console.log("1-end");
});

app.use(async (ctx) => {
  console.log("2-start");
  ctx.body = "hello world";
  console.log("2-end");
});

app.listen(3000, () => {
  console.log("listening on 3000");
});

// 1-start
// 2-start
// 2-end
// 1-end

现对这第一步总结下:

已完成:

  1. 已实现中间件;

缺点:

  • 缺少错误捕获和错误处理

错误捕获和错误处理

因为compose组合之后的函数返回的仍然是Promise对象,所以我们可以在catch捕获异常

修改application.js中handleRequest方法

handleRequest(ctx, fnMiddleware) {
  const handleResponse = () => respond(ctx);
  const onerror = err => ctx.onerror(err);
  // catch捕获,触发ctx的onerror方法
  return fnMiddleware(ctx).then(handleResponse).catch(onerror);
}

修改context.js

const proto = module.exports = {
  // 报错可以捕获
  onerror(err) {
    if (null == err) return;
    const { res } = this;
    let statusCode = err.status || err.statusCode;
    if ("ENOENT" === err.code) statusCode = 404;
    // default to 500
    if ("number" !== typeof statusCode )
      statusCode = 500;

    // respond
    this.status = err.status = statusCode;
    res.end(err.message);
  },
};

写个例子测试下错误:

const Koa = require("./application");
let app = new Koa();

app.use(async (ctx, next) => {
  a.b = 1;
  ctx.body = "hello world";
});

app.listen(3000, () => {
  console.log("listening on 3000");
});

运行后:提示a is not defined

现在我们已经实现了中间件的错误异常捕获,但是我们还缺少框架层发生错误的捕获机制。我们可以让Application继承原生的Emitter,从而实现error监听

修改application.js

const Emitter = require('events');

// 继承Emitter
class Application extends Emitter {
  constructor() {
    // 调用super
    super();
    this.middleware = [];
    // ...
  }
}

修改context.js

const proto = module.exports = {
  // 报错可以捕获
  onerror(err) {
    if (null == err) return;
    // 触发error事件
    this.app.emit('error', err, this);
    const { res } = this;
    let statusCode = err.status || err.statusCode;
    if ("ENOENT" === err.code) statusCode = 404;
    // default to 500
    if ("number" !== typeof statusCode )
      statusCode = 500;

    // respond
    this.status = err.status = statusCode;
    // 触发error事件
    res.end(err.message);
  },
};

改下example.js测试下:

const Koa = require("./application");
let app = new Koa();

app.use(async (ctx, next) => {
  a.b = 1;
  ctx.body = "hello world";
});


app.listen(3000, () => {
  console.log("listening on 3000");
});

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

 至此我们可以了解到Koa异常捕获的两种方式:

  • 中间件捕获(Promise catch)
  • 框架捕获(Emitter error)

参考资料:

https://segmentfault.com/a/1190000019603834

https://segmentfault.com/a/1190000016952053

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值