大家好,我是加菲猫🐱 ,在这里祝各位小伙伴新春快乐,虎年大吉,虎虎生威!
Koa 是最受欢迎的服务端框架之一,相信用过 Nodejs 的小伙伴应该都有了解。相比 Express 的 Al in one 思想,Koa 的源码非常简洁,核心库只包含一个中间件内核,其他功能全部需要引入第三方中间件库才能实现。Koa 的源码不多,通俗易懂,仅仅只有四个文件,因此非常适合用于学习源码。
1. Koa 整体架构
Koa 架构的一大特点就是中间件机制和洋葱模型,可以这么说,Koa 中所有的逻辑都是通过各种中间件来实现的。关于中间件,官网有一张图:
通过这张图我们可以看出,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 方法则用于启动服务。对于每个中间件,可以接收到两个参数,ctx 和 next,其中 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 里面接受的是一个回调函数,这个回调函数可以拿到 req 和 res 两个参数:
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 中回调函数可以接受两个参数 req 和 res,这两个参数实际上都是 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,如果传了 true,koa-static 会让其他中间件优先响应,即使其他中间件写在 koa-static 后面。这实际上就是控制了 next 方法的调用时机,当配置了 defer = true 之后,koa-static 会先调用 next ,让其他中间件先执行,然后再执行 koa-static 的逻辑。
koa-send源码这里就不展开了,感兴趣的同学可以自己阅读,核心就是ctx.body = fs.createReadStream(path)通过流的方式进行响应,外加各种路径拼接、404 处理、缓存响应头等等,源码也就一个文件,百来行代码
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 对象,然后将对象 push 到 stack 数组中:
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 的实现机制,中间件和洋葱模型的实现,以及一些常见中间件的实现,顺便也探讨了源码中一些值得学习借鉴的地方。希望对大家有所帮助,能够写出更好、更优雅的代码。