深入源码分析 Koa 中间件与洋葱模型

1,580 阅读13分钟

大家好,我是加菲猫🐱 ,在这里祝各位小伙伴新春快乐,虎年大吉,虎虎生威!

Koa 是最受欢迎的服务端框架之一,相信用过 Nodejs 的小伙伴应该都有了解。相比 Express 的 Al in one 思想,Koa 的源码非常简洁,核心库只包含一个中间件内核,其他功能全部需要引入第三方中间件库才能实现。Koa 的源码不多,通俗易懂,仅仅只有四个文件,因此非常适合用于学习源码。

1. Koa 整体架构

Koa 架构的一大特点就是中间件机制和洋葱模型,可以这么说,Koa 中所有的逻辑都是通过各种中间件来实现的。关于中间件,官网有一张图:

Screen Shot 2022-02-01 at 1.06.23 PM.png

通过这张图我们可以看出,Koa 中间件机制,每次请求进来,会先执行最外面的中间件,遇到 next 方法,就进入下一层的中间件,当执行完所有中间件后,再一层一层向外执行,就完成了整个请求的逻辑。

这么一讲好像还是有点抽象,我们来看一个 demo 了解一下:

const Koa = require('koa');
const app = new Koa();

app.use(async (ctx, next) => {
  console.log('1');
  await next();
  console.log('2');
});

app.use(async (ctx, next) => {
  console.log('3');
  await next();
  console.log('4');

  ctx.body = 'hello, world';
});

app.listen(3000);

// console 输出顺序:1 3 4 2

从上面的代码可以看出,Koa 本身就是一个类,是一个基本框架,这个类上面有两个实例方法,use 方法用于注册中间件,listen 方法则用于启动服务。对于每个中间件,可以接收到两个参数,ctxnext,其中 ctx 代表 Context,是对 http 请求和响应封装的一个对象,next 代表下一个中间件,调用 next 就进入下一个中间件。

按这个思路,我们可以搭建出如下的基本框架:

import http from "node:http";

class Application {
  constructor() {
    this.middlewares = [];
  }
  
  use(fn) {
    this.middlewares.push(fn);
    // 返回自身实例,用于实现链式调用
    return this;
  }
  
  listen(...args) {
    const server = http.createServer(this.callback());
    return server.listen(...args);
  }
  
  callback() {
    // TODO: implement this
  }
}

从上面的代码可以看出,use 方法其实很简单,就是将接收到的函数 push 到中间件数组中,最后返回自身实例,用于实现链式调用。listen 方法也很简单,就是通过 http.createServer 启动服务,也没啥可说的。关键的逻辑都在 this.callback() 里面。

我们知道,http.createServer 里面接受的是一个回调函数,这个回调函数可以拿到 reqres 两个参数:

http.createServer((req, res) => {
  // ...
})

按照这个逻辑,this.callback() 也必须返回一个函数,而且里面包含了所有服务端的处理逻辑。我们知道在 Koa 中的处理逻辑都是以中间件的形式组织的,因此还需要实现洋葱模型,实现中间件的调度。我们先来看一下源码:

callback () {
  // compose 函数是从 koa-compose 这个库引入的
  // 将中间件整合为一个函数
  const fn = compose(this.middleware)

  const handleRequest = (req, res) => {
    // 创建 Context 对象
    const ctx = this.createContext(req, res)
    // 这边才是真正处理网络请求
    return this.handleRequest(ctx, fn)
  }

  return handleRequest
}

这里我们主要研究中间件和洋葱模型,也就是 compose 函数,Context 和 this.handleRequest 我们简单过一下。我们知道,Koa 中间件接收的并非原生 req/res,而是一个 ctx 对象,因此我们来简单实现 Contenxt:

createContext(req, res) {
  const context = Object.create(this.context);
  context.app = this;
  context.req = req;
  context.res = res;
}

handleRequest 负责传入 ctx 对象,调用中间件,等中间件执行完毕之后进行响应,并进行默认异常处理:

handleRequest (ctx, fnMiddleware) {
  const res = ctx.res
  res.statusCode = 404
  const onerror = err => ctx.onerror(err)
  const handleResponse = () => respond(ctx)
  onFinished(res, onerror)
  // 调用中间件,所有中间件执行完毕后调用 handleResponse 进行响应
  return fnMiddleware(ctx).then(handleResponse).catch(onerror)
}

respond 是一个 helper 函数,里面做的事情很简单,就是对请求进行响应:

function respond(ctx) {
  const res = ctx.res;
  let body = ctx.body;
  
  // 如果 body 是一个 stream,则通过 pipe 方法进行响应
  if (body instanceof Stream) return body.pipe(res)
  
  body = JSON.stringify(body)
  return res.end(body);
}

这里提一下,我们知道 http.createServer 中回调函数可以接受两个参数 reqres,这两个参数实际上都是 stream,req 为 Readable,res 为 Writable,如果需要将请求体内容原封不动进行响应,可以这样写:

http.createServer((req, res) => {
  req.pipe(res);
}).listen(3000);

Readable 写入 Writable,需要一边监听 data 事件然后一边 write,但是这样存在 stream 背压问题,需要监听 drain 事件,在 Writable 缓冲区数据排空了再继续写入。使用 pipe 方法解决了 stream 背压问题

因此 Koa 实际增加了对 stream 的支持,可以直接响应 stream 类型的数据:

import { Readable } from "node:stream";

app.use(async (ctx, next) => {
  // 这个会直接响应
  ctx.body = Readable.from("2333");
})

整个 Koa 执行的流程就是这样,下面我们重点来看一下 compose 函数的实现。

2. Koa-compose

Koa-compose 实际上是一个单独的库,作用非常关键,实现了洋葱模型,将中间件串联起来,合并为一个函数,便于外部调用。

说到 compose 大家应该都很熟悉,函数组合是函数式编程中一个重要的概念,在 Redux 中也有应用。一般来说 compose 是将一系列函数合并成一个函数进行调用,具体的实现并不是固定的,我们之前写过一个简单的 compose:

function compose<T>(...middlewares: ((arg: T) => T)[]): (initValue: T) => T {
	return (initValue) => {
		return middlewares.reduce((accu, cur) => cur(accu), initValue)
	}
}

在 Koa 中实际上是根据自己的需求实现了一个 compose。在看源码之前,我们可以先考虑一下该怎么实现。对于每一个中间件,有如下的 API:

middleware(ctx, next)

而每个中间件的 next 函数调用之后,又会执行下一个中间件,我们可以把 next 函数抽出来,但是又发现 next 函数中又有 next,这该怎么处理呢:

const next = () => middlewares[i](ctx, next)

没错,使用一个递归实现中间件的逻辑,把中间件连接起来:

const dispatch = (i) => {
  return middlewares[i](ctx, () => dispatch(i + 1));
}

此外还需要一个结束递归的条件,当最后一个中间件函数调用 next 时,直接返回:

const dispatch = (i) => {
  const middleware = middlewares[i];
  if (i === middlewares.length) return;
  return middleware(ctx, () => dispatch(i + 1));
}

最终的 compose 函数如下:

function compose(middlewares = []) {
  return ctx => {
    const dispatch = (i) => {
      const middleware = middlewares[i];
      if (i === middlewares.length) return;
      return middleware(ctx, () => dispatch(i + 1));
    }
    return dispatch(0);
  }
}

我们自己实现的 compose 比较简单,Koa-compose 中还进行了额外的类型校验,以及对返回值额外包裹一层 Promise:

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!')
  }

  // compose 返回的函数,也可以传入中间件
  // 这个中间件会在最后执行
  return function (context, next) {
    return dispatch(0) // 从第一个中间件开始执行
    function dispatch (i) {
      let fn = middleware[i]
      // 结束递归
      // 如果 compose 返回的函数传入了中间件
      // 就把这个中间件取出来执行
      if (i === middleware.length) fn = next
      if (!fn) return Promise.resolve()
      try {
        // 执行中间件
        // 这边 `dispatch.bind()` 的效果与 `() => dispatch()` 一致
        // 都是返回一个待执行函数
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)))
      } catch (err) {
        return Promise.reject(err)
      }
    }
  }
}

3. 常见的 Koa 中间件

上面我们已经实现了 compose 的中间件机制,下面我们来看一下如何实现一些常用的中间件。

1) 异常处理

在后端框架中,异常处理非常重要,假如不进行异常处理,随便一个报错就可能把整个服务挂掉。因此 Koa 中已经默认提供了异常处理,但是很多时候我们需要做一些异常上报的任务,需要在框架层的异常捕获之前就要捕获到,我们可以封装一个异常处理的中间件:

app.use(async (ctx, next) => {
  try {
    await next();
  } catch(err) {
    console.log(err);
    ctx.res.statusCode = 500;
    ctx.res.write("Internel Server Error");
  }
})

注意异常捕获的中间件需要放到所有中间件的最前面

2) logger

接下来实现一个 logger,用于记录处理当前请求花了多长时间,也很简单:

app.use(async (ctx, next) => {
  const start = Date.now();
  await next();
  const ms = Date.now() - start;
  // 这边是根据上面实现的 Context 来写的
  const { method, url } = ctx.req;
  console.log(`${method} ${url} - ${ms}ms`);
})

3) koa-static

koa-static 是一个用来搭建静态资源服务器的中间件。实际上静态资源服务器对于前端来说应该都很熟悉,例如 nginx 就是一个典型的静态资源服务器,webpack-dev-server 也是一个静态资源服务器。静态资源服务器的工作流程很简单:根据请求地址获取到文件路径,然后根据路径获取到对应文件,将文件内容作为响应体返回,并设置缓存响应头。

koa-static 的使用非常简单:

const Koa = require('koa');
const serve = require('koa-static');

const app = new Koa();

app.use(serve('public'));

app.listen(3000, () => {
    console.log('listening on port 3000');
});

下面我们来看一下源码:

// server 可以接受两个参数
// 一个是路径地址,还有一个是配置项
function serve (root, opts) {
  // 强迫症表示这块代码可以改成参数默认值
  opts = Object.assign(Object.create(null), opts)
  // 将 root 解析为合法路径并添加到配置项中
  opts.root = resolve(root)
  
  // 如果请求的路径是一个目录,默认去取 index.html
  if (opts.index !== false) opts.index = opts.index || 'index.html'

  // 返回值是一个 Koa 中间件
  return async function serve (ctx, next) {
    let done = false // 标记文件是否成功响应

    if (ctx.method === 'HEAD' || ctx.method === 'GET') {
      try {
        // 调用 koa-send 响应文件
        // 如果发送成功,会返回路径
        done = await send(ctx, ctx.path, opts)
      } catch (err) {
        // 如果是 400、500 等错误,向外抛出异常
        if (err.status !== 404) {
          throw err
        }
      }
    }

    // 如果文件发送成功,本次请求就到此为止了
    // 如果没成功,就让后续的中间件继续处理
    if (!done) {
      await next()
    }
  }
}

看完直呼上当,原来 koa-static 只不过封装了 koa-send。不过值得一提的是,配置项可以传递一个 opt.defer 参数,默认为 false,如果传了 truekoa-static 会让其他中间件优先响应,即使其他中间件写在 koa-static 后面。这实际上就是控制了 next 方法的调用时机,当配置了 defer = true 之后,koa-static 会先调用 next ,让其他中间件先执行,然后再执行 koa-static 的逻辑。

koa-send 源码这里就不展开了,感兴趣的同学可以自己阅读,核心就是 ctx.body = fs.createReadStream(path) 通过流的方式进行响应,外加各种路径拼接、404 处理、缓存响应头等等,源码也就一个文件,百来行代码

github.com/koajs/send/…

4) router

实际上路由的概念最早是从后端提出的,一个请求打进去,根据请求的路径匹配到对应的 Controller 进行处理。后来变成前后端分离架构,然后前端也出现了路由。因此对于一个服务端框架来说,路由是必不可少的。@koa/router 的实现实际上参考了 Express,我们先来看看如何使用:

const Koa = require("koa");
const Router = require("@koa/router");
const bodyParser = require("koa-bodyparser");

const app = new Koa();
const router = new Router();

app.use(bodyParser());

router.get("/", (ctx) => {
  ctx.body = "Hello World";
});

router.get("/api/users", (ctx) => {
  const userList = [
    {
      id: 1,
      name: "dby",
      age: 12
    }
  ];
  
  ctx.body = userList;
});

router.post("/api/users", async (ctx) => {
  // 使用 koa-bodyparser 之后就可以从 ctx.request 获取到 body
  const postData = ctx.request.body;
  ctx.body = postData;
})

app.use(router.routes());

app.listen(3000);

看到这里应该很明确了,实现 koa-router 主要就是实现 router[method] 方法注册路由,以及 router.routes 方法匹配路由。

看到 koa-router 的用法,大家应该都知道是一个类,但是在源码中是通过构造函数的方式实现的,这主要是为了能够动态添加实例方法(ES6 class 是静态的)。源码中有一个比较有意思的地方:

function Router() {
  // 如果没有通过 new 调用,则自动改成 new 调用
  if (!(this instanceof Router)) return new Router();

  this.stack = [];
  
  // ...
}

我们知道 ES6 class 必须通过 new 调用,如果直接调用会报错。而构造函数本身没有 classCallCheck 的机制,需要我们自己判断,也就是判断 this 是否为 Router 的实例:

function Router() {
  if (!(this instanceof Router)) {
    throw new Error("Router must call with new");
  }
  // ...
}

如果直接抛出异常,整个程序的执行都中断了,这样不是很优雅。在源码中兼容处理了下,使得 Router 直接调用会自动改成 new 调用:

function Router() {
  if (!(this instanceof Router)) {
    return new Router();
  }
  // ...
}

接下来看一下路由是怎么注册的:

for (let i = 0; i < methods.length; i++) {
  const method = methods[i];
  
  Router.prototype[method] = function(path, middleware) {
    // 支持传入多个 middleware
    // 将 middleware 转为一个数组
    // 在 ES6 中用剩余参数 ...middleware 就可以了
    middleware = Array.prototype.slice.call(arguments, 1);

    // 调用 this.register 注册路由
    this.register(path, [method], middleware);

    // 返回自身实例,实现链式调用
    return this;
  };
}

这里的 methods 来自这个库:github.com/jshttp/meth… HTTP 的请求动词

arguments 对象在 ES5 语法中用的比较多,在 ES6 中用剩余参数 ...middleware 就可以了。顺便说一下在 TS 中不要用 arguments,会导致类型推导不出来

this.register 方法做的事情也很简单,创建了一个 Layer 对象,然后将对象 pushstack 数组中:

Router.prototype.register = function (path, methods, middleware) {
  const stack = this.stack;

  const route = new Layer(path, methods, middleware);

  stack.push(route);

  return route;
};

Layer 的代码就不细看了,其实就是一个这样的对象:

class Layer {
  methods: string[];
  stack: middleware[];
  path: string;
  regexp: RegExp;
}

下面我们再看一下 router.routes 如何实现路由匹配的。通过刚才的用法可以知道,router.routes 返回值实际上就是一个 Koa 中间件,

Router.prototype.routes = Router.prototype.middleware = function () {
  const router = this;

  // 待返回的中间件
  let dispatch = function dispatch(ctx, next) {
    const path = router.opts.routerPath || ctx.routerPath || ctx.path;
    // 获取所有匹配的 layer
    const matched = router.match(path, ctx.method);
    
    // 保存所有匹配的 middleware
    let layerChain;

    // 把 router 实例挂到 ctx 对象上,给其他 koa 中间件使用
    ctx.router = router;

    // 如果一个 layer 都没匹配上,直接返回并执行下一个 Koa 中间件
    if (!matched.route) return next();

    // 获取所有 path 和 method 都匹配的 layer
    const matchedLayers = matched.pathAndMethod

    // 将所有 layer 上的 stack(也就是 middleware)合并到一个数组中
    layerChain = matchedLayers.reduce(function(memo, layer) {
      return memo.concat(layer.stack);
    }, []);

    // 这里的 compose 就是 koa-compose 这个库
    return compose(layerChain)(ctx, next);
  };

  return dispatch;
};

上面的 router.match 方法实际上就是遍历所有的 layer,找出匹配到的 layer,最后返回这样的对象:

type Matched = {
  path: Layer[]; // 仅仅 path 匹配的 layer
  pathAndMethod: Layer[]; // path 和 method 都匹配的 layer
  route: boolean; // 如果 pathAndMethod 不为空,说明路由匹配上的,这个属性为 true
}

然后这边又用到了 compose 方法。这里有个问题要注意一下,我们一般在使用路由时候,虽说传入的函数可以获取到 next 方法,但是我们一般不会用到:

router.get("/", (ctx, next) => {
  // 这边可以不需要调用 next
  ctx.body = "Hello World";
});

但是假如我们给同一个路由传入了多个回调函数,这时候就必须要调用 next,否则不会进入下一个回调函数:

router.get("/", (ctx, next) => {
  console.log("2333");
  // 这里必须调用 next,不然不会进入下一个回调函数
  next();
});

router.get("/", (ctx, next) => {
  ctx.body = "Hello World";
});

4. 总结

这篇文章我们分析了 Koa 整体架构,koa-compose 的实现机制,中间件和洋葱模型的实现,以及一些常见中间件的实现,顺便也探讨了源码中一些值得学习借鉴的地方。希望对大家有所帮助,能够写出更好、更优雅的代码。

参考

github.com/koajs/koa

github.com/koajs/compo…

github.com/koajs/stati…

github.com/koajs/route…