响应缓存与上游服务集成
MyIP 通过路由级 Cache-Control 策略控制响应的可缓存性,并通过共享的 Fetch 封装统一上游请求超时、取消信号与 User-Agent。两者分别解决重复查询成本与外部服务调用边界问题,不应混同为服务端结果缓存。
目的与范围
本页覆盖 /api 默认缓存策略、路由 TTL、聚合响应降级规则,以及 cacheable、fetchWithTimeout、fetchUpstream 和 initUpstreamUserAgent 的实现与运维约束。
各供应商的响应字段转换、业务查询逻辑、离线数据库下载与诊断报告存储属于相邻主题,本页只解释它们如何影响缓存边界。运行时未提供相邻目录的准确链接,因此不虚构 Wiki 地址。本文依据四个核心文件的限定摘录;未检查全部处理器、部署代理配置或全部测试。
概述
这一机制由三条原则组成:
- 默认不缓存,路由显式选择缓存。
/api中间件先写入no-store;适合共享的数据查询再通过cacheable(...)覆盖响应头。 - 传输成功不代表业务结果完整。 ASN Profile 与 Cloudflare Radar 可以返回部分数据,需要额外的完整性判定,不能仅根据 HTTP 状态决定缓存。
- 上游访问具有明确的时间与身份边界。 普通 Fetch 默认等待 5 秒,上游预设默认等待 8 秒;后端可以注入项目标识,且不会主动覆盖调用方已有的 User-Agent。
这里的缓存中间件只修改响应头和 res.json,没有保存响应体,也没有实现缓存命中查询、Redis/KV 写入或并发请求合并。实际缓存是否发生,取决于浏览器、代理或 CDN 是否遵守这些头部;不能仅凭后端代码认定部署已经启用边缘缓存。
依据:backend-server.js、cache-control.js、fetch-with-timeout.js。
架构
Sources:
两个机制并不直接互相调用:缓存规则属于响应出口,上游封装属于请求出口。某个业务处理器是否使用 fetchUpstream、如何解析结果以及如何映射异常,需要检查该处理器;不能从路由注册推断所有服务都走统一封装。
响应缓存的实际执行路径
1. 先设置保守默认值
1app.use('/api', (req, res, next) => {
2 res.setHeader('Cache-Control', 'no-store');
3 next();
4});Source: backend-server.js
该中间件位于全局 requireReferer 之前。大部分带输入校验或就绪检查的路由,也把这些检查放在 cacheable 之前,因此正常进入这段管线后,早期拒绝响应保留 no-store。注意 express.json({ limit: '500kb' }) 位于它之前:这里并不能证明 JSON 解析阶段就被拒绝的请求一定经过默认缓存中间件。
2. 在进入路由时解析正常 TTL
cacheable(maxAge, { cacheIf, degradedMaxAge } = {}) 返回 Express 中间件。执行时先解析 maxAge:数字直接使用,函数以 req 为参数调用。只有结果为真值时才保存 res.locals.cacheControl 并包装 res.json;否则直接执行 next()。
这意味着动态 TTL 是在中间件执行时读取的,而不是在 JSON 发送时读取。中间件也没有检查 TTL 必须为正整数;调用方应确保配置有效,不能把真值判断当成参数校验。
3. 在发送 JSON 时决定是否覆盖头部
1 res.json = (body) => {
2 if (res.statusCode < 400) {
3 if (!cacheIf || cacheIf(body)) {
4 res.setHeader('Cache-Control', res.locals.cacheControl);
5 } else {
6 const degradedSeconds = resolve(degradedMaxAge);
7 if (degradedSeconds) res.setHeader('Cache-Control', `public, max-age=${degradedSeconds}`);
8 }
9 }
10 return originalJson(body);
11 };Source: cache-control.js
需要特别注意以下实现细节:
- 注释将其描述为“2xx JSON”,但实际条件是
res.statusCode < 400,也会包含通过res.json发送的 3xx 响应。 - 没有
cacheIf或其返回真值时,使用完整 TTL。 cacheIf(body)否决后,才在发送时解析degradedMaxAge;该解析函数不接收req或body。- 降级 TTL 为假值,或状态码至少为 400 时,不写新头部。这里是“保持已有头部”,不是主动重置为
no-store,所以默认中间件和后续头部修改都很重要。 - 判定函数是同步调用,代码不会
await。不要提供返回 Promise 的完整性检查或 TTL 解析器。 originalJson通过bind(res)保存,包装后仍调用原始 JSON 发送方法。
4. 二进制输出需要显式配合
中间件只拦截 res.json。源码为绕过 JSON 的二进制流处理器提供 res.locals.cacheControl,让处理器在自己的成功分支手动使用。该字段始终保存完整 TTL,并不表示已经通过 cacheIf;未读取具体二进制处理器,不能保证它已正确应用此约定。
依据:cache-control.js。
路由缓存策略
TTL 都以秒表示,以下是后端路由中明确注册的策略。
| TTL | 路由 | 说明 |
|---|---|---|
| 300(5 分钟) | /api/service-status、/api/service-status/detail | 带服务状态就绪检查 |
| 3600(1 小时) | /api/configs | 注释说明环境派生功能开关通常随重新部署变化 |
| 86400(1 天) | /api/ipinfo、/api/ipapicom、/api/ipsb、/api/ipapiis、/api/ip2location、/api/maxmind | IP 查询;路由带公共 IP 校验及时区处理 |
| 86400(1 天) | /api/whois、/api/github-stars、/api/ooni-blocking | OONI 聚合窗口按 UTC 日对齐,路由注释以降低免费 API 压力解释此 TTL |
| 604800(7 天) | /api/globalping-probes | 探针国家覆盖变化较慢 |
| 604800 / 86400 / 不缓存 | /api/asn-profile | 完整、降级、启动期降级分别处理 |
| 按视图决定 | /api/cfradar | TTL 来自 RADAR_VIEWS[req.query.view]?.ttl |
| 2592000(30 天) | /api/asn-history、/api/asn-connectivity、/api/macchecker | 历史、互联和注册类数据 |
| 31536000(365 天) | /api/map | 长期缓存声明;流输出仍需处理器自行配合 |
默认 no-store | /api/ipchecking、/api/dnsresolver、/api/dnsleaktest/session/:token、/api/invisibility、/api/getuserinfo、/api/updateuserachievement、/api/report/:id、/api/report、/api/persona/evaluate | 用户上下文、诊断或逐请求结果,没有挂载 cacheable |
这些差异不是简单的“所有 GET 都缓存”。例如报告明确保留 no-store,避免边缘缓存超过 KV 过期时间继续暴露报告,也避免把私有诊断数据放进公共缓存。TTL 常量是代码配置,本段没有展示环境变量覆盖机制。
聚合上游结果的完整性与降级
ASN Profile:完整缓存一周,降级通常缓存一天
1app.get('/api/asn-profile', requireValidASN(), cacheable(SEVEN_DAYS_CACHE, {
2 cacheIf: isCompleteProfile,
3 degradedMaxAge: () => (isOfflineBootstrapping() ? 0 : ONE_DAY_CACHE),
4}), asnProfileHandler);Source: backend-server.js
这个路由没有整体就绪门禁。根据注册处注释,本地数据尚未下载完成时,相应 section 自行返回错误;不完整结果在启动下载期间不缓存,启动期结束后允许缓存一天。原因是某些上游可能持续无法查询特定 ASN,如果所有部分失败都完全禁用缓存,每次访问都会重新访问多个数据源。
isCompleteProfile 的内部字段判定不在已读取范围内。因此本页只能确认它是完整性判定入口,不能列出未经验证的状态枚举或字段规则。
Sources:
Cloudflare Radar:动态 TTL,部分结果不缓存
app.get('/api/cfradar', cacheable((req) => RADAR_VIEWS[req.query.view]?.ttl, { cacheIf: isCompleteRadarAnswer }), cfRadarHandler);Source: backend-server.js
该路由按 view 查找 TTL,同时使用 isCompleteRadarAnswer 拒绝不完整结果进入缓存。没有提供 degradedMaxAge,因此否决时保留默认策略。
注册处注释描述:ASN 摘要及前缀列表为 7 天,国家流量为 30 天,中断信息为 1 小时。实际值应以 RADAR_VIEWS 注册项为准,本次未读取注册表。若视图没有对应 TTL,可选链产生 undefined,缓存中间件不包装响应;无效视图对应的 HTTP 错误则由处理器决定,本页不推断。
上游请求生命周期
统一超时与取消信号
fetchWithTimeout(url, init = {}) 从 init 拆出 timeoutMs,默认 5000 毫秒;其余选项传给原生 fetch。每次调用独立创建 AbortController 与计时器。
1 const callerSignal = rest.signal;
2 if (callerSignal) {
3 if (callerSignal.aborted) {
4 controller.abort();
5 } else {
6 callerSignal.addEventListener('abort', () => controller.abort(), { once: true });
7 }
8 }
9
10 try {
11 return await fetch(url, { ...rest, signal: controller.signal });
12 } finally {
13 clearTimeout(timer);
14 }Source: fetch-with-timeout.js
调用方取消和定时取消都会作用于内部控制器。已经取消的 signal 会在调用 fetch 之前取消内部控制器,但函数仍然执行 fetch,由底层 Fetch 处理这一状态。finally 无论成功失败都会清理计时器,异常不在封装内吞掉。
**超时覆盖范围很重要:**计时器在 await fetch(...) 完成后就清除,而不是等待调用方完成 response.json() 或流读取。因此这个实现不是完整响应体消费的总时限。注释指出超时表现为 AbortError;代码没有引入自己的超时异常类型。
Source: fetch-with-timeout.js
后端预设:8 秒与可选 User-Agent
1export const fetchUpstream = (url, init = {}) => {
2 const headers = upstreamUserAgent && !headersCarryUA(init.headers)
3 ? { ...init.headers, 'User-Agent': upstreamUserAgent }
4 : init.headers;
5 return fetchWithTimeout(url, { timeoutMs: 8000, ...init, headers });
6};Source: fetch-with-timeout.js
展开顺序决定调用方的 init.timeoutMs 能覆盖 8000。一个细节是:如果显式传入 timeoutMs: undefined,它先覆盖 8000,随后在 fetchWithTimeout 解构时采用 5000 默认值。
headersCarryUA 的行为如下:
| 输入 headers | 判断方式 | 注入行为 |
|---|---|---|
| 未提供 | 返回 false | 已注册 UA 时创建带 UA 的对象 |
| 普通对象 | 对键名执行大小写不敏感比较 | 有 UA 就保留,否则扩展对象后添加 |
带 has 方法的对象,例如 Headers | 调用 has('user-agent') | 已有 UA 不覆盖 |
| 二维键值数组 | 直接返回 true | 无论是否实际包含 UA,都跳过注入 |
数组分支是明确的保守选择,而非完整的 Fetch headers 合并器。另一个集成风险是:没有 UA 的 Headers 实例进入注入分支后,会使用对象展开而非 new Headers(...) 合并;对象展开不能作为保留 Headers 内全部请求头的可靠方式。扩展调用点时应特别验证这一组合,不能假设所有合法 HeadersInit 形式都能透明合并。
User-Agent 初始化与兼容性边界
1 const site = (process.env.VITE_SITE_URL || '').trim();
2 const ua = ['MyIP', version && `v${version}`, site].filter(Boolean).join('/');
3 setUpstreamUserAgent(ua);
4 return ua;Source: upstream-ua.js
初始化函数同步读取相对于模块位置的项目包清单版本,将 MyIP、可选的 v<version> 与站点地址用 / 拼接。文件不可读或 JSON 解析失败会被捕获,仍然生成缺少版本段的 UA;站点变量缺失则省略站点段。
源码说明其目的在于避免部分上游 WAF 阻止 undici 默认的 User-Agent: node。文件系统和环境变量读取被放在后端专用模块中,而共享 Fetch 模块不访问 fs 或 process,保持浏览器可用性。初始化模块注释要求在 dotenv.config() 之后调用,以读取 .env 中的站点地址;本次未读取后端初始化调用位置,因此不据此断言启动顺序已被独立验证。
依据:upstream-ua.js。
配置与接口速查
以下签名保留源码的 JavaScript 形式,不添加不存在的 TypeScript 类型声明。
| 接口 / 配置 | 参数或类型 | 默认值 | 返回及职责 |
|---|---|---|---|
cacheable(maxAge, { cacheIf, degradedMaxAge } = {}) | maxAge 为秒数或同步 (req) => seconds | 无有效 TTL 则不包装 | 返回 Express 中间件 |
cacheIf | 同步 (body) => 真值或假值 | 未提供即接受 | 决定使用完整 TTL 还是降级策略 |
degradedMaxAge | 秒数或同步 () => seconds | 未提供时不覆盖缓存头 | 仅在 cacheIf 否决时读取 |
fetchWithTimeout(url, init = {}) | Fetch 地址与选项;额外支持 timeoutMs | 5000 毫秒 | 异步返回原生 Fetch 响应 |
fetchUpstream(url, init = {}) | 同上 | 通常为 8000 毫秒 | 返回 fetchWithTimeout 的 Promise,附加可选 UA |
setUpstreamUserAgent(ua) | UA 值 | 模块初值为 null | 设置共享 UA;假值重置为 null,无显式返回值 |
initUpstreamUserAgent() | 无参数 | 缺失段被省略 | 注册并返回生成的 UA 字符串 |
VITE_SITE_URL | 环境变量字符串 | 空字符串 | trim() 后作为 UA 的站点段 |
依据:cache-control.js、fetch-with-timeout.js、upstream-ua.js。
返回值与异常约定
- Fetch 封装不解析 JSON、不检查
response.ok,也不把 HTTP 4xx/5xx 主动转换成异常。业务处理器仍需解释状态码和上游响应体。 - 原生 Fetch 拒绝与取消异常向调用方传播;封装只负责在
finally中清理定时器。 cacheable没有捕获 TTL 解析器、cacheIf或 JSON 发送过程的异常,也没有验证其返回类型。initUpstreamUserAgent捕获的是读取及解析包清单的失败,不会因此中止 UA 生成。
故障、边界与并发
| 情况 | 已验证行为 | 集成注意事项 |
|---|---|---|
| HTTP 状态至少为 400 | JSON 包装器不写缓存头 | 依赖上游中间件已设置 no-store;不会纠正处理器先前设置的公共缓存头 |
| 业务失败仍以低于 400 的状态返回 | 未提供 cacheIf 时仍可能使用完整 TTL | 对部分成功或软失败接口提供同步完整性判定 |
| ASN 启动期部分失败 | 降级 TTL 返回 0 | 避免短暂未就绪结果长时间污染缓存 |
| 上游网络慢或调用方取消 | 内部控制器被取消 | 未实现自动重试、退避或供应商切换 |
| 上游返回 429 或 500 | 封装返回 Fetch 响应,不自行重试 | 处理器需要自己的状态处理策略 |
| TTL 解析结果为假值 | 不包装 res.json | 同时不会设置 res.locals.cacheControl |
| 非 JSON 响应 | 不经过包装器 | 成功流输出需手动应用局部缓存头 |
| 多个相同查询同时到达 | 这些公共模块没有请求去重或锁 | 冷缓存、绕过 CDN 或多实例启动仍可能同时访问上游 |
每个 fetchWithTimeout 调用有独立控制器和计时器,不会因其他请求超时而自动取消。UA 则保存在模块级变量中:运行期间调用 setUpstreamUserAgent 会影响后续 fetchUpstream 调用,不是按请求隔离的配置。
调用方 signal 的监听器使用 { once: true },但函数结束时没有显式移除。若长期复用同一 signal 且从不取消,完成请求留下的监听器可能累积;once 只保证事件实际触发后移除,并不等于请求结束后移除。
以上结论限定于所读取公共模块,不能扩大为“全仓库没有重试、缓存或并发控制”。依据:cache-control.js、fetch-with-timeout.js。
性能、运维与扩展建议
运维检查顺序
- 先检查响应状态和实际缓存头。 未命中缓存不一定意味着 TTL 失效,也可能是路由没有缓存声明、请求被校验拒绝或完整性判定否决。
- 区分默认 TTL 与降级 TTL。 ASN Profile 的一天响应可能是有意保护失败上游,而非配置错误;启动期间不应出现这种降级缓存。
- 检查代理缓存键。 Radar 按
view选择数据,其他查询也可能受参数影响。代理必须正确区分请求参数;仓库摘录没有证明实际 CDN 缓存键配置。 - 确认缓存与访问控制的关系。
public允许共享缓存,而 Referer 检查位于应用内部;外部缓存命中时的访问控制需要部署层另外核实,不能假定请求一定回源执行门禁。 - 排查上游 WAF 时检查实际 UA。 包版本或站点地址缺失不会让初始化报错退出,排障时应确认最终构造值,而不是仅检查是否存在配置变量。
- 区分获取响应与读取响应体超时。 当前定时器不覆盖完整流消费,处理慢响应体时需要额外设计。
安全扩展点
- 新增可共享查询时,沿用“校验 / 就绪检查 →
cacheable→ 处理器”的注册顺序,并按数据更新频率选择 TTL。 - 新增聚合接口时,把业务完整性显式放进
cacheIf;只有在允许短期复用部分结果时才设置degradedMaxAge。 - 新增上游调用时,根据供应商延迟覆盖
timeoutMs,并在处理器中处理 HTTP 状态、响应体解析和异常映射。 - 修改请求头合并时,应覆盖普通对象、
Headers和键值数组,不要破坏“已有 UA 不覆盖”的约定。 - 若需要服务端结果缓存、重试、熔断、请求合并或失效清理,应独立设计;现有 HTTP 头部中间件并不提供这些功能。
这些是基于当前扩展接口提出的工程建议,不代表仓库已经实现相应增强机制。
测试证据与验证限制
定向搜索发现了缓存中间件测试,显示至少包含以下场景:正常 TTL 与 502、按请求选择 TTL、cacheIf 否决以及启动期降级策略。还发现 Radar 部分结果标记为不完整的处理器测试名称。
- 缓存规则断言见 cache-control.test.js。
- Radar 部分结果测试入口见 api-handlers.test.js。
本次仅获得测试搜索摘录,没有完整读取或执行测试,因而不宣称测试全部通过,也不宣称上述边界已有覆盖。后续验证应重点补查 3xx JSON、Headers 注入、显式 timeoutMs: undefined、响应体读取超时及复用取消 signal 的生命周期。
相关链接
- 理解缓存判定与二进制处理约定:cache-control.js。
- 调整接口 TTL 或确认不缓存路由:backend-server.js。
- 扩展超时、取消及请求头行为:fetch-with-timeout.js。
- 排查后端身份标识生成:upstream-ua.js。