Koa -- 基于 Node.js 平台的下一代 web 开发框架
在juejin.cn有幸看到一个程序员讲述如何阅读源代码,主要分为三步:领悟思想、把握设计、体会细节。
领悟思想:只需体会作者设计框架的初衷和目的 把握设计:只需体会代码的接口和抽象类以及宏观的设计 体会细节:是基于顶层的抽象接口设计,逐渐展开代码的画卷 基于上述三步法,迫不及待的拿Express开刀了。本次源码解析有什么不到位的地方各位读者可以在下面留言,我们一起交流。
以上是内容是参考执鸢者文章聊一聊三步法解析Express源码。私以为言之有理,决定将该内容作为自己每篇源码分析的开头模板。
领悟思想
在Koa官网上,介绍Koa 是一个新的 web 框架,由 Express 幕后的原班人马打造, 致力于成为 web 应用和 API 开发领域中的一个更小、更富有表现力、更健壮的基石。 通过利用 async 函数,Koa 帮你丢弃回调函数,并有力地增强错误处理。 Koa 并没有捆绑任何中间件, 而是提供了一套优雅的方法,帮助您快速而愉快地编写服务端应用程序。在这句话里面可以得到解读出以下几点含义:
Express内置了许多中间件可供使用,而koa没有。
Express包含路由,视图渲染等特性,而koa只有http模块。
Express的中间件模型为线型,而koa的中间件模型为U型,也可称为洋葱模型构造中间件。
Express通过回调实现异步函数,在多个回调、多个中间件中写起来容易逻辑混乱。
Koa是Web框架,解决了Express的一些问题,相对于Express更小。
线性模型和洋葱模型
Express是线性模型,Koa是洋葱模型。
引用网友总结:其实中间件执行逻辑没有什么特别的不同,都是依赖函数调用栈的执行顺序,抬杠一点讲都可以叫做洋葱模型。Koa 依靠 async/await(generator + co)让异步操作可以变成同步写法,更好理解。最关键的不是这些中间的执行顺序,而是响应的时机,Express 使用 res.end() 是立即返回,这样想要做出些响应前的操作变得比较麻烦;而 Koa 是在所有中间件中使用 ctx.body 设置响应数据,但是并不立即响应,而是在所有中间件执行结束后,再调用 res.end(ctx.body) 进行响应,这样就为响应前的操作预留了空间,所以是请求与响应都在最外层,中间件处理是一层层进行,所以被理解成洋葱模型,个人拙见。
参考链接:(Koa与Express的中间件机制揭秘)[https://cloud.tencent.com/developer/article/1467268]
Express对async和await支持不好
通过一个Express例子来看
const Express = require('express');
const app = new Express();
const sleep = () => new Promise(resolve => setTimeout(function(){resolve(1)}, 2000));
const port = 8210;
function f1(req, res, next) {
console.log('this is function f1....');
next();
console.log('f1 fn executed done');
}
function f2(req, res, next) {
console.log('this is function f2....');
next();
console.log('f2 fn executed done');
}
async function f3(req, res) {
await sleep();
console.log('f3 send to client');
res.send('Send To Client Done');
}
app.use(f1);
app.use(f2);
app.use(f3);
app.get('/', f3)
app.listen(port, () => console.log(`Example app listening on port ${port}!`))
Express的执行顺序,console.log('f2 fn executed done');
在console.log('f3 send to client');
前执行,原因next()
不是返回的promise对象,否则可以通过await实现同步。
this is function f1....
this is function f2....
f1 fn executed done
f2 fn executed done
f3 send to client
再通过一个Koa例子来看:
const Koa = require('koa');
const app = new Koa();
const sleep = () => new Promise(resolve => setTimeout(function(){resolve()}, 1000))
app.use(async (ctx, next) => {
console.log('middleware 1 start');
await next();
console.log('middleware 1 end');
});
app.use(async (ctx, next) => {
await sleep();
console.log('middleware 2 start');
await next();
console.log('middleware 2 end');
});
app.use(async (ctx, next) => {
console.log('middleware 3 start')
ctx.body = 'test middleware executed';
})
不出意料,执行顺序如下:
middleware 1 start
middleware 2 start
middleware 3 start
middleware 2 end
middleware 1 end
原因是Koa内部next返回是一个Promise对象,await next()
所以能够控制顺序的执行。
把握设计
理解了作者设计的思想,下面从源码目录、核心设计原理及抽象接口三个层面来对Koa进行整体的把握。
2.1源码目录
如下所示是Koa的源码目录,非常的简单,只有4个js文件。
├─application.js---Koa类的文件(核心)
├─context.js--- Context类的文件
├─request.js---Request类的文件
├─response.js---Response类的文件
2.2接口抽象
下面UML类图描述了Koa中各个类的关系,Application对象包含Context、Request和Response三个对象。(图中仅展示必要的属性和方法,详细细节可以查看源码)
2.3设计原理
我们先看一段代码:
const Koa = require("./lib/application");
const app = new Koa();
// middleware1
app.use(async (ctx, next) => {
console.log("middleware1 start");
await next();
console.log("middleware1 end");
});
// middleware2
app.use(async (ctx, next) => {
console.log("middleware2 start");
await next();
console.log("middleware2 end");
});
// middleware3
app.use(async (ctx, next) => {
console.log("middleware3 start");
ctx.body = "hello world";
console.log("middleware3 end");
});
app.listen(3000);
这段代码的运行流程如下图所示。类似一个洋葱从外到里,再从里到外。
了解完上述概念后,结合该幅图,就大概能对整个流程有了直观感受。首先启动服务,然后客户端发起了http://localhost:3000的请求,该过程应该如何运行呢?
启动服务的时候,会生成一个Application对象。同时对中间件进行注册的时候(调用use方法),会对处理函数进行存储。
对相应的地址进行监听,等待请求到达。
请求到达后,按照洋葱模型调用每一个处理函数。(由于Koa没有内置路由,这里就分析最简单的中间件调用)
** 上述解释的比较简单,后续会在细节部分进一步阐述。**
体会细节
通过上述对Koa设计原理的分析,下面将从两个方面做进一步的源码解读,下面流程图是一个常见的Koa项目的过程,首先会进行app实例初始化、然后调用一系列中间件,最后建立监听。对于整个工程的运行来说,主要分为两个阶段:初始化阶段、请求处理阶段,下面将以app.use()为例来阐述一下该核心细节。
3.1初始化阶段
下面利用app.use()这个中间件来了解一下工程的初始化阶段。
1.在Application构造函数,创建了context、request、response和middlware对象。
constructor (options) {
this.middleware = []
this.context = Object.create(context)
this.request = Object.create(request)
this.response = Object.create(response)
}
2.在app.use函数中,将中间件函数保存到数组。
// middleware1
app.use(async (ctx, next) => {
console.log("middleware1 start");
await next();
console.log("middleware1 end");
});
// middleware2
app.use(async (ctx, next) => {
console.log("middleware2 start");
await next();
console.log("middleware2 end");
});
// middleware3
app.use(async (ctx, next) => {
console.log("middleware3 start");
ctx.body = "hello world";
console.log("middleware3 end");
});
use (fn) {
this.middleware.push(fn)
return this
}
3.app.listen(),其实就是调用this.callback()返回的函数。(this.callback()下面继续分析)
listen (...args) {
const server = http.createServer(this.callback())
return server.listen(...args)
}
通过上述的分析,可以看出初始化阶段主要做一件事情:将中间件函数保存到middleware数组。
3.2请求处理阶段
当服务启动后即进入监听状态,等待请求到达后进行处理。
1.compose函数,对中间件数组进行合并,compose函数来自koa-compose包。
const compose = require('koa-compose')
callback () {
const fn = this.compose(this.middleware)
if (!this.listenerCount('error')) this.on('error', this.onerror)
const handleRequest = (req, res) => {
const ctx = this.createContext(req, res)
return this.handleRequest(ctx, fn)
}
return handleRequest
}
2.从下面函数可以看出,compose函数最后返回一个function(context,next)
函数。
function compose (middleware) {
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 times'))
index = i
let fn = middleware[i]
if (i === middleware.length) fn = next
if (!fn) return Promise.resolve()
try {
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
} catch (err) {
return Promise.reject(err)
}
}
}
}
function(context,next){
//
}
3.调用Application.handleRequest函数。可以看出将ctx对象传入上一步骤生成的函数function(context,next)
fnMiddleware,并且调用。
handleRequest (ctx, fnMiddleware) {
const onerror = err => ctx.onerror(err)
const handleResponse = () => respond(ctx)
return fnMiddleware(ctx).then(handleResponse).catch(onerror)
}
4.调用dispatch函数,即调用中间件函数。dispatch(0),fn是第一个中间件,并且返回一个Promise对象,第一个中间件中next函数是 dispatch.bind(null,1)
,因此调用next函数,即调用下一个中间件函数。同时因为dispatch(0)返回是Promise对象,因此可以使用await。
function dispatch (i) {
try {
return Promise.resolve(fn(context, dispatch.bind(null,1)));
} catch (err) {
return Promise.reject(err)
}
}
5.fnMiddleware依次调用中间件函数后,最后有handleResponse和onerror,分别进行最后的处理。handleResponse处理Response body,最后调用res.end(body)
;onerror处理异常。
handleRequest (ctx, fnMiddleware) {
const onerror = err => ctx.onerror(err)
const handleResponse = () => respond(ctx)
return fnMiddleware(ctx).then(handleResponse).catch(onerror)
}
通过上述的分析,可以看出初始化阶段主要做两件事情:
- compose 中间件函数数组。
- next依次执行下一个中间件函数。
Comments