Koa Request 对象 API 全解析:从请求头到内容协商的源码级实践指南
Koa 的 Request 对象是对 Node.js 原生请求对象(http.IncomingMessage)的轻量抽象,它通过 ES2017 async 函数风格的 API,为日常 HTTP 服务开发补齐了请求头解析、URL 与查询串读写、缓存协商、代理信任、内容协商等高频能力。阅读本文后,你将掌握 ctx.request 上全部 getter/setter 与方法的语义、底层实现与适用边界,并能在真实中间件中正确使用 ctx.get()、ctx.accepts()、ctx.is()、ctx.ips 等工具处理请求。
本文对应的 API 权威出处为 docs/api/request.md,实现源码为 lib/request.js,测试用例位于 tests/request/ 与 tests/application/ 目录。仓库当前版本为 koa 3.2.1(见 package.json)。
理解 Request 对象与 ctx 的关系
Koa 文档把请求相关能力集中描述为 Request 对象,但日常代码里多数时候直接写在 ctx 上。二者的一致性来自委托机制:在 lib/context.js 的 delegate(proto, 'request') 声明中,method('accepts')、method('is')、method('get')、access('querystring')、access('query')、access('path')、access('url')、access('method'),以及 getter('origin')、getter('href')、getter('protocol')、getter('host')、getter('hostname')、getter('header')、getter('secure')、getter('fresh')、getter('ips')、getter('ip') 等都被逐项转发。
也就是说:
ctx.request.header === ctx.header // true
ctx.request.query === ctx.query // true
ctx.request.get('X') === ctx.get('X') // true
应用层在 lib/application.js 的 createContext() 中,把 this.request 原型通过 Object.create 实例化并挂到 context.request 上,同时回填 request.ctx、request.app、request.req、request.response,因此 Request 的 getter 能反向访问到 ctx、app 与原生 req。
请求头:header / headers / get(field)
读取与整体替换
request.header 与 request.headers 是完全等价的一对别名,返回与 Node 原生请求一致的请求头对象;文档还提供了对应的 setter(request.header= / request.headers=)用于整体替换。源码中它们都直接读写 this.req.headers(lib/request.js),因此类型为普通对象,键名小写、值为字符串或字符串数组。
ctx.header // => { 'user-agent': 'curl/8.0', host: 'localhost:3000', ... }
ctx.request.header // 等价
单个头:request.get(field)
按字段名取单个请求头,大小写不敏感。实现上对 referer / referrer 做了特殊处理——两个拼写互通(lib/request.js):
ctx.get('Content-Type') // => 'text/plain'
ctx.get('content-type') // 同样命中
ctx.get('Referer') // 兼容拼写 'referrer' 与 'referer'
ctx.get('Something') // 无此头时返回 ''
注意取不到时返回的是空字符串而非 undefined,这与 HTTP 模块返回 undefined 不同,做判空时不要写 if (ctx.get('X-Foo')) 之外依赖 undefined 的代码。
方法与 URL 族:method、url、originalUrl、href、path、origin
method 读写
request.method 对应 req.method。setter 存在意义在于实现 methodOverride() 这类中间件:先在请求体/自定义头里读取“伪装方法”,再把 ctx.method 改写为真实的 PUT/DELETE 后继续放行到下游中间件。
ctx.method // => 'POST'
ctx.method = 'DELETE'
URL 相关
request.url:req.url的直通读写,通常为不含协议与 host 的路径加查询串(如/foo/bar?q=1)。setter 常用于 URL 重写。request.originalUrl:请求到达时的原始 URL,在createContext()中由req.url固化(lib/application.js)。即便此后通过ctx.url=、ctx.path=、ctx.query=改写,originalUrl也保持不变,见测试 tests/request/query.test.js 中 “should change .url but not .originalUrl” 用例。request.origin:只取origin请求头(如http://example.com),无此头返回null;它并不拼接 host,注意与下面href区分。request.href:完整 URL。若originalUrl本身是http(s)://开头的绝对地址则直接返回,否则拼装protocol + '://' + host + originalUrl(lib/request.js)。
ctx.request.origin
// => http://example.com
ctx.request.href
// => http://example.com/foo/bar?q=1
request.path:路径名(不含查询串),底层经parseurl解析req得到pathname。setter 会保留已有的查询串——实现是先解析 URL、改pathname并清空path缓存后用url.format重新序列化写回this.url(lib/request.js)。
// 假设当前 URL 是 /shop?page=2
ctx.path = '/store' // ctx.url 变为 /store?page=2
ctx.path // => '/store'
查询字符串三件套:query、querystring、search
三个属性分别呈现查询串的不同形态,全部提供读写:
| 属性 | 含义 | 示例(URL=/foo?page=2&color=blue) |
|---|---|---|
request.querystring |
原始查询串,不含 ? |
'page=2&color=blue' |
request.search |
原始查询串,含前导 ? |
'?page=2&color=blue' |
request.query |
解析后的对象 | { page: '2', color: 'blue' } |
search的实现就是querystring加前导?(为空时返回'');search=与querystring=等价(lib/request.js)。querygetter 调用本仓库自研的 lib/search-params.js 的parse(),它基于 WHATWGURLSearchParams:单值键返回字符串,多值键返回数组;结果按原始查询串做内存缓存(this._querycache),同一 URL 多次读取返回同一对象(测试 tests/request/query.test.js 验证了“每次访问返回同一对象”)。- 文档明确:
query的 getter 与 setter 都不支持嵌套对象解析。query=会把对象用URLSearchParams序列化(数字会被当作字符串、数组键会展开成多个同名参数)后写回 URL,测试见ctx.query = { page: 2, color: 'blue' }得到/store/shoes?page=2&color=blue。
ctx.query = { next: '/login' }; // ctx.url 变为 ...?next=%2Flogin
如需嵌套查询,可自行引入 qs 等库,这已超出 Koa 默认行为。
长度、类型与字符集:length、type、charset
request.length:把Content-Length头解析为数字返回;头缺失返回undefined,非法值也返回undefined(parseInt后做NaN判断,见 lib/request.js)。request.type:取Content-Type并去掉参数部分(以;截断),无该头返回'':
const ct = ctx.request.type;
// => "image/png" (即使原始值为 image/png; charset=utf-8)
request.charset:用content-type模块解析Content-Type参数,返回charset参数值,无则返回'';解析失败(如非法头)也返回''(lib/request.js):
ctx.request.charset;
// => 'utf-8'
缓存协商:fresh / stale
request.fresh 用于判断请求缓存是否“新鲜”(资源内容未变化),服务于 If-None-Match/ETag 与 If-Modified-Since/Last-Modified 两对缓存协商头。它必须在你设置好对应响应头之后再读取。
判定条件(实现见 lib/request.js):
- 仅对
GET/HEAD生效,其余方法直接返回false; - 响应状态码必须是
2xx或304; - 委托
fresh库比较请求头与响应头是否匹配。
stale 就是 !fresh。典型用法来自文档:
// freshness check requires status 20x or 304
ctx.status = 200;
ctx.set('ETag', '123');
// cache is ok
if (ctx.fresh) {
ctx.status = 304;
return;
}
// cache is stale
// fetch new data
ctx.body = await db.find('something');
tests/request/fresh.test.js 覆盖了三种关键分支:POST 请求恒为 false、404 响应恒为 false、200 + ETag 匹配返回 true。
代理与客户端标识:protocol、secure、ip、ips
这一族属性都受应用级开关 app.proxy 控制——只有当 app.proxy 为 true 时 Koa 才信任各 X-Forwarded-* 头。Koa 默认不信任任何代理头,避免未设置代理时被伪造(lib/application.js 中默认 proxy = false)。
request.protocol:优先看底层 socket 是否 TLS 加密(返回'https');否则在app.proxy开启时信任X-Forwarded-Proto(取逗号分隔第一个值并去空白,见辅助函数 splitCommaSeparatedValues);都没有则'http'。request.secure:protocol === 'https'的简写,常用于给 Cookie 打Secure标记等场景。request.ips:开启proxy后读取 IP 列表头(默认X-Forwarded-For),按逗号切分为数组,顺序为上游 → 下游。例:头值为"client, proxy1, proxy2"时返回["client", "proxy1", "proxy2"]。关闭proxy时返回空数组。request.ip:ips的第一项(最接近客户端的地址)优先,否则回退到 socket 的remoteAddress。实现带Symbol('context#ip')缓存并支持 setter 覆写(lib/request.js)。
X-Forwarded-For 伪造风险与两个官方缓解项
文档专门提醒:多数反向代理(如 nginx)用 proxy_add_x_forwarded_for 追加 X-Forwarded-For,这存在安全风险——恶意客户端可以预先伪造 X-Forwarded-For 头,经代理转发后 request.ips 会变成 ['forged', 'client', 'proxy1', 'proxy2'],从而绕过按 IP 做的限流/封禁。Koa 提供两个选项:
方案一:更换读取的 IP 头(app.proxyIpHeader)。若能控制反向代理,可让代理把真实客户端 IP 写入自定义头(如 X-Real-IP),并配置 Koa 不再读 x-forwarded-for:
const app = new Koa({
proxy: true,
proxyIpHeader: 'X-Real-IP',
});
方案二:限制信任的 IP 数量(app.maxIpsCount)。若明确知道服务器前面有几层代理,只信任最下游的那几个 IP,从而忽略用户伪造的部分:
const app = new Koa({
proxy: true,
maxIpsCount: 1, // only one proxy in front of the server
});
// request.header['X-Forwarded-For'] === [ '127.0.0.1', '127.0.0.2' ];
// ctx.ips === [ '127.0.0.2' ];
源码层面对应 lib/request.js:ips 从 this.app.proxyIpHeader 指定的头取逗号分隔值,且当 this.app.maxIpsCount > 0 时执行 ips.slice(-this.app.maxIpsCount) 只保留末尾 N 个;tests/request/ips.test.js 完整验证了未信任代理被忽略、proxyIpHeader 生效、maxIpsCount 只取下游 IP 三组行为。
host 与 hostname
-
request.host:返回hostname:port(含端口)。取值优先级(lib/request.js):app.proxy开启时优先取X-Forwarded-Host(取逗号分隔的首个值);- HTTP/2(
req.httpVersionMajor >= 2)下回退用:authority伪头; - 最后回退
Host头; - 都不存在返回
''。
另外,由于
Host头按 RFC 7230 不允许携带 userinfo,为防御evil.com:fake@legitimate.com这类注入,当 host 中含@时 Koa 会用new URL('http://' + host).host重新解析出真正的 host 部分,解析失败返回''——详见 tests/request/host.test.js 中 userinfo 相关用例。 -
request.hostname:在host基础上剥掉端口:IPv6([开头)走 WHATWG URL 解析得到 hostname,其余用split(':', 1)[0]截取。 -
request.URL:将protocol + '://' + host + originalUrl交给 WHATWGURL构造后得到的标准 URL 对象,带惰性缓存(memoizedURL);构造失败时返回空对象(lib/request.js)。当需要.pathname、.searchParams等标准字段时可复用该对象,避免重复解析。
子域:subdomains 与 app.subdomainOffset
request.subdomains 返回子域数组。所谓子域即主应用域之前、以点分隔的 host 片段。默认认为主域是 host 的最后两段(app.subdomainOffset 默认 2),该阈值可通过 app.subdomainOffset 调整。
例如 host 为 tobi.ferrets.example.com:
- 未设置
subdomainOffset(默认 2):ctx.subdomains为["ferrets", "tobi"]; app.subdomainOffset为 3:只剩["tobi"]。
实现先把 hostname 按 . 切分、reverse() 后 slice(offset)(lib/request.js);当 hostname 是 IP 地址(经 net.isIP 判断,含带端口的 IP)时返回空数组。行为均由 tests/request/subdomains.test.js 佐证:
const app = new Koa({ subdomainOffset: 3 });
// 对 tobi.ferrets.example.com 的请求
ctx.subdomains // => ['tobi']
幂等判断:idempotent
request.idempotent 判断请求是否幂等——内置集合为 ['GET', 'HEAD', 'PUT', 'DELETE', 'OPTIONS', 'TRACE'](lib/request.js)。幂等方法可以安全重试,常用于网络重试策略:
if (ctx.idempotent) {
// 可安全进行自动重试的业务分支
}
底层连接与 socket
request.socket 返回底层请求 socket(即 req.socket),常用于读取本地地址、判断连接是否 TLS、或挂接连接级事件。通过 context 委托,ctx.socket 同样可用。
内容协商:request.is(types...)
request.is(...) 检查请求的 Content-Type 是否命中给定的 MIME 类型,底层委托 type-is 库(lib/request.js)。返回值语义:
- 无请求体 → 返回
null; - 无 Content-Type 头或全部不匹配 → 返回
false; - 匹配 → 返回命中的内容类型字符串。
支持 mime 全名、扩展名与 * 通配(由 type-is 提供):
// With Content-Type: text/html; charset=utf-8
ctx.is('html'); // => 'html'
ctx.is('text/html'); // => 'text/html'
ctx.is('text/*', 'text/html'); // => 'text/html'
// When Content-Type is application/json
ctx.is('json', 'urlencoded'); // => 'json'
ctx.is('application/json'); // => 'application/json'
ctx.is('html', 'application/*'); // => 'application/json'
ctx.is('html'); // => false
真实场景示例——只允许图片到达某路由,否则直接 415:
if (ctx.is('image/*')) {
// process
} else {
ctx.throw(415, 'images only!');
}
内容协商:accepts 家族(Accept 系列头)
Koa 基于 accepts 与 negotiator 封装了四个内容协商工具:request.accepts、request.acceptsEncodings、request.acceptsCharsets、request.acceptsLanguages。它们背后的 accept 对象惰性创建并缓存于 this._accept(lib/request.js)。统一约定:
- 不传任何类型时,返回客户端所有可接受类型(按优先级排序的数组);
- 传入多个类型时返回最佳匹配;若无匹配返回
false,此时应向客户端回406 "Not Acceptable"; - 客户端缺少相应
Accept头(任何类型都接受)时返回你提供的第一个类型——因此传入顺序很重要。
request.accepts(types)
type 可以是完整 mime("application/json")、扩展名("json")或数组(["json", "html", "text/plain"]):
// Accept: text/html
ctx.accepts('html');
// => "html"
// Accept: text/*, application/json
ctx.accepts('html');
// => "html"
ctx.accepts('text/html');
// => "text/html"
ctx.accepts('json', 'text');
// => "json"
ctx.accepts('application/json');
// => "application/json"
// Accept: text/*, application/json
ctx.accepts('image/png');
ctx.accepts('png');
// => false
// Accept: text/*;q=.5, application/json
ctx.accepts(['html', 'json']);
ctx.accepts('html', 'json');
// => "json"
// No Accept header
ctx.accepts('html', 'json');
// => "html"
ctx.accepts('json', 'html');
// => "json"
最后一个示例说明“无 Accept 头时返回第一个类型”,所以顺序必须体现服务端偏好。accepts 可反复调用,也适合用 switch:
switch (ctx.accepts('json', 'html', 'text')) {
case 'json': break;
case 'html': break;
case 'text': break;
default: ctx.throw(406, 'json, html, or text only');
}
request.acceptsEncodings(encodings)
针对 Accept-Encoding。文档特别提醒:务必把 identity(无编码)也放进候选,否则处理不了未压缩请求:
// Accept-Encoding: gzip
ctx.acceptsEncodings('gzip', 'deflate', 'identity');
// => "gzip"
ctx.acceptsEncodings(['gzip', 'deflate', 'identity']);
// => "gzip"
// Accept-Encoding: gzip, deflate
ctx.acceptsEncodings();
// => ["gzip", "deflate", "identity"]
边界情况:客户端若显式发送 identity;q=0,identity 就不可接受,此时方法可能返回 false(虽然少见,仍需处理)。
request.acceptsCharsets(charsets)
针对 Accept-Charset:
// Accept-Charset: utf-8, iso-8859-1;q=0.2, utf-7;q=0.5
ctx.acceptsCharsets('utf-8', 'utf-7');
// => "utf-8"
ctx.acceptsCharsets(['utf-7', 'utf-8']);
// => "utf-8"
ctx.acceptsCharsets();
// => ["utf-8", "utf-7", "iso-8859-1"]
注意不传参时的返回顺序是按质量值排序后的数组,与传入参数无关。
request.acceptsLanguages(langs)
针对 Accept-Language:
// Accept-Language: en;q=0.8, es, pt
ctx.acceptsLanguages('es', 'en');
// => "es"
ctx.acceptsLanguages(['en', 'es']);
// => "es"
ctx.acceptsLanguages();
// => ["es", "pt", "en"]
序列化与调试:inspect / toJSON
request.toJSON() 通过 lib/only.js 仅提取 method、url、header 三个字段(lib/request.js),inspect() 则等价返回该结果,并适配了新版 Node 的 util.inspect.custom 钩子,方便控制台打印请求对象时不泄露原生 req 内部结构。context 层的 lib/context.js 在 toJSON() 中会显式调用 this.request.toJSON()。
完整方法速查表
| 成员 | 类型 | 说明 | 受 app.proxy 影响 |
|---|---|---|---|
request.header / request.headers |
get/set | 请求头对象整体读写 | 否 |
request.get(field) |
方法 | 大小写不敏感地读取单个头(referer/referrer 互通) |
否 |
request.method |
get/set | 请求方法,可改写以支持 methodOverride |
否 |
request.url / request.originalUrl |
get/set / get | 当前 URL / 进入时的原始 URL | 否 |
request.origin |
get | origin 请求头(或 null) |
否 |
request.href |
get | 拼装 protocol://host + originalUrl 的完整 URL |
是(host/protocol) |
request.path |
get/set | 路径名,setter 保留查询串 | 否 |
request.querystring |
get/set | 不含 ? 的查询串 |
否 |
request.search |
get/set | 含 ? 的查询串,setter 等价 querystring= |
否 |
request.query |
get/set | 解析后的查询对象(不支持嵌套),带缓存 | 否 |
request.length |
get | Content-Length 的数值或 undefined |
否 |
request.type |
get | Content-Type 去掉参数 |
否 |
request.charset |
get | Content-Type 的 charset 参数或 '' |
否 |
request.fresh / stale |
get | GET/HEAD + 2xx/304 下的缓存新鲜度协商 | 否 |
request.protocol |
get | http/https,代理下信任 X-Forwarded-Proto |
是 |
request.secure |
get | 是否 HTTPS 的布尔简写 | 是 |
request.ip |
get/set | 远端地址,优先 ips[0] |
是 |
request.ips |
get | IP 链数组(上游→下游),支持 maxIpsCount 截断 |
是 |
request.host |
get | hostname:port,代理下信任 X-Forwarded-Host |
是 |
request.hostname |
get | 去掉端口,IPv6 委托 WHATWG URL 解析 | 是 |
request.URL |
get | 缓存化的 WHATWG URL 对象 | 是 |
request.subdomains |
get | 子域数组,受 app.subdomainOffset 控制 |
否(受 hostname) |
request.idempotent |
get | 是否幂等(GET/HEAD/PUT/DELETE/OPTIONS/TRACE) | 否 |
request.socket |
get | 底层 socket | 否 |
request.is(...types) |
方法 | Content-Type 匹配判断(null/false/命中的类型) |
否 |
request.accepts(...) |
方法 | Accept 协商,返回最佳匹配或 false |
否 |
request.acceptsEncodings(...) |
方法 | Accept-Encoding 协商 |
否 |
request.acceptsCharsets(...) |
方法 | Accept-Charset 协商 |
否 |
request.acceptsLanguages(...) |
方法 | Accept-Language 协商 |
否 |
request.toJSON() / inspect() |
方法 | 提取 method/url/header 的调试表示 |
否 |
需要深入验证行为的读者,建议直接阅读 lib/request.js 各 getter/setter 的实现,并结合 tests/request/ 下与属性一一对应的测试文件(如 host.test.js、ips.test.js、query.test.js、fresh.test.js、accept.test.js、is.test.js)亲手运行 npm test 观察结果。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00