【100 Line Code】第二幕:Koa

274 阅读6分钟

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 有两项最主要的工作:

  1. 实现一个 http 服务
  2. 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 函数实现了这些功能:

  1. 将所有中间件组装成一个 composedMiddlewares函数
  2. 执行第一个中间件函数:也就是调用了 dispatch(0)
  3. 将 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。😉