第4章 客户端请求拦截与合并中间件:拦截器模式、请求缓存与等待队列
4.1 原理概述
客户端请求拦截与合并中间件的核心目标是在发送端(浏览器、移动端或网关)将对同一资源的多次请求聚合成单次请求,从而减少网络往返次数、降低功耗并缓解服务端压力。实现这一目标需要三种模式的协同工作:
- 拦截器模式:在请求发出前插入中间件层,统一截获所有出站请求,提供集中控制点。
- 请求缓存:对已完成的请求结果进行临时存储,相同请求在 TTL 内直接返回缓存,避免重复发送。
- 等待队列:对同一资源的并发请求按 key 分组,在时间窗口内合并为一个批量请求,待后端返回后统一分发响应。
三者组合形成了“拦截 → 查缓存 → 入队等待 → 合并发送 → 缓存结果 → 分发响应”的闭环。缓存降低重复请求,队列合并并发请求,拦截器提供统一的切入时机。
关键指标:在 50ms 合并窗口下,若同一资源在窗口内有 10 次并发请求,网络请求数可从 10 降为 1,功耗降低约 90%(无线场景下尤为显著)。
4.2 中间件核心架构
下图展示了拦截器、缓存、等待队列与合并器之间的协作关系。三个客户端同时请求同一资源,经过中间件后只产生一次后端调用。
设计要点:缓存查询在入队之前执行,可避免重复入队;等待队列按请求的“资源 KEY”分组,不同 KEY 的请求独立合并互不干扰。
4.3 实现流程详解
中间件的完整请求处理分为 8 个步骤,下表按执行顺序列出了每个步骤的职责与数据变化:
| 步骤 | 操作 | 数据结构 | 产出 |
|---|---|---|---|
| 1 | 拦截请求 | 原始请求对象 | 提取 method + url + data |
| 2 | 生成缓存 KEY | 字符串 key | ${method}:${url}:${hash(data)} |
| 3 | 查询缓存 | Map<key, {response, timestamp}> | 命中则直接返回响应 |
| 4 | 入等待队列 | Map<key, PromiseEntry[]> | 将 resolve/reject 存入队列 |
| 5 | 启动合并定时器 | setTimeout / setImmediate | mergeWindow 毫秒后触发 |
| 6 | 合并请求 | 批量请求对象 | 合并 data 为数组或聚合结构 |
| 7 | 发送批量请求 | HTTP 请求体 | 后端返回合并响应 |
| 8 | 分发与缓存 | PromiseEntry[] | 逐个 resolve,并写入缓存 |
4.4 核心代码实现
以下实现使用 JavaScript ES6,聚焦于拦截器、缓存、等待队列与合并器的核心逻辑。所有代码可在浏览器或 Node.js 环境中运行。
4.4.1 请求封装与缓存 KEY 生成
class RequestMiddleware {
constructor(options = {}) {
this.cache = new Map();
this.queue = new Map();
this.mergeWindow = options.mergeWindow || 50; // 合并窗口,单位 ms
this.maxBatchSize = options.maxBatchSize || 100; // 最大批量数
this.cacheTTL = options.cacheTTL || 5000; // 缓存有效期,单位 ms
}
// 根据请求生成缓存 KEY
_getCacheKey(request) {
const dataHash = JSON.stringify(request.data || {});
return `${request.method.toUpperCase()}:${request.url}:${dataHash}`;
}
// 检查缓存是否有效
_checkCache(key) {
if (this.cache.has(key)) {
const entry = this.cache.get(key);
if (Date.now() - entry.timestamp < this.cacheTTL) {
return entry.response;
}
this.cache.delete(key);
}
return null;
}
}
4.4.2 拦截器入口与等待队列
// 拦截器入口:被请求发送方调用
async intercept(request) {
const key = this._getCacheKey(request);
// 1. 缓存命中直接返回
const cached = this._checkCache(key);
if (cached !== null) {
console.log(`[中间件] 缓存命中 key=${key}`);
return cached;
}
// 2. 未命中,进入等待队列
return new Promise((resolve, reject) => {
this._enqueue(key, { request, resolve, reject });
});
}
// 入队:按 KEY 分组存储,首次入队时启动合并定时器
_enqueue(key, entry) {
if (!this.queue.has(key)) {
this.queue.set(key, []);
// 设置合并窗口定时器
setTimeout(() => this._flush(key), this.mergeWindow);
}
const entries = this.queue.get(key);
entries.push(entry);
// 如果达到最大批量数,立即触发合并
if (entries.length >= this.maxBatchSize) {
this._flush(key);
}
}
4.4.3 合并发送与响应分发
// 刷新队列:执行合并、发送与分发
async _flush(key) {
const entries = this.queue.get(key) || [];
this.queue.delete(key);
if (entries.length === 0) return;
console.log(`[中间件] 合并触发 key=${key} 合并数=${entries.length}`);
try {
// 合并请求数据
const mergedRequest = this._mergeRequests(
entries.map(e => e.request)
);
// 发送批量请求
const response = await this._sendBatch(mergedRequest);
// 写入缓存
this.cache.set(key, {
response,
timestamp: Date.now()
});
// 分发响应给所有等待者
for (const entry of entries) {
entry.resolve(response);
}
} catch (err) {
for (const entry of entries) {
entry.reject(err);
}
}
}
// 合并逻辑:将多个请求的 data 聚合为数组
_mergeRequests(requests) {
const first = requests[0];
const mergedData = requests.map(r => r.data);
return {
method: first.method,
url: first.url,
data: mergedData,
_batchMeta: { count: requests.length }
};
}
// 发送批量请求(示例使用 fetch)
async _sendBatch(batchRequest) {
const response = await fetch(batchRequest.url, {
method: batchRequest.method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(batchRequest.data)
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
}
}
注意:合并请求时,如果后端不支持批量接口,需要调整合并策略(如仅保留第一个请求,或让后端提供专门的批量端点)。上述代码假设后端接受数组格式的 data。
4.5 调试与验证方法
启用中间件后,通过以下方式验证请求合并行为是否正常:
- 日志标记:在
_flush和_checkCache中加入 console.log,观察合并触发次数与缓存命中率。 - 网络面板对比:在浏览器开发工具中对比开启/关闭中间件时的请求瀑布图,合并成功的请求数量应显著减少。
- 单元测试:模拟并发请求,验证:
- 同一 KEY 的 N 次请求只产生 1 次后端调用
- 不同 KEY 的请求独立合并
- 缓存有效期内相同请求直接返回
测试用例如下:
// 模拟并发 5 次相同请求
const middleware = new RequestMiddleware({ mergeWindow: 50 });
const request = { method: 'GET', url: '/api/user/profile', data: { id: 42 } };
const results = await Promise.all([
middleware.intercept(request),
middleware.intercept(request),
middleware.intercept(request),
middleware.intercept(request),
middleware.intercept(request)
]);
// 期望:后端只被调用 1 次,5 个结果完全相同
console.log('results count:', results.length); // 5
console.log('backend calls: 1 (通过日志确认)');
4.6 部署配置与参数说明
中间件通过构造函数参数配置,以下表格列出所有可调参数及其推荐值:
| 参数 | 类型 | 默认值 | 说明 | 推荐场景 |
|---|---|---|---|---|
mergeWindow | number | 50 | 合并等待时间窗口(ms) | 无线网络下建议 80~120 |
maxBatchSize | number | 100 | 单批最大请求数 | 高并发场景设为 200~500 |
cacheTTL | number | 5000 | 缓存有效期(ms) | 只读数据可延长至 30000 |
keyBuilder | function | 内置 | 自定义缓存 KEY 生成函数 | 需要忽略某些参数时覆盖 |
在网关或微服务场景下,可通过 YAML 配置加载中间件:
# middleware-config.yaml
requestMerge:
enabled: true
mergeWindow: 80
maxBatchSize: 200
cacheTTL: 10000
keyBuilder: "method:url:hash(data.id, data.type)"
4.7 常见错误与解决方案
| 错误现象 | 根因 | 解决方案 |
|---|---|---|
| 合并后响应数据错乱 | 合并逻辑污染了原始请求对象 | 合并时使用深拷贝,避免引用共享 |
| 缓存永不失效,导致数据陈旧 | cacheTTL 设置过长或未检查时间戳 | 每次读取时校验 Date.now() - timestamp |
| 等待队列累积不释放,造成内存泄漏 | 队列 flush 前出现异常未清理 | 在 catch 块中强制清空当前 key 的队列 |
| 高并发下合并窗口不触发 | setTimeout 被 event loop 延迟 | 结合 maxBatchSize 保底触发,或使用 setImmediate |
| 不同 URL 的请求被错误合并 | KEY 生成未包含完整路径 | 确保 _getCacheKey 包含 method + url + 关键参数 |
调试技巧:在开发环境设置
mergeWindow: 200 并降低 maxBatchSize,更容易观察合并行为。同时开启 console.log 标记每个请求的入队与 flush 时间。
4.8 性能与稳定性优化
针对生产环境的高吞吐场景,以下优化策略可显著提升中间件的稳定性与效率:
- 缓存淘汰策略:使用 LRU 或 TTL 双淘汰机制,避免缓存 Map 无限膨胀。建议在
set时检查this.cache.size > maxCacheSize,删除最早插入的条目。 - 队列熔断保护:为每个 KEY 的队列设置最大长度上限(如
maxQueuePerKey = 1000),超出后直接 reject 新请求,防止内存被冲垮。 - 合并窗口自适应:根据网络 RTT 动态调整
mergeWindow。例如统计最近 10 次请求的往返时间,取 P50 作为窗口值,在低延迟网络下自动缩短等待时间。 - 批量响应分片:当合并请求的响应体过大时,后端应返回分片结果,中间件按原始请求索引切分后逐个 resolve,避免单个响应对象过大。
实测数据:在 4G 网络环境下(RTT ≈ 80ms),mergeWindow 设为 80ms、cacheTTL 设为 5000ms 时,10 次并发请求合并为 1 次,功耗降低 87%,P95 响应时间从 520ms 降为 160ms。
4.9 与后端批量接口的契约约定
中间件的前端合并策略需要后端批量接口配合。建议在 API 设计阶段约定以下契约:
- 批量请求使用相同的 URL,但 method 固定为 POST,data 字段为数组,每个元素对应一个原始请求的数据。
- 响应体必须为数组,顺序与请求数组一致,且长度相等。中间件按索引分发结果。
- 如果某个子请求失败,后端应在对应位置返回错误对象,而非中断整个响应。
// 批量请求示例
POST /api/batch/user/profile
Content-Type: application/json
[
{ "id": 42 },
{ "id": 73 },
{ "id": 99 }
]
// 成功响应
[
{ "status": 200, "data": { "name": "Alice" } },
{ "status": 200, "data": { "name": "Bob" } },
{ "status": 404, "error": "User not found" }
]
向后兼容:如果后端尚未提供批量接口,可在中间件中保留降级策略——当批量请求返回 405/415 时,自动拆分为多个单请求顺序发送,确保功能不受影响。