简介
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”。
现对这第一步总结下:
已完成:
- 对http server进行了简单的封装和创建了一个可以生成koa实例的类class;
- 实现了app.use用来注册中间件和注册回调函数;
- app.listen用来开启服务器实例并传入callback回调函数
缺点:
- use传入的回调函数,接收的参数依旧是原生的
req
和res
- 多次调用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
,仅仅是代理request
和response
的属性和方法。
真正注入原生对象,是在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;
现对这第一步总结下:
已完成:
- 封装了request、response、context对象 ;
- 通过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
现对这第一步总结下:
已完成:
- 已实现中间件;
缺点:
- 缺少错误捕获和错误处理
错误捕获和错误处理
因为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)
参考资料: