跳到主要内容

揭秘Koa原理,带你实现一个mini-koa

上一篇讲解了 koa 洋葱模型是如何实现的,并写了一个最小实现方案,这一篇讲全面讲解 koa 源码实现的细节,看看有哪些点值得我们学习。

_koa@2.14.1@koa
├─History.md
├─LICENSE
├─Readme.md
├─package.json
├─lib
| ├─application.js
| ├─context.js
| ├─request.js
| ├─response.js
| └test.md
├─dist
| └koa.mjs

koa库的文件结构非常简单,实现的逻辑都在lib目录下面,一共有四个模块,分别是applicationcontextrequestresponse

delegates 库是干什么用的

在讲解源码之前,先要介绍一下一个库:delegatescontext的部分实现就是用这个库,将一些方法和属性在访问的时候委托(或者说代理)到了requestrespone上(类似于Object.defineProperty)。

delegates 接收两个参数,一个是 proto,一个是 target,proto 是访问对象,target 是代理对象。

  • delegate(proto, target).setter(name) 注册 setter 的 name,比如当给 proto[name]赋值的时候,其实是给 target[name]赋值
  • delegate(proto, target).getter(name) 注册 getter 的 name,比如当获取 proto[name]的值的时候,得到的是 target[name]的值
  • delegate(proto, target).access(name) access 是既注册了 setter,又注册了 getter,实现了真正的完全代理,不管赋值还是取值,都是代理到了 target 对象上。
  • delegate(proto, target).method(name) 注册 method 的 name,当 proto[name]()调用一个方法时,相当于 target[method]()。 例如:
const delegate = require("delegates");
const proto = {};
let target = {
foo: () => {
return "foo";
},
};
proto.target = target;
delegate(proto, "target").method("foo");
// 当访问proto.foo()时,其实是代理到了target对象的foo方法
console.log(proto.foo()); // 'foo'

// Object.defineProperty实现
const proto = {};
let target = {
foo: () => {
return "foo";
},
};
proto.target = target;
Object.defineProperty(proto, "foo", {
get() {
return target["foo"];
},
});
console.log(proto.foo()); // 'foo'

new koa 做了什么?

koa 源码地址 让我们通过一个简单的 demo,来一步步实现 mini-koa。

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

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

app.listen(3000);

源码展示: image.png 去除一些不影响主逻辑的代码,constructor函数其实只创建了四个变量。

// index.js
const Emitter = require("events");
const context = require("./context.js");
const request = require("./request.js");
const response = require("./response.js");
class Application extends Emitter {
// 继承events模块,方便监听
constructor(options) {
super();
this.middleares = []; // 用于收集中间件
this.context = Object.create(context);
this.request = Object.create(request);
this.response = Object.create(response);
}
}

实现 use 和 listen

  use(fn) {
if (typeof fn !== 'function') throw new TypeError('middleware must be a function!');
if (isGeneratorFunction(fn)) {
deprecate('Support for generators will be removed in v3. ' +
'See the documentation for examples of how to convert old middleware ' +
'https://github.com/koajs/koa/blob/master/docs/migration.md');
fn = convert(fn); // 将generators函数转成async函数
}
debug('use %s', fn._name || fn.name || '-');
this.middleware.push(fn);
return this;
}

去掉一些边界判断条件,use方法其实只做了两件事:1.收集中间件;2.返回 this,支持链式调用。再看listen方法。

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

listen方法也很简单,就是利用http模块,创建了一个服务,并传入了一个this.callback回调函数,同时将参数透传给http服务的listen方法。

http.createServer 接收一个回调函数,参数形式为:http.createServer((res, req) => {})

继续完善我们的 mini-koa

// index.js
const Emitter = require("events");
const context = require("./context");
const request = require("./request");
const response = require("./response");
const http = require("http");
class Application extends Emitter {
constructor(options) {
this.middleares = [];
this.context = Object.create(context);
this.request = Object.create(request);
this.response = Object.create(response);
}
use(middleare) {
this.middleares.push(middleare);
return this;
}
listen(...args) {
const server = http.createServer(this.callback());
return server.listen(...args);
}
callback() {
return (req, res) => {
// todo
};
}
}

实现完整版的 compose 函数

继续看看源码的callback如何实现:

  callback() {
const fn = compose(this.middleware);
if (!this.listenerCount('error')) this.on('error', this.onerror);

const handleRequest = (req, res) => {
const ctx = this.createContext(req, res); // 创建了context对象
return this.handleRequest(ctx, fn);
};

return handleRequest;
}
handleRequest(ctx, fnMiddleware) {
const res = ctx.res;
res.statusCode = 404;
const onerror = err => ctx.onerror(err);
const handleResponse = () => respond(ctx);
onFinished(res, onerror);
return fnMiddleware(ctx).then(handleResponse).catch(onerror);
}
createContext(req, res) { // 创建一个context,并且定义了对外暴露context的属性
const context = Object.create(this.context);
const request = context.request = Object.create(this.request); // 挂在koa的request
const response = context.response = Object.create(this.response); // 挂载koa的response
context.app = request.app = response.app = this; // 挂载app实例
context.req = request.req = response.req = req; // 挂载原生的req对象
context.res = request.res = response.res = res; // 挂载原生的res对象
request.ctx = response.ctx = context;
request.response = response;
response.request = request;
context.originalUrl = request.originalUrl = req.url;
context.state = {};
return context;
}

首先看callback函数第一行代码const fn = compose(this.middleware),利用compose函数,组合了中间件,返回了一个fn,后面将fn传给了this.handleRequest()的第二个参数(第一个参数是context)。在this.handleRequest这个函数里面,fn被命名为fnMiddleare,最后this.handleRequest是返回了fnMiddleware(ctx).then(handleResponse).catch(onerror),所以可以得到以下几点:

  • 调用compose函数返回的是一个 fn 函数,这个函数接收context作为参数
  • 调用fn函数返回了一个promise

所以在上一篇原来 koa 实现洋葱模型只有 11 行代码文章的基础之上,稍微修改一下即可。

// compose.js
function compose(middleares) {
return function fn(context) {
// 1. fn接收一个context作为参数
let index = -1;
function dispatch(i) {
// 2. dispatch必须返回的是一个promise
if (i <= index)
return Promise.reject(new Error("next() called multiple times"));
index = i;
let middleare = middleares[i];
// 别忘记fn函数的返回值是一个promise
if (!middleare) return Promise.resolve();
try {
return Promise.resolve(middleare(context, dispatch.bind(null, i + 1)));
} catch (err) {
return Promise.reject(err);
}
}
return dispatch(0); // 返回dispatch的执行结果
};
}

实现 Context(利用 delegates 委托机制)

koa 的 Context 源码地址 伪代码如下:

const delegate = require('delegates');

const proto = module.exports = {
...
inspect(){
// ...
}
...
}
delegate(proto, 'response')
.method('attachment')
.access('body')

delegate(proto, 'request')
.method('get')
.access('method')
.getter('href')

前面已经简单讲过delegates的使用方法,context除了一些自身定义的方法之外,还有许多方法是直接委托到responserequest上。

实现 request/response

// request.js
module.exports = {
get method(){
return this.req.method;
},
set method(val) {
return this.req.method = val;
}
...
}

koarequest是在原生的req基础之上,做了很多方法的封装,方便操作;response也是同理的,这里不再展开讨论。

错误拦截

注册错误函数

// application.js
callback() {
...
// 检测有没有已经注册过error事件
if (!this.listenerCount('error')) this.on('error', this.onerror);
...
}

onerror(){
const isNativeError =
Object.prototype.toString.call(err) === '[object Error]' ||
err instanceof Error;
if (!isNativeError) throw new TypeError(util.format('non-error thrown: %j', err));

if (404 === err.status || err.expose) return;
if (this.silent) return;

const msg = err.stack || err.toString();
console.error(`\n${msg.replace(/^/gm, ' ')}\n`);
}

如上图,new koa的时候,会先检测app实例上有没有你自己写的error事件,比如app.on('error', () => {}),如果有的话,就用你的;如果没有的话,就注册this.onerror函数。this.onerror函数主要就是将错误信息用console.error方法打印出来。

拦截中间件错误

this.handleRequest函数里面,可以看到fnMiddleare是一个promise,也就是说当中间件里面发生错误的时候,就会走到catch函数,调用ctx.onerror方法。

// application.js
handleRequest(ctx, fnMiddleware) {
...
const onerror = err => ctx.onerror(err);
const handleResponse = () => respond(ctx);
return fnMiddleware(ctx).then(handleResponse).catch(onerror);
}

继续看看ctx.onerror方法

// context.js
onerror(err) {
if (null == err) return;
const isNativeError =
Object.prototype.toString.call(err) === '[object Error]' ||
err instanceof Error;
if (!isNativeError) err = new Error(util.format('non-error thrown: %j', err));
...
// delegate
this.app.emit('error', err, this); // 触发error事件,将错误信息发射到app.onerror
const { res } = this;
...
// respond
const code = statuses[statusCode];
const msg = err.expose ? err.message : code;
...
res.end(msg);
},

当中间件发生错误时,ctx.onerror方法会调用this.app.emit('error', err, this)方法,由于之前我们已经注册过apponerror方法了,就会将错误信息打印出来。

emit 和 on 方法都是 application 继承自 Events 模块

ctx.body 是如何做到的

// application.js
handleRequest(ctx, fnMiddleware) {
...
const onerror = err => ctx.onerror(err);
const handleResponse = () => respond(ctx);
return fnMiddleware(ctx).then(handleResponse).catch(onerror);
}

还是在application.jsthis.handleRequest函数里面,当执行完fnMiddleare(ctx)之后会调用then方法,相当于调用了respond方法。

function respond(ctx) {
...
const res = ctx.res;
let body = ctx.body;
const code = ctx.status;
...
// responses
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);
}

可以看到,respond函数主要就是对bodyBufferStringStreamJson的类型分别判断处理。

总结

重新梳理一下koa的运行流程

  • new koa的时候,会实例化一些属性,主要有this.contextthis.requestthis.responsethis.middleares
  • 调用 use 的时候,将中间件收集起来,放到this.middleares里面,等待后续一起处理(发布订阅模式);
  • 调用listen的时候,会调用callback方法,组合了中间件,返回了fn函数,等待处理;
  • 当页面访问对应的端口的时候,就触发了http的回调,开始执行handleRequest函数,然后依次执行中间件
  • 如果中间件没有错误,就用调用respond方法,将body内容传给http服务器;否则,调用catch方法

戳这里查看mini-koa源码,总体来说 koa 的源码并不复杂,唯一有点绕的地方可能就是 compose 函数了。有什么不懂或者我理解不对的地方,欢迎评论区讨论。

作者:sensFeng 链接:https://juejin.cn/post/7206871218031575077 来源:稀土掘金 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。