1025 lines
32 KiB
JavaScript
1025 lines
32 KiB
JavaScript
/**
|
||
* @fileoverview API 响应缓存中间件
|
||
*
|
||
* 来源: 基于 [Binaryify/NeteaseCloudMusicApi](https://github.com/Binaryify/NeteaseCloudMusicApi) 修改
|
||
*
|
||
* 本模块提供 Express 中间件级别的 API 响应缓存功能,支持:
|
||
* - 内存缓存(默认)和 Redis 缓存(可选)
|
||
* - 按时间字符串(如 "2 minutes"、"1 hour")设置缓存过期
|
||
* - 按分组管理缓存条目,支持批量清除
|
||
* - 自定义缓存条件(状态码过滤、请求/响应 toggle 函数)
|
||
* - 缓存命中率统计(可选,用于性能监控)
|
||
* - ETag/304 协商缓存支持
|
||
* - JSONP 请求的 URL 去参处理
|
||
*
|
||
* 使用示例:
|
||
* const cache = require('./apicache').middleware;
|
||
* app.use(cache('2 minutes', (req, res) => res.statusCode === 200));
|
||
*
|
||
* @module apicache
|
||
* @requires url - URL 解析(用于 JSONP 模式下去除查询参数)
|
||
* @requires ./memory-cache - 内存缓存实现
|
||
*/
|
||
|
||
const url = require('url');
|
||
const MemoryCache = require('./memory-cache');
|
||
|
||
/**
|
||
* 时间单位到毫秒的转换表
|
||
* 用于解析人类可读的时间字符串(如 "2 minutes"、"1 hour")
|
||
* @type {Object.<string, number>}
|
||
*/
|
||
const t = {
|
||
ms: 1, // 毫秒
|
||
second: 1000, // 秒 → 1000ms
|
||
minute: 60000, // 分钟 → 60000ms
|
||
hour: 3600000, // 小时 → 3600000ms
|
||
day: 3600000 * 24, // 天 → 86400000ms
|
||
week: 3600000 * 24 * 7, // 周 → 604800000ms
|
||
month: 3600000 * 24 * 30, // 月(30天)→ 2592000000ms
|
||
};
|
||
|
||
/**
|
||
* 所有 ApiCache 实例的全局注册表
|
||
* 用于跟踪和管理多个缓存实例
|
||
* @type {Array<ApiCache>}
|
||
*/
|
||
const instances = [];
|
||
|
||
/**
|
||
* 创建严格相等匹配函数
|
||
* @param {string} a - 要匹配的值
|
||
* @returns {function(string): boolean} 匹配函数
|
||
*/
|
||
const matches = function (a) {
|
||
return function (b) {
|
||
return a === b;
|
||
};
|
||
};
|
||
|
||
/**
|
||
* 创建不匹配函数(matches 的反函数)
|
||
* @param {string} a - 要排除的值
|
||
* @returns {function(string): boolean} 不匹配函数
|
||
*/
|
||
const doesntMatch = function (a) {
|
||
return function (b) {
|
||
return !matches(a)(b);
|
||
};
|
||
};
|
||
|
||
/**
|
||
* 格式化持续时间为人类可读的日志字符串
|
||
* 超过 1000ms 显示为秒(如 "1.23sec"),否则显示为毫秒(如 "456ms")
|
||
* 输出带黄色 ANSI 转义码
|
||
*
|
||
* @param {number} d - 持续时间(毫秒)
|
||
* @param {string} [prefix] - 可选的前缀文本
|
||
* @returns {string} 带颜色的日志字符串
|
||
*/
|
||
const logDuration = function (d, prefix) {
|
||
const str = d > 1000 ? `${(d / 1000).toFixed(2)}sec` : `${d}ms`;
|
||
return `\x1B[33m- ${prefix ? `${prefix} ` : ''}${str}\x1B[0m`;
|
||
};
|
||
|
||
/**
|
||
* 安全地获取响应头对象
|
||
* 兼容不同版本的 Express/Node.js
|
||
* @param {Object} res - Express 响应对象
|
||
* @returns {Object} 响应头键值对
|
||
*/
|
||
function getSafeHeaders(res) {
|
||
return res.getHeaders ? res.getHeaders() : res._headers;
|
||
}
|
||
|
||
/**
|
||
* ApiCache 缓存中间件构造函数
|
||
*
|
||
* 每个实例维护独立的:
|
||
* - 内存缓存(MemoryCache)
|
||
* - 全局配置选项
|
||
* - 缓存索引(all + groups)
|
||
* - 定时器集合(用于自动过期清除)
|
||
* - 性能统计数组
|
||
*
|
||
* @constructor
|
||
*/
|
||
function ApiCache() {
|
||
const memCache = new MemoryCache();
|
||
|
||
/**
|
||
* 全局配置选项
|
||
* @type {Object}
|
||
*/
|
||
const globalOptions = {
|
||
debug: false, // 是否开启调试日志
|
||
defaultDuration: 3600000, // 默认缓存时长(1小时,毫秒)
|
||
enabled: true, // 是否启用缓存
|
||
appendKey: [], // 自定义缓存键追加字段(数组或函数)
|
||
jsonp: false, // 是否为 JSONP 请求(启用后会去除 URL 中的查询参数)
|
||
redisClient: false, // Redis 客户端实例(false 表示使用内存缓存)
|
||
headerBlacklist: [], // 不缓存的响应头黑名单
|
||
statusCodes: {
|
||
include: [], // 仅缓存这些状态码(空数组表示不限制)
|
||
exclude: [], // 排除这些状态码
|
||
},
|
||
events: {
|
||
expire: undefined, // 缓存过期时的回调函数
|
||
},
|
||
headers: {}, // 强制覆盖的响应头(如 'cache-control': 'no-cache')
|
||
trackPerformance: false, // 是否跟踪缓存命中率(会增加内存消耗)
|
||
};
|
||
|
||
const middlewareOptions = []; // 所有中间件的选项注册表
|
||
const instance = this;
|
||
let index = null; // 缓存索引:{ all: [key...], groups: { groupName: [key...] } }
|
||
const timers = {}; // 自动过期定时器集合 { key: setTimeout ID }
|
||
const performanceArray = []; // 缓存命中率统计数组
|
||
|
||
// 将当前实例注册到全局实例列表
|
||
instances.push(this);
|
||
this.id = instances.length;
|
||
|
||
/**
|
||
* 调试日志输出函数
|
||
* 仅在 debug 模式或环境变量 DEBUG 包含 'apicache' 时输出
|
||
* @param {...*} args - 日志参数
|
||
*/
|
||
function debug(a, b, c, d) {
|
||
const arr = ['\x1B[36m[apicache]\x1B[0m', a, b, c, d].filter((arg) => {
|
||
return arg !== undefined;
|
||
});
|
||
const debugEnv = process.env.DEBUG && process.env.DEBUG.split(',').includes('apicache');
|
||
|
||
return (globalOptions.debug || debugEnv) && console.log.apply(null, arr);
|
||
}
|
||
|
||
/**
|
||
* 判断响应是否应该被缓存
|
||
*
|
||
* 判断逻辑:
|
||
* 1. 响应对象必须存在
|
||
* 2. 如果提供了 toggle 函数,toggle 必须返回 true
|
||
* 3. 响应状态码不在 exclude 列表中
|
||
* 4. 响应状态码在 include 列表中(如果 include 非空)
|
||
*
|
||
* @param {Object} request - Express 请求对象
|
||
* @param {Object} response - Express 响应对象
|
||
* @param {function} [toggle] - 可选的缓存条件函数 (req, res) => boolean
|
||
* @returns {boolean} 是否应该缓存
|
||
*/
|
||
function shouldCacheResponse(request, response, toggle) {
|
||
const opt = globalOptions;
|
||
const codes = opt.statusCodes;
|
||
|
||
if (!response) {
|
||
return false;
|
||
}
|
||
|
||
// 自定义 toggle 函数判断
|
||
if (toggle && !toggle(request, response)) {
|
||
return false;
|
||
}
|
||
|
||
// 状态码排除列表
|
||
if (codes.exclude && codes.exclude.length && codes.exclude.includes(response.statusCode)) {
|
||
return false;
|
||
}
|
||
// 状态码包含列表(白名单模式)
|
||
if (codes.include && codes.include.length && codes.include.includes(response.statusCode)) {
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 将缓存键添加到索引中
|
||
*
|
||
* 索引结构:
|
||
* - index.all: 所有缓存键的数组(用于全量清除)
|
||
* - index.groups: 按分组名索引的缓存键数组(用于按组清除)
|
||
*
|
||
* @param {string} key - 缓存键
|
||
* @param {Object} req - Express 请求对象(req.apicacheGroup 可指定分组名)
|
||
*/
|
||
function addIndexEntries(key, req) {
|
||
const groupName = req.apicacheGroup;
|
||
|
||
if (groupName) {
|
||
debug(`group detected "${groupName}"`);
|
||
// 将键添加到分组索引(unshift 添加到数组头部)
|
||
const group = (index.groups[groupName] = index.groups[groupName] || []);
|
||
group.unshift(key);
|
||
}
|
||
|
||
// 将键添加到全局索引
|
||
index.all.unshift(key);
|
||
}
|
||
|
||
/**
|
||
* 过滤掉黑名单中的响应头
|
||
* @param {Object} headers - 原始响应头
|
||
* @returns {Object} 过滤后的响应头
|
||
*/
|
||
function filterBlacklistedHeaders(headers) {
|
||
return Object.keys(headers)
|
||
.filter((key) => {
|
||
return !globalOptions.headerBlacklist.includes(key);
|
||
})
|
||
.reduce((acc, header) => {
|
||
acc[header] = headers[header];
|
||
return acc;
|
||
}, {});
|
||
}
|
||
|
||
/**
|
||
* 创建缓存对象
|
||
*
|
||
* @param {number} status - HTTP 状态码
|
||
* @param {Object} headers - 响应头
|
||
* @param {string|Buffer} data - 响应体数据
|
||
* @param {string} encoding - 编码方式
|
||
* @returns {Object} 缓存对象
|
||
*/
|
||
function createCacheObject(status, headers, data, encoding) {
|
||
return {
|
||
status,
|
||
headers: filterBlacklistedHeaders(headers), // 过滤黑名单头
|
||
data,
|
||
encoding,
|
||
timestamp: new Date().getTime() / 1000, // Unix 时间戳(秒),用于计算剩余 max-age
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 将响应数据存入缓存
|
||
*
|
||
* 支持两种存储后端:
|
||
* - Redis: 使用 hset 存储,expire 设置过期
|
||
* - 内存: 使用 MemoryCache 存储
|
||
*
|
||
* 同时设置自动清除定时器(setTimeout 最大值限制为 2147483647ms ≈ 24.8 天)
|
||
*
|
||
* @param {string} key - 缓存键
|
||
* @param {Object} value - 缓存对象
|
||
* @param {number} duration - 缓存时长(毫秒)
|
||
*/
|
||
function cacheResponse(key, value, duration) {
|
||
const redis = globalOptions.redisClient;
|
||
const expireCallback = globalOptions.events.expire;
|
||
|
||
if (redis && redis.connected) {
|
||
// Redis 存储
|
||
try {
|
||
redis.hset(key, 'response', JSON.stringify(value));
|
||
redis.hset(key, 'duration', duration);
|
||
redis.expire(key, duration / 1000, expireCallback || (() => {}));
|
||
} catch (err) {
|
||
debug('[apicache] error in redis.hset()');
|
||
}
|
||
} else {
|
||
// 内存存储
|
||
memCache.add(key, value, duration, expireCallback);
|
||
}
|
||
|
||
// 设置自动清除定时器(限制最大值防止 setTimeout 溢出)
|
||
timers[key] = setTimeout(() => {
|
||
instance.clear(key, true);
|
||
}, Math.min(duration, 2147483647));
|
||
}
|
||
|
||
/**
|
||
* 累积响应内容到 res._apicache.content
|
||
*
|
||
* Express 的 res.write() 可能被多次调用,每次传递部分内容。
|
||
* 此函数将所有部分内容累积起来,用于最终缓存完整的响应。
|
||
*
|
||
* 支持三种内容类型:
|
||
* - string: 字符串拼接
|
||
* - Buffer: Buffer.concat 拼接
|
||
* - 其他: 直接赋值(如 JSON 对象)
|
||
*
|
||
* @param {Object} res - Express 响应对象
|
||
* @param {string|Buffer} content - 本次写入的内容
|
||
*/
|
||
function accumulateContent(res, content) {
|
||
if (content) {
|
||
if (typeof content == 'string') {
|
||
// 字符串拼接
|
||
res._apicache.content = (res._apicache.content || '') + content;
|
||
} else if (Buffer.isBuffer(content)) {
|
||
let oldContent = res._apicache.content;
|
||
|
||
// 将旧的字符串内容转为 Buffer
|
||
if (typeof oldContent === 'string') {
|
||
oldContent = !Buffer.from ? new Buffer(oldContent) : Buffer.from(oldContent);
|
||
}
|
||
|
||
if (!oldContent) {
|
||
oldContent = !Buffer.alloc ? Buffer.alloc(0) : Buffer.alloc(0);
|
||
}
|
||
|
||
// Buffer 拼接
|
||
res._apicache.content = Buffer.concat([oldContent, content], oldContent.length + content.length);
|
||
} else {
|
||
// 其他类型直接赋值
|
||
res._apicache.content = content;
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 使响应变得可缓存(Monkey-patch res 的方法)
|
||
*
|
||
* 通过劫持 res.writeHead、res.write、res.end 三个方法,
|
||
* 在响应完成时自动将内容存入缓存。
|
||
*
|
||
* 流程:
|
||
* 1. 保存原始方法引用到 res._apicache
|
||
* 2. 应用全局响应头覆盖
|
||
* 3. 重写 res.writeHead: 添加 cache-control 头,保存响应头快照
|
||
* 4. 重写 res.write: 累积部分内容
|
||
* 5. 重写 res.end: 判断是否缓存,创建缓存对象并存储
|
||
*
|
||
* @param {Object} req - Express 请求对象
|
||
* @param {Object} res - Express 响应对象
|
||
* @param {function} next - Express next 函数
|
||
* @param {string} key - 缓存键
|
||
* @param {number} duration - 缓存时长(毫秒)
|
||
* @param {string} strDuration - 原始时长字符串(用于日志)
|
||
* @param {function} [toggle] - 缓存条件函数
|
||
*/
|
||
function makeResponseCacheable(req, res, next, key, duration, strDuration, toggle) {
|
||
// 保存原始方法引用,用于后续调用
|
||
res._apicache = {
|
||
write: res.write,
|
||
writeHead: res.writeHead,
|
||
end: res.end,
|
||
cacheable: true,
|
||
content: undefined,
|
||
};
|
||
|
||
// 应用全局响应头覆盖
|
||
Object.keys(globalOptions.headers).forEach((name) => {
|
||
res.setHeader(name, globalOptions.headers[name]);
|
||
});
|
||
|
||
// 重写 res.writeHead:添加 cache-control 头并保存响应头快照
|
||
res.writeHead = function () {
|
||
if (!globalOptions.headers['cache-control']) {
|
||
if (shouldCacheResponse(req, res, toggle)) {
|
||
// 可缓存:设置 max-age
|
||
res.setHeader('cache-control', `max-age=${(duration / 1000).toFixed(0)}`);
|
||
} else {
|
||
// 不可缓存:禁止缓存
|
||
res.setHeader('cache-control', 'no-cache, no-store, must-revalidate');
|
||
}
|
||
}
|
||
|
||
// 保存响应头快照(在 writeHead 被调用后,headers 已确定)
|
||
res._apicache.headers = Object.assign({}, getSafeHeaders(res));
|
||
return res._apicache.writeHead.apply(this, arguments);
|
||
};
|
||
|
||
// 重写 res.write:累积部分内容
|
||
res.write = function (content) {
|
||
accumulateContent(res, content);
|
||
return res._apicache.write.apply(this, arguments);
|
||
};
|
||
|
||
// 重写 res.end:判断是否缓存,创建缓存对象并存储
|
||
res.end = function (content, encoding) {
|
||
if (shouldCacheResponse(req, res, toggle)) {
|
||
accumulateContent(res, content);
|
||
|
||
if (res._apicache.cacheable && res._apicache.content) {
|
||
// 添加到缓存索引
|
||
addIndexEntries(key, req);
|
||
const headers = res._apicache.headers || getSafeHeaders(res);
|
||
// 创建缓存对象并存储
|
||
const cacheObject = createCacheObject(res.statusCode, headers, res._apicache.content, encoding);
|
||
cacheResponse(key, cacheObject, duration);
|
||
|
||
// 调试日志
|
||
const elapsed = new Date() - req.apicacheTimer;
|
||
debug(`adding cache entry for "${key}" @ ${strDuration}`, logDuration(elapsed));
|
||
debug('_apicache.headers: ', res._apicache.headers);
|
||
debug('res.getHeaders(): ', getSafeHeaders(res));
|
||
debug('cacheObject: ', cacheObject);
|
||
}
|
||
}
|
||
|
||
// 调用原始 res.end
|
||
return res._apicache.end.apply(this, arguments);
|
||
};
|
||
|
||
next();
|
||
}
|
||
|
||
/**
|
||
* 发送缓存的响应
|
||
*
|
||
* 处理逻辑:
|
||
* 1. 检查 toggle 函数是否允许返回缓存
|
||
* 2. 合并响应头,正确递减 max-age(减去已过去的时间)
|
||
* 3. 反序列化 Buffer 数据
|
||
* 4. 检查 ETag/If-None-Match,满足条件返回 304
|
||
* 5. 返回完整的缓存响应
|
||
*
|
||
* @param {Object} request - Express 请求对象
|
||
* @param {Object} response - Express 响应对象
|
||
* @param {Object} cacheObject - 缓存对象
|
||
* @param {function} [toggle] - 缓存条件函数
|
||
* @param {function} next - Express next 函数
|
||
* @param {number} duration - 缓存时长(毫秒)
|
||
*/
|
||
function sendCachedResponse(request, response, cacheObject, toggle, next, duration) {
|
||
if (toggle && !toggle(request, response)) {
|
||
return next();
|
||
}
|
||
|
||
const headers = getSafeHeaders(response);
|
||
|
||
// 合并缓存的响应头,并正确递减 max-age
|
||
Object.assign(headers, filterBlacklistedHeaders(cacheObject.headers || {}), {
|
||
// max-age = 原始时长 - 已过去的时间,最小为 0
|
||
'cache-control': `max-age=${Math.max(0, (duration / 1000 - (new Date().getTime() / 1000 - cacheObject.timestamp)).toFixed(0))}`,
|
||
});
|
||
|
||
// 反序列化 Buffer 数据(JSON 序列化后的 Buffer 会变成 { type: 'Buffer', data: [...] } 格式)
|
||
let data = cacheObject.data;
|
||
if (data && data.type === 'Buffer') {
|
||
data = typeof data.data === 'number' ? new Buffer.alloc(data.data) : new Buffer.from(data.data);
|
||
}
|
||
|
||
// ETag 协商缓存:如果请求的 If-None-Match 与缓存的 ETag 匹配,返回 304
|
||
const cachedEtag = cacheObject.headers.etag;
|
||
const requestEtag = request.headers['if-none-match'];
|
||
|
||
if (requestEtag && cachedEtag === requestEtag) {
|
||
response.writeHead(304, headers);
|
||
return response.end();
|
||
}
|
||
|
||
// 返回缓存的响应
|
||
response.writeHead(cacheObject.status || 200, headers);
|
||
|
||
return response.end(data, cacheObject.encoding);
|
||
}
|
||
|
||
/**
|
||
* 同步全局选项到所有中间件实例
|
||
* 将 globalOptions 合并到每个中间件的 opt 中,并应用各自的 localOptions
|
||
*/
|
||
function syncOptions() {
|
||
for (const i in middlewareOptions) {
|
||
Object.assign(middlewareOptions[i].options, globalOptions, middlewareOptions[i].localOptions);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 清除缓存
|
||
*
|
||
* 支持三种清除模式:
|
||
* 1. 按分组名清除: target 为分组名,清除该分组下的所有缓存
|
||
* 2. 按缓存键清除: target 为具体的缓存键
|
||
* 3. 全部清除: target 为空,清除所有缓存
|
||
*
|
||
* @param {string} [target] - 分组名或缓存键,为空则清除全部
|
||
* @param {boolean} [isAutomatic] - 是否为自动过期清除(用于日志区分)
|
||
* @returns {Object} 当前缓存索引
|
||
*/
|
||
this.clear = function (target, isAutomatic) {
|
||
const group = index.groups[target];
|
||
const redis = globalOptions.redisClient;
|
||
|
||
if (group) {
|
||
// 模式1: 按分组名清除
|
||
debug(`clearing group "${target}"`);
|
||
|
||
group.forEach((key) => {
|
||
debug(`clearing cached entry for "${key}"`);
|
||
clearTimeout(timers[key]);
|
||
delete timers[key];
|
||
if (!globalOptions.redisClient) {
|
||
memCache.delete(key);
|
||
} else {
|
||
try {
|
||
redis.del(key);
|
||
} catch (err) {
|
||
console.log(`[apicache] error in redis.del("${key}")`);
|
||
}
|
||
}
|
||
index.all = index.all.filter(doesntMatch(key));
|
||
});
|
||
|
||
delete index.groups[target];
|
||
} else if (target) {
|
||
// 模式2: 按缓存键清除
|
||
debug(`clearing ${isAutomatic ? 'expired' : 'cached'} entry for "${target}"`);
|
||
clearTimeout(timers[target]);
|
||
delete timers[target];
|
||
|
||
if (!redis) {
|
||
memCache.delete(target);
|
||
} else {
|
||
try {
|
||
redis.del(target);
|
||
} catch (err) {
|
||
console.log(`[apicache] error in redis.del("${target}")`);
|
||
}
|
||
}
|
||
|
||
// 从全局索引中移除
|
||
index.all = index.all.filter(doesntMatch(target));
|
||
|
||
// 从所有分组中移除,并清理空分组
|
||
Object.keys(index.groups).forEach((groupName) => {
|
||
index.groups[groupName] = index.groups[groupName].filter(doesntMatch(target));
|
||
|
||
if (!index.groups[groupName].length) {
|
||
delete index.groups[groupName];
|
||
}
|
||
});
|
||
} else {
|
||
// 模式3: 全部清除
|
||
debug('clearing entire index');
|
||
|
||
Object.keys(timers).forEach((key) => {
|
||
clearTimeout(timers[key]);
|
||
delete timers[key];
|
||
});
|
||
|
||
if (!redis) {
|
||
memCache.clear();
|
||
} else {
|
||
// 逐个清除 Redis 键(避免误删非 apicache 的条目)
|
||
index.all.forEach((key) => {
|
||
try {
|
||
redis.del(key);
|
||
} catch (err) {
|
||
console.log(`[apicache] error in redis.del("${key}")`);
|
||
}
|
||
});
|
||
}
|
||
this.resetIndex();
|
||
}
|
||
|
||
return this.getIndex();
|
||
};
|
||
|
||
/**
|
||
* 解析时长字符串为毫秒数
|
||
*
|
||
* 支持的格式:
|
||
* - 数字: 直接返回(毫秒)
|
||
* - 字符串: "2 minutes"、"1 hour"、"30 seconds"、"500ms"
|
||
* - 数字部分支持小数点(如 "1.5 hours")
|
||
* - 单位支持复数(自动去掉末尾的 s)
|
||
* - "m" 会被解析为 "ms"(毫秒缩写)
|
||
*
|
||
* @param {number|string} duration - 时长(数字或字符串)
|
||
* @param {number} defaultDuration - 默认时长(解析失败时使用)
|
||
* @returns {number} 时长(毫秒)
|
||
*/
|
||
function parseDuration(duration, defaultDuration) {
|
||
if (typeof duration === 'number') {
|
||
return duration;
|
||
}
|
||
|
||
if (typeof duration === 'string') {
|
||
const split = duration.match(/^([\d\.,]+)\s?(\w+)$/);
|
||
|
||
if (split.length === 3) {
|
||
const len = Number.parseFloat(split[1]);
|
||
let unit = split[2].replace(/s$/i, '').toLowerCase();
|
||
if (unit === 'm') {
|
||
unit = 'ms';
|
||
}
|
||
|
||
return (len || 1) * (t[unit] || 0);
|
||
}
|
||
}
|
||
|
||
return defaultDuration;
|
||
}
|
||
|
||
/**
|
||
* 获取时长(公开方法)
|
||
* @param {number|string} duration - 时长
|
||
* @returns {number} 解析后的毫秒数
|
||
*/
|
||
this.getDuration = function (duration) {
|
||
return parseDuration(duration, globalOptions.defaultDuration);
|
||
};
|
||
|
||
/**
|
||
* 获取缓存性能统计(命中率)
|
||
*
|
||
* 可用于创建性能监控接口:
|
||
* app.get('/api/cache/performance', (req, res) => {
|
||
* res.json(apicache.getPerformance())
|
||
* })
|
||
*
|
||
* @returns {Array<Object>} 每个中间件的性能报告数组
|
||
*/
|
||
this.getPerformance = function () {
|
||
return performanceArray.map((p) => {
|
||
return p.report();
|
||
});
|
||
};
|
||
|
||
/**
|
||
* 获取缓存索引
|
||
* @param {string} [group] - 可选的分组名,不传则返回完整索引
|
||
* @returns {Object} 缓存索引
|
||
*/
|
||
this.getIndex = function (group) {
|
||
if (group) {
|
||
return index.groups[group];
|
||
} else {
|
||
return index;
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 创建缓存中间件
|
||
*
|
||
* 这是主要的公开 API,返回一个 Express 中间件函数。
|
||
* 中间件会拦截请求,检查缓存,命中则直接返回缓存响应,未命中则继续处理并缓存响应。
|
||
*
|
||
* @param {string|number} strDuration - 缓存时长(如 "2 minutes" 或 120000)
|
||
* @param {function} [middlewareToggle] - 中间件级别的缓存条件函数 (req, res) => boolean
|
||
* @param {Object} [localOptions] - 本中间件的局部配置(覆盖全局配置)
|
||
* @returns {function} Express 中间件函数
|
||
*/
|
||
this.middleware = function cache(strDuration, middlewareToggle, localOptions) {
|
||
const duration = instance.getDuration(strDuration);
|
||
const opt = {};
|
||
|
||
// 注册中间件选项
|
||
middlewareOptions.push({
|
||
options: opt,
|
||
});
|
||
|
||
/**
|
||
* 更新或获取中间件选项
|
||
* @param {Object} [localOptions] - 局部配置
|
||
* @returns {Object} 当前中间件的配置
|
||
*/
|
||
const options = function (localOptions) {
|
||
if (localOptions) {
|
||
middlewareOptions.find((middleware) => {
|
||
return middleware.options === opt;
|
||
}).localOptions = localOptions;
|
||
}
|
||
|
||
syncOptions();
|
||
|
||
return opt;
|
||
};
|
||
|
||
options(localOptions);
|
||
|
||
/**
|
||
* 空操作的性能统计类(不跟踪性能时使用)
|
||
* 所有方法都是空函数,避免条件判断开销
|
||
*/
|
||
function NOOPCachePerformance() {
|
||
this.report = this.hit = this.miss = function () {}; // noop;
|
||
}
|
||
|
||
/**
|
||
* 缓存命中率统计类
|
||
*
|
||
* 使用位压缩技术高效存储历史命中/未命中记录:
|
||
* - 每个 Uint8Array 元素存储 4 条记录
|
||
* - 每条记录占 2 bit: 00=无记录, 01=命中, 10=未命中
|
||
* - 支持 4 个时间窗口: 最近 100/1000/10000/100000 次请求
|
||
*/
|
||
function CachePerformance() {
|
||
/**
|
||
* 最近 100 次请求的命中记录(位压缩存储)
|
||
* @type {Uint8Array}
|
||
*/
|
||
this.hitsLast100 = new Uint8Array(100 / 4); // each hit is 2 bits
|
||
|
||
/**
|
||
* 最近 1000 次请求的命中记录
|
||
* @type {Uint8Array}
|
||
*/
|
||
this.hitsLast1000 = new Uint8Array(1000 / 4); // each hit is 2 bits
|
||
|
||
/**
|
||
* 最近 10000 次请求的命中记录
|
||
* @type {Uint8Array}
|
||
*/
|
||
this.hitsLast10000 = new Uint8Array(10000 / 4); // each hit is 2 bits
|
||
|
||
/**
|
||
* 最近 100000 次请求的命中记录
|
||
* @type {Uint8Array}
|
||
*/
|
||
this.hitsLast100000 = new Uint8Array(100000 / 4); // each hit is 2 bits
|
||
|
||
/**
|
||
* 总请求次数(自服务器启动以来)
|
||
* @type {number}
|
||
*/
|
||
this.callCount = 0;
|
||
|
||
/**
|
||
* 总命中次数
|
||
* @type {number}
|
||
*/
|
||
this.hitCount = 0;
|
||
|
||
/**
|
||
* 最后一次缓存命中的键(用于识别对应路由)
|
||
* @type {string|null}
|
||
*/
|
||
this.lastCacheHit = null;
|
||
|
||
/**
|
||
* 最后一次缓存未命中的键
|
||
* @type {string|null}
|
||
*/
|
||
this.lastCacheMiss = null;
|
||
|
||
/**
|
||
* 生成性能统计报告
|
||
* @returns {Object} 包含各项统计指标的报告对象
|
||
*/
|
||
this.report = function () {
|
||
return {
|
||
lastCacheHit: this.lastCacheHit,
|
||
lastCacheMiss: this.lastCacheMiss,
|
||
callCount: this.callCount,
|
||
hitCount: this.hitCount,
|
||
missCount: this.callCount - this.hitCount,
|
||
hitRate: this.callCount == 0 ? null : this.hitCount / this.callCount,
|
||
hitRateLast100: this.hitRate(this.hitsLast100),
|
||
hitRateLast1000: this.hitRate(this.hitsLast1000),
|
||
hitRateLast10000: this.hitRate(this.hitsLast10000),
|
||
hitRateLast100000: this.hitRate(this.hitsLast100000),
|
||
};
|
||
};
|
||
|
||
/**
|
||
* 从位压缩数组中计算命中率
|
||
*
|
||
* 遍历每个字节的 4 个 2-bit 记录:
|
||
* - 01 (1) = 命中
|
||
* - 10 (2) = 未命中
|
||
* - 00 (0) = 无记录(忽略)
|
||
*
|
||
* @param {Uint8Array} array - 位压缩的命中记录数组
|
||
* @returns {number|null} 命中率(0~1),无记录时返回 null
|
||
*/
|
||
this.hitRate = function (array) {
|
||
let hits = 0;
|
||
let misses = 0;
|
||
for (let i = 0; i < array.length; i++) {
|
||
let n8 = array[i];
|
||
for (j = 0; j < 4; j++) {
|
||
switch (n8 & 3) {
|
||
case 1:
|
||
hits++;
|
||
break;
|
||
case 2:
|
||
misses++;
|
||
break;
|
||
}
|
||
n8 >>= 2;
|
||
}
|
||
}
|
||
const total = hits + misses;
|
||
if (total == 0) {
|
||
return null;
|
||
}
|
||
return hits / total;
|
||
};
|
||
|
||
/**
|
||
* 在位压缩数组中记录一次命中或未命中
|
||
*
|
||
* 编码规则(每个 2-bit 记录):
|
||
* - 00: 无记录
|
||
* - 01: 命中
|
||
* - 10: 未命中
|
||
*
|
||
* @param {Uint8Array} array - 位压缩数组
|
||
* @param {boolean} hit - true=命中, false=未命中
|
||
*/
|
||
this.recordHitInArray = function (array, hit) {
|
||
const arrayIndex = ~~(this.callCount / 4) % array.length; // 数组索引
|
||
const bitOffset = (this.callCount % 4) * 2; // 位偏移(每 2 bit 一条记录)
|
||
const clearMask = ~(3 << bitOffset); // 清除掩码
|
||
const record = (hit ? 1 : 2) << bitOffset; // 编码记录
|
||
array[arrayIndex] = (array[arrayIndex] & clearMask) | record;
|
||
};
|
||
|
||
/**
|
||
* 记录一次命中/未命中到所有时间窗口的数组
|
||
* @param {boolean} hit - true=命中, false=未命中
|
||
*/
|
||
this.recordHit = function (hit) {
|
||
this.recordHitInArray(this.hitsLast100, hit);
|
||
this.recordHitInArray(this.hitsLast1000, hit);
|
||
this.recordHitInArray(this.hitsLast10000, hit);
|
||
this.recordHitInArray(this.hitsLast100000, hit);
|
||
if (hit) {
|
||
this.hitCount++;
|
||
}
|
||
this.callCount++;
|
||
};
|
||
|
||
/**
|
||
* 记录一次缓存命中
|
||
* @param {string} key - 命中的缓存键
|
||
*/
|
||
this.hit = function (key) {
|
||
this.recordHit(true);
|
||
this.lastCacheHit = key;
|
||
};
|
||
|
||
/**
|
||
* 记录一次缓存未命中
|
||
* @param {string} key - 未命中的缓存键
|
||
*/
|
||
this.miss = function (key) {
|
||
this.recordHit(false);
|
||
this.lastCacheMiss = key;
|
||
};
|
||
}
|
||
|
||
// 根据配置决定使用真实的性能统计还是空操作
|
||
const perf = globalOptions.trackPerformance ? new CachePerformance() : new NOOPCachePerformance();
|
||
|
||
performanceArray.push(perf);
|
||
|
||
/**
|
||
* 核心缓存中间件函数
|
||
*
|
||
* 请求处理流程:
|
||
* 1. 检查缓存是否启用、是否有 bypass 头
|
||
* 2. 生成缓存键(hostname + URL + appendKey)
|
||
* 3. 尝试从内存缓存获取 → 命中则直接返回
|
||
* 4. 尝试从 Redis 获取 → 命中则直接返回
|
||
* 5. 未命中 → 劫持 res 方法,等待响应完成后缓存
|
||
*/
|
||
const cache = function (req, res, next) {
|
||
/**
|
||
* 跳过缓存,直接进入下一个中间件
|
||
*/
|
||
function bypass() {
|
||
debug('bypass detected, skipping cache.');
|
||
return next();
|
||
}
|
||
|
||
// 初始跳过检查
|
||
if (!opt.enabled) {
|
||
return bypass();
|
||
}
|
||
// 通过请求头强制跳过缓存
|
||
if (req.headers['x-apicache-bypass'] || req.headers['x-apicache-force-fetch']) {
|
||
return bypass();
|
||
}
|
||
|
||
// 记录请求开始时间(用于计算耗时)
|
||
req.apicacheTimer = new Date();
|
||
|
||
// 生成缓存键: hostname + URL
|
||
// Express 4.x 中 req.url 可能是路由相对路径,使用 originalUrl 获取完整路径
|
||
let key = req.hostname + (req.originalUrl || req.url);
|
||
// JSONP 模式:去除查询参数(避免不同的 callback 参数产生不同的缓存键)
|
||
if (opt.jsonp) {
|
||
key = url.parse(key).pathname;
|
||
}
|
||
|
||
// 追加自定义缓存键(支持函数或属性路径数组)
|
||
if (typeof opt.appendKey === 'function') {
|
||
key += `$$appendKey=${opt.appendKey(req, res)}`;
|
||
} else if (opt.appendKey.length > 0) {
|
||
let appendKey = req;
|
||
|
||
for (let i = 0; i < opt.appendKey.length; i++) {
|
||
appendKey = appendKey[opt.appendKey[i]];
|
||
}
|
||
key += `$$appendKey=${appendKey}`;
|
||
}
|
||
|
||
// 尝试从缓存获取
|
||
const redis = opt.redisClient;
|
||
const cached = !redis ? memCache.getValue(key) : null;
|
||
|
||
// 内存缓存命中
|
||
if (cached) {
|
||
const elapsed = new Date() - req.apicacheTimer;
|
||
debug('sending cached (memory-cache) version of', key, logDuration(elapsed));
|
||
|
||
perf.hit(key);
|
||
return sendCachedResponse(req, res, cached, middlewareToggle, next, duration);
|
||
}
|
||
|
||
// Redis 缓存命中
|
||
if (redis && redis.connected) {
|
||
try {
|
||
redis.hgetall(key, (err, obj) => {
|
||
if (!err && obj && obj.response) {
|
||
const elapsed = new Date() - req.apicacheTimer;
|
||
debug('sending cached (redis) version of', key, logDuration(elapsed));
|
||
|
||
perf.hit(key);
|
||
return sendCachedResponse(req, res, JSON.parse(obj.response), middlewareToggle, next, duration);
|
||
} else {
|
||
// Redis 未命中,进入缓存写入流程
|
||
perf.miss(key);
|
||
return makeResponseCacheable(req, res, next, key, duration, strDuration, middlewareToggle);
|
||
}
|
||
});
|
||
} catch (err) {
|
||
// Redis 出错时降级为未命中
|
||
perf.miss(key);
|
||
return makeResponseCacheable(req, res, next, key, duration, strDuration, middlewareToggle);
|
||
}
|
||
} else {
|
||
// 无 Redis,内存也未命中,进入缓存写入流程
|
||
perf.miss(key);
|
||
return makeResponseCacheable(req, res, next, key, duration, strDuration, middlewareToggle);
|
||
}
|
||
};
|
||
|
||
// 暴露 options 函数,允许运行时修改配置
|
||
cache.options = options;
|
||
|
||
return cache;
|
||
};
|
||
|
||
/**
|
||
* 设置或获取全局配置选项
|
||
*
|
||
* @param {Object} [options] - 配置对象,不传则返回当前配置
|
||
* @returns {ApiCache|Object} 传入 options 时返回 this(支持链式调用),否则返回当前配置
|
||
*/
|
||
this.options = function (options) {
|
||
if (options) {
|
||
Object.assign(globalOptions, options);
|
||
syncOptions();
|
||
|
||
if ('defaultDuration' in options) {
|
||
// 将默认时长转换为毫秒数
|
||
globalOptions.defaultDuration = parseDuration(globalOptions.defaultDuration, 3600000);
|
||
}
|
||
|
||
if (globalOptions.trackPerformance) {
|
||
debug('WARNING: using trackPerformance flag can cause high memory usage!');
|
||
}
|
||
|
||
return this;
|
||
} else {
|
||
return globalOptions;
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 重置缓存索引(清空所有索引记录)
|
||
*/
|
||
this.resetIndex = function () {
|
||
index = {
|
||
all: [], // 所有缓存键
|
||
groups: {}, // 分组索引 { groupName: [key...] }
|
||
};
|
||
};
|
||
|
||
/**
|
||
* 创建新的 ApiCache 实例(可选配置)
|
||
* @param {Object} [config] - 初始配置
|
||
* @returns {ApiCache} 新实例
|
||
*/
|
||
this.newInstance = function (config) {
|
||
const instance = new ApiCache();
|
||
|
||
if (config) {
|
||
instance.options(config);
|
||
}
|
||
|
||
return instance;
|
||
};
|
||
|
||
/**
|
||
* 克隆当前实例(复制配置)
|
||
* @returns {ApiCache} 克隆的实例
|
||
*/
|
||
this.clone = function () {
|
||
return this.newInstance(this.options());
|
||
};
|
||
|
||
// 初始化缓存索引
|
||
this.resetIndex();
|
||
}
|
||
|
||
// 导出单例实例(整个应用共享一个缓存管理器)
|
||
module.exports = new ApiCache();
|