We don't know what we can't create.
-- Feynman
ps:在这里查看和运行本帖涉及的完整 代码
🍚 食用前
本帖是面向了解 Koa2 的读者,如果你不是的话,你完全可以花几分钟读一读 官方文档 和 Demo 然后跑个 Web 小服务试试之后再食用本贴。
Koa2 是基于 ES2017 的 Promise 和 Async 语法构建的 Web 框架,它代码逻辑其实非常清晰简单:对 HTTP 请求(request)、相应(response)和上下文(context)的封装。
Koa2 本身小而美,而真正使他强大的(官方说法:Expressive middleware for node.js)是他的中间件思想,也就是经常被人提及的洋葱模型。
🧅 Onion(洋葱)
本帖的目标是仿照 Koa2 实现一个略微寒酸的 Web 服务端框架玩具,以及几个实用的中间件,为了向 Koa 的洋葱模型思想致敬,我把这个玩具直接叫做 Onion 好了。
与在 Koa 中一样,我们可以使用 Onion 方便地创建一个 http 服务,当我们访问本地 3000 端口时能看到服务端返回的 Hello, World! 字符串。
要实现 Onion,主要的工作是实现 http 服务 与 中间件扩展方法,它们都通过 Onion 类的实例调用,我们首先创建一个 Onion 实例:
const Onion = require('onion');
const app = new Onion();
http 服务
和 Koa 一样,我们通过 app.listen 方法建立一个 http 服务
app.listen(3000, () => {
console.log('Server start at port 3000!')
})
中间件扩展方法
这是使用 Onion 中间件最简单的一个例子,我们在后面会使用 Onion 的中间件机制处理一些更为复杂的逻辑(比如异步函数 😏)。
const Onion = require('onion');
const app = new Onion();
app.use(async (ctx, next) {
ctx.body = 'Hello, World!';
});
中间件扩展机制的核心是通过 compose 函数实现的,compose 的实现并不是很复杂,我们会在本帖后面看到如何实现 compose 方法。
👨🏻🍳 制作 Onion
实现 Onion 有两项最主要的工作:
- 实现一个 http 服务
- Onion 中间件扩展方法,即 app.use(...) 方法
🌐 实现 Http 服务:基于 Node http 模块
实际上,Koa 的 http 服务是基于 Node 的原生 http 模块实现的,实现一个 http 服务用不了多少代码,下面的几行代码就是 Onion 实现 http 服务的主要逻辑。
我们使用 Node 的 http 模块的 createServer 方法创建了一个 server 实例,并在创建时向它传入了 callback 方法执行后返回的东西(我们在后面会看到,callback 实际上返回了一个函数)。
const http = require('http');
class Onion {
...
callback () { ... }
listen(...args) {
const server = http.createServer(this.callback());
server.listen(...args);
}
...
}
⚙️ 扩展 Onion:实现 app.use 中间件扩展方法
Onion 所做的核心的工作是对 http 一次请求响应的洋葱式的处理逻辑,也就是实现中间件扩展方法。Koa 的洋葱模型实际上也就是中间件执行的过程,通过使用 Promise 与 async await,我们就能够在同一个中间件中方便地处理 request 和 response 过程。
异步中间件实例
下面是两个 Onion 中间件用于展示处理每次请求的洋葱模型,在外层中间件 outerMiddleware 中调用 await next() 方法后,将会等待其他中间件处理完成后再执行。
const outerMiddleware = async function (ctx, next) {
console.log('outer start!')
await next()
console.log('state num:', ctx.state.num)
console.log('outer end!')
}
const innerMiddleware = async function (ctx, next) {
console.log('inner start!')
await new Promise((resolve, reject) => {
setTimeout(() => {
// Koa 在 ctx 上挂载了 state 对象,用于存放每次请求的数据
resolve(ctx.state.num = Math.random())
}, 1000)
})
console.log('inner end!')
ctx.body = 'Hello, world!'
}
每个中间件在调用 await next() 前后的处理逻辑就构成了对 Request、Response 的洋葱式处理。在下面的例子中,outerMiddlewares 中间件能够在调用 next 方法后获取到 innerMiddlewares 中间件生成的 num 随机数。
类似这样的中间件机制,我们就能够通过中间件处理每次请求的状态、错误、路由等等信息,并能够将这些信息一层层传递到各个中间件中。
在完成 Onion 后我们可以通过 app.use 方法使用他们处理 http 请求,每次请求将会在 1s 后返回 Hello, world! 字符串,并在服务端依次打印:
outer start!
inner start!
inner end!
state num: 0.9064343598013311 # 随机数
outer end!
使用 compose 方法组装中间件
下面的 compose 函数是实现中间件扩展方法的核心逻辑。参数 middlewares 是中间件函数数组,compose 函数实现了这些功能:
- 将所有中间件组装成一个 composedMiddlewares函数
- 执行第一个中间件函数:也就是调用了 dispatch(0)
- 将 Onion 创建的 ctx 对象以及调用下一个中间件的方法 next 作为参数传递给中间件函数
我们可以看到,中间件的 next 方法实际上是返回了一个 Promise,这使得我们能够使用 async 中间件,并在中间件中调用 await next() 方法来等待洋葱模型下内层中间件的执行。
/**
* compose 将所有中间件组装成一个 composedMiddlewares
* 函数,并在函数中自动执行第一个中间件
*/
function compose (middlewares) {
return function composedMiddlewares (ctx) {
function dispatch (index) {
let fn = middlewares[index]
if (!fn) return Promise.resolve()
// 将 ctx 和 next 参数传递给中间件
return Promise.resolve(fn(ctx, dispatch.bind(null, index + 1)))
}
// 调用第一个中间件
return dispatch(0)
}
}
实现完整的 Onion 类
有了 compose 函数,我们就能够在 Onion 类的 callback 函数中使用它来组装通过 app.use 方法注册的中间件函数。
下面是 Onion 类的完整代码,这是仿照 Koa2 的一个粗糙的实现。中间件组装和注册的机制,就是通过前面的 compose 函数在 Onion 的 callback 方法中完成的。
class Onion {
constructor () {
this.middlewares = []
}
/**
* 通过实例 use 方法将中间件注册到 http 服务回调函数中
*/
use(fn) {
this.middlewares.push(fn)
}
/**
* 通过 createContext 方法创建一个简单的上下文对象
* 存放 http.createServer 回调参数 req, res 和 state
*/
createContext(req, res) {
const context = {
req, res, state: {}
}
return context
}
/**
* 调用完所有中间件后对 Response 的处理
*/
handleResponse(ctx) {
let { res, body } = ctx
res.end(body)
}
/**
* 通过 compose 函数组装中间件,创建 ctx 上下文
* 返回一个注入到 http.createServer 方法中的回调函数
*/
callback() {
const fn = compose(this.middlewares)
return (req, res) => {
const ctx = this.createContext(req, res);
fn(ctx).then(() => this.handleResponse(ctx))
}
}
listen(...args) {
const server = http.createServer(this.callback());
server.listen(...args);
}
}
👋🏻 最后
我们实现了 Onion,这只是一个仿照 Koa2 粗糙的 Web 服务端框架玩具。在 koa2 的源码实现中,除了与 Onion 类似的核心逻辑外,还包括:HTTP 请求方法的判断、请求路径参数的格式化;响应数据类型、缓存信息、cookie的处理;错误处理等。
最后,你可以在 这里 查看本帖的所有代码,包括两个异步 Onion 中间的使用 Demo。😉