Koa 介绍
在 Koa 的官网上,slogan 是:基于 Node.js 平台的下一代 Web 开发框架。我们都知道, Koa 与 Express 都诞生自同一个团队。如何解析这个 slogan?
该 slogan 中所有包含“比较”含义的词汇,都可以理解为是相对 Express 而言的。所谓的"下一代""更小""更快",都特有所指。我们来看看现阶段 Express 与 Koa 的下载量对比:
可以看出,Express 的市场占有量与下载量(以及增速)都远远高于 Koa。所以自称为“下一代”并不为过(当然,这个数据层面来对比是否公平,值得商榷)。
同时,与 Express 内部囊括了一套基础的业务处理框架的做法不同,Koa 只包含了被称为“洋葱圈模型”的中间件核心处理逻辑。Koa 期望由社区来健全完善更多的业务模块。这样看来,将 Koa 看做 Express 之前内置的 Connect 来对比更合适(Express 4 内部已经移除 Connect)。不过,“更小”的 Koa 定位更加明确,其内部一些突出的特性也让开发者感到欢喜。
接下来,我们就来详细看看 Koa 相比于 Express 有什么不同?只有理解了不同,我们才会更加理解 Koa 的特性。
自我定位
在 Koa 官方网页上,有这样一段关于 Koa 定位的描述:
Philosophically, Koa aims to "fix and replace node", whereas Express "augments node". Koa uses promises and async functions to rid apps of callback hell and simplify error handling. It exposes its own ctx.request and ctx.response objects instead of node's req and res objects.
Express, on the other hand, augments node's req and res objects with additional properties and methods and includes many other "framework" features, such as routing and templating, which Koa does not.
摘抄网络上的一段翻译如下:
从理念上来说,Koa 意图“修复并取代 node”,而 Express 做的是“增强 node”。Koa 使用 promise 和 async 函数来摆脱回调地狱并简化异常处理逻辑。它暴露了自身的 ctx.request 和 ctx.response 对象而取代了 node 的 req 和 res 对象。
Express 从另一方面,通过增加额外的属性和方法增强了 node 的 req 和 res 对象,并引入了许多框架上的功能,例如路由和模板,而 Koa 没有这么做。
其实现是否符合定位,各位可自行看待。
中间件处理逻辑
在本质上,Koa 与 Express 4 的区别在于下面两个点:
- Koa 是使用
Promise+async/await来处理中间件传递逻辑;而 Express 4 是使用回调函数来处理中间件传递逻辑。 - Koa 是走完所有的中间件处理逻辑才最终返回请求;而 Express 4 是遇到
res.send()就返回请求。
借助于新 ES 规范中 async/await 写法的 Promise 支持,Koa 能够完美支持异步处理方式与摆脱回调地狱。下面的模拟实现,看代码的区别就可以体会到两者的不同之处。
// express 与 Koa 在处理中间件逻辑上的底层模拟
// express:回调函数
app.use(function middleware1(req, res, next) {
console.log('middleware1 开始')
// next()
(function (req, res, next) {
console.log('middleware2 开始')
// next()
(function (req, res, next) {
console.log('middleware3 开始')
// next()
(function handler(req, res, next) {
res.send("end")
console.log('123456')
})()
console.log('middleware3 结束')
})()
console.log('middleware2 结束')
})()
console.log('middleware1 结束')
})
// Koa: 基于 Promise + async/await
function compose() {
return function () {
const ctx = {}
// 每一次调用 next(),都包了一层 Promise.resolve
Promise.resolve(function fn1(context){
console.log("middleware1 开始");
yield Promise.resolve(function fn2(context){
console.log("middleware2 开始");
yield Promise.resolve(function fn3(context){
console.log("middleware3 开始");
yield Promise.resolve(function fn4(context){
// ...more
});
console.log("middleware3 结束");
});
console.log("middleware2 结束");
})
console.log("middleware1 结束");
});
}
}
错误处理逻辑
在 Koa 内部,由于是洋葱圈模型,正常的中间件处理过程可以不处理报错逻辑,只需要在中间件数组的第一个中间件设置为错误函数中间件即可。而在 Express 4 中,需要在每一个正常的中间件中都包含错误处理逻辑,并且需要通过 next() 函数抛出来,否则该错误将会被隐没。
更小的核心代码
与 Express 4 包含有齐整的 Web 工具(路由、模板等)不同,Koa 只专注于核心代码。因此它的体积更小。
Koa 解析
简单的描述并不能让我们对 Koa 的原理有更深的理解。我们尝试来看看源码。最简单的演示代码:
const Koa = require('koa');
const app = new Koa();
// response
app.use(ctx => {
ctx.body = 'Hello Koa';
});
console.log("start the test1 server !");
app.listen(3000);
在这里,我们想要去源码上弄明白几个事情:
- 初始化的时候,做了什么?
app.use()做了什么?app.listen()做了什么?
Koa 初始化应用实例
Koa 应用中,我们通过 const app = new Koa() 来构建一个实例应用。核心代码(有删减):
constructor(options) {
super();
this.middleware = [];
// 每一个 app 实例,都有下面三个对象的实例
this.context = Object.create(context);
this.request = Object.create(request);
this.response = Object.create(response);
if (util.inspect.custom) {
this[util.inspect.custom] = this.inspect;
}
}
初始化应用实例的过程可以看出:为 app 实例添加 context、request、response、middleware 等属性。
在这个初始化的过程中,Koa 会把 Koa 官方提供的方法都挂载到相应的位置,方便在代码中调用。
app.use() 添加中间件
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);
}
debug('use %s', fn._name || fn.name || '-');
// 直接存入到 middleware 数组中,后续统一处理
this.middleware.push(fn);
return this;
}
在这里会检查传入函数的类型,如果是老的 Generator 函数类型会转换一下,然后直接放到 middleware 这个数组中。数组中的中间件,会在每一个请求中去挨个执行一遍。
app.listen() 监听--核心逻辑
在 listen 函数执行的时候,才会创建 server:
listen(...args) {
debug('listen');
const server = http.createServer(this.callback());
return server.listen(...args);
}
在 Node 的 http 模块,对于每一个请求,都会走到回调函数 callback 中去。所以这个 callback 是用于处理实际请求的。我们来看看 callback 做了啥:
callback() {
// 包装所有的中间件,返回一个可执行的函数。koa-compose 实现了洋葱圈模型
const fn = compose(this.middleware);
if (!this.listenerCount('error')) this.on('error', this.onerror);
const handleRequest = (req, res) => {
// req res 是 node 原生请求参数
const ctx = this.createContext(req, res);
// 将创建的 ctx 返回,传给所有中间件,作为整个请求的上下文
return this.handleRequest(ctx, fn);
};
return handleRequest;
}
这里的内容并不简单,涉及到几个点:
createContext都干了什么compose是如何实现洋葱模型的this.handleRequest(ctx, fn)干了什么
解析 createContext
createContext(req, res) {
// 每一个请求对应一个 ctx、request、response、req、res
const context = Object.create(this.context);
const request = context.request = Object.create(this.request);
const response = context.response = Object.create(this.response);
context.app = request.app = response.app = this;
// 挂载 node 原生请求参数 req res 到 context、request、response 上
context.req = request.req = response.req = req;
context.res = request.res = response.res = res;
request.ctx = response.ctx = context;
request.response = response;
response.request = request;
context.originalUrl = request.originalUrl = req.url;
context.state = {};
return context;
}
在这里主要是明确 context 挂载的内容,这个 context 是每一次请求都会生成的。由代码可知:
- 每个应用实例
app都会有对应的context、request、response实例(即this.xxx)。每个请求,都会基于这些实例来创建自己的实例。这样做的目的是为了不污染全局的变量。 - 将
node原生的req、res以及this挂载到context、request、response上。 - 将创建的
context返回,传入所有中间件的第一个参数,作为这个请求的上下文。
着重解释一下第 2 点,为什么要把这些属性挂载上去。除了便利性外,我们通过查看源码 resquest.js response.js 文件还可以知道:所有的这些访问都是代理,最终访问的还是 node 原生的 req、res。
koa-compose 实现洋葱圈模型
接下来,我们来看看源代码中的 koa-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) {
// 上一次被执行的中间件
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
// 没有fn的话,直接返回一个已经reolved的Promise对象
if (!fn) return Promise.resolve()
try {
/*
原代码是一行,为了方便理解拆成了三行
const next = dispatch.bind(null, i + 1);
const fnResult = fn(context, next);
return Promise.resolve(fnResult);
*/
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
} catch (err) {
return Promise.reject(err)
}
}
}
}
简单来看,在 compose 中会根据变量 i 挨个执行中间件,以递归的方式来执行。 整个过程不难理解,实现较为巧妙。值得注意的是:每一次 dispatch() 的返回值都是 Promise。可参考下面的动图来过一遍上面代码的实现。
解析 handleRequest
我们来看看,得到了每个请求的上下文信息 context 与 经过 compose 后的中间件逻辑之后,我们来看看最终如何处理请求的:
handleRequest(ctx, fnMiddleware) {
const res = ctx.res;
res.statusCode = 404;
const onerror = err => ctx.onerror(err);
const handleResponse = () => respond(ctx);
onFinished(res, onerror);
// 执行 compose 后的中间件函数,最后执行 respond
return fnMiddleware(ctx).then(handleResponse).catch(onerror);
}
执行 compose 后的中间件函数,最后执行 respond 方法。经查看,可以知道,主要是对响应进行封装,并且赋值给到 context.res.end(body)。
总结
综上,我们简单地对 Koa 进行了介绍;同时通过解析 Demo 文件的执行过程,我们从源码级别窥见了 Koa 的一些相关原理实现。至此,我们就已经将 Koa 相关的介绍与解析讲完了。我在写下来本文章的过程中,会对整个过程更加理解,也希望对你有所帮助!