Koa 是由 Express 原班团队打造的新型互联网应用开发框架,旨在为 Web 应用和 API 提供一个更小、更具表现力且更加健壮的基础架构。
Koa 的设计哲学是"没有编程范式",它致力于提供一个极简的、高度模块化的基础,让开发者可以根据自己的需求自由选择合适的工具和库来构建应用程序。这与 Express 不同,Express 更像是一个全功能的Web框架,而 Koa 则更像是一个微框架,提供了最小化的核心功能,并鼓励通过中间件来扩展功能。
由于其灵活性和强大的社区支持,Koa 迅速成为了构建高效、可靠的 Node.js 应用程序的热门选择之一。无论你是要开发 RESTful API、单页应用(SPA)后端,还是传统的多页面应用,Koa 都能为你提供所需的工具和支持。
我们将对Koa的源码实现进行分析。一方面,通过理解源码,可以更好地掌握Koa的相关技能,从而在使用框架时快速精准地定位相关问题并处理。另一方面,Koa 的整体实现简洁,涉及很多巧妙的思想,值得我们学习。
Koa 目录结构
Koa 的源码非常简洁,核心实现都在/lib
目录中,主要由以下4部分组成:
application.js
: Koa 的核心文件,定义了Application
类,是 Koa 的入口类,负责初始化应用实例、注册中间件、启动服务器等。context.js
:定义了Context
类,是 Koa 的上下文对象,封装了请求(Request
)和响应(Response
)的相关信息。request.js
:定义了Request
类,是对 Node.js 原生req
对象的封装,提供了更友好的 API 来操作请求数据。response.js
:定义了Response
类, 是对 Node.js 原生res
对象的封装,提供了更友好的 API 来操作响应数据。
Application 的实现
lib/application.js
是 Koa 的核心文件,负责以下关键任务:
- 创建应用实例:初始化上下文(
context
)、请求(request
)和响应对象(response
)。 - 注册中间件:通过
use()
方法存储中间件。 - 启动服务器:通过
listen()
方法启动 HTTP 服务器。 - 执行中间件链:使用
koa-compose
组合中间件,并按照洋葱模型执行。 - 错误处理:捕获未处理的异常,并提供自定义错误处理的能力。
创建服务
在深入探索源码之前,我们可以先从API的使用方式入手,猜测其内部实现,然后再通过源码分析验证猜测。这样可以加速对代码逻辑的理解速度,而且理解和记忆更加深刻。
- Node原生创建服务 :通过HTTP模块的
createServer
方法创建
js
const http = require('http');
const server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Hello, World!\n');
});
server.listen(3000, () => {
console.log(`服务器运行在 http://localhost:${PORT}`);
});
弊端:createServer
使用回调函数处理,臃肿庞大,难以维护
- 使用Koa创建服务:使用Koa库创建app对象,然后调用app对象的listen方法创建服务。
js
const Koa = require('koa');
const app = new Koa();
app.listen(3000, () => {
console.log(`Koa 服务器运行在 http://localhost:${PORT}`);
});
通过观察API的使用方式,可以猜想Koa的实现:Koa是一个类,包含listen
的类方法。模块内部使用Node的HTTP模块实现创建服务。
上述的实现中,涉及的源码如下:
js
module.exports = class Application extends Emitter {
listen(...args) {
const server = http.createServer(this.callback()); // 创建 HTTP 服务器
return server.listen(...args); // 启动服务器并监听指定端口
}
callback() {
// 组合中间件
const fn = compose(this.middleware); // 使用koa-compose库将中间件数组组合成一个函数链
// 实际的请求处理函数,每次请求都会调用它
const handleRequest = (req, res) => {
const ctx = this.createContext(req, res); // 为每个请求创建一个新的上下文对象
return this.handleRequest(ctx, fn); // 处理请求,执行中间件链并生成响应
};
return handleRequest;
}
// 处理request逻辑
handleRequest(ctx, fnMiddleware) {
const res = ctx.res;
res.statusCode = 404;
const onerror = err => ctx.onerror(err); // 捕获未处理的异常,并进行错误处理
const handleResponse = () => respond(ctx); // 调用 respond 函数生成最终的响应
// 执行中间件链,传入上下文对象
return fnMiddleware(ctx).then(handleResponse).catch(onerror);
}
};
-
整体导出一个class,
app
就是class的一个实例,app.listen
就是调用class中的listen
方法 -
listen
的实现:使用HTTP模块创建一个server,并提供this.callback
回调方法
中间件
中间件是Koa框架的灵魂,洋葱模型的经典设计就是来自中间件的巧妙实现。这部分知识也属于Koa的难点。
先看看中间件的使用示例:
js
const Koa = require('koa');
const app = new Koa();
app.use(async (ctx, next) => {
console.log('Middleware 1 - Before');
await next();
console.log('Middleware 1 - After');
});
app.use(async (ctx, next) => {
console.log('Middleware 2 - Before');
await next();
console.log('Middleware 2 - After');
});
app.use(async (ctx) => {
console.log('Middleware 3 - Handling Request');
ctx.body = 'Hello, Koa!';
});
app.listen(3000, () => {
console.log(`Koa 服务器运行在 http://localhost:${PORT}`);
});
输出结果是:
vbscript
Middleware 1 - Before
Middleware 2 - Before
Middleware 3 - Handling Request
Middleware 2 - After
Middleware 1 - After
可见,中间件的回调函数中,await next()
之前的逻辑是按照中间件注册的顺序从上往下执行的,await next()
之后的逻辑是按照中间件注册的顺序从下往上执行的。
那么注册中间件的use
方法是怎么实现的呢?
js
module.exports = class Application extends Emitter {
constructor() {
super();
this.middleware = []; // 存储中间件
}
use(fn) { // fn 是传入的中间件函数
if (typeof fn !== 'function') throw new TypeError('中间件必须是函数');
this.middleware.push(fn); // 将中间件函数添加到 this.middleware 数组中
return this; // 返回当前实例(支持链式调用)
}
};
Application的构造函数中使用middleware
队列存储中间件,当执行use
方法时就执行入队操作。
中间件注册后,当请求进来时,就开始执行中间件队列中的逻辑。
js
const compose = require('koa-compose');
module.exports = class Application extends Emitter {
callback() {
// 组合中间件
const fn = compose(this.middleware); // 使用koa-compose库将中间件数组组合成一个函数链
// 实际的请求处理函数,每次请求都会调用它
const handleRequest = (req, res) => {
const ctx = this.createContext(req, res); // 为每个请求创建一个新的上下文对象
return this.handleRequest(ctx, fn); // 处理请求,执行中间件链并生成响应
};
return handleRequest;
}
};
由于有next
的分割,一个中间件会分成两部分执行。核心的实现就藏在koa-compose
依赖中。
koa-compose
是 Koa 框架中实现中间件洋葱模型的核心依赖。它的作用是将多个中间件组合成一个函数链,使得每个中间件可以按照顺序执行,并且支持通过 await next()
实现双向控制流(请求阶段和响应阶段)。
使用示例:
js
const fn = compose([middleware1, middleware2]);
fn(ctx).then(() => console.log('中间件执行完成'));
compose
方法的核心作用是:
- 将一组异步中间件函数组合成一个函数链。
- 确保中间件按照注册顺序依次执行。
- 支持通过
await next()
调用下一个中间件,并在后续中间件执行完成后返回。
compose
方法的核心源码:
js
function compose(middleware) {
// 验证 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!');
}
/**
* 返回一个函数,该函数接收上下文对象 ctx 和可选的 next 函数
*/
return function (context, next) {
// 定义一个索引变量,用于跟踪当前执行的中间件位置
let index = -1;
return dispatch(0);
/**
* 调度函数,用于递归调用中间件
*/
function dispatch(i) {
// 如果 i 小于等于 index,表示 next() 被多次调用
if (i <= index) return Promise.reject(new Error('next() called multiple times'));
index = i;
// 获取当前中间件
let fn = middleware[i];
// 如果没有更多中间件,则使用传入的 next 函数
if (i === middleware.length) fn = next;
// 如果没有中间件或 next,则直接返回
if (!fn) return Promise.resolve();
try {
// 使用递归方法执行每个中间件,传入 ctx 和下一个调度函数
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
} catch (err) {
// 捕获同步错误
return Promise.reject(err);
}
}
};
}
dispatch
是一个递归函数,用于逐个调用中间件。它的核心作用是:
- 获取当前中间件:根据索引
i
获取对应的中间件函数。 - 调用中间件:将
context
和下一个中间件的调度函数dispatch(i + 1)
传递给当前中间件。 - 递归调用:当
await next()
被调用时,会触发下一个中间件的执行。
封装ctx
在使用中间件时,有两个参数:ctx
和next
。了解了中间件原理后,我们应该知道next
相当于把当前中间件的执行权力交给了下一个中间件,那ctx
对象是什么样的呢?
我们先看看ctx
对象的使用:
js
const Koa = require('koa');
const app = new Koa();
app.use(async (ctx) => {
// 获取请求路径
console.log(ctx.req.url);
console.log(ctx.request.req.url);
console.log(ctx.response.req.url);
console.log(ctx.url);
// 设置响应状态码
ctx.response.status = 200;
ctx.body = 'Hello world'
});
app.listen(PORT, () => {
console.log(`Koa 服务器运行在 http://localhost:${PORT}`);
});
可以看到,我们可以通过多种方式获取ctx
对象中的请求路径。那ctx
对象到底是怎么封装的呢?
js
module.exports = class Application extends Emitter {
constructor() {
super();
this.context = Object.create(context); // 上下文原型,继承自lib/context.js
this.request = Object.create(request); // 请求原型,继承自lib/request.js
this.response = Object.create(response); // 响应原型,继承自lib/response.js
}
callback() {
// 组合中间件
const fn = compose(this.middleware); // 使用koa-compose库将中间件数组组合成一个函数链
// 实际的请求处理函数,每次请求都会调用它
const handleRequest = (req, res) => {
const ctx = this.createContext(req, res); // 为每个请求创建一个新的上下文对象
return this.handleRequest(ctx, fn); // 处理请求,执行中间件链并生成响应
};
return handleRequest;
}
createContext(req, res) {
// 创建一个新的上下文对象 context,并将其与请求和响应对象关联
const context = Object.create(this.context);
const request = (context.request = Object.create(this.request));
const response = (context.response = Object.create(this.response));
// 初始化上下文的属性
context.app = request.app = response.app = this;
context.req = request.req = response.req = req;
context.res = request.res = response.res = res;
// 互相挂载,方便获取信息
request.ctx = response.ctx = context;
request.response = response;
response.request = request;
context.originalUrl = request.originalUrl = req.url;
context.state = {}; // 提供一个空对象,用于在中间件之间共享数据
return context;
}
};
可以看到,中间件中的ctx对象经过createContext
方法封装,它继承自this.context
对象,后者又继承自lib/context.js
中导出的对象。最终,http.IncomingMessage
类和http.ServerResponse
类都挂载到了ctx.req
和ctx.res
属性上。这是为了方便开发者从ctx对象上获取需要的信息。
另外,还将app
、req
、res
、ctx
都存放了在request
和response
对象中。这是为了使它们同时共享这几个对象,方便处理职责进行转移。用户只需要通过ctx
即可获取Koa提供的所有数据和方法,而Koa又继续将这些职责进行划分,从而分散职责,降低耦合度,同时共享所有资源使上下文具有高内聚性,内部元素互相能访问到。
单一上下文原则
单一上下文原则指的是为每个 HTTP 请求创建一个独立的上下文对象,并将所有与该请求相关的数据和方法都封装在这个上下文中。Koa 通过 createContext
方法实现这一点,确保每个请求的上下文对象都是唯一且相互隔离的。
具体来说,在 createContext
方法中,Koa 使用 Object.create()
来创建一个新的上下文对象,而不是直接赋值。每个请求的context
对象都是唯一的,并且共享给所有全局中间件使用,包含所有关于请求和响应的信息。
单一上下文原则有以下优点:
- 降低复杂度:中间件中只有一个ctx,所有信息都在里面,使用很方便。
- 便于维护:上下文中的必要信息都在ctx中,方便维护
- 降低风险:context对象是唯一的,信息高内聚,改动的风险也会大大降低。
处理请求
请求处理在handleRequest
方法中实现。
js
handleRequest(ctx, fnMiddleware) {
const res = ctx.res;
res.statusCode = 404;
const onerror = err => ctx.onerror(err); // 捕获未处理的异常,并进行错误处理
const handleResponse = () => respond(ctx); // 调用 respond 函数生成最终的响应
// 执行中间件链,传入上下文对象
return fnMiddleware(ctx).then(handleResponse).catch(onerror);
}
默认返回的状态码是404,返回的方法是一个链式调用:执行所有中间件后处理返回逻辑,并捕获异常进行错误处理
respond
函数的作用是根据上下文对象(ctx
)中的状态生成最终的 HTTP 响应。它会检查 ctx.body
和其他相关属性,并将结果写入到 res
对象中。
js
function respond(ctx) {
const res = ctx.res;
let body = ctx.body;
const code = ctx.status;
// 如果没有设置状态码,默认返回 404
if (code === 404 || !ctx.length) {
return res.end();
}
// 设置响应头
if (!res.headersSent) {
if (ctx.type) {
res.setHeader('Content-Type', ctx.type);
}
if (ctx.length) {
res.setHeader('Content-Length', ctx.length);
}
}
// 根据 body 的类型生成响应内容
if (body == null) {
// 如果 body 为 null 或 undefined,则发送空响应
res.end();
} else if (typeof body === 'string' || Buffer.isBuffer(body)) {
// 如果 body 是字符串或 Buffer,直接写入响应
res.end(body);
} else if (body instanceof Stream) {
// 如果 body 是流对象,则通过管道写入响应
body.pipe(res);
} else {
// 如果 body 是对象或数组,则序列化为 JSON 并写入响应
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify(body));
}
}
onerror
方法用于捕获未处理的异常,并对错误进行统一处理。它的设计目标是确保即使出现错误,服务器也不会崩溃,而是能够返回一个友好的错误响应。
js
function onerror(err) {
// 确保 err 是 Error 类型
if (!(err instanceof Error)) throw new TypeError('非 Error 对象');
// 获取错误信息
const msg = err.stack || err.toString();
// 打印错误日志
console.error(`\n${msg.replace(/^/gm, ' ')}\n`);
// 如果响应已发送,则无法再修改响应
if (this.headerSent || !this.writable) {
return;
}
// 设置响应状态码为 500
this.status = 500;
// 设置响应体为错误信息(可选)
this.body = 'Internal Server Error';
// 触发 error 事件,允许开发者自定义错误处理逻辑
this.app.emit('error', err, this);
}
注意,如果中间件中有一些异步操作异常,Koa是无法捕获的,最终会导致进程异常退出。此时就需要我们做一些兜底处理了,通用的做法监听全局的uncaughtException
和unhandledRejection
事件。
js
process.on('uncaughtException', e => console.log(e));
Context的核心实现
context.js
是 Koa 框架中 ctx
对象的核心实现文件,它封装了请求和响应的所有相关信息,并提供了许多便捷的方法和属性来操作这些信息。
委托机制
Koa 的 ctx
对象通过委托机制,将许多 request
和 response
对象的属性和方法直接暴露给 ctx
,从而简化了 API 的使用。比如:
ctx.query
:等价于ctx.request.query
ctx.redirect()
:等价于ctx.response.redirect()
这样做的好处是:
- 简化 API :开发者无需记住
request
和response
的具体属性,只需通过ctx
即可操作所有相关内容。 - 减少冗余代码 :避免在中间件中频繁地引用
ctx.request
和ctx.response
。
具体来说,ctx的委托机制确实是通过 delegates
包实现的。delegates
是一个轻量级的工具库,用于将对象的属性和方法委托到另一个对象上。它的核心API如下:
delegate(proto, prop)
:
proto
:目标对象的原型。prop
:要委托的目标属性名(例如request
或response
)。
delegate
还支持链式调用:
.method(name)
:委托方法。.getter(name)
:委托 getter。.setter(name)
:委托 setter。.access(name)
:委托 getter 和 setter。
使用示例:
js
delegates(proto, 'request').access('url');
// 等价于
Object.defineProperty(proto, 'url', {
get() {
return this.request.url;
},
set(val) {
this.request.url = val;
},
});
可以看出,delegates
的主要优势包括:
- 代码简洁:避免手动定义大量的 getter、setter 和方法。
- 灵活性高:支持多种委托类型,满足不同需求。
- 易于扩展:通过链式调用,可以轻松添加多个委托。
delegates
的源码非常简单,整个核心实现只有150多行。可参考:从源码学习使用 node-delegates
在 Koa 的源码中,context.js
使用 delegates
将 ctx.request
和 ctx.response
的属性和方法委托到 ctx
上。
js
const delegates = require('delegates');
const proto = module.exports = {};
// 委托 request 对象的属性和方法到 ctx
delegates(proto, 'request')
.method('get') // ctx.get() 委托到 ctx.request.get()
.method('post') // ctx.post() 委托到 ctx.request.post()
.access('querystring') // ctx.querystring 委托到 ctx.request.querystring
.access('url') // ctx.url 委托到 ctx.request.url
.getter('path'); // ctx.path 委托到 ctx.request.path
// 委托 response 对象的属性和方法到 ctx
delegates(proto, 'response')
.method('redirect') // ctx.redirect() 委托到 ctx.response.redirect()
.access('body') // ctx.body 委托到 ctx.response.body
.access('status') // ctx.status 委托到 ctx.response.status
.getter('headerSent'); // ctx.headerSent 委托到 ctx.response.headerSent
Cookie 操作
Koa 提供了对 Cookie 的便捷操作,主要通过 ctx.cookies
属性实现。ctx.cookies
是一个 Cookies
类的实例,支持读取和设置 Cookie。
ctx.cookies
的使用示例:
js
// 设置 Cookie
ctx.cookies.set('name', 'koa', { httpOnly: true, signed: true });
// 获取 Cookie
const name = ctx.cookies.get('name', { signed: true });
context.js
中定义了 cookies
的 getter 和 setter 方法对Cookies
进行操作。Cookies
类来自 cookies
模块(Koa 的依赖之一),负责处理 Cookie 的读取和写入。
js
get cookies() {
if (!this[COOKIES]) {
this[COOKIES] = new Cookies(this.req, this.res, {
keys: this.app.keys, // 支持签名密钥
secure: this.request.secure, // 是否启用 HTTPS
});
}
return this[COOKIES];
}
set cookies(_cookies) {
this[COOKIES] = _cookies;
}
错误处理
context.js
中的 onerror
方法是 Koa 的核心错误处理机制,用于捕获未处理的异常并生成友好的错误响应。即使发生错误,也不会导致服务器崩溃。
onerror
方法的源码大体实现如下:
js
onerror(err) {
if (!(err instanceof Error)) throw new TypeError('非 Error 对象');
const msg = err.stack || err.toString();
console.error(`\n${msg.replace(/^/gm, ' ')}\n`);
if (this.headerSent || !this.writable) return; // 如果响应已经完成,则忽略错误
this.status = err.status || 500;
this.body = 'Internal Server Error';
this.app.emit('error', err, this); // 触发应用级别的错误事件
}
所有的错误都通过 ctx.onerror
方法处理,确保了一致性。另外,通过触发 app.emit('error')
可以允许开发者自定义全局错误处理逻辑。
request的具体实现
request.js
的核心是 Request
类,它封装了对请求对象的操作,提供了一个更简洁直观的 API 来操作 Node.js 原生的 http.IncomingMessage
对象(即 req
对象)。你可以通过 ctx.request.xxx
访问请求属性。
主要属性如下:
header
和headers
:获取或设置请求头。这两个属性其实是一样的,主要为了兼容写法。你可以通过ctx.request.header
或ctx.request.headers
访问请求头
js
get header() {
return this.req.headers;
},
set header(val) {
this.req.headers = val;
},
get headers() {
return this.req.headers;
},
set headers(val) {
this.req.headers = val;
}
method
:获取或设置 HTTP 请求方法(如GET
、POST
等)。
js
get method() {
return this.req.method;
},
set method(val) {
this.req.method = val;
}
url
:获取或设置请求的 URL。
js
get url() {
return this.req.url;
},
set url(val) {
this.req.url = val;
}
secure
:判断请求是否为 HTTPS。
js
get secure() {
return this.protocol === 'https';
fresh
:判断客户端的缓存是否仍然有效。使用第三方库fresh
根据请求头(如If-None-Match
和If-Modified-Since
)与响应头(如ETag
和Last-Modified
)进行比较。
js
const fresh = require('fresh');
get fresh() {
const method = this.method;
const status = this.ctx.status;
// 只有 GET 或 HEAD 请求才需要检查缓存
if (method !== 'GET' && method !== 'HEAD') return false;
// 如果响应状态码不是 2xx 或 304,则不认为是新鲜的
if ((status >= 200 && status < 300) || status === 304) {
return fresh(this.header, this.response.header);
}
return false;
}
response的具体实现
response.js
封装了 HTTP 响应的核心模块。它对 Node.js 原生的 http.ServerResponse
对象(即 res
对象)进行了封装,提供了一组简洁且直观的 API,用于操作响应头、状态码、响应体等内容。
整体实现与 reauest.js
类似,主要属性和方法如下:
status
:获取或设置 HTTP 响应状态码。status
是对res.statusCode
的封装。在setter中,使用statuses
库将状态码映射为标准的状态消息(如200 OK
或404 Not Found
)
js
get status() {
return this.res.statusCode;
},
set status(code) {
if (typeof code !== 'number') {
throw new Error('status code must be a number');
}
this.res.statusCode = code;
this.res.statusMessage = statuses[code]; // 设置状态消息
}
message
:获取或设置响应的状态消息。如果未设置状态消息,则使用默认的状态消息。
js
get message() {
return this.res.statusMessage || statuses[this.status];
},
set message(msg) {
this.res.statusMessage = msg;
}
body
:获取或设置响应体内容。支持多种类型的内容(字符串、JSON 对象、Buffer 等)。
js
get body() {
return this._body;
},
set body(val) {
this._body = val;
// 自动推断 Content-Type
if (val != null) {
if (!this.header['Content-Type']) {
this.type = typeof val === 'string' ? 'text/plain' : 'application/json';
}
}
// 设置 Content-Length
if (Buffer.isBuffer(val)) {
this.length = val.length;
} else if (typeof val === 'string') {
this.length = Buffer.byteLength(val);
} else {
this.length = undefined;
}
}
length
:获取或设置响应体的长度(Content-Length
)。
js
get length() {
return this.header['Content-Length'];
},
set length(n) {
this.set('Content-Length', n);
}
type
:获取或设置响应的 MIME 类型(Content-Type
)。
js
get type() {
const type = this.header['Content-Type'];
return type ? type.split(';')[0] : '';
},
set type(val) {
if (val) {
this.set('Content-Type', val);
} else {
this.remove('Content-Type');
}
}
is
:检查响应的Content-Type
是否匹配指定的 MIME 类型。使用type-is
库进行 MIME 类型匹配。
js
is(types) {
return typeis(this.type, types);
}
redirect
:重定向到指定的 URL。
js
redirect(url, alt) {
this.status = 302; // 设置状态码为 302
this.set('Location', url); // 设置 Location 头
// 如果客户端不支持重定向,则返回备用内容
if (alt) {
this.body = alt;
} else {
this.body = `Redirecting to ${url}`;
}
}
attachment
:设置响应为文件下载,并可指定文件名。通常用于文件下载场景。
js
attachment(filename) {
this.type = 'application/octet-stream'; // 设置 MIME 类型
if (filename) {
this.set('Content-Disposition', `attachment; filename="${filename}"`);
}
}
总结
Koa 的源码以极简的设计理念为核心,通过中间件机制和现代化的异步流程控制(如 async/await),为开发者提供了一个轻量且灵活的框架基础。其代码结构清晰,模块化程度高,剥离了非核心功能,将扩展的自由交给了开发者。这种设计不仅提升了性能与可维护性,也鼓励开发者根据实际需求构建定制化的解决方案。通过深入解读 Koa 源码,不仅能帮助我们更好地理解其运行机制,还能启发我们在日常开发中追求简洁与高效的设计哲学。
参考: