UNPKG

@ghini/kit

Version:

js practical tools to assist efficient development

605 lines (585 loc) 19.1 kB
export { xredis, redis }; import Redis from "ioredis"; import lua from "./lua.js"; // import { sync } from "./sync.js"; /** * 返回普通的ioredis实例,就不用再额外写ioredis的导入 * @param {...any} argv * @returns */ function redis(...argv) { const redis = new Redis(...argv); redis.on("error", (err) => console.error(err)); return redis; } /* 使用lua,完成联表查询的redis封装 1. 找到pattern='plan:*'的hash中stop字段为1的key 找到pattern='user:*'的hash中plans字段为mSIusSz2Ku3YUWxB85cQa1的key 2. */ function xredis(...argv) { const host = argv[0]?.host || "127.0.0.1"; const redis = new Redis(...argv); redis.on("error", (err) => console.error("Redis错误:", host, err)); // redis.on("connect", () => console.log(`Redis ${argv[0].host} 已连接`)); // redis.on("ready", () => console.log(`Redis ${argv[0].host} 已就绪`)); // redis.on("close", () => console.log(`Redis ${argv[0].host} 连接关闭`)); return Object.assign(redis, { host, scankey, scankeys, sync, avatar, hsql, hquery, sum, join, num, }); } /** * 化身,全部做相同操作,包括自身(内部tmparr操作新数组避免了引用对外部rearr的影响) * @param {Redis[]} rearr * @param {*} fn 参数re进行操作 * @returns {Promise<any[]>} */ async function avatar(rearr, fn) { const tmparr = [...rearr, this]; // 过滤掉未就绪的 Redis 实例 const availableRedis = tmparr.filter((redis) => { return redis.status === "ready"; }); // 如果有实例被过滤掉,打印日志 if (availableRedis.length < tmparr.length) { const filteredHosts = tmparr .filter((redis) => redis.status !== "ready") .map((redis) => redis.host); console.warn( `avatar 跳过(避免阻塞): ${filteredHosts.join(", ")}` ); } return Promise.all(availableRedis.map(fn)); // return Promise.all( // tmparr.map(async (re) => { // try{ // return await fn(re); // }catch(err){ // console.error(`avatar ${re.host} ${err}`); // return null; // } // }) // ); } /** * 返回键数量 * @param {*} pattern "user:*" * @returns {Promise<number>} */ async function num(pattern) { return this.eval(`return #redis.call('keys', ARGV[1])`, 0, pattern); } /** * 联表查询 * 由query1+条件+query2构成 * @param {*} aa 基本都是xx:*结构,其*部分作为关联字段值 * @param {*} bb 标准query options * @param {*} cc 基本都是xx:*结构 * @param {*} dd _fields,dd[0]作为联表字段 * 更复杂的联表后面等有需求再改进补充 * @returns * * example: t3( "plan:*", { download: [">", 1000000], _sortby: "download", _sort: "desc", _limit: 10, _fields: ["download", "upload"], }, "user:*", ["plans", "regdate", "pwd"] ); * 此联表现阶段能很好的将plan和user连接起来. */ async function join(aa, bb, cc, dd) { let res = await this.hquery(aa, bb); // 找到他们的账号 let res1 = await this.hquery(cc, { [dd[0]]: res.map((v) => v[0].split(":")[1]), _fields: dd, }); // 完成联表 res.forEach((v, i) => { const tmp = res1.filter((v1) => v1[1] == v[0].split(":")[1])[0]; // v.push(tmp[0]); res[i] = [...v, ...tmp]; }); return res; } /** * hsql因为要支持各种复杂条件,可以() && ||一起用,因此对宽松多变的通配符不好支持 * hsql和hquery两个函数都过于复杂了,建议对期望结果进行校验 * @param {string} pattern 键模式,如 'plan:*' * @param {string} expression 表达式条件,如 '(stop<>1&&remain=null)||remain>0' * @param {Object} options 可选参数 * @param {number} options.limit 限制返回的结果数量 * @param {string} options.sort 排序规格,如 'upload desc' * @param {string[]} options.fields 要返回的字段列表 * @returns {Promise<Array>} 查询结果 */ async function hsql(pattern, expression, options = {}) { if (!pattern || typeof pattern !== 'string') { throw new Error('Pattern must be a string'); } if (!expression || typeof expression !== 'string') { throw new Error('Expression must be a string'); } const { sort, limit, fields } = options; // 构建参数数组 const params = [ pattern, // 键模式 expression, // 表达式条件 sort || '', // 排序规格 limit || 0, // 限制结果数量 fields ? fields.join(',') : '' // 要返回的字段 ]; // 调用Lua脚本执行查询 const result = await this.eval(lua.hsql, 0, ...params); // 解析结果 return JSON.parse(result); } /** * hquery则相反,处理相对没那么复杂,全是and或or,对字段和值的通配符都进行支持 * hsql和hquery两个函数都过于复杂了,建议对期望结果进行校验 * @param {*} pattern * @param {*} options 对于不等于操作符<>,如果字段不存在(并且比较值不是NULL),这个条件会被视为满足;其它运算符都要求字段必须存在;feild:null则是专门找空或不存在的字段匹配. * { _sort, _limit, _fields }为预设查询字段,js只负责传参,在lua中实现功能;其它为匹配字段 * _fields :["download","upload"] 指定返回字段 最终返回二维数组[[key,download,upload]];不指定则返回key的一维数组 * _sort :"createDate desc" "createDate" "desc", 当_sort没设置时不排序;使用split(' ')得到的数组长1时,检测字符是否为desc或asc,是则对key排序;否则认为是指定的sortby默认asc排序;得到的数组长2时,第一个sortby(无效则对key排序) 第二个sort(desc|asc,其它无效字符为asc) * _limit 限制返回条数 * @param {*} logic "and"|"or" * @returns */ async function hquery(pattern, options = {}, logic="and") { // 使用_feilds之前只有一个值,所以返回一维数组,使用_fields之后返回二维数组 const { _sort, _limit, _fields, ...filters } = options; // 处理排序参数 let sort = ""; if (typeof _sort === "string") { sort = _sort.trim(); } else if (options._sortby) { // 向后兼容 _sortby 参数 sort = `${options._sortby} ${options._sort || "asc"}`.trim(); } const filterArray = []; for (const [key, value] of Object.entries(filters)) { if (Array.isArray(value)) { const isOperatorArray = value[0] && [">", "<", ">=", "<=", "=", "<>", "!="].includes(value[0]); if (isOperatorArray) { // 对于长数字,确保使用字符串形式 let finalValue = value[1]; if (finalValue === null || finalValue === undefined) { finalValue = "NULL"; } else { finalValue = finalValue.toString(); } // 将 != 转换为 <> 以便在 Lua 中统一处理 const operator = value[0] === "!=" ? "<>" : value[0]; filterArray.push(key, operator, finalValue); } else { // 确保数组中的长数字也转换为字符串 const safeValues = value.map((v) => v === null || v === undefined ? "NULL" : v.toString() ); filterArray.push(key, "IN", JSON.stringify(safeValues)); } } else if (typeof value === "string") { // 检查是否为不等于操作 if (value.startsWith("!=") || value.startsWith("<>")) { const operator = "<>"; // 统一使用 <> 作为不等于操作符 const val = value.substring(value.startsWith("!=") ? 2 : 2).trim(); filterArray.push(key, operator, val || ""); } // 检查是否包含表达式操作符 else if (value.includes('>') || value.includes('<') || value.includes('=') || value.includes('&&') || value.includes('||')) { filterArray.push(key, "EXPR", value); } // 检查是否包含通配符 else if (value.includes("*")) { // 添加对字符串通配符的支持 filterArray.push(key, "LIKE", value); } // 默认为精确匹配 else { filterArray.push(key, "=", value); } } else if (value === null || value === undefined) { // 添加对NULL值的支持 - 明确使用IS NULL操作符 filterArray.push(key, "IS", "NULL"); } else { // 对普通值进行处理,确保长数字转换为字符串 const safeValue = typeof value === "number" && value > Number.MAX_SAFE_INTEGER ? value.toString() : value; filterArray.push(key, "=", safeValue); } } const params = [ pattern, sort, // 合并后的排序参数 _limit || 0, // limit 参数前移 _fields ? _fields.join(",") : "", // fields 参数前移 filterArray.length, // filters 数量 logic, // 添加 logic 参数,传递给 Lua 脚本 ...filterArray, // filters 数组 ]; const result = await this.eval(lua.hquery, 0, ...params); return JSON.parse(result); } /** * * @param {*} pattern * @param {*} fields * @returns */ async function sum(pattern, fields) { try { if (!pattern || typeof pattern !== "string") { throw new Error("Pattern must be a string"); } if (!Array.isArray(fields)) { throw new Error("Fields must be an array"); } const result = await this.eval(lua.sum, 0, pattern, JSON.stringify(fields)); // 提取并打印调试信息 const resultObj = {}; for (const [key, value] of result) { if (key === "debug") { console.log("Debug info:", JSON.parse(value)); } else { resultObj[key] = value; } } return resultObj; } catch (error) { console.error("Sum error:", error); throw error; } } // 用scan找到首个匹配的key返回 async function scankey(pattern) { let cursor = "0"; const batchSize = 5000; do { // 使用 SCAN 命令带 MATCH 和 COUNT 参数 const [newCursor, keys] = await this.scan( cursor, "MATCH", pattern, "COUNT", batchSize ); // 如果找到匹配的 keys,返回第一个匹配的 key if (keys.length > 0) { return keys[0]; } // 更新游标 cursor = newCursor; } while (cursor !== "0"); return null; } async function scankeys(pattern) { let cursor = "0"; const batchSize = 5000; const allKeys = []; do { // 使用 SCAN 命令带 MATCH 和 COUNT 参数 const [newCursor, keys] = await this.scan( cursor, "MATCH", pattern, "COUNT", batchSize ); // 合并找到的匹配 keys allKeys.push(...keys); // 更新游标 cursor = newCursor; } while (cursor !== "0"); return allKeys; } const FILTER_SCRIPTS = { // string 类型的过滤脚本 string: ` local key = KEYS[1] local pattern = ARGV[1] local value = redis.call('GET', key) if value == false then return nil end -- 如果 pattern 为空,返回所有;否则进行匹配 if pattern == '' or string.match(value, pattern) then return value end return nil `, // hash 类型的过滤脚本 hash: ` local key = KEYS[1] local fields = cjson.decode(ARGV[1]) -- 如果字段列表为空,返回所有字段 if #fields == 0 then return redis.call('HGETALL', key) end -- 否则处理指定字段 local result = {} local allFields = redis.call('HKEYS', key) for _, field in ipairs(fields) do -- 检查是否包含通配符 local isPattern = string.find(field, '[%*%?]') ~= nil if isPattern then -- 对于包含通配符的情况,使用模式匹配 for _, existingField in ipairs(allFields) do if string.match(existingField, field) then local value = redis.call('HGET', key, existingField) if value ~= false then table.insert(result, existingField) table.insert(result, value) end end end else -- 对于不包含通配符的情况,使用精确匹配 local value = redis.call('HGET', key, field) if value ~= false then table.insert(result, field) table.insert(result, value) end end end return result `, // set 类型的过滤脚本 set: ` local key = KEYS[1] local members = cjson.decode(ARGV[1]) -- 如果成员列表为空,返回所有成员 if #members == 0 then return redis.call('SMEMBERS', key) end -- 否则只返回指定成员中存在的部分 local result = {} for _, member in ipairs(members) do if redis.call('SISMEMBER', key, member) == 1 then table.insert(result, member) end end return result `, // zset 类型的过滤脚本 zset: ` local key = KEYS[1] local members = cjson.decode(ARGV[1]) -- 如果成员列表为空,返回所有成员和分数 if #members == 0 then return redis.call('ZRANGE', key, 0, -1, 'WITHSCORES') end -- 否则只返回指定成员及其分数 local result = {} for _, member in ipairs(members) do local score = redis.call('ZSCORE', key, member) if score ~= false then table.insert(result, member) table.insert(result, score) end end return result `, // list 类型的过滤脚本 list: ` local key = KEYS[1] local values = cjson.decode(ARGV[1]) -- 如果值列表为空,返回所有元素 if #values == 0 then return redis.call('LRANGE', key, 0, -1) end -- 否则只返回匹配的元素(保持原顺序) local all = redis.call('LRANGE', key, 0, -1) local result = {} local valueSet = {} for _, v in ipairs(values) do valueSet[v] = true end for _, v in ipairs(all) do if valueSet[v] then table.insert(result, v) end end return result `, }; /** * * @param {*} targetRedisList [redis,...] * @param {*} pattern 精确匹配及通配符匹配,可以单条或多条的数组,支持混合使用 ['a','b','c',...] "plan:*|user:*" * @param {*} options {hash:['a','b],set:['some','field']} * 对于指定的比如hash set,就会挑选出对应的field,没指定的会返回所有(只要匹配到) * * @returns */ async function sync(targetRedisList, pattern, options = {}) { // this是redis const batch = options.batch || 2000; if (!Array.isArray(targetRedisList)) { if (targetRedisList instanceof Redis) { targetRedisList = [targetRedisList]; } else { console.error("Need Redis clients"); return; } } else if (targetRedisList.length === 0) { console.error("Need Redis clients"); return; } // 预加载所有Lua脚本,使用返回的hash调用 const scriptShas = {}; for (const [type, script] of Object.entries(FILTER_SCRIPTS)) { scriptShas[type] = await this.script("LOAD", script); } const patterns = Array.isArray(pattern) ? pattern : [pattern]; // 判断是否所有的pattern都是精确匹配(不包含通配符) const allExactMatch = patterns.every( (p) => !p.includes("*") && !p.includes("?") ); // 用于存储所有匹配到的唯一键 const uniqueKeys = new Set(); if (allExactMatch) { // 对于精确匹配,直接使用EXISTS检查键是否存在 for (const key of patterns) { const exists = await this.exists(key); if (exists) { uniqueKeys.add(key); } } } else { // 对于模式匹配,使用SCAN let cursor = "0"; for (const currentPattern of patterns) { if (!currentPattern) continue; if (!currentPattern.includes("*") && !currentPattern.includes("?")) { // 对于混合情况中的精确匹配,直接检查存在性 const exists = await this.exists(currentPattern); if (exists) { uniqueKeys.add(currentPattern); } continue; } do { const [newCursor, keys] = await this.scan( cursor, "MATCH", currentPattern, "COUNT", batch ); cursor = newCursor; keys.forEach((key) => uniqueKeys.add(key)); } while (cursor !== "0"); } } const allKeys = Array.from(uniqueKeys); console.dev( `Sync start ${patterns.join(",")} to ${ targetRedisList.length } target, total ${allKeys.length} keys` ); for (let i = 0; i < allKeys.length; i += batch) { // 分批处理 // 对每个key,根据类型使用对应的过滤脚本,除了string用pattern匹配,其它都是用field匹配 const batchKeys = allKeys.slice(i, i + batch); const pipelines = targetRedisList.map((target) => { const p = target.pipeline(); p.org = target; return p; }); for (const key of batchKeys) { const type = await this.type(key); const ttlPromise = this.ttl(key); let data = null; if (type === "string") { const stringPattern = options.string || ""; data = await this.evalsha(scriptShas.string, 1, key, stringPattern); } else if (type in FILTER_SCRIPTS) { const fields = options[type] || []; data = await this.evalsha( scriptShas[type], 1, key, JSON.stringify(fields) ); } const ttl = await ttlPromise; if (data) { pipelines.forEach((pipeline) => { switch (type) { case "string": pipeline.set(key, data); break; case "hash": if (data.length) { const hash = {}; for (let i = 0; i < data.length; i += 2) { hash[data[i]] = data[i + 1]; } pipeline.hmset(key, hash); } break; case "set": if (data.length) { pipeline.sadd(key, data); } break; case "zset": if (data.length) { const args = [key]; for (let i = 0; i < data.length; i += 2) { args.push(data[i + 1]); // score args.push(data[i]); // member } pipeline.zadd(...args); } break; case "list": if (data.length) { pipeline.rpush(key, data); } break; } if (ttl > 0) { pipeline.expire(key, ttl); } }); } } await Promise.all( pipelines.map(async (pipeline) => { await pipeline.exec(); if (pipeline.org.status === "ready") { // console.dev("Sync ok", pipeline.org.options.host); } else { console.error( "error", pipeline.org.options.host, pipeline.org.status ); } }) ); } console.dev(`Sync OK`); }