cj-tauri 0.8.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/cjpm.toml +52 -25
- package/cli/cjpm.toml +1 -1
- package/cli/src/doctor.cj +304 -0
- package/cli/src/main.cj +33 -7
- package/cli/src/project.cj +59 -1
- package/cli/src/resolve.cj +348 -304
- package/cli/src/scaffold.cj +139 -22
- package/cli/templates/ohos-pc/AppScope/app.json5 +13 -0
- package/cli/templates/ohos-pc/AppScope/resources/base/element/string.json +8 -0
- package/cli/templates/ohos-pc/AppScope/resources/base/media/background.png +0 -0
- package/cli/templates/ohos-pc/AppScope/resources/base/media/foreground.png +0 -0
- package/cli/templates/ohos-pc/AppScope/resources/base/media/layered_image.json +6 -0
- package/cli/templates/ohos-pc/README.md +79 -0
- package/cli/templates/ohos-pc/build-hap.sh +190 -0
- package/cli/templates/ohos-pc/build-profile.json5 +46 -0
- package/cli/templates/ohos-pc/entry/build-profile.json5 +26 -0
- package/cli/templates/ohos-pc/entry/cjpm.toml +43 -0
- package/cli/templates/ohos-pc/entry/hvigorfile.ts +6 -0
- package/cli/templates/ohos-pc/entry/oh-package.json5 +9 -0
- package/cli/templates/ohos-pc/entry/src/main/cangjie/ability_stage.cj +17 -0
- package/cli/templates/ohos-pc/entry/src/main/cangjie/app_host.cj +94 -0
- package/cli/templates/ohos-pc/entry/src/main/cangjie/index.cj +167 -0
- package/cli/templates/ohos-pc/entry/src/main/cangjie/main_ability.cj +139 -0
- package/cli/templates/ohos-pc/entry/src/main/module.json5 +49 -0
- package/cli/templates/ohos-pc/entry/src/main/resources/base/element/color.json +8 -0
- package/cli/templates/ohos-pc/entry/src/main/resources/base/element/string.json +16 -0
- package/cli/templates/ohos-pc/entry/src/main/resources/base/media/background.png +0 -0
- package/cli/templates/ohos-pc/entry/src/main/resources/base/media/foreground.png +0 -0
- package/cli/templates/ohos-pc/entry/src/main/resources/base/media/layered_image.json +6 -0
- package/cli/templates/ohos-pc/entry/src/main/resources/base/media/startIcon.png +0 -0
- package/cli/templates/ohos-pc/entry/src/main/resources/base/profile/main_pages.json +2 -0
- package/cli/templates/ohos-pc/entry/src/main/resources/rawfile/capabilities.json +6 -0
- package/cli/templates/ohos-pc/entry/src/main/resources/rawfile/index.html +92 -0
- package/cli/templates/ohos-pc/hvigor/hvigor-config.json5 +5 -0
- package/cli/templates/ohos-pc/hvigorfile.ts +6 -0
- package/cli/templates/ohos-pc/oh-package.json5 +10 -0
- package/native/build_linux.sh +30 -2
- package/native/build_win.bat +62 -61
- package/native/core/bridge_core.c +1626 -0
- package/native/{bridge_core.h → core/bridge_core.h} +246 -6
- package/native/platforms/harmonyos/README.md +106 -0
- package/native/platforms/linux/bridge_linux.c +2485 -0
- package/native/platforms/windows/bridge_win.c +3248 -0
- package/native/tests/test_bridge_core.c +528 -1
- package/package.json +12 -3
- package/prebuilt/win32-x64/cj-tauri.exe +0 -0
- package/src/api_system.cj +10 -2
- package/src/app.cj +248 -85
- package/src/arkweb_proxy.cj +56 -0
- package/src/arkweb_script.cj +154 -0
- package/src/capability.cj +140 -0
- package/src/clipboard_bridge.cj +55 -0
- package/src/dragdrop.cj +26 -0
- package/src/embedded_assets.cj +98 -0
- package/src/hexutil.cj +53 -0
- package/src/host.cj +197 -26
- package/src/host_arkweb.cj +740 -0
- package/src/host_factory.cj +48 -0
- package/src/host_webkit.cj +202 -26
- package/src/host_webview2.cj +176 -3
- package/src/hotkey.cj +66 -0
- package/src/ipc_hub.cj +49 -0
- package/src/ipc_message.cj +2 -1
- package/src/jsutil.cj +33 -0
- package/src/log_bridge.cj +107 -0
- package/src/logging.cj +127 -0
- package/src/menu.cj +197 -196
- package/src/plugin.cj +40 -0
- package/src/plugins/clipboard/plugin_clipboard.cj +144 -0
- package/src/{plugin_dialog.cj → plugins/dialog/plugin_dialog.cj} +74 -17
- package/src/plugins/fs/plugin_fs.cj +194 -0
- package/src/plugins/http/plugin_http.cj +459 -0
- package/src/plugins/log/plugin_log.cj +105 -0
- package/src/{plugin_menu.cj → plugins/menu/plugin_menu.cj} +4 -2
- package/src/plugins/mqtt/mqtt_codec.cj +661 -0
- package/src/plugins/mqtt/plugin_mqtt.cj +1446 -0
- package/src/plugins/notification/plugin_notification.cj +146 -0
- package/src/plugins/plugins.cj +5 -0
- package/src/plugins/redis/plugin_redis.cj +2409 -0
- package/src/plugins/redis/redis_codec.cj +556 -0
- package/src/{plugin_serial.cj → plugins/serial/plugin_serial.cj} +442 -489
- package/src/{serial_bridge.cj → plugins/serial/serial_bridge.cj} +185 -184
- package/src/{plugin_shell.cj → plugins/shell/plugin_shell.cj} +230 -228
- package/src/plugins/sqlite/plugin_sqlite.cj +697 -0
- package/src/plugins/sqlite/sqlite_ffi.cj +206 -0
- package/src/plugins/store/plugin_store.cj +287 -0
- package/src/plugins/ws/plugin_ws.cj +470 -0
- package/src/process_hidden.cj +174 -0
- package/src/scope.cj +391 -0
- package/src/shell_event.cj +69 -68
- package/src/tests/capability_test.cj +53 -0
- package/src/tests/fake_host.cj +85 -20
- package/src/tests/ipc_hub_test.cj +13 -0
- package/src/tests/jsutil_test.cj +62 -0
- package/src/tests/logging_test.cj +62 -0
- package/src/tests/mqtt_codec_test.cj +405 -0
- package/src/tests/plugin_clipboard_test.cj +222 -0
- package/src/tests/plugin_dialog_test.cj +98 -4
- package/src/tests/plugin_http_test.cj +124 -0
- package/src/tests/plugin_menu_test.cj +1 -0
- package/src/tests/plugin_notification_test.cj +205 -0
- package/src/tests/plugin_serial_test.cj +269 -267
- package/src/tests/plugin_shell_test.cj +189 -188
- package/src/tests/plugin_sqlite_test.cj +230 -0
- package/src/tests/plugin_store_test.cj +260 -0
- package/src/tests/plugin_test.cj +542 -524
- package/src/tests/plugin_ws_test.cj +40 -0
- package/src/tests/process_hidden_test.cj +129 -0
- package/src/tests/redis_codec_test.cj +396 -0
- package/src/tests/scope_test.cj +350 -0
- package/src/tests/shell_event_test.cj +0 -1
- package/src/tests/system_host_test.cj +81 -79
- package/src/tests/urlutil_test.cj +53 -0
- package/src/tests/version_test.cj +75 -65
- package/src/tray.cj +90 -0
- package/src/urlutil.cj +159 -0
- package/src/version.cj +23 -2
- package/src/window.cj +51 -42
- package/native/bridge_core.c +0 -797
- package/native/bridge_linux.c +0 -1268
- package/native/bridge_win.c +0 -1633
- package/src/plugin_fs.cj +0 -118
- /package/native/{bridge_js.h → core/bridge_js.h} +0 -0
|
@@ -0,0 +1,2409 @@
|
|
|
1
|
+
package cjTauri.plugins.redis
|
|
2
|
+
|
|
3
|
+
import cjTauri.*
|
|
4
|
+
|
|
5
|
+
import std.collection.*
|
|
6
|
+
import std.net.*
|
|
7
|
+
import std.sync.*
|
|
8
|
+
import std.time.*
|
|
9
|
+
import stdx.encoding.json.*
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 官方插件 redis:Redis 客户端调试工具(键空间浏览、类型化取值、发布订阅、服务器诊断)
|
|
13
|
+
*
|
|
14
|
+
* 设计取舍见 `docs/设计决策记录.md` 的 **D-016**(以插件交付 + 配套示例;协议用纯仓颉 `std.net` 自实现,
|
|
15
|
+
* 不进 C 桥)。协议编解码在 `src/plugins/redis_codec.cj`(纯函数 + 单测),本文件只管连接、会话状态与命令。
|
|
16
|
+
*
|
|
17
|
+
* 命令:`redis:connect` / `disconnect` / `select` / `status` / `info` / `dbsize` / `slowlog` /
|
|
18
|
+
* `scan` / `keyInfo` / `keyRename` / `keyDelete` / `keyExpire` / `keyPersist` /
|
|
19
|
+
* `value` / `valueWrite` / `subscribe` / `unsubscribe` / `publish` / `messages`
|
|
20
|
+
* 事件:`redis:message`(发布订阅收到的一条)+ `redis:state`(连接与订阅状态变化)
|
|
21
|
+
* 权限:**不自动放行**,应用要在 capabilities 里显式声明(明文 `"commands"` 或命名集 `"permissions": ["redis:default"]`)
|
|
22
|
+
* 前端:`window.__CJ_TAURI__.redis.connect({ host: "127.0.0.1", port: 6379 })`
|
|
23
|
+
*
|
|
24
|
+
* **边界如实(D-016)**:只做 RESP2 与单实例(不做 Cluster / Sentinel 拓扑发现)、不做 TLS(`rediss://`)、
|
|
25
|
+
* 不做 ACL 管理类命令(`ACL *`)、不做事务(MULTI/EXEC)与脚本(EVAL);控制台式「任意命令直发」**有意不做**——
|
|
26
|
+
* 那等于把整台服务器的权限面直接摊给页面,与「命令白名单」的模型相悖。
|
|
27
|
+
* 协议版本由编解码层把关:RESP3 的 map / set / push 前缀一律判协议错误(不猜),页面对端要是 RESP3 会说清。
|
|
28
|
+
*
|
|
29
|
+
* 线程模型:**命令侧**(worker 线程)独占「写一发一读」的 cmd socket,一问一答由 `callLock` 串行化;
|
|
30
|
+
* **发布订阅**走**另一条** socket,其读侧由订阅读线程独占(`redisSubLoop`),命令侧只写。
|
|
31
|
+
* 两条线程都**不调 GTK / WebKit**:事件经 `app.emit()` → 桥的 jsSink 回投(任意线程调用都安全,
|
|
32
|
+
* 见 `src/ipc_hub.cj` 的线程模型注释)。会话可变字段一律在 `RedisSession.lock` 下访问。
|
|
33
|
+
*
|
|
34
|
+
* 命令是**同步阻塞**的(一问一答),跑在命令 worker 线程上(分发本身是异步的,窗口不会被冻住),
|
|
35
|
+
* 因此没有流式输出、也没有取消;每条命令的等待上限由 connect 时的 `timeoutMs` 决定(默认 3s)。
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/** `redis:messages` 环形缓冲上限(条):超限丢最旧并计入 dropped(与 D-015 同一口径) */
|
|
39
|
+
let REDIS_RECENT_LIMIT: Int64 = 500
|
|
40
|
+
|
|
41
|
+
/** 单条发布订阅正文进缓冲 / 事件前的截断上限:256 KiB;超了照实报 truncated,不假装全量 */
|
|
42
|
+
let REDIS_RECENT_BODY_LIMIT: Int64 = 262144
|
|
43
|
+
|
|
44
|
+
/** 建连超时(毫秒):死地址不能把命令 worker 挂住 */
|
|
45
|
+
let REDIS_CONNECT_TIMEOUT_MS: Int64 = 5000
|
|
46
|
+
|
|
47
|
+
/** 一问一答的默认读超时(毫秒):可用 connect 的 timeoutMs 覆盖 */
|
|
48
|
+
let REDIS_DEFAULT_TIMEOUT_MS: Int64 = 3000
|
|
49
|
+
|
|
50
|
+
/** 订阅读线程的读超时(毫秒):它既是「有数据就解」也是「停下来检查」的时基 */
|
|
51
|
+
let REDIS_SUB_TICK_MS: Int64 = 200
|
|
52
|
+
|
|
53
|
+
/** 订阅确认的有界等待(毫秒):超时就如实报「回执未到」,不假装订阅成功 */
|
|
54
|
+
let REDIS_SUB_ACK_TIMEOUT_MS: Int64 = 3000
|
|
55
|
+
|
|
56
|
+
/** 收下一条命令回复时一次读的字节数 */
|
|
57
|
+
let REDIS_READ_CHUNK: Int64 = 16384
|
|
58
|
+
|
|
59
|
+
/** 等订阅读线程收尾的上限(毫秒):等不到就如实打一行 stderr 再继续 */
|
|
60
|
+
let REDIS_SUB_EXIT_WAIT_MS: Int64 = 1000
|
|
61
|
+
|
|
62
|
+
/** 单条字符串值 / 集合元素的展示上限:超了只给前 N 字节并置 truncated(页面照实显示) */
|
|
63
|
+
let REDIS_VALUE_TEXT_LIMIT: Int64 = 65536
|
|
64
|
+
|
|
65
|
+
/** 一次 `scan` / `value` 分页最多取回的条目数(页面要的是「看一眼」,不是导库) */
|
|
66
|
+
let REDIS_PAGE_MAX: Int64 = 200
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 插件的全部命令短名(`permissions()` 的 `default` 集就是它,别再抄第二份)。
|
|
70
|
+
*
|
|
71
|
+
* 用 `let` 而不是 `const`:仓颉的 `const` 只收编译期常量表达式,`Array` 字面量编译不过(AGENTS §4)。
|
|
72
|
+
*/
|
|
73
|
+
let REDIS_PLUGIN_COMMANDS: Array<String> = [
|
|
74
|
+
"connect", "disconnect", "select", "status", "info", "dbsize", "slowlog",
|
|
75
|
+
"scan", "keyInfo", "keyRename", "keyDelete", "keyExpire", "keyPersist",
|
|
76
|
+
"value", "valueWrite", "subscribe", "unsubscribe", "publish", "messages"
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
/** 只观不控的集合:浏览与诊断都算,改数据的一个都不给(`permissions()` 的 `readonly` 集) */
|
|
80
|
+
let REDIS_READONLY_COMMANDS: Array<String> = [
|
|
81
|
+
"connect", "disconnect", "select", "status", "info", "dbsize", "slowlog",
|
|
82
|
+
"scan", "keyInfo", "value", "subscribe", "unsubscribe", "messages"
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
/** 值类型名(TYPE 命令的返回值):判断走哪条取值路径时用 */
|
|
86
|
+
let REDIS_TYPE_STRING = "string"
|
|
87
|
+
let REDIS_TYPE_LIST = "list"
|
|
88
|
+
let REDIS_TYPE_SET = "set"
|
|
89
|
+
let REDIS_TYPE_ZSET = "zset"
|
|
90
|
+
let REDIS_TYPE_HASH = "hash"
|
|
91
|
+
let REDIS_TYPE_STREAM = "stream"
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 一条 Redis 连接的全部可变状态(**所有字段都在 lock 下访问**——两次命令可能落在不同 worker 上)。
|
|
95
|
+
*
|
|
96
|
+
* cmd 与 sub 是两条独立的 TCP 连接:发布订阅一旦订阅上,那条连接就(按协议)只能收推送了,
|
|
97
|
+
* 拿它继续发键空间命令是错的。所以「浏览 + 订阅」在用同一份连接参数的前提下各占一条。
|
|
98
|
+
*/
|
|
99
|
+
class RedisSession {
|
|
100
|
+
let lock: Mutex = Mutex()
|
|
101
|
+
|
|
102
|
+
/** 一问一答的串行化锁:请求与应答必须成对,两条命令交错写会把回复读串 */
|
|
103
|
+
let callLock: Mutex = Mutex()
|
|
104
|
+
|
|
105
|
+
/** 命令连接(None = 未连接);读侧由命令线程独占 */
|
|
106
|
+
var cmd: Option<TcpSocket> = None
|
|
107
|
+
|
|
108
|
+
/** 命令连接的增量解析器(半条回复留在里面,跨 read 累计) */
|
|
109
|
+
var cmdReader: RespReader = RespReader()
|
|
110
|
+
|
|
111
|
+
/** 订阅连接与它的解析器(读侧由订阅读线程独占) */
|
|
112
|
+
var sub: Option<TcpSocket> = None
|
|
113
|
+
var subReader: RespReader = RespReader()
|
|
114
|
+
|
|
115
|
+
/** idle / connecting / connected / closed / error:页面直接显示,所以用字符串而不是枚举 */
|
|
116
|
+
var state: String = "idle"
|
|
117
|
+
var host: String = ""
|
|
118
|
+
var port: Int64 = 6379
|
|
119
|
+
var db: Int64 = 0
|
|
120
|
+
var authUsed: Bool = false
|
|
121
|
+
var timeoutMs: Int64 = REDIS_DEFAULT_TIMEOUT_MS
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 认证信息**只在内存里留一份**:订阅连接要再认证一次(两条连接各自 AUTH),所以会话得留着。
|
|
125
|
+
* 它不进 status、不进事件、不进日志——任何回给页面的报文里都不该出现口令本身
|
|
126
|
+
* (页面本来就知道自己填了什么,回显只会让口令多一份副本躺在缓冲与日志里)。
|
|
127
|
+
*/
|
|
128
|
+
var username: String = ""
|
|
129
|
+
var password: String = ""
|
|
130
|
+
|
|
131
|
+
/** 已订阅的频道 / 模式(订阅命令写入,读线程按回执对账) */
|
|
132
|
+
var channels: ArrayList<String> = ArrayList<String>()
|
|
133
|
+
var patterns: ArrayList<String> = ArrayList<String>()
|
|
134
|
+
|
|
135
|
+
/** 订阅读线程是否在跑;stopSubRequested 是「请读线程退出」的旗标 */
|
|
136
|
+
var subAlive: Bool = false
|
|
137
|
+
var stopSubRequested: Bool = false
|
|
138
|
+
|
|
139
|
+
/** 已发出的订阅请求待确认条数(`subscribe` 命令读它做有界等待) */
|
|
140
|
+
var subPendingAcks: Int64 = 0
|
|
141
|
+
|
|
142
|
+
/** 序号与计数:seq 给页面去重(同一条约也走事件与拉取两条通道),其余供 status 对账 */
|
|
143
|
+
var seq: Int64 = 0
|
|
144
|
+
var calls: Int64 = 0
|
|
145
|
+
var msgCount: Int64 = 0
|
|
146
|
+
var dropped: Int64 = 0
|
|
147
|
+
var lastError: String = ""
|
|
148
|
+
|
|
149
|
+
/** 最近一次收发时刻(status 里给页面看「还活着吗」) */
|
|
150
|
+
var lastTxAt: DateTime = DateTime.now()
|
|
151
|
+
var lastRxAt: DateTime = DateTime.now()
|
|
152
|
+
|
|
153
|
+
/** 发布订阅的环形缓冲(元素是给前端的 JSON);满了丢最旧并累计 dropped */
|
|
154
|
+
var recent: ArrayList<JsonValue> = ArrayList<JsonValue>()
|
|
155
|
+
|
|
156
|
+
/** app 引用:`RedisPlugin.setup()` 装配进来,事件回投要用它 */
|
|
157
|
+
var app: Option<TauriApp> = None
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---------------------------------------------------------------- 事件与失败收口
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* 记一条发布订阅报文:**赋 seq → 入缓冲(超限丢最旧并计 dropped)→ 推事件**,两条路在这里收口。
|
|
164
|
+
*
|
|
165
|
+
* 顺序刻意固定(与 MQTT 的 D-015 同款):先入缓冲再推事件——清单没声明 `events` 时事件会被框架丢弃
|
|
166
|
+
* (打一行 stderr),而 `redis:messages` 照旧拿得到,这正是「事件可以丢、拉取兜底」那条取舍。
|
|
167
|
+
*
|
|
168
|
+
* 调用方**不要**持有 `sess.lock`(本函数自己加锁)。
|
|
169
|
+
*/
|
|
170
|
+
func redisRecordMessage(sess: RedisSession, obj: JsonObject): Unit {
|
|
171
|
+
var app: Option<TauriApp> = None
|
|
172
|
+
sess.lock.lock()
|
|
173
|
+
sess.seq += 1
|
|
174
|
+
obj.put("seq", JsonInt(sess.seq))
|
|
175
|
+
obj.put("ts", JsonString(DateTime.now().toString()))
|
|
176
|
+
sess.msgCount += 1
|
|
177
|
+
app = sess.app
|
|
178
|
+
sess.recent.add(obj)
|
|
179
|
+
// 超限:整块搬一次最旧的 N 条,代价 O(上限),500 条量级可忽略;
|
|
180
|
+
// 不用集合层的删除重载是为了让「丢的是什么」在这段代码里一眼可见(dropped 必须如实对得上)
|
|
181
|
+
if (sess.recent.size > REDIS_RECENT_LIMIT) {
|
|
182
|
+
let from = sess.recent.size - REDIS_RECENT_LIMIT
|
|
183
|
+
let keep = ArrayList<JsonValue>()
|
|
184
|
+
var i: Int64 = from
|
|
185
|
+
while (i < sess.recent.size) {
|
|
186
|
+
keep.add(sess.recent[i])
|
|
187
|
+
i += 1
|
|
188
|
+
}
|
|
189
|
+
sess.dropped += from
|
|
190
|
+
sess.recent = keep
|
|
191
|
+
eprintln("[cj-tauri] redis: 订阅缓冲超限,丢弃最旧 ${from} 条(累计 dropped=${sess.dropped})")
|
|
192
|
+
}
|
|
193
|
+
sess.lock.unlock()
|
|
194
|
+
match (app) {
|
|
195
|
+
case Some(a) => a.emit("redis:message", obj)
|
|
196
|
+
case None => ()
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* 推 `redis:state`:连接与订阅状态是页面最需要**立刻**知道的事(被拒 / 掉线不能只躺在缓冲里)。
|
|
202
|
+
* 调用方不要持锁。
|
|
203
|
+
*/
|
|
204
|
+
func redisEmitState(sess: RedisSession): Unit {
|
|
205
|
+
var app: Option<TauriApp> = None
|
|
206
|
+
let obj = JsonObject()
|
|
207
|
+
sess.lock.lock()
|
|
208
|
+
app = sess.app
|
|
209
|
+
obj.put("state", JsonString(sess.state))
|
|
210
|
+
obj.put("host", JsonString(sess.host))
|
|
211
|
+
obj.put("port", JsonInt(sess.port))
|
|
212
|
+
obj.put("db", JsonInt(sess.db))
|
|
213
|
+
obj.put("auth", JsonBool(sess.authUsed))
|
|
214
|
+
obj.put("subAlive", JsonBool(sess.subAlive))
|
|
215
|
+
obj.put("channels", redisStringArray(sess.channels))
|
|
216
|
+
obj.put("patterns", redisStringArray(sess.patterns))
|
|
217
|
+
obj.put("calls", JsonInt(sess.calls))
|
|
218
|
+
obj.put("messages", JsonInt(sess.msgCount))
|
|
219
|
+
obj.put("dropped", JsonInt(sess.dropped))
|
|
220
|
+
obj.put("error", JsonString(sess.lastError))
|
|
221
|
+
// 状态变迁同时落一行 stderr:事件通道页面没接住时(没声明 events / 页面在重载),
|
|
222
|
+
// 「连上过没有、什么时候掉的」仍能端到端对账——与框架其余能力的取证口径一致(AGENTS §1)
|
|
223
|
+
eprintln("[cj-tauri] redis state: state=${sess.state} host=${sess.host}:${sess.port} db=${sess.db}" +
|
|
224
|
+
" sub=${sess.subAlive} channels=${sess.channels.size} calls=${sess.calls} msgs=${sess.msgCount}" +
|
|
225
|
+
" dropped=${sess.dropped}")
|
|
226
|
+
sess.lock.unlock()
|
|
227
|
+
match (app) {
|
|
228
|
+
case Some(a) => a.emit("redis:state", obj)
|
|
229
|
+
case None => ()
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* 置会话为错误态:停订阅线程、关两条 socket、记原因、推 `redis:state`。
|
|
235
|
+
*
|
|
236
|
+
* 命令线程与订阅读线程都可能调它(读失败 / 写失败 / 协议错误),所以必须**幂等**:
|
|
237
|
+
* 谁先到谁把原因写进去,后到的看到 socket 已经是 None 就只收尾。
|
|
238
|
+
*/
|
|
239
|
+
func redisFailSession(sess: RedisSession, reason: String): Unit {
|
|
240
|
+
var cmdSock: Option<TcpSocket> = None
|
|
241
|
+
var subSock: Option<TcpSocket> = None
|
|
242
|
+
var first = false
|
|
243
|
+
sess.lock.lock()
|
|
244
|
+
cmdSock = sess.cmd
|
|
245
|
+
subSock = sess.sub
|
|
246
|
+
sess.cmd = None
|
|
247
|
+
sess.sub = None
|
|
248
|
+
if (sess.state != "error") {
|
|
249
|
+
first = true
|
|
250
|
+
sess.state = "error"
|
|
251
|
+
sess.lastError = reason
|
|
252
|
+
}
|
|
253
|
+
sess.stopSubRequested = true
|
|
254
|
+
sess.subAlive = false
|
|
255
|
+
sess.subPendingAcks = 0
|
|
256
|
+
sess.lock.unlock()
|
|
257
|
+
match (cmdSock) {
|
|
258
|
+
case Some(s) => redisCloseQuiet(s)
|
|
259
|
+
case None => ()
|
|
260
|
+
}
|
|
261
|
+
match (subSock) {
|
|
262
|
+
case Some(s) => redisCloseQuiet(s)
|
|
263
|
+
case None => ()
|
|
264
|
+
}
|
|
265
|
+
if (first) {
|
|
266
|
+
eprintln("[cj-tauri] redis: ${reason}")
|
|
267
|
+
}
|
|
268
|
+
redisEmitState(sess)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** 关一条 socket 且不因关闭失败打扰调用方(收尾路径上不该再抛异常) */
|
|
272
|
+
func redisCloseQuiet(sock: TcpSocket): Unit {
|
|
273
|
+
try {
|
|
274
|
+
sock.close()
|
|
275
|
+
} catch (e: Exception) {
|
|
276
|
+
let _ = e
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** 字符串数组 → JSON 数组(保持原顺序:订阅顺序对用户是有意义的) */
|
|
281
|
+
func redisStringArray(items: ArrayList<String>): JsonValue {
|
|
282
|
+
let arr = ArrayList<JsonValue>()
|
|
283
|
+
for (s in items) {
|
|
284
|
+
arr.add(JsonString(s))
|
|
285
|
+
}
|
|
286
|
+
return JsonArray(arr)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ---------------------------------------------------------------- 参数与数值助手
|
|
290
|
+
|
|
291
|
+
/** 夹范围:浮点转整数前必须先夹,越界会抛异常把整条命令带走(AGENTS §4 的那条坑) */
|
|
292
|
+
func redisClampInt(v: Int64, lo: Int64, hi: Int64): Int64 {
|
|
293
|
+
if (v < lo) {
|
|
294
|
+
return lo
|
|
295
|
+
}
|
|
296
|
+
if (v > hi) {
|
|
297
|
+
return hi
|
|
298
|
+
}
|
|
299
|
+
return v
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** 主机名 / IP 的安全字符集检查:不把它塞进 shell(走 argv),但仍拒绝明显荒唐的输入 */
|
|
303
|
+
func redisValidHost(host: String): Bool {
|
|
304
|
+
if (host.size == 0 || host.size > 253) {
|
|
305
|
+
return false
|
|
306
|
+
}
|
|
307
|
+
for (b in host) {
|
|
308
|
+
let ok = (b >= 97u8 && b <= 122u8) || (b >= 65u8 && b <= 90u8) || (b >= 48u8 && b <= 57u8) ||
|
|
309
|
+
b == 46u8 || b == 45u8 || b == 95u8 || b == 58u8 || b == 91u8 || b == 93u8
|
|
310
|
+
if (!ok) {
|
|
311
|
+
return false
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return true
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** 端口合法性:1..65535(0 不是可连的服务端口) */
|
|
318
|
+
func redisValidPort(port: Int64): Bool {
|
|
319
|
+
return port >= 1 && port <= 65535
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** 库号合法性:0..255(Redis 默认 16 个库,但配置可调,这里只挡明显越界的) */
|
|
323
|
+
func redisValidDb(db: Int64): Bool {
|
|
324
|
+
return db >= 0 && db <= 255
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* 把一个 RESP 回复翻成 JSON(页面直接可用的形状)。
|
|
329
|
+
*
|
|
330
|
+
* `Failure` 在这里**不抛异常**而是翻成 `{"error": "..."}`:只有调用方知道「这个错误算不算失败」
|
|
331
|
+
* (例如 `TYPE` 对不存在的键回的是 simple 而不是 error,但 `GET` 打错类型会回 WRONGTYPE)。
|
|
332
|
+
* 真正需要把错误当失败的命令自己调 `redisExpectOk`。
|
|
333
|
+
*/
|
|
334
|
+
func redisReplyJson(v: RespValue): JsonValue {
|
|
335
|
+
match (v) {
|
|
336
|
+
case Simple(s) => return JsonString(s)
|
|
337
|
+
case Failure(m) =>
|
|
338
|
+
let obj = JsonObject()
|
|
339
|
+
obj.put("error", JsonString(m))
|
|
340
|
+
return obj
|
|
341
|
+
case Number(n) => return JsonInt(n)
|
|
342
|
+
case Bulk(None) => return JsonNull()
|
|
343
|
+
case Bulk(Some(b)) => return JsonString(respSafeText(b))
|
|
344
|
+
case Arr(None) => return JsonNull()
|
|
345
|
+
case Arr(Some(items)) =>
|
|
346
|
+
let arr = ArrayList<JsonValue>()
|
|
347
|
+
for (it in items) {
|
|
348
|
+
arr.add(redisReplyJson(it))
|
|
349
|
+
}
|
|
350
|
+
return JsonArray(arr)
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* 回复必须是「不是错误」:`-ERR ...` 一律翻成业务错误抛给前端。
|
|
356
|
+
*
|
|
357
|
+
* 抛之前落一行 stderr(`redis command failed`),与 MQTT 那条教训同口径:光有 reject 没有日志,
|
|
358
|
+
* 命令「没反应」时端到端毫无凭证。
|
|
359
|
+
*/
|
|
360
|
+
func redisExpectOk(v: RespValue, what: String): RespValue {
|
|
361
|
+
match (v) {
|
|
362
|
+
case Failure(m) =>
|
|
363
|
+
eprintln("[cj-tauri] redis command failed (${what}): ${m}")
|
|
364
|
+
throw CommandException("redis: ${what} 失败(${m})")
|
|
365
|
+
case _ => return v
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** 取整数回复(`Number`);别的形状一律判协议异常(不该出现,出现了要能查) */
|
|
370
|
+
func redisNumberReply(v: RespValue, what: String): Int64 {
|
|
371
|
+
match (v) {
|
|
372
|
+
case Number(n) => return n
|
|
373
|
+
case Failure(m) =>
|
|
374
|
+
eprintln("[cj-tauri] redis command failed (${what}): ${m}")
|
|
375
|
+
throw CommandException("redis: ${what} 失败(${m})")
|
|
376
|
+
case _ => throw CommandException("redis: ${what} 期望整数回复,实际是 ${respBrief(v)}")
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** 取文本回复(simple / bulk 都算);数组等复杂形状按调用方自己解 */
|
|
381
|
+
func redisTextReply(v: RespValue, what: String): String {
|
|
382
|
+
match (v) {
|
|
383
|
+
case Simple(s) => return s
|
|
384
|
+
case Bulk(Some(b)) => return respSafeText(b)
|
|
385
|
+
case Bulk(None) => return ""
|
|
386
|
+
case Failure(m) =>
|
|
387
|
+
eprintln("[cj-tauri] redis command failed (${what}): ${m}")
|
|
388
|
+
throw CommandException("redis: ${what} 失败(${m})")
|
|
389
|
+
case _ => throw CommandException("redis: ${what} 期望文本回复,实际是 ${respBrief(v)}")
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ---------------------------------------------------------------- socket 与「一问一答」
|
|
394
|
+
|
|
395
|
+
/** 日志用:超长文本按字节截断(不设上限的一条回复会把整段日志淹掉,排查反而更难) */
|
|
396
|
+
func redisClip(s: String, limit: Int64): String {
|
|
397
|
+
if (s.size <= limit) {
|
|
398
|
+
return s
|
|
399
|
+
}
|
|
400
|
+
// 字节切片可能切在 UTF-8 中间,交给与取值同一套口径(坏字节走 hex)保证日志可读
|
|
401
|
+
return respSafeText(s.toArray()[0..limit]) + "…(截断)"
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** 日志用的回复摘要:simple / error 正文也截断,其余交给 respBrief(它本身就只说规模) */
|
|
405
|
+
func redisBriefClipped(v: RespValue): String {
|
|
406
|
+
match (v) {
|
|
407
|
+
case Simple(s) => return "simple(" + redisClip(s, 64) + ")"
|
|
408
|
+
case Failure(m) => return "error(" + redisClip(m, 64) + ")"
|
|
409
|
+
case _ => return respBrief(v)
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** 落一行调用日志:命令名 + 回复摘要 + 累计调用数(**不打参数值**,免得用户的键值进日志) */
|
|
414
|
+
func redisLogCall(sess: RedisSession, what: String, reply: RespValue): Unit {
|
|
415
|
+
sess.lock.lock()
|
|
416
|
+
sess.calls += 1
|
|
417
|
+
let n = sess.calls
|
|
418
|
+
sess.lock.unlock()
|
|
419
|
+
eprintln("[cj-tauri] redis call: ${what} -> ${redisBriefClipped(reply)} (n=${n})")
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* 读一个完整回复:先看解析器里有没有攒够,没有就从 socket 再收。
|
|
424
|
+
*
|
|
425
|
+
* 超时口径分两层:socket 的 readTimeout 管「对端一直不说话」,本函数的 elapsed 管「说了半句停住」——
|
|
426
|
+
* 两者都判会话坏(半条回复留在解析器里,接着用就是对错位的字节)。`what` 是命令短名,只进日志。
|
|
427
|
+
*/
|
|
428
|
+
func redisReadReply(sess: RedisSession, sock: TcpSocket, reader: RespReader, what: String): RespValue {
|
|
429
|
+
let started = DateTime.now()
|
|
430
|
+
// 用 `Option` 当循环条件而不是 `while (true)` + `return`:仓颉不把 `while (true)` 当发散表达式,
|
|
431
|
+
// 函数尾表达式会被判成 Unit(`expected 'RespValue', found 'Unit'`),这类写法只能显式带出结果。
|
|
432
|
+
var out: Option<RespValue> = None
|
|
433
|
+
while (out.isNone()) {
|
|
434
|
+
match (reader.respTryRead()) {
|
|
435
|
+
case RespRead.Value(v) => out = Some(v)
|
|
436
|
+
case RespRead.Malformed(m) =>
|
|
437
|
+
redisFailSession(sess, "对端回复不符合 RESP2(${what}):${m}")
|
|
438
|
+
throw CommandException("redis: 协议错误(${m})")
|
|
439
|
+
case RespRead.Incomplete =>
|
|
440
|
+
let buf = Array<UInt8>(REDIS_READ_CHUNK, repeat: 0)
|
|
441
|
+
var got: Int64 = -1
|
|
442
|
+
try {
|
|
443
|
+
got = sock.read(buf)
|
|
444
|
+
} catch (e: SocketTimeoutException) {
|
|
445
|
+
if ((DateTime.now() - started).toMilliseconds() > sess.timeoutMs) {
|
|
446
|
+
redisFailSession(sess, "${what} 超时(${sess.timeoutMs}ms 未收到完整回复)")
|
|
447
|
+
throw CommandException("redis: ${what} 超时(${sess.timeoutMs}ms 未收到完整回复,会话已判坏)")
|
|
448
|
+
}
|
|
449
|
+
got = -1
|
|
450
|
+
} catch (e: Exception) {
|
|
451
|
+
redisFailSession(sess, "${what} 读取失败:${e.message}")
|
|
452
|
+
throw CommandException("redis: ${what} 读取失败(${e.message})")
|
|
453
|
+
}
|
|
454
|
+
if (got > 0) {
|
|
455
|
+
reader.feed(buf[0..got])
|
|
456
|
+
sess.lock.lock()
|
|
457
|
+
sess.lastRxAt = DateTime.now()
|
|
458
|
+
sess.lock.unlock()
|
|
459
|
+
} else if (got == 0) {
|
|
460
|
+
redisFailSession(sess, "${what} 期间对端关闭了连接")
|
|
461
|
+
throw CommandException("redis: 对端关闭了连接(${what})")
|
|
462
|
+
}
|
|
463
|
+
// got < 0:socket 读超时(窗口比命令总超时短),转下一轮继续等
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
out.getOrThrow()
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** 写一帧(**调用方须持 callLock**):写失败一律置会话错误态并抛出——宁可当场报错,不留假连接 */
|
|
470
|
+
func redisWriteFrame(sess: RedisSession, sock: TcpSocket, frame: Array<UInt8>): Unit {
|
|
471
|
+
try {
|
|
472
|
+
sock.write(frame)
|
|
473
|
+
} catch (e: Exception) {
|
|
474
|
+
redisFailSession(sess, "写入失败:${e.message}")
|
|
475
|
+
throw CommandException("redis: 写入失败(${e.message})")
|
|
476
|
+
}
|
|
477
|
+
sess.lock.lock()
|
|
478
|
+
sess.lastTxAt = DateTime.now()
|
|
479
|
+
sess.lock.unlock()
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** 取命令 socket:未连接就明确报错,不进后续流程(调试工具最怕「命令看着发出去了」) */
|
|
483
|
+
func redisRequireCmd(sess: RedisSession): TcpSocket {
|
|
484
|
+
sess.lock.lock()
|
|
485
|
+
let st = sess.state
|
|
486
|
+
let sock = sess.cmd
|
|
487
|
+
sess.lock.unlock()
|
|
488
|
+
if (st != "connected") {
|
|
489
|
+
throw CommandException("redis: 未连接(当前状态 ${st}),请先 redis:connect")
|
|
490
|
+
}
|
|
491
|
+
match (sock) {
|
|
492
|
+
case Some(s) => return s
|
|
493
|
+
case None => throw CommandException("redis: 连接已断开(socket 已回收),请重新 connect")
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* 在指定 socket 上「写一帧 + 读一个回复 + 记一行日志」(**调用方须持 callLock**)。
|
|
499
|
+
* 握手(命令连接与订阅连接都要走)与流水线三条路都汇到这里,日志形状因此只有一个来源。
|
|
500
|
+
*/
|
|
501
|
+
func redisCallOn(sess: RedisSession, sock: TcpSocket, reader: RespReader, frame: Array<UInt8>,
|
|
502
|
+
what: String): RespValue {
|
|
503
|
+
redisWriteFrame(sess, sock, frame)
|
|
504
|
+
let reply = redisReadReply(sess, sock, reader, what)
|
|
505
|
+
redisLogCall(sess, what, reply)
|
|
506
|
+
return reply
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* 帧级一问一答(**自己加锁**):请求与应答必须成对,两条命令交错写会把回复读串。
|
|
511
|
+
*
|
|
512
|
+
* 这层保证「异常也一定解锁」——漏解锁会让之后所有命令永久卡住,比报错严重得多。
|
|
513
|
+
*/
|
|
514
|
+
func redisCallFrame(sess: RedisSession, frame: Array<UInt8>, what: String): RespValue {
|
|
515
|
+
let sock = redisRequireCmd(sess)
|
|
516
|
+
sess.callLock.lock()
|
|
517
|
+
try {
|
|
518
|
+
let reply = redisCallOn(sess, sock, sess.cmdReader, frame, what)
|
|
519
|
+
sess.callLock.unlock()
|
|
520
|
+
return reply
|
|
521
|
+
} catch (e: Exception) {
|
|
522
|
+
sess.callLock.unlock()
|
|
523
|
+
throw e
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** 文本命令:组帧后走帧级入口(参数里**不要**放二进制;要放二进制就自己组帧走 redisCallFrame) */
|
|
528
|
+
func redisCall(sess: RedisSession, parts: Array<String>): RespValue {
|
|
529
|
+
return redisCallFrame(sess, respEncodeCommand(parts), parts[0])
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* 流水线:一口气写 N 条命令再**按顺序**读 N 个回复(`scan` 之后给每个键取 TYPE/TTL 就是它)。
|
|
534
|
+
* 整段在同一把锁里,因此不会与别的命令交错;返回数组按位置与 frames 一一对应,缺位会让页面拿错数据。
|
|
535
|
+
*/
|
|
536
|
+
func redisCallMany(sess: RedisSession, frames: ArrayList<Array<UInt8>>, what: String): ArrayList<RespValue> {
|
|
537
|
+
let out = ArrayList<RespValue>()
|
|
538
|
+
if (frames.size == 0) {
|
|
539
|
+
return out
|
|
540
|
+
}
|
|
541
|
+
let sock = redisRequireCmd(sess)
|
|
542
|
+
sess.callLock.lock()
|
|
543
|
+
try {
|
|
544
|
+
for (f in frames) {
|
|
545
|
+
redisWriteFrame(sess, sock, f)
|
|
546
|
+
}
|
|
547
|
+
var i: Int64 = 0
|
|
548
|
+
while (i < frames.size) {
|
|
549
|
+
out.add(redisReadReply(sess, sock, sess.cmdReader, what))
|
|
550
|
+
i += 1
|
|
551
|
+
}
|
|
552
|
+
sess.lock.lock()
|
|
553
|
+
sess.calls += frames.size
|
|
554
|
+
let n = sess.calls
|
|
555
|
+
sess.lock.unlock()
|
|
556
|
+
eprintln("[cj-tauri] redis call: PIPELINE ${what} n=${frames.size} 回执已按序读齐 (n=${n})")
|
|
557
|
+
} catch (e: Exception) {
|
|
558
|
+
sess.callLock.unlock()
|
|
559
|
+
throw e
|
|
560
|
+
}
|
|
561
|
+
sess.callLock.unlock()
|
|
562
|
+
return out
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* 建一条 TCP 连接并设好超时(命令连接与订阅连接共用)。
|
|
567
|
+
*
|
|
568
|
+
* `readTimeoutMillis` 由调用方给:命令连接用一问一答的超时(等不到就报错),订阅连接用短 tick
|
|
569
|
+
* (它就是读线程「有数据就解、没数据就查停不停」的时基)。连不上直接抛出,**不静默重试**。
|
|
570
|
+
*/
|
|
571
|
+
func redisOpenSocket(sess: RedisSession, readTimeoutMillis: Int64): TcpSocket {
|
|
572
|
+
sess.lock.lock()
|
|
573
|
+
let host = sess.host
|
|
574
|
+
let port = sess.port
|
|
575
|
+
sess.lock.unlock()
|
|
576
|
+
let sock = TcpSocket(host, UInt16(port))
|
|
577
|
+
try {
|
|
578
|
+
sock.connect(timeout: Duration.millisecond * REDIS_CONNECT_TIMEOUT_MS)
|
|
579
|
+
} catch (e: Exception) {
|
|
580
|
+
throw CommandException("redis: 连接 ${host}:${port} 失败(${e.message})")
|
|
581
|
+
}
|
|
582
|
+
sock.noDelay = true
|
|
583
|
+
sock.writeTimeout = Duration.millisecond * REDIS_CONNECT_TIMEOUT_MS
|
|
584
|
+
sock.readTimeout = Duration.millisecond * readTimeoutMillis
|
|
585
|
+
return sock
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* 建连后的握手:AUTH(建连时给了口令才做)→ SELECT(非 0 库才做)→ PING 验活。
|
|
590
|
+
*
|
|
591
|
+
* 三步都在这条 socket 上**同步**做(此时还没有第二个读者),任一步失败都置错误态并抛出——
|
|
592
|
+
* 「看着连上了其实没通过认证」是调试工具最容易骗人的失败形状,必须当场暴露。
|
|
593
|
+
* 订阅连接复用同一个函数:它也要 AUTH,但**不做 SELECT**(发布订阅与库无关,见 D-016)。
|
|
594
|
+
*/
|
|
595
|
+
func redisHandshake(sess: RedisSession, sock: TcpSocket, reader: RespReader, withSelect: Bool): Unit {
|
|
596
|
+
sess.lock.lock()
|
|
597
|
+
let pass = sess.password
|
|
598
|
+
let user = sess.username
|
|
599
|
+
let db = sess.db
|
|
600
|
+
sess.lock.unlock()
|
|
601
|
+
if (pass.size > 0) {
|
|
602
|
+
var frame = respEncodeCommand(["AUTH", pass])
|
|
603
|
+
if (user.size > 0) {
|
|
604
|
+
frame = respEncodeCommand(["AUTH", user, pass])
|
|
605
|
+
}
|
|
606
|
+
redisExpectOk(redisCallOn(sess, sock, reader, frame, "AUTH"), "AUTH")
|
|
607
|
+
sess.lock.lock()
|
|
608
|
+
sess.authUsed = true
|
|
609
|
+
sess.lock.unlock()
|
|
610
|
+
}
|
|
611
|
+
if (withSelect && db != 0) {
|
|
612
|
+
redisExpectOk(redisCallOn(sess, sock, reader, respEncodeCommand(["SELECT", "${db}"]), "SELECT"), "SELECT")
|
|
613
|
+
}
|
|
614
|
+
// PING 是「这条连接真会谈 RESP 吗」的最小验证:回的不是 PONG 就当握手失败(不猜对端是什么)
|
|
615
|
+
let pong = redisExpectOk(redisCallOn(sess, sock, reader, respEncodeCommand(["PING"]), "PING"), "PING")
|
|
616
|
+
match (pong) {
|
|
617
|
+
case Simple(s) =>
|
|
618
|
+
if (s != "PONG") {
|
|
619
|
+
throw CommandException("redis: 握手失败(PING 回的是 ${s},不是 PONG)")
|
|
620
|
+
}
|
|
621
|
+
case _ => throw CommandException("redis: 握手失败(PING 回的形状是 ${respBrief(pong)},不是 -PONG)")
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// ---------------------------------------------------------------- 命令侧:分派与各命令实现
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* redis 插件的命令处理器:19 个命令共用一份,按全名分派(与 SerialCommands / MqttCommands 同形)。
|
|
629
|
+
*
|
|
630
|
+
* **每次 connect 都换一份新会话**,不复用上一份状态:旧会话的订阅线程只碰它自己那份,于是永远不会
|
|
631
|
+
* 误关新连接的 socket、也不会把旧连接的断线状态盖到新连接上。重连路径额外做一件事——先让旧会话收尾
|
|
632
|
+
* (`closePrevious`),否则它的收尾事件可能落在新连接的「connected」之后,页面就莫名其妙显成断线。
|
|
633
|
+
*/
|
|
634
|
+
class RedisCommands <: CommandHandler {
|
|
635
|
+
/** 保护 current 指针:命令在 worker 线程上执行,页面连点 / 多条命令同时飞都是常态 */
|
|
636
|
+
private let lock: Mutex = Mutex()
|
|
637
|
+
private var current: RedisSession = RedisSession()
|
|
638
|
+
private var app: Option<TauriApp> = None
|
|
639
|
+
|
|
640
|
+
public init() {}
|
|
641
|
+
|
|
642
|
+
/** 由 RedisPlugin.setup() 调用:拿 app 才能推事件(redis:message / redis:state) */
|
|
643
|
+
public func attachApp(a: TauriApp): Unit {
|
|
644
|
+
this.app = Some(a)
|
|
645
|
+
this.lock.lock()
|
|
646
|
+
this.current.app = this.app
|
|
647
|
+
this.lock.unlock()
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
public func handle(cmd: String, args: JsonObject, ipc: IpcContext): JsonValue {
|
|
651
|
+
let _ = ipc
|
|
652
|
+
match (cmd) {
|
|
653
|
+
case "redis:connect" => this.connectCmd(args)
|
|
654
|
+
case "redis:disconnect" => this.disconnectCmd(args)
|
|
655
|
+
case "redis:select" => this.selectCmd(args)
|
|
656
|
+
case "redis:status" => this.statusCmd(args)
|
|
657
|
+
case "redis:info" => this.infoCmd(args)
|
|
658
|
+
case "redis:dbsize" => this.dbsizeCmd(args)
|
|
659
|
+
case "redis:slowlog" => this.slowlogCmd(args)
|
|
660
|
+
case "redis:scan" => this.scanCmd(args)
|
|
661
|
+
case "redis:keyInfo" => this.keyInfoCmd(args)
|
|
662
|
+
case "redis:keyRename" => this.keyRenameCmd(args)
|
|
663
|
+
case "redis:keyDelete" => this.keyDeleteCmd(args)
|
|
664
|
+
case "redis:keyExpire" => this.keyExpireCmd(args)
|
|
665
|
+
case "redis:keyPersist" => this.keyPersistCmd(args)
|
|
666
|
+
case "redis:value" => this.valueCmd(args)
|
|
667
|
+
case "redis:valueWrite" => this.valueWriteCmd(args)
|
|
668
|
+
case "redis:subscribe" => this.subscribeCmd(args)
|
|
669
|
+
case "redis:unsubscribe" => this.unsubscribeCmd(args)
|
|
670
|
+
case "redis:publish" => this.publishCmd(args)
|
|
671
|
+
case "redis:messages" => this.messagesCmd(args)
|
|
672
|
+
case _ => throw CommandException("unknown redis command: ${cmd}")
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/** 取当前会话(拿的是引用;字段读写由调用方自己加锁) */
|
|
677
|
+
private func session(): RedisSession {
|
|
678
|
+
this.lock.lock()
|
|
679
|
+
let s = this.current
|
|
680
|
+
this.lock.unlock()
|
|
681
|
+
return s
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/** 取当前会话的 app 引用(会话里的 app 由 attachApp 填,这里只是搭桥给顶层函数用) */
|
|
685
|
+
private func attachTo(sess: RedisSession): Unit {
|
|
686
|
+
this.lock.lock()
|
|
687
|
+
sess.app = this.app
|
|
688
|
+
this.lock.unlock()
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// ------------------------------------------------------------ 参数解析
|
|
692
|
+
|
|
693
|
+
private func requireString(args: JsonObject, key: String): String {
|
|
694
|
+
match (args.get(key)) {
|
|
695
|
+
case Some(v) => match (v.kind()) {
|
|
696
|
+
case JsonKind.JsString => v.asString().getValue()
|
|
697
|
+
case _ => throw CommandException("redis: argument \"${key}\" must be a string")
|
|
698
|
+
}
|
|
699
|
+
case None => throw CommandException("redis: missing argument \"${key}\"")
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/** 可选字符串参数:缺失 / null 都按空串(用例:host 必填、db 可选) */
|
|
704
|
+
private func optionalString(args: JsonObject, key: String): String {
|
|
705
|
+
match (args.get(key)) {
|
|
706
|
+
case Some(v) => match (v.kind()) {
|
|
707
|
+
case JsonKind.JsString => v.asString().getValue()
|
|
708
|
+
case JsonKind.JsNull => ""
|
|
709
|
+
case _ => throw CommandException("redis: argument \"${key}\" must be a string")
|
|
710
|
+
}
|
|
711
|
+
case None => ""
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/** 可选字符串参数(要区分「没给」与「给了空串」时用它,例如 password) */
|
|
716
|
+
private func optionalStringValue(args: JsonObject, key: String): Option<String> {
|
|
717
|
+
match (args.get(key)) {
|
|
718
|
+
case Some(v) => match (v.kind()) {
|
|
719
|
+
case JsonKind.JsString => Some(v.asString().getValue())
|
|
720
|
+
case JsonKind.JsNull => None
|
|
721
|
+
case _ => throw CommandException("redis: argument \"${key}\" must be a string")
|
|
722
|
+
}
|
|
723
|
+
case None => None
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/** 可选整数参数:缺失 / null 都取 fallback;JSON 侧 numeric 可能是整数也可能是浮点 */
|
|
728
|
+
private func optionalInt(args: JsonObject, key: String, fallback: Int64): Int64 {
|
|
729
|
+
match (args.get(key)) {
|
|
730
|
+
case Some(v) => match (v.kind()) {
|
|
731
|
+
case JsonKind.JsInt => v.asInt().getValue()
|
|
732
|
+
case JsonKind.JsFloat =>
|
|
733
|
+
// 浮点转整数先夹范围:越界会抛异常把整条命令带走(AGENTS §4 的那条坑)
|
|
734
|
+
redisClampInt(Int64(v.asFloat().getValue()), -65536, 65536)
|
|
735
|
+
case JsonKind.JsNull => fallback
|
|
736
|
+
case _ => throw CommandException("redis: argument \"${key}\" must be a number")
|
|
737
|
+
}
|
|
738
|
+
case None => fallback
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/** 可选布尔参数:缺失 / null 都取 fallback */
|
|
743
|
+
private func optionalBool(args: JsonObject, key: String, fallback: Bool): Bool {
|
|
744
|
+
match (args.get(key)) {
|
|
745
|
+
case Some(v) => match (v.kind()) {
|
|
746
|
+
case JsonKind.JsBool => v.asBool().getValue()
|
|
747
|
+
case JsonKind.JsNull => fallback
|
|
748
|
+
case _ => throw CommandException("redis: argument \"${key}\" must be a boolean")
|
|
749
|
+
}
|
|
750
|
+
case None => fallback
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/** 必填的非空字符串数组(channels / keys):元素必须都是字符串,空数组即拒(免得生成 `DEL` 这种残命令) */
|
|
755
|
+
private func requireStringArray(args: JsonObject, key: String): ArrayList<String> {
|
|
756
|
+
let out = ArrayList<String>()
|
|
757
|
+
match (args.get(key)) {
|
|
758
|
+
case Some(v) => match (v.kind()) {
|
|
759
|
+
case JsonKind.JsArray =>
|
|
760
|
+
let items = v.asArray().getItems()
|
|
761
|
+
for (it in items) {
|
|
762
|
+
match (it.kind()) {
|
|
763
|
+
case JsonKind.JsString => out.add(it.asString().getValue())
|
|
764
|
+
case _ => throw CommandException("redis: argument \"${key}\" must be an array of strings")
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
case _ => throw CommandException("redis: argument \"${key}\" must be an array of strings")
|
|
768
|
+
}
|
|
769
|
+
case None => throw CommandException("redis: missing argument \"${key}\"")
|
|
770
|
+
}
|
|
771
|
+
if (out.size == 0) {
|
|
772
|
+
throw CommandException("redis: argument \"${key}\" must not be empty")
|
|
773
|
+
}
|
|
774
|
+
return out
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/** 可选字符串数组:缺失 / null 都给空表 */
|
|
778
|
+
private func optionalStringArray(args: JsonObject, key: String): ArrayList<String> {
|
|
779
|
+
match (args.get(key)) {
|
|
780
|
+
case Some(v) => match (v.kind()) {
|
|
781
|
+
case JsonKind.JsArray =>
|
|
782
|
+
let out = ArrayList<String>()
|
|
783
|
+
for (it in v.asArray().getItems()) {
|
|
784
|
+
match (it.kind()) {
|
|
785
|
+
case JsonKind.JsString => out.add(it.asString().getValue())
|
|
786
|
+
case _ => throw CommandException("redis: argument \"${key}\" must be an array of strings")
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
return out
|
|
790
|
+
case JsonKind.JsNull => return ArrayList<String>()
|
|
791
|
+
case _ => throw CommandException("redis: argument \"${key}\" must be an array of strings")
|
|
792
|
+
}
|
|
793
|
+
case None => return ArrayList<String>()
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* 取要写进 Redis 的字节:优先 `valueHex`(页面拿非 UTF-8 的旧值原样回写时走这条),
|
|
799
|
+
* 否则用 `value` 的 UTF-8 字节。两者都给时以 hex 为准(它是无损的那一份)。
|
|
800
|
+
*/
|
|
801
|
+
private func valueBytes(args: JsonObject, valueKey: String, hexKey: String): Array<UInt8> {
|
|
802
|
+
let hex = this.optionalString(args, hexKey)
|
|
803
|
+
if (hex.size > 0) {
|
|
804
|
+
match (hexToBytes(hex)) {
|
|
805
|
+
case Some(b) => return b
|
|
806
|
+
case None => throw CommandException("redis: argument \"${hexKey}\" is not valid hex")
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
return this.optionalString(args, valueKey).toArray()
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// ------------------------------------------------------------ 连接类命令
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* `redis:connect { host, port?, db?, username?, password?, timeoutMs? }`
|
|
816
|
+
*
|
|
817
|
+
* 握手在**本命令线程上同步做**(TCP → AUTH → SELECT → PING),结果随命令结果回给前端:
|
|
818
|
+
* 页面不用猜「到底连上没有」。有意**不做自动重连、不做静默降级**——调试工具最怕假连接。
|
|
819
|
+
*/
|
|
820
|
+
private func connectCmd(args: JsonObject): JsonValue {
|
|
821
|
+
let host = this.requireString(args, "host")
|
|
822
|
+
let port = this.optionalInt(args, "port", 6379)
|
|
823
|
+
let db = this.optionalInt(args, "db", 0)
|
|
824
|
+
let user = this.optionalString(args, "username")
|
|
825
|
+
let pass = this.optionalStringValue(args, "password")
|
|
826
|
+
let timeoutMs = this.optionalInt(args, "timeoutMs", REDIS_DEFAULT_TIMEOUT_MS)
|
|
827
|
+
if (!redisValidHost(host)) {
|
|
828
|
+
throw CommandException("redis: host 非法(只收 IP / 主机名的安全字符 [A-Za-z0-9._:-],长度 1..253)")
|
|
829
|
+
}
|
|
830
|
+
if (!redisValidPort(port)) {
|
|
831
|
+
throw CommandException("redis: port 非法(须在 1..65535)")
|
|
832
|
+
}
|
|
833
|
+
if (!redisValidDb(db)) {
|
|
834
|
+
throw CommandException("redis: db 非法(须在 0..255)")
|
|
835
|
+
}
|
|
836
|
+
if (timeoutMs < 200 || timeoutMs > 60000) {
|
|
837
|
+
throw CommandException("redis: timeoutMs 非法(须在 200..60000)")
|
|
838
|
+
}
|
|
839
|
+
this.closePrevious()
|
|
840
|
+
// 新会话(不复用旧状态):旧会话的订阅线程只碰它自己那份,见类注释
|
|
841
|
+
let sess = RedisSession()
|
|
842
|
+
sess.host = host
|
|
843
|
+
sess.port = port
|
|
844
|
+
sess.db = db
|
|
845
|
+
sess.timeoutMs = timeoutMs
|
|
846
|
+
sess.state = "connecting"
|
|
847
|
+
sess.username = user
|
|
848
|
+
match (pass) {
|
|
849
|
+
case Some(p) => sess.password = p
|
|
850
|
+
case None => ()
|
|
851
|
+
}
|
|
852
|
+
this.lock.lock()
|
|
853
|
+
this.current = sess
|
|
854
|
+
this.lock.unlock()
|
|
855
|
+
this.attachTo(sess)
|
|
856
|
+
|
|
857
|
+
var opened: Option<TcpSocket> = None
|
|
858
|
+
try {
|
|
859
|
+
let sock = redisOpenSocket(sess, timeoutMs)
|
|
860
|
+
opened = Some(sock)
|
|
861
|
+
redisHandshake(sess, sock, sess.cmdReader, true)
|
|
862
|
+
sess.lock.lock()
|
|
863
|
+
sess.cmd = Some(sock)
|
|
864
|
+
sess.state = "connected"
|
|
865
|
+
sess.lock.unlock()
|
|
866
|
+
} catch (e: CommandException) {
|
|
867
|
+
match (opened) {
|
|
868
|
+
case Some(s) => redisCloseQuiet(s)
|
|
869
|
+
case None => ()
|
|
870
|
+
}
|
|
871
|
+
redisFailSession(sess, e.message)
|
|
872
|
+
throw e
|
|
873
|
+
}
|
|
874
|
+
redisEmitState(sess)
|
|
875
|
+
eprintln("[cj-tauri] redis connect: host=${host} port=${port} db=${db} auth=${sess.authUsed} ok")
|
|
876
|
+
let out = JsonObject()
|
|
877
|
+
out.put("ok", JsonBool(true))
|
|
878
|
+
out.put("host", JsonString(host))
|
|
879
|
+
out.put("port", JsonInt(port))
|
|
880
|
+
out.put("db", JsonInt(db))
|
|
881
|
+
out.put("auth", JsonBool(sess.authUsed))
|
|
882
|
+
out.put("timeoutMs", JsonInt(timeoutMs))
|
|
883
|
+
out.put("note", JsonString("握手已含 AUTH(给了口令时)→ SELECT(非 0 库时)→ PING 验活"))
|
|
884
|
+
out
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/** `redis:disconnect`:收掉订阅线程与两条连接;状态置 closed(不是 error——是本端主动收) */
|
|
888
|
+
private func disconnectCmd(args: JsonObject): JsonValue {
|
|
889
|
+
let _ = args
|
|
890
|
+
let sess = this.session()
|
|
891
|
+
sess.lock.lock()
|
|
892
|
+
sess.stopSubRequested = true
|
|
893
|
+
let hadSub = sess.subAlive
|
|
894
|
+
let hadChannels = sess.channels.size
|
|
895
|
+
if (sess.state == "connected" || sess.state == "connecting") {
|
|
896
|
+
sess.state = "closed"
|
|
897
|
+
}
|
|
898
|
+
sess.lock.unlock()
|
|
899
|
+
this.waitSubExit(sess)
|
|
900
|
+
sess.lock.lock()
|
|
901
|
+
let subSock = sess.sub
|
|
902
|
+
let cmdSock = sess.cmd
|
|
903
|
+
sess.sub = None
|
|
904
|
+
sess.cmd = None
|
|
905
|
+
sess.subAlive = false
|
|
906
|
+
sess.subPendingAcks = 0
|
|
907
|
+
sess.channels = ArrayList<String>()
|
|
908
|
+
sess.patterns = ArrayList<String>()
|
|
909
|
+
sess.lock.unlock()
|
|
910
|
+
match (subSock) {
|
|
911
|
+
case Some(s) => redisCloseQuiet(s)
|
|
912
|
+
case None => ()
|
|
913
|
+
}
|
|
914
|
+
match (cmdSock) {
|
|
915
|
+
case Some(s) => redisCloseQuiet(s)
|
|
916
|
+
case None => ()
|
|
917
|
+
}
|
|
918
|
+
redisEmitState(sess)
|
|
919
|
+
let out = JsonObject()
|
|
920
|
+
out.put("ok", JsonBool(true))
|
|
921
|
+
out.put("hadSub", JsonBool(hadSub))
|
|
922
|
+
out.put("droppedChannels", JsonInt(hadChannels))
|
|
923
|
+
out
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/** `redis:select { db }`:切库(会话记下新库号,status 与事件里的 db 会跟着变) */
|
|
927
|
+
private func selectCmd(args: JsonObject): JsonValue {
|
|
928
|
+
let db = this.optionalInt(args, "db", 0)
|
|
929
|
+
if (!redisValidDb(db)) {
|
|
930
|
+
throw CommandException("redis: db 非法(须在 0..255)")
|
|
931
|
+
}
|
|
932
|
+
let sess = this.session()
|
|
933
|
+
redisExpectOk(redisCall(sess, ["SELECT", "${db}"]), "SELECT")
|
|
934
|
+
sess.lock.lock()
|
|
935
|
+
sess.db = db
|
|
936
|
+
sess.lock.unlock()
|
|
937
|
+
redisEmitState(sess)
|
|
938
|
+
let out = JsonObject()
|
|
939
|
+
out.put("ok", JsonBool(true))
|
|
940
|
+
out.put("db", JsonInt(db))
|
|
941
|
+
out
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* `redis:status`:一份可对账的连接快照(页面顶栏就是它)。
|
|
946
|
+
*
|
|
947
|
+
* 口令**不在其中**(会话里留着是为了给订阅连接再认证一次,回显只会多一份副本)。
|
|
948
|
+
* 注意 `state` 是字符串而不是枚举,页面直接显示,不需要自己攒一张对照表。
|
|
949
|
+
*/
|
|
950
|
+
private func statusCmd(args: JsonObject): JsonValue {
|
|
951
|
+
let _ = args
|
|
952
|
+
let sess = this.session()
|
|
953
|
+
let out = JsonObject()
|
|
954
|
+
sess.lock.lock()
|
|
955
|
+
out.put("state", JsonString(sess.state))
|
|
956
|
+
out.put("host", JsonString(sess.host))
|
|
957
|
+
out.put("port", JsonInt(sess.port))
|
|
958
|
+
out.put("db", JsonInt(sess.db))
|
|
959
|
+
out.put("auth", JsonBool(sess.authUsed))
|
|
960
|
+
out.put("timeoutMs", JsonInt(sess.timeoutMs))
|
|
961
|
+
out.put("subAlive", JsonBool(sess.subAlive))
|
|
962
|
+
out.put("calls", JsonInt(sess.calls))
|
|
963
|
+
out.put("messages", JsonInt(sess.msgCount))
|
|
964
|
+
out.put("dropped", JsonInt(sess.dropped))
|
|
965
|
+
out.put("buffered", JsonInt(sess.recent.size))
|
|
966
|
+
out.put("lastError", JsonString(sess.lastError))
|
|
967
|
+
out.put("lastTxAt", JsonString(sess.lastTxAt.toString()))
|
|
968
|
+
out.put("lastRxAt", JsonString(sess.lastRxAt.toString()))
|
|
969
|
+
out.put("channels", redisStringArray(sess.channels))
|
|
970
|
+
out.put("patterns", redisStringArray(sess.patterns))
|
|
971
|
+
sess.lock.unlock()
|
|
972
|
+
out.put("note", JsonString("calls = 已往返的 RESP 命令数;dropped = 订阅缓冲满被丢弃的报文数"))
|
|
973
|
+
out
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// ------------------------------------------------------------ 服务器信息类命令
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* `redis:info { section? }`:服务器信息(不给 section 就是 INFO 全量,几 KB 量级)。
|
|
980
|
+
*
|
|
981
|
+
* 返回值同时给 `text`(原文)与 `sections`(结构化解析):解析是**尽力而为**的——各版本字段会变,
|
|
982
|
+
* 解析不出来时 text 永远是对的,页面别把解析结果当唯一真源。
|
|
983
|
+
*/
|
|
984
|
+
private func infoCmd(args: JsonObject): JsonValue {
|
|
985
|
+
let sess = this.session()
|
|
986
|
+
let section = this.optionalString(args, "section")
|
|
987
|
+
var reply = RespValue.Simple("")
|
|
988
|
+
if (section.size > 0) {
|
|
989
|
+
reply = redisCall(sess, ["INFO", section])
|
|
990
|
+
} else {
|
|
991
|
+
reply = redisCall(sess, ["INFO"])
|
|
992
|
+
}
|
|
993
|
+
let text = redisTextReply(reply, "INFO")
|
|
994
|
+
let out = JsonObject()
|
|
995
|
+
out.put("section", JsonString(section))
|
|
996
|
+
out.put("bytes", JsonInt(text.size))
|
|
997
|
+
out.put("text", JsonString(text))
|
|
998
|
+
out.put("sections", redisInfoParse(text))
|
|
999
|
+
out
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/** `redis:dbsize`:当前库的键总数(一个 O(1) 的规模感来源,页面顶栏用它) */
|
|
1003
|
+
private func dbsizeCmd(args: JsonObject): JsonValue {
|
|
1004
|
+
let _ = args
|
|
1005
|
+
let sess = this.session()
|
|
1006
|
+
let size = redisNumberReply(redisCall(sess, ["DBSIZE"]), "DBSIZE")
|
|
1007
|
+
sess.lock.lock()
|
|
1008
|
+
let db = sess.db
|
|
1009
|
+
sess.lock.unlock()
|
|
1010
|
+
let out = JsonObject()
|
|
1011
|
+
out.put("db", JsonInt(db))
|
|
1012
|
+
out.put("size", JsonInt(size))
|
|
1013
|
+
out
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/**
|
|
1017
|
+
* `redis:slowlog { count? }`:慢查询日志(`SLOWLOG GET <n>`,n 夹在 1..128)。
|
|
1018
|
+
*
|
|
1019
|
+
* 没开慢日志的服务器会回空数组——那是**对的答案**,不是错误,别把它翻成失败。
|
|
1020
|
+
*/
|
|
1021
|
+
private func slowlogCmd(args: JsonObject): JsonValue {
|
|
1022
|
+
let sess = this.session()
|
|
1023
|
+
let count = redisClampInt(this.optionalInt(args, "count", 10), 1, 128)
|
|
1024
|
+
let reply = redisCall(sess, ["SLOWLOG", "GET", "${count}"])
|
|
1025
|
+
let entries = ArrayList<JsonValue>()
|
|
1026
|
+
for (it in respItemsOrEmpty(reply)) {
|
|
1027
|
+
entries.add(redisSlowlogEntry(it))
|
|
1028
|
+
}
|
|
1029
|
+
let out = JsonObject()
|
|
1030
|
+
out.put("requested", JsonInt(count))
|
|
1031
|
+
out.put("count", JsonInt(Int64(entries.size)))
|
|
1032
|
+
out.put("entries", JsonArray(entries))
|
|
1033
|
+
out
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
// ------------------------------------------------------------ 键空间类命令
|
|
1037
|
+
|
|
1038
|
+
/**
|
|
1039
|
+
* `redis:scan { cursor?, match?, count?, type? }`:SCAN 一页,并**给每个键补 TYPE 与 TTL**。
|
|
1040
|
+
*
|
|
1041
|
+
* 为什么要补:浏览列表的用处一半在「这个键是什么、还有多久过期」,而 SCAN 只给键名。
|
|
1042
|
+
* 用一次流水线取齐(少一堆往返);TYPE/TTL 都是 O(1),不会把一页拖慢。
|
|
1043
|
+
*
|
|
1044
|
+
* `type` 过滤交给服务端(SCAN 的 TYPE 选项,Redis 6.0+):老服务端不认会回错误,**那就如实抛**
|
|
1045
|
+
* ——退化成「客户端自己过滤」会让页面以为过滤生效了,而返回的条数对不上。
|
|
1046
|
+
*/
|
|
1047
|
+
private func scanCmd(args: JsonObject): JsonValue {
|
|
1048
|
+
let sess = this.session()
|
|
1049
|
+
let cursor = this.optionalString(args, "cursor")
|
|
1050
|
+
let pattern = this.optionalString(args, "match")
|
|
1051
|
+
let typeFilter = this.optionalString(args, "type")
|
|
1052
|
+
let count = redisClampInt(this.optionalInt(args, "count", 100), 1, 1000)
|
|
1053
|
+
var parts = ArrayList<String>()
|
|
1054
|
+
parts.add("SCAN")
|
|
1055
|
+
if (cursor.size > 0) {
|
|
1056
|
+
parts.add(cursor)
|
|
1057
|
+
} else {
|
|
1058
|
+
parts.add("0")
|
|
1059
|
+
}
|
|
1060
|
+
if (pattern.size > 0) {
|
|
1061
|
+
parts.add("MATCH")
|
|
1062
|
+
parts.add(pattern)
|
|
1063
|
+
}
|
|
1064
|
+
parts.add("COUNT")
|
|
1065
|
+
parts.add("${count}")
|
|
1066
|
+
if (typeFilter.size > 0) {
|
|
1067
|
+
parts.add("TYPE")
|
|
1068
|
+
parts.add(typeFilter)
|
|
1069
|
+
}
|
|
1070
|
+
let reply = redisCall(sess, parts.toArray())
|
|
1071
|
+
// 回复形状固定是 `[下一个游标, [键 ...]]`:形状不对说明对端不是我以为的东西,当场报
|
|
1072
|
+
let top = respItemsOrEmpty(reply)
|
|
1073
|
+
if (top.size < 2) {
|
|
1074
|
+
throw CommandException("redis: SCAN 回复形状不对(${respBrief(reply)})")
|
|
1075
|
+
}
|
|
1076
|
+
let next = respValueText(top[0])
|
|
1077
|
+
let keys = ArrayList<String>()
|
|
1078
|
+
for (k in respItemsOrEmpty(top[1])) {
|
|
1079
|
+
keys.add(respValueText(k))
|
|
1080
|
+
}
|
|
1081
|
+
let frames = ArrayList<Array<UInt8>>()
|
|
1082
|
+
var i: Int64 = 0
|
|
1083
|
+
while (i < Int64(keys.size) && i < REDIS_PAGE_MAX) {
|
|
1084
|
+
frames.add(respCmd2("TYPE", keys[i]))
|
|
1085
|
+
frames.add(respCmd2("TTL", keys[i]))
|
|
1086
|
+
i += 1
|
|
1087
|
+
}
|
|
1088
|
+
let replies = redisCallMany(sess, frames, "TYPE/TTL")
|
|
1089
|
+
let entries = ArrayList<JsonValue>()
|
|
1090
|
+
i = 0
|
|
1091
|
+
while (i < Int64(keys.size)) {
|
|
1092
|
+
let o = JsonObject()
|
|
1093
|
+
o.put("key", JsonString(keys[i]))
|
|
1094
|
+
if (i < REDIS_PAGE_MAX && i * 2 + 1 < Int64(replies.size)) {
|
|
1095
|
+
o.put("type", JsonString(respValueText(replies[i * 2])))
|
|
1096
|
+
o.put("ttl", JsonInt(redisNumberOr(replies[i * 2 + 1], -1)))
|
|
1097
|
+
o.put("enriched", JsonBool(true))
|
|
1098
|
+
} else {
|
|
1099
|
+
// 超出补全上限的键**如实标注未补全**:页面要么翻页要么调小 count,
|
|
1100
|
+
// 不许出现「type=?」这种看着像数据的东西(那比缺字段更容易被误读)
|
|
1101
|
+
o.put("type", JsonString(""))
|
|
1102
|
+
o.put("ttl", JsonInt(-1))
|
|
1103
|
+
o.put("enriched", JsonBool(false))
|
|
1104
|
+
}
|
|
1105
|
+
entries.add(o)
|
|
1106
|
+
i += 1
|
|
1107
|
+
}
|
|
1108
|
+
let out = JsonObject()
|
|
1109
|
+
out.put("cursor", JsonString(next))
|
|
1110
|
+
out.put("done", JsonBool(next == "0"))
|
|
1111
|
+
out.put("keys", JsonArray(entries))
|
|
1112
|
+
out.put("count", JsonInt(Int64(keys.size)))
|
|
1113
|
+
out.put("enriched", JsonInt(redisClampInt(Int64(keys.size), 0, REDIS_PAGE_MAX)))
|
|
1114
|
+
out.put("countOption", JsonInt(count))
|
|
1115
|
+
out
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
/** `redis:keyInfo { key }`:TYPE + TTL + 该类型的规模(STRLEN/HLEN/…/XLEN 之一) */
|
|
1119
|
+
private func keyInfoCmd(args: JsonObject): JsonValue {
|
|
1120
|
+
let sess = this.session()
|
|
1121
|
+
let key = this.requireString(args, "key")
|
|
1122
|
+
let typeName = respValueText(redisCall(sess, ["TYPE", key]))
|
|
1123
|
+
let out = JsonObject()
|
|
1124
|
+
out.put("key", JsonString(key))
|
|
1125
|
+
out.put("type", JsonString(typeName))
|
|
1126
|
+
out.put("exists", JsonBool(typeName != "none"))
|
|
1127
|
+
if (typeName == "none") {
|
|
1128
|
+
// 键不在了就到此为止:再发 TTL / 规模命令只会在日志里制造「这些命令为什么失败」的噪音
|
|
1129
|
+
return out
|
|
1130
|
+
}
|
|
1131
|
+
out.put("ttl", JsonInt(redisNumberOr(redisCall(sess, ["TTL", key]), -1)))
|
|
1132
|
+
out.put("size", JsonInt(redisKeySize(sess, key, typeName)))
|
|
1133
|
+
out.put("sizeUnit", JsonString(redisKeySizeUnit(typeName)))
|
|
1134
|
+
out
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/** `redis:keyRename { key, newKey }`:RENAME(源键不存在时 Redis 回错误,如实抛出) */
|
|
1138
|
+
private func keyRenameCmd(args: JsonObject): JsonValue {
|
|
1139
|
+
let sess = this.session()
|
|
1140
|
+
let key = this.requireString(args, "key")
|
|
1141
|
+
let newKey = this.requireString(args, "newKey")
|
|
1142
|
+
if (key == newKey) {
|
|
1143
|
+
throw CommandException("redis: 新旧键名相同,RENAME 会被服务端判为错误(要么改名,要么别改)")
|
|
1144
|
+
}
|
|
1145
|
+
redisExpectOk(redisCall(sess, ["RENAME", key, newKey]), "RENAME")
|
|
1146
|
+
let out = JsonObject()
|
|
1147
|
+
out.put("ok", JsonBool(true))
|
|
1148
|
+
out.put("key", JsonString(key))
|
|
1149
|
+
out.put("newKey", JsonString(newKey))
|
|
1150
|
+
out
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
/** `redis:keyDelete { keys: [...] }`:DEL 一把(一次发完,回的是真删掉的条数) */
|
|
1154
|
+
private func keyDeleteCmd(args: JsonObject): JsonValue {
|
|
1155
|
+
let sess = this.session()
|
|
1156
|
+
let keys = this.requireStringArray(args, "keys")
|
|
1157
|
+
var parts = ArrayList<String>()
|
|
1158
|
+
parts.add("DEL")
|
|
1159
|
+
for (k in keys) {
|
|
1160
|
+
parts.add(k)
|
|
1161
|
+
}
|
|
1162
|
+
let deleted = redisNumberReply(redisCall(sess, parts.toArray()), "DEL")
|
|
1163
|
+
let out = JsonObject()
|
|
1164
|
+
out.put("requested", JsonInt(Int64(keys.size)))
|
|
1165
|
+
out.put("deleted", JsonInt(deleted))
|
|
1166
|
+
out
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
/** `redis:keyExpire { key, seconds }`:EXPIRE(seconds=0 即立刻过期,允许;负数拒) */
|
|
1170
|
+
private func keyExpireCmd(args: JsonObject): JsonValue {
|
|
1171
|
+
let sess = this.session()
|
|
1172
|
+
let key = this.requireString(args, "key")
|
|
1173
|
+
let seconds = this.optionalInt(args, "seconds", 0)
|
|
1174
|
+
if (seconds < 0) {
|
|
1175
|
+
throw CommandException("redis: seconds 非法(不能为负;0 表示立刻过期)")
|
|
1176
|
+
}
|
|
1177
|
+
let applied = redisNumberReply(redisCall(sess, ["EXPIRE", key, "${seconds}"]), "EXPIRE")
|
|
1178
|
+
let out = JsonObject()
|
|
1179
|
+
out.put("key", JsonString(key))
|
|
1180
|
+
out.put("seconds", JsonInt(seconds))
|
|
1181
|
+
out.put("applied", JsonBool(applied == 1))
|
|
1182
|
+
out
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
/** `redis:keyPersist { key }`:PERSIST(去掉过期时间;键本身没了或原本就没过期时间都回 0) */
|
|
1186
|
+
private func keyPersistCmd(args: JsonObject): JsonValue {
|
|
1187
|
+
let sess = this.session()
|
|
1188
|
+
let key = this.requireString(args, "key")
|
|
1189
|
+
let applied = redisNumberReply(redisCall(sess, ["PERSIST", key]), "PERSIST")
|
|
1190
|
+
let out = JsonObject()
|
|
1191
|
+
out.put("key", JsonString(key))
|
|
1192
|
+
out.put("applied", JsonBool(applied == 1))
|
|
1193
|
+
out
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
// ------------------------------------------------------------ 取值类命令
|
|
1197
|
+
|
|
1198
|
+
/**
|
|
1199
|
+
* `redis:value { key, cursor?, count?, match?, offset?, limit? }`:按类型取值。
|
|
1200
|
+
*
|
|
1201
|
+
* 每种类型走**它自己的分页语义**(hash/set 是游标,list/zset/stream 是区间,string 是一次取回),
|
|
1202
|
+
* 不硬套一种:硬套会让大键一次拉全量(页面卡死),或让游标型结构没法翻页。
|
|
1203
|
+
* 返回体里的 `mode`(whole / cursor / range / missing / unsupported)说明走的是哪条路,页面据此翻页。
|
|
1204
|
+
*/
|
|
1205
|
+
private func valueCmd(args: JsonObject): JsonValue {
|
|
1206
|
+
let sess = this.session()
|
|
1207
|
+
let key = this.requireString(args, "key")
|
|
1208
|
+
let typeName = respValueText(redisCall(sess, ["TYPE", key]))
|
|
1209
|
+
let out = JsonObject()
|
|
1210
|
+
out.put("key", JsonString(key))
|
|
1211
|
+
out.put("type", JsonString(typeName))
|
|
1212
|
+
out.put("exists", JsonBool(typeName != "none"))
|
|
1213
|
+
if (typeName == "none") {
|
|
1214
|
+
out.put("mode", JsonString("missing"))
|
|
1215
|
+
return out
|
|
1216
|
+
}
|
|
1217
|
+
out.put("ttl", JsonInt(redisNumberOr(redisCall(sess, ["TTL", key]), -1)))
|
|
1218
|
+
if (typeName == REDIS_TYPE_STRING) {
|
|
1219
|
+
this.stringValue(sess, key, out)
|
|
1220
|
+
} else if (typeName == REDIS_TYPE_HASH) {
|
|
1221
|
+
this.hashValue(args, sess, key, out)
|
|
1222
|
+
} else if (typeName == REDIS_TYPE_SET) {
|
|
1223
|
+
this.setValue(args, sess, key, out)
|
|
1224
|
+
} else if (typeName == REDIS_TYPE_LIST) {
|
|
1225
|
+
this.listValue(args, sess, key, out)
|
|
1226
|
+
} else if (typeName == REDIS_TYPE_ZSET) {
|
|
1227
|
+
this.zsetValue(args, sess, key, out)
|
|
1228
|
+
} else if (typeName == REDIS_TYPE_STREAM) {
|
|
1229
|
+
this.streamValue(args, sess, key, out)
|
|
1230
|
+
} else {
|
|
1231
|
+
// 认不出的类型(新版本可能引入):如实说「这个版本的工具还不认识它」,**不猜成 string**
|
|
1232
|
+
out.put("mode", JsonString("unsupported"))
|
|
1233
|
+
out.put("note", JsonString("这个类型当前工具还不支持取值(type=${typeName})"))
|
|
1234
|
+
}
|
|
1235
|
+
out
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
/** 字符串:STRLEN + GET(一次取回;超展示上限只给前缀并置 truncated,原文长度照实报) */
|
|
1239
|
+
private func stringValue(sess: RedisSession, key: String, out: JsonObject): Unit {
|
|
1240
|
+
out.put("mode", JsonString("whole"))
|
|
1241
|
+
out.put("length", JsonInt(redisNumberReply(redisCall(sess, ["STRLEN", key]), "STRLEN")))
|
|
1242
|
+
match (respBytes(redisCall(sess, ["GET", key]))) {
|
|
1243
|
+
case Some(b) => out.put("value", redisValueDisplay(b))
|
|
1244
|
+
case None =>
|
|
1245
|
+
// GET 对不存在的键回 nil:TYPE 刚说是 string,说明两次命令之间键被删了——如实说,别假装是空串
|
|
1246
|
+
out.put("exists", JsonBool(false))
|
|
1247
|
+
out.put("mode", JsonString("missing"))
|
|
1248
|
+
out.put("note", JsonString("键在两次命令之间消失了(TYPE 说是 string,GET 回的是 nil)"))
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
/** 哈希:HSCAN 游标分页(游标由页面原样带回来),总数用 HLEN 补 */
|
|
1253
|
+
private func hashValue(args: JsonObject, sess: RedisSession, key: String, out: JsonObject): Unit {
|
|
1254
|
+
out.put("mode", JsonString("cursor"))
|
|
1255
|
+
let top = respItemsOrEmpty(redisCall(sess, this.scanParts("HSCAN", key, args)))
|
|
1256
|
+
if (top.size < 2) {
|
|
1257
|
+
throw CommandException("redis: HSCAN 回复形状不对(${respBrief(redisCall(sess, ["HSCAN", key]))})")
|
|
1258
|
+
}
|
|
1259
|
+
out.put("cursor", JsonString(respValueText(top[0])))
|
|
1260
|
+
out.put("total", JsonInt(redisNumberOr(redisCall(sess, ["HLEN", key]), -1)))
|
|
1261
|
+
let entries = ArrayList<JsonValue>()
|
|
1262
|
+
for (pair in respPairs(top[1])) {
|
|
1263
|
+
let e = JsonObject()
|
|
1264
|
+
e.put("field", JsonString(pair[0]))
|
|
1265
|
+
e.put("value", JsonString(pair[1]))
|
|
1266
|
+
entries.add(e)
|
|
1267
|
+
}
|
|
1268
|
+
out.put("entries", JsonArray(entries))
|
|
1269
|
+
out.put("count", JsonInt(Int64(entries.size)))
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
/** 集合:SSCAN 游标分页,总数用 SCARD 补 */
|
|
1273
|
+
private func setValue(args: JsonObject, sess: RedisSession, key: String, out: JsonObject): Unit {
|
|
1274
|
+
out.put("mode", JsonString("cursor"))
|
|
1275
|
+
let top = respItemsOrEmpty(redisCall(sess, this.scanParts("SSCAN", key, args)))
|
|
1276
|
+
if (top.size < 2) {
|
|
1277
|
+
throw CommandException("redis: SSCAN 回复形状不对(${respBrief(redisCall(sess, ["SSCAN", key]))})")
|
|
1278
|
+
}
|
|
1279
|
+
out.put("cursor", JsonString(respValueText(top[0])))
|
|
1280
|
+
out.put("total", JsonInt(redisNumberOr(redisCall(sess, ["SCARD", key]), -1)))
|
|
1281
|
+
let members = ArrayList<JsonValue>()
|
|
1282
|
+
for (m in respItemsOrEmpty(top[1])) {
|
|
1283
|
+
members.add(JsonString(respValueText(m)))
|
|
1284
|
+
}
|
|
1285
|
+
out.put("members", JsonArray(members))
|
|
1286
|
+
out.put("count", JsonInt(Int64(members.size)))
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
/** 列表:区间读(offset + limit),总长用 LLEN 给(页面据此画分页) */
|
|
1290
|
+
private func listValue(args: JsonObject, sess: RedisSession, key: String, out: JsonObject): Unit {
|
|
1291
|
+
out.put("mode", JsonString("range"))
|
|
1292
|
+
let total = redisNumberReply(redisCall(sess, ["LLEN", key]), "LLEN")
|
|
1293
|
+
out.put("total", JsonInt(total))
|
|
1294
|
+
if (total == 0) {
|
|
1295
|
+
out.put("items", JsonArray(ArrayList<JsonValue>()))
|
|
1296
|
+
out.put("offset", JsonInt(0))
|
|
1297
|
+
out.put("limit", JsonInt(0))
|
|
1298
|
+
out.put("note", JsonString("列表是空的(LLEN 为 0)"))
|
|
1299
|
+
return
|
|
1300
|
+
}
|
|
1301
|
+
let offset = redisClampInt(this.optionalInt(args, "offset", 0), 0, 9007199254740991)
|
|
1302
|
+
let limit = redisClampInt(this.optionalInt(args, "limit", 50), 1, REDIS_PAGE_MAX)
|
|
1303
|
+
var stopIdx = offset + limit - 1
|
|
1304
|
+
if (stopIdx > total - 1) {
|
|
1305
|
+
stopIdx = total - 1
|
|
1306
|
+
}
|
|
1307
|
+
let items = ArrayList<JsonValue>()
|
|
1308
|
+
for (it in respItemsOrEmpty(redisCall(sess, ["LRANGE", key, "${offset}", "${stopIdx}"]))) {
|
|
1309
|
+
items.add(JsonString(respValueText(it)))
|
|
1310
|
+
}
|
|
1311
|
+
out.put("items", JsonArray(items))
|
|
1312
|
+
out.put("offset", JsonInt(offset))
|
|
1313
|
+
out.put("limit", JsonInt(stopIdx - offset + 1))
|
|
1314
|
+
out.put("note", JsonString("LRANGE ${offset} .. ${stopIdx}"))
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
/** 有序集合:区间读 + WITHSCORES(回复是 `[成员, 分值, ...]` 的扁平对) */
|
|
1318
|
+
private func zsetValue(args: JsonObject, sess: RedisSession, key: String, out: JsonObject): Unit {
|
|
1319
|
+
out.put("mode", JsonString("range"))
|
|
1320
|
+
let total = redisNumberReply(redisCall(sess, ["ZCARD", key]), "ZCARD")
|
|
1321
|
+
out.put("total", JsonInt(total))
|
|
1322
|
+
if (total == 0) {
|
|
1323
|
+
out.put("entries", JsonArray(ArrayList<JsonValue>()))
|
|
1324
|
+
out.put("offset", JsonInt(0))
|
|
1325
|
+
out.put("limit", JsonInt(0))
|
|
1326
|
+
out.put("note", JsonString("有序集合是空的(ZCARD 为 0)"))
|
|
1327
|
+
return
|
|
1328
|
+
}
|
|
1329
|
+
let offset = redisClampInt(this.optionalInt(args, "offset", 0), 0, 9007199254740991)
|
|
1330
|
+
let limit = redisClampInt(this.optionalInt(args, "limit", 50), 1, REDIS_PAGE_MAX)
|
|
1331
|
+
var stopIdx = offset + limit - 1
|
|
1332
|
+
if (stopIdx > total - 1) {
|
|
1333
|
+
stopIdx = total - 1
|
|
1334
|
+
}
|
|
1335
|
+
let entries = ArrayList<JsonValue>()
|
|
1336
|
+
for (pair in respPairs(redisCall(sess, ["ZRANGE", key, "${offset}", "${stopIdx}", "WITHSCORES"]))) {
|
|
1337
|
+
let e = JsonObject()
|
|
1338
|
+
e.put("member", JsonString(pair[0]))
|
|
1339
|
+
e.put("score", JsonString(pair[1]))
|
|
1340
|
+
entries.add(e)
|
|
1341
|
+
}
|
|
1342
|
+
out.put("entries", JsonArray(entries))
|
|
1343
|
+
out.put("offset", JsonInt(offset))
|
|
1344
|
+
out.put("limit", JsonInt(stopIdx - offset + 1))
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
/** 流:XRANGE 一页(`[id, [字段, 值, ...]]` 的嵌套形状),总数用 XLEN 补 */
|
|
1348
|
+
private func streamValue(args: JsonObject, sess: RedisSession, key: String, out: JsonObject): Unit {
|
|
1349
|
+
out.put("mode", JsonString("range"))
|
|
1350
|
+
out.put("total", JsonInt(redisNumberReply(redisCall(sess, ["XLEN", key]), "XLEN")))
|
|
1351
|
+
let count = redisClampInt(this.optionalInt(args, "count", 20), 1, REDIS_PAGE_MAX)
|
|
1352
|
+
let reply = redisCall(sess, ["XRANGE", key, "-", "+", "COUNT", "${count}"])
|
|
1353
|
+
let entries = ArrayList<JsonValue>()
|
|
1354
|
+
for (entry in respItemsOrEmpty(reply)) {
|
|
1355
|
+
let pair = respItemsOrEmpty(entry)
|
|
1356
|
+
let e = JsonObject()
|
|
1357
|
+
if (pair.size >= 2) {
|
|
1358
|
+
e.put("id", JsonString(respValueText(pair[0])))
|
|
1359
|
+
let fields = ArrayList<JsonValue>()
|
|
1360
|
+
for (fp in respPairs(pair[1])) {
|
|
1361
|
+
let f = JsonObject()
|
|
1362
|
+
f.put("field", JsonString(fp[0]))
|
|
1363
|
+
f.put("value", JsonString(fp[1]))
|
|
1364
|
+
fields.add(f)
|
|
1365
|
+
}
|
|
1366
|
+
e.put("fields", JsonArray(fields))
|
|
1367
|
+
} else {
|
|
1368
|
+
// 形状不认识就原样把人看的渲染给它,别丢——排查时「知道收到了什么怪东西」比好看重要
|
|
1369
|
+
e.put("raw", JsonString(respRender(entry)))
|
|
1370
|
+
}
|
|
1371
|
+
entries.add(e)
|
|
1372
|
+
}
|
|
1373
|
+
out.put("entries", JsonArray(entries))
|
|
1374
|
+
out.put("count", JsonInt(Int64(entries.size)))
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
/** HSCAN / SSCAN 的公共参数尾:`<key> <cursor> [MATCH 模式] COUNT 条数` */
|
|
1378
|
+
private func scanParts(cmd: String, key: String, args: JsonObject): Array<String> {
|
|
1379
|
+
let cursor = this.optionalString(args, "cursor")
|
|
1380
|
+
let pattern = this.optionalString(args, "match")
|
|
1381
|
+
let count = redisClampInt(this.optionalInt(args, "count", 100), 1, 1000)
|
|
1382
|
+
var parts = ArrayList<String>()
|
|
1383
|
+
parts.add(cmd)
|
|
1384
|
+
parts.add(key)
|
|
1385
|
+
if (cursor.size > 0) {
|
|
1386
|
+
parts.add(cursor)
|
|
1387
|
+
} else {
|
|
1388
|
+
parts.add("0")
|
|
1389
|
+
}
|
|
1390
|
+
if (pattern.size > 0) {
|
|
1391
|
+
parts.add("MATCH")
|
|
1392
|
+
parts.add(pattern)
|
|
1393
|
+
}
|
|
1394
|
+
parts.add("COUNT")
|
|
1395
|
+
parts.add("${count}")
|
|
1396
|
+
return parts.toArray()
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
// ------------------------------------------------------------ 改值类命令
|
|
1400
|
+
|
|
1401
|
+
/**
|
|
1402
|
+
* `redis:valueWrite { key, type, op, ... }`:按类型改数据。
|
|
1403
|
+
*
|
|
1404
|
+
* `type` 必填且**不由服务端探测**:让页面明确说出它以为是哪种类型——先探测再改会有 TOCTOU
|
|
1405
|
+
* (两次命令之间类型可能已被别人换掉,改的就成另一件事了)。认识的 op 是白名单,
|
|
1406
|
+
* **不认识的 op 一律拒**,页面可控的字符串不进任何分支。
|
|
1407
|
+
*/
|
|
1408
|
+
private func valueWriteCmd(args: JsonObject): JsonValue {
|
|
1409
|
+
let sess = this.session()
|
|
1410
|
+
let key = this.requireString(args, "key")
|
|
1411
|
+
let typeName = this.requireString(args, "type")
|
|
1412
|
+
let op = this.requireString(args, "op")
|
|
1413
|
+
var reply = RespValue.Simple("")
|
|
1414
|
+
if (typeName == REDIS_TYPE_STRING) {
|
|
1415
|
+
reply = this.writeString(args, sess, key, op)
|
|
1416
|
+
} else if (typeName == REDIS_TYPE_HASH) {
|
|
1417
|
+
reply = this.writeHash(args, sess, key, op)
|
|
1418
|
+
} else if (typeName == REDIS_TYPE_LIST) {
|
|
1419
|
+
reply = this.writeList(args, sess, key, op)
|
|
1420
|
+
} else if (typeName == REDIS_TYPE_SET) {
|
|
1421
|
+
reply = this.writeSet(args, sess, key, op)
|
|
1422
|
+
} else if (typeName == REDIS_TYPE_ZSET) {
|
|
1423
|
+
reply = this.writeZset(args, sess, key, op)
|
|
1424
|
+
} else if (typeName == REDIS_TYPE_STREAM) {
|
|
1425
|
+
reply = this.writeStream(args, sess, key, op)
|
|
1426
|
+
} else {
|
|
1427
|
+
throw CommandException("redis: type 非法(支持 string/hash/list/set/zset/stream,收到 ${typeName})")
|
|
1428
|
+
}
|
|
1429
|
+
let out = JsonObject()
|
|
1430
|
+
out.put("ok", JsonBool(true))
|
|
1431
|
+
out.put("key", JsonString(key))
|
|
1432
|
+
out.put("type", JsonString(typeName))
|
|
1433
|
+
out.put("op", JsonString(op))
|
|
1434
|
+
out.put("reply", JsonString(respBrief(reply)))
|
|
1435
|
+
match (reply) {
|
|
1436
|
+
case Number(n) => out.put("number", JsonInt(n))
|
|
1437
|
+
case Simple(s) => out.put("text", JsonString(s))
|
|
1438
|
+
case Bulk(Some(b)) => out.put("value", JsonString(respSafeText(b)))
|
|
1439
|
+
case Bulk(None) => out.put("value", JsonString(""))
|
|
1440
|
+
case _ => ()
|
|
1441
|
+
}
|
|
1442
|
+
out
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
private func writeString(args: JsonObject, sess: RedisSession, key: String, op: String): RespValue {
|
|
1446
|
+
let v = this.valueBytes(args, "value", "valueHex")
|
|
1447
|
+
if (op == "set") {
|
|
1448
|
+
return redisCallFrame(sess, respEncodeCommandRaw([redisRaw("SET"), redisRaw(key), v]), "SET")
|
|
1449
|
+
}
|
|
1450
|
+
if (op == "append") {
|
|
1451
|
+
return redisCallFrame(sess, respEncodeCommandRaw([redisRaw("APPEND"), redisRaw(key), v]), "APPEND")
|
|
1452
|
+
}
|
|
1453
|
+
throw CommandException("redis: string 不支持的 op(可用:set / append)")
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
private func writeHash(args: JsonObject, sess: RedisSession, key: String, op: String): RespValue {
|
|
1457
|
+
let field = this.requireString(args, "field")
|
|
1458
|
+
if (op == "hset") {
|
|
1459
|
+
let v = this.valueBytes(args, "value", "valueHex")
|
|
1460
|
+
return redisCallFrame(sess,
|
|
1461
|
+
respEncodeCommandRaw([redisRaw("HSET"), redisRaw(key), redisRaw(field), v]), "HSET")
|
|
1462
|
+
}
|
|
1463
|
+
if (op == "hdel") {
|
|
1464
|
+
return redisCall(sess, ["HDEL", key, field])
|
|
1465
|
+
}
|
|
1466
|
+
throw CommandException("redis: hash 不支持的 op(可用:hset / hdel)")
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
private func writeList(args: JsonObject, sess: RedisSession, key: String, op: String): RespValue {
|
|
1470
|
+
if (op == "push") {
|
|
1471
|
+
let v = this.valueBytes(args, "value", "valueHex")
|
|
1472
|
+
let head = this.optionalBool(args, "head", false)
|
|
1473
|
+
var cmd = "RPUSH"
|
|
1474
|
+
if (head) {
|
|
1475
|
+
cmd = "LPUSH"
|
|
1476
|
+
}
|
|
1477
|
+
return redisCallFrame(sess, respEncodeCommandRaw([redisRaw(cmd), redisRaw(key), v]), cmd)
|
|
1478
|
+
}
|
|
1479
|
+
if (op == "lset") {
|
|
1480
|
+
let index = this.optionalInt(args, "index", 0)
|
|
1481
|
+
if (index < 0) {
|
|
1482
|
+
throw CommandException("redis: lset 的 index 非法(不能为负)")
|
|
1483
|
+
}
|
|
1484
|
+
let v = this.valueBytes(args, "value", "valueHex")
|
|
1485
|
+
return redisCallFrame(sess,
|
|
1486
|
+
respEncodeCommandRaw([redisRaw("LSET"), redisRaw(key), redisRaw("${index}"), v]), "LSET")
|
|
1487
|
+
}
|
|
1488
|
+
if (op == "lrem") {
|
|
1489
|
+
let count = redisClampInt(this.optionalInt(args, "count", 0), -9007199254740991, 9007199254740991)
|
|
1490
|
+
let v = this.valueBytes(args, "value", "valueHex")
|
|
1491
|
+
return redisCallFrame(sess,
|
|
1492
|
+
respEncodeCommandRaw([redisRaw("LREM"), redisRaw(key), redisRaw("${count}"), v]), "LREM")
|
|
1493
|
+
}
|
|
1494
|
+
if (op == "pop") {
|
|
1495
|
+
let head = this.optionalBool(args, "head", false)
|
|
1496
|
+
var cmd = "RPOP"
|
|
1497
|
+
if (head) {
|
|
1498
|
+
cmd = "LPOP"
|
|
1499
|
+
}
|
|
1500
|
+
return redisCall(sess, [cmd, key])
|
|
1501
|
+
}
|
|
1502
|
+
throw CommandException("redis: list 不支持的 op(可用:push / lset / lrem / pop)")
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
private func writeSet(args: JsonObject, sess: RedisSession, key: String, op: String): RespValue {
|
|
1506
|
+
let member = this.valueBytes(args, "member", "memberHex")
|
|
1507
|
+
if (op == "sadd") {
|
|
1508
|
+
return redisCallFrame(sess,
|
|
1509
|
+
respEncodeCommandRaw([redisRaw("SADD"), redisRaw(key), member]), "SADD")
|
|
1510
|
+
}
|
|
1511
|
+
if (op == "srem") {
|
|
1512
|
+
return redisCallFrame(sess,
|
|
1513
|
+
respEncodeCommandRaw([redisRaw("SREM"), redisRaw(key), member]), "SREM")
|
|
1514
|
+
}
|
|
1515
|
+
throw CommandException("redis: set 不支持的 op(可用:sadd / srem)")
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
private func writeZset(args: JsonObject, sess: RedisSession, key: String, op: String): RespValue {
|
|
1519
|
+
let member = this.valueBytes(args, "member", "memberHex")
|
|
1520
|
+
if (op == "zadd") {
|
|
1521
|
+
// 分值走文本:Redis 自己认浮点文本(用 Double 转字符串反而会因精度写法不同被它当非法输入)
|
|
1522
|
+
let score = this.optionalString(args, "score")
|
|
1523
|
+
if (score.size == 0) {
|
|
1524
|
+
throw CommandException("redis: zadd 需要 score(例如 \"1.5\")")
|
|
1525
|
+
}
|
|
1526
|
+
return redisCallFrame(sess,
|
|
1527
|
+
respEncodeCommandRaw([redisRaw("ZADD"), redisRaw(key), redisRaw(score), member]), "ZADD")
|
|
1528
|
+
}
|
|
1529
|
+
if (op == "zrem") {
|
|
1530
|
+
return redisCallFrame(sess,
|
|
1531
|
+
respEncodeCommandRaw([redisRaw("ZREM"), redisRaw(key), member]), "ZREM")
|
|
1532
|
+
}
|
|
1533
|
+
throw CommandException("redis: zset 不支持的 op(可用:zadd / zrem)")
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
private func writeStream(args: JsonObject, sess: RedisSession, key: String, op: String): RespValue {
|
|
1537
|
+
if (op != "xadd") {
|
|
1538
|
+
throw CommandException("redis: stream 不支持的 op(可用:xadd)")
|
|
1539
|
+
}
|
|
1540
|
+
let fields = this.requireStringArray(args, "fields")
|
|
1541
|
+
if (fields.size % 2 != 0) {
|
|
1542
|
+
throw CommandException("redis: xadd 的 fields 必须是「名, 值」成对的偶数个")
|
|
1543
|
+
}
|
|
1544
|
+
var id = this.optionalString(args, "id")
|
|
1545
|
+
if (id.size == 0) {
|
|
1546
|
+
id = "*"
|
|
1547
|
+
}
|
|
1548
|
+
let raws = ArrayList<Array<UInt8>>()
|
|
1549
|
+
raws.add(redisRaw("XADD"))
|
|
1550
|
+
raws.add(redisRaw(key))
|
|
1551
|
+
raws.add(redisRaw(id))
|
|
1552
|
+
var i: Int64 = 0
|
|
1553
|
+
while (i < Int64(fields.size)) {
|
|
1554
|
+
raws.add(redisRaw(fields[i]))
|
|
1555
|
+
raws.add(redisRaw(fields[i + 1]))
|
|
1556
|
+
i += 2
|
|
1557
|
+
}
|
|
1558
|
+
return redisCallFrame(sess, respEncodeCommandRaw(raws.toArray()), "XADD")
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
// ------------------------------------------------------------ 发布订阅类命令
|
|
1562
|
+
|
|
1563
|
+
/**
|
|
1564
|
+
* `redis:subscribe { channels: [...], patterns?: [...] }`:订阅频道 / 模式。
|
|
1565
|
+
*
|
|
1566
|
+
* 订阅在**独立的一条连接**上做(协议:订阅状态下的连接只能收推送与发订阅类命令),
|
|
1567
|
+
* 读侧由 `redisSubLoop` 线程独占。请求写出后**有界等待回执**:到点没齐就如实报 `pending`
|
|
1568
|
+
* ——回执在路上但没到是真实状态,报 pending 比假装成功诚实(页面能凭它再查一次状态)。
|
|
1569
|
+
*/
|
|
1570
|
+
private func subscribeCmd(args: JsonObject): JsonValue {
|
|
1571
|
+
let sess = this.session()
|
|
1572
|
+
let channels = this.optionalStringArray(args, "channels")
|
|
1573
|
+
let patterns = this.optionalStringArray(args, "patterns")
|
|
1574
|
+
if (channels.size == 0 && patterns.size == 0) {
|
|
1575
|
+
throw CommandException("redis: 至少要给 channels 或 patterns 之一")
|
|
1576
|
+
}
|
|
1577
|
+
this.ensureSubSocket(sess)
|
|
1578
|
+
// 只订阅「还没订过的」:重复 SUBSCRIBE 服务端会认,但那会让回执计数与去重都没必要
|
|
1579
|
+
let frames = ArrayList<Array<UInt8>>()
|
|
1580
|
+
let addedChannels = ArrayList<String>()
|
|
1581
|
+
let addedPatterns = ArrayList<String>()
|
|
1582
|
+
sess.lock.lock()
|
|
1583
|
+
for (c in channels) {
|
|
1584
|
+
if (!redisListHas(sess.channels, c)) {
|
|
1585
|
+
frames.add(respCmd2("SUBSCRIBE", c))
|
|
1586
|
+
sess.channels.add(c)
|
|
1587
|
+
addedChannels.add(c)
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
for (p in patterns) {
|
|
1591
|
+
if (!redisListHas(sess.patterns, p)) {
|
|
1592
|
+
frames.add(respCmd2("PSUBSCRIBE", p))
|
|
1593
|
+
sess.patterns.add(p)
|
|
1594
|
+
addedPatterns.add(p)
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
sess.subPendingAcks += Int64(frames.size)
|
|
1598
|
+
sess.lock.unlock()
|
|
1599
|
+
this.writeSubFrames(sess, frames)
|
|
1600
|
+
let allAcked = this.waitSubAcks(sess, REDIS_SUB_ACK_TIMEOUT_MS)
|
|
1601
|
+
sess.lock.lock()
|
|
1602
|
+
let pending = sess.subPendingAcks
|
|
1603
|
+
let channelsJson = redisStringArray(sess.channels)
|
|
1604
|
+
let patternsJson = redisStringArray(sess.patterns)
|
|
1605
|
+
sess.lock.unlock()
|
|
1606
|
+
if (!allAcked) {
|
|
1607
|
+
eprintln("[cj-tauri] redis sub: 订阅回执未在 ${REDIS_SUB_ACK_TIMEOUT_MS}ms 内到齐" +
|
|
1608
|
+
"(pending=${pending}),如实上报,不假装订阅成功")
|
|
1609
|
+
}
|
|
1610
|
+
redisEmitState(sess)
|
|
1611
|
+
let out = JsonObject()
|
|
1612
|
+
out.put("ok", JsonBool(allAcked))
|
|
1613
|
+
out.put("pending", JsonInt(pending))
|
|
1614
|
+
out.put("addedChannels", redisStringArray(addedChannels))
|
|
1615
|
+
out.put("addedPatterns", redisStringArray(addedPatterns))
|
|
1616
|
+
out.put("channels", channelsJson)
|
|
1617
|
+
out.put("patterns", patternsJson)
|
|
1618
|
+
out.put("note", JsonString("ok=false 表示有回执未在限时内到齐(pending 是没到的那几条);" +
|
|
1619
|
+
"事件 redis:message 与拉取 redis:messages 两条通道都能拿到消息"))
|
|
1620
|
+
out
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
/**
|
|
1624
|
+
* `redis:unsubscribe { channels?: [...], patterns?: [...] }`:退订。
|
|
1625
|
+
*
|
|
1626
|
+
* 两个数组都不给 = **退订全部**(也就是把订阅连接收掉)。频道全退完时主动关闭订阅连接与读线程:
|
|
1627
|
+
* 留一条什么都不订的连接只会在服务器侧白占一个连接位。
|
|
1628
|
+
*/
|
|
1629
|
+
private func unsubscribeCmd(args: JsonObject): JsonValue {
|
|
1630
|
+
let sess = this.session()
|
|
1631
|
+
let channels = this.optionalStringArray(args, "channels")
|
|
1632
|
+
let patterns = this.optionalStringArray(args, "patterns")
|
|
1633
|
+
let all = channels.size == 0 && patterns.size == 0
|
|
1634
|
+
var channelList = channels
|
|
1635
|
+
var patternList = patterns
|
|
1636
|
+
sess.lock.lock()
|
|
1637
|
+
if (all) {
|
|
1638
|
+
for (c in sess.channels) {
|
|
1639
|
+
channelList.add(c)
|
|
1640
|
+
}
|
|
1641
|
+
for (p in sess.patterns) {
|
|
1642
|
+
patternList.add(p)
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
let frames = ArrayList<Array<UInt8>>()
|
|
1646
|
+
for (c in channelList) {
|
|
1647
|
+
if (redisListHas(sess.channels, c)) {
|
|
1648
|
+
frames.add(respCmd2("UNSUBSCRIBE", c))
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
for (p in patternList) {
|
|
1652
|
+
if (redisListHas(sess.patterns, p)) {
|
|
1653
|
+
frames.add(respCmd2("PUNSUBSCRIBE", p))
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
// 直接按「退完还剩什么」重建两张表(就地删元素要按索引倒着删,重建更好读也更不容易错)
|
|
1657
|
+
let keepChannels = ArrayList<String>()
|
|
1658
|
+
for (c in sess.channels) {
|
|
1659
|
+
if (!redisListHas(channelList, c)) {
|
|
1660
|
+
keepChannels.add(c)
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
let keepPatterns = ArrayList<String>()
|
|
1664
|
+
for (p in sess.patterns) {
|
|
1665
|
+
if (!redisListHas(patternList, p)) {
|
|
1666
|
+
keepPatterns.add(p)
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
sess.channels = keepChannels
|
|
1670
|
+
sess.patterns = keepPatterns
|
|
1671
|
+
let leftChannels = sess.channels.size
|
|
1672
|
+
let leftPatterns = sess.patterns.size
|
|
1673
|
+
let hasSub = match (sess.sub) {
|
|
1674
|
+
case Some(_) => true
|
|
1675
|
+
case None => false
|
|
1676
|
+
}
|
|
1677
|
+
if (leftChannels == 0 && leftPatterns == 0) {
|
|
1678
|
+
sess.stopSubRequested = true
|
|
1679
|
+
}
|
|
1680
|
+
sess.subPendingAcks += Int64(frames.size)
|
|
1681
|
+
sess.lock.unlock()
|
|
1682
|
+
if (!hasSub) {
|
|
1683
|
+
// 本来就没有订阅连接:退订是幂等的,如实说「没什么可退」而不是报错
|
|
1684
|
+
let o = JsonObject()
|
|
1685
|
+
o.put("ok", JsonBool(true))
|
|
1686
|
+
o.put("unsubscribed", JsonInt(0))
|
|
1687
|
+
o.put("channels", JsonArray(ArrayList<JsonValue>()))
|
|
1688
|
+
o.put("patterns", JsonArray(ArrayList<JsonValue>()))
|
|
1689
|
+
o.put("note", JsonString("没有活跃的订阅连接(或已经全退完),这次没有可退的订阅"))
|
|
1690
|
+
return o
|
|
1691
|
+
}
|
|
1692
|
+
this.writeSubFrames(sess, frames)
|
|
1693
|
+
if (leftChannels == 0 && leftPatterns == 0) {
|
|
1694
|
+
// 全退完:等读线程收尾再关连接。**先置停止旗标再关**,否则读线程会把「主动关闭」
|
|
1695
|
+
// 记成一次真故障(那条错读会把好好的会话判成 error)
|
|
1696
|
+
this.waitSubExit(sess)
|
|
1697
|
+
sess.lock.lock()
|
|
1698
|
+
let subSock = sess.sub
|
|
1699
|
+
sess.sub = None
|
|
1700
|
+
sess.subAlive = false
|
|
1701
|
+
sess.subPendingAcks = 0
|
|
1702
|
+
sess.lock.unlock()
|
|
1703
|
+
match (subSock) {
|
|
1704
|
+
case Some(s) => redisCloseQuiet(s)
|
|
1705
|
+
case None => ()
|
|
1706
|
+
}
|
|
1707
|
+
eprintln("[cj-tauri] redis sub: 订阅已全部退掉,订阅连接与读线程已收")
|
|
1708
|
+
} else {
|
|
1709
|
+
let _ = this.waitSubAcks(sess, REDIS_SUB_ACK_TIMEOUT_MS)
|
|
1710
|
+
}
|
|
1711
|
+
redisEmitState(sess)
|
|
1712
|
+
let out = JsonObject()
|
|
1713
|
+
out.put("ok", JsonBool(true))
|
|
1714
|
+
out.put("unsubscribed", JsonInt(Int64(frames.size)))
|
|
1715
|
+
out.put("channels", JsonArray(ArrayList<JsonValue>()))
|
|
1716
|
+
out.put("patterns", JsonArray(ArrayList<JsonValue>()))
|
|
1717
|
+
out.put("note", JsonString("remaining 见 redis:status 的 channels / patterns"))
|
|
1718
|
+
out
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
/**
|
|
1722
|
+
* `redis:publish { channel, message | messageHex }`:发布一条消息,回的是**收到它的订阅者数**。
|
|
1723
|
+
*
|
|
1724
|
+
* 这个数字来自服务端,不含「本端另一条订阅连接」之外的自己——本端订阅连接算一个订阅者,
|
|
1725
|
+
* 所以自己订阅了自己的频道时,回值通常 ≥1,别把这个数当成「别人收到了」。
|
|
1726
|
+
*/
|
|
1727
|
+
private func publishCmd(args: JsonObject): JsonValue {
|
|
1728
|
+
let sess = this.session()
|
|
1729
|
+
let channel = this.requireString(args, "channel")
|
|
1730
|
+
let payload = this.valueBytes(args, "message", "messageHex")
|
|
1731
|
+
let receivers = redisNumberReply(
|
|
1732
|
+
redisCallFrame(sess, respEncodeCommandRaw([redisRaw("PUBLISH"), redisRaw(channel), payload]), "PUBLISH"),
|
|
1733
|
+
"PUBLISH")
|
|
1734
|
+
eprintln("[cj-tauri] redis publish: channel=${channel} bytes=${payload.size} receivers=${receivers}")
|
|
1735
|
+
let out = JsonObject()
|
|
1736
|
+
out.put("channel", JsonString(channel))
|
|
1737
|
+
out.put("bytes", JsonInt(Int64(payload.size)))
|
|
1738
|
+
out.put("receivers", JsonInt(receivers))
|
|
1739
|
+
out
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
/**
|
|
1743
|
+
* `redis:messages { limit? }`:把缓冲里的订阅报文**取走**(取走即清,与 MQTT 的 `recent` 同款,D-015)。
|
|
1744
|
+
*
|
|
1745
|
+
* 为什么留这条拉取通道:事件可能丢(页面没在监听 / 清单没声明 `events` / 页面正在重载),
|
|
1746
|
+
* 而缓冲还在——页面回来一拉就能补上。`dropped` 是「上次拉取以来因缓冲满丢掉的条数」,
|
|
1747
|
+
* 如实报,不假装无损。
|
|
1748
|
+
*/
|
|
1749
|
+
private func messagesCmd(args: JsonObject): JsonValue {
|
|
1750
|
+
let sess = this.session()
|
|
1751
|
+
let limit = redisClampInt(this.optionalInt(args, "limit", REDIS_RECENT_LIMIT), 1, REDIS_RECENT_LIMIT)
|
|
1752
|
+
let taken = ArrayList<JsonValue>()
|
|
1753
|
+
var dropped: Int64 = 0
|
|
1754
|
+
sess.lock.lock()
|
|
1755
|
+
let take = redisClampInt(Int64(sess.recent.size), 0, limit)
|
|
1756
|
+
var i: Int64 = 0
|
|
1757
|
+
while (i < take) {
|
|
1758
|
+
taken.add(sess.recent[i])
|
|
1759
|
+
i += 1
|
|
1760
|
+
}
|
|
1761
|
+
let keep = ArrayList<JsonValue>()
|
|
1762
|
+
i = take
|
|
1763
|
+
while (i < sess.recent.size) {
|
|
1764
|
+
keep.add(sess.recent[i])
|
|
1765
|
+
i += 1
|
|
1766
|
+
}
|
|
1767
|
+
sess.recent = keep
|
|
1768
|
+
dropped = sess.dropped
|
|
1769
|
+
sess.dropped = 0
|
|
1770
|
+
sess.lock.unlock()
|
|
1771
|
+
let out = JsonObject()
|
|
1772
|
+
out.put("count", JsonInt(Int64(taken.size)))
|
|
1773
|
+
out.put("dropped", JsonInt(dropped))
|
|
1774
|
+
out.put("messages", JsonArray(taken))
|
|
1775
|
+
out.put("note", JsonString("取走即清;dropped = 上次拉取以来因缓冲满丢弃的条数"))
|
|
1776
|
+
out
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
// ------------------------------------------------------------ 订阅连接与线程收尾
|
|
1780
|
+
|
|
1781
|
+
/**
|
|
1782
|
+
* 确保订阅连接在位:没有就新建(含 AUTH,**不做 SELECT**——发布订阅与库号无关)、
|
|
1783
|
+
* 装进会话、起读线程。建连与握手在**调用线程**上同步做(此时还没有读线程,不会有第二个读者)。
|
|
1784
|
+
*/
|
|
1785
|
+
private func ensureSubSocket(sess: RedisSession): Unit {
|
|
1786
|
+
sess.lock.lock()
|
|
1787
|
+
let has = match (sess.sub) {
|
|
1788
|
+
case Some(_) => true
|
|
1789
|
+
case None => false
|
|
1790
|
+
}
|
|
1791
|
+
sess.lock.unlock()
|
|
1792
|
+
if (has) {
|
|
1793
|
+
return
|
|
1794
|
+
}
|
|
1795
|
+
let sock = redisOpenSocket(sess, REDIS_SUB_TICK_MS)
|
|
1796
|
+
try {
|
|
1797
|
+
redisHandshake(sess, sock, sess.subReader, false)
|
|
1798
|
+
} catch (e: CommandException) {
|
|
1799
|
+
redisCloseQuiet(sock)
|
|
1800
|
+
redisFailSession(sess, "订阅连接握手失败:${e.message}")
|
|
1801
|
+
throw e
|
|
1802
|
+
}
|
|
1803
|
+
sess.lock.lock()
|
|
1804
|
+
sess.sub = Some(sock)
|
|
1805
|
+
// 新连接:解析器里可能残留上一条连接的半截字节,必须丢掉,否则第一帧就错位
|
|
1806
|
+
sess.subReader = RespReader()
|
|
1807
|
+
sess.stopSubRequested = false
|
|
1808
|
+
sess.subAlive = true
|
|
1809
|
+
sess.lock.unlock()
|
|
1810
|
+
eprintln("[cj-tauri] redis sub: 订阅连接已建立(读线程接管读侧)")
|
|
1811
|
+
spawn { => redisSubLoop(sess) }
|
|
1812
|
+
redisEmitState(sess)
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
/** 把订阅类命令写进订阅连接(**加 callLock**):命令线程之间要串行写,否则两段帧会交错 */
|
|
1816
|
+
private func writeSubFrames(sess: RedisSession, frames: ArrayList<Array<UInt8>>): Unit {
|
|
1817
|
+
if (frames.size == 0) {
|
|
1818
|
+
return
|
|
1819
|
+
}
|
|
1820
|
+
sess.callLock.lock()
|
|
1821
|
+
var target: Option<TcpSocket> = None
|
|
1822
|
+
sess.lock.lock()
|
|
1823
|
+
target = sess.sub
|
|
1824
|
+
sess.lock.unlock()
|
|
1825
|
+
var wrote = false
|
|
1826
|
+
match (target) {
|
|
1827
|
+
case Some(sock) =>
|
|
1828
|
+
try {
|
|
1829
|
+
for (f in frames) {
|
|
1830
|
+
redisWriteFrame(sess, sock, f)
|
|
1831
|
+
}
|
|
1832
|
+
wrote = true
|
|
1833
|
+
} catch (e: Exception) {
|
|
1834
|
+
sess.callLock.unlock()
|
|
1835
|
+
throw e
|
|
1836
|
+
}
|
|
1837
|
+
case None => ()
|
|
1838
|
+
}
|
|
1839
|
+
sess.callLock.unlock()
|
|
1840
|
+
if (!wrote) {
|
|
1841
|
+
throw CommandException("redis: 订阅连接不在了(可能刚被断开),请重试 subscribe")
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
/** 有界等订阅回执:返回 true 表示全齐(最多等 timeoutMs,不把命令线程挂住) */
|
|
1846
|
+
private func waitSubAcks(sess: RedisSession, timeoutMs: Int64): Bool {
|
|
1847
|
+
let started = DateTime.now()
|
|
1848
|
+
var ok = false
|
|
1849
|
+
var done = false
|
|
1850
|
+
// 不用 `while (true)` + `return`:仓颉不认这种发散写法(尾表达式被判 Unit),循环条件写显式布尔
|
|
1851
|
+
while (!done) {
|
|
1852
|
+
sess.lock.lock()
|
|
1853
|
+
let pending = sess.subPendingAcks
|
|
1854
|
+
sess.lock.unlock()
|
|
1855
|
+
if (pending <= 0) {
|
|
1856
|
+
ok = true
|
|
1857
|
+
done = true
|
|
1858
|
+
} else if ((DateTime.now() - started).toMilliseconds() > timeoutMs) {
|
|
1859
|
+
done = true
|
|
1860
|
+
} else {
|
|
1861
|
+
sleep(Duration.millisecond * 20)
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
ok
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
/**
|
|
1868
|
+
* 有界等订阅读线程退出(上限 REDIS_SUB_EXIT_WAIT_MS)。
|
|
1869
|
+
*
|
|
1870
|
+
* 等不到就**照直继续**并打一行 stderr:命令线程不能为了收尾无限期挂住(与 MQTT 的
|
|
1871
|
+
* `closePrevious` 同一取舍——收尾可以慢,但不能把命令卡死)。
|
|
1872
|
+
*/
|
|
1873
|
+
private func waitSubExit(sess: RedisSession): Bool {
|
|
1874
|
+
let started = DateTime.now()
|
|
1875
|
+
var exited = false
|
|
1876
|
+
var done = false
|
|
1877
|
+
while (!done) {
|
|
1878
|
+
sess.lock.lock()
|
|
1879
|
+
let alive = sess.subAlive
|
|
1880
|
+
sess.lock.unlock()
|
|
1881
|
+
if (!alive) {
|
|
1882
|
+
exited = true
|
|
1883
|
+
done = true
|
|
1884
|
+
} else if ((DateTime.now() - started).toMilliseconds() > REDIS_SUB_EXIT_WAIT_MS) {
|
|
1885
|
+
eprintln("[cj-tauri] redis sub: 读线程未在 ${REDIS_SUB_EXIT_WAIT_MS}ms 内退出,收尾继续(不无限等)")
|
|
1886
|
+
done = true
|
|
1887
|
+
} else {
|
|
1888
|
+
sleep(Duration.millisecond * 20)
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
exited
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
/**
|
|
1895
|
+
* 重连前收掉旧会话:置停止旗标 → 等读线程退出 → 关两条 socket。
|
|
1896
|
+
*
|
|
1897
|
+
* **顺序不能反**:先关 socket 的话,正阻塞在 read 上的读线程会收到一次错读,
|
|
1898
|
+
* 那条错读若被当成真故障,就会把一次「主动重连」记成 error(还会往页面推一条 error 状态)。
|
|
1899
|
+
*/
|
|
1900
|
+
private func closePrevious(): Unit {
|
|
1901
|
+
this.lock.lock()
|
|
1902
|
+
let old = this.current
|
|
1903
|
+
this.lock.unlock()
|
|
1904
|
+
old.lock.lock()
|
|
1905
|
+
old.stopSubRequested = true
|
|
1906
|
+
if (old.state == "connected" || old.state == "connecting") {
|
|
1907
|
+
old.state = "closed"
|
|
1908
|
+
}
|
|
1909
|
+
let subSock = old.sub
|
|
1910
|
+
let cmdSock = old.cmd
|
|
1911
|
+
old.sub = None
|
|
1912
|
+
old.cmd = None
|
|
1913
|
+
old.subAlive = false
|
|
1914
|
+
old.subPendingAcks = 0
|
|
1915
|
+
old.lock.unlock()
|
|
1916
|
+
let exited = this.waitSubExit(old)
|
|
1917
|
+
match (subSock) {
|
|
1918
|
+
case Some(s) => redisCloseQuiet(s)
|
|
1919
|
+
case None => ()
|
|
1920
|
+
}
|
|
1921
|
+
match (cmdSock) {
|
|
1922
|
+
case Some(s) => redisCloseQuiet(s)
|
|
1923
|
+
case None => ()
|
|
1924
|
+
}
|
|
1925
|
+
if (!exited) {
|
|
1926
|
+
eprintln("[cj-tauri] redis: 旧会话的订阅线程未按时退出,socket 已关(状态泄漏一次,好过卡死命令)")
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
// ------------------------------------------------------------ 订阅读线程
|
|
1932
|
+
|
|
1933
|
+
/**
|
|
1934
|
+
* 订阅读线程:只做一件事——从订阅连接收推送,翻成 `redis:message` 事件 + 缓冲。
|
|
1935
|
+
*
|
|
1936
|
+
* 它**不写**这条 socket 的业务数据(订阅 / 退订都由命令侧写),所以两个方向不会互相踩;
|
|
1937
|
+
* 共享的只有会话字段,一律在 `lock` 下访问。读超时是**正常的**(REDIS_SUB_TICK_MS 就是时基),
|
|
1938
|
+
* 借它回头查一次「要不要停」;只有真异常 / 读到 0 字节才判连接坏。
|
|
1939
|
+
*
|
|
1940
|
+
* 停止旗标由命令侧置(退订全部 / 重连收尾),异常与读零这两条路都**先查旗标**再定性:
|
|
1941
|
+
* 主动关闭必然让阻塞中的 read 报错,那条错读要是被记成真故障,一次「正常退订」就会在页面上显示成 error。
|
|
1942
|
+
*/
|
|
1943
|
+
func redisSubLoop(sess: RedisSession): Unit {
|
|
1944
|
+
var alive = true
|
|
1945
|
+
while (alive) {
|
|
1946
|
+
sess.lock.lock()
|
|
1947
|
+
let stop = sess.stopSubRequested
|
|
1948
|
+
let sock = sess.sub
|
|
1949
|
+
sess.lock.unlock()
|
|
1950
|
+
if (stop) {
|
|
1951
|
+
alive = false
|
|
1952
|
+
} else {
|
|
1953
|
+
match (sock) {
|
|
1954
|
+
case None => alive = false
|
|
1955
|
+
case Some(x) =>
|
|
1956
|
+
let buf = Array<UInt8>(REDIS_READ_CHUNK, repeat: 0)
|
|
1957
|
+
var got: Int64 = -1
|
|
1958
|
+
var failed = false
|
|
1959
|
+
try {
|
|
1960
|
+
got = x.read(buf)
|
|
1961
|
+
} catch (e: SocketTimeoutException) {
|
|
1962
|
+
got = -1
|
|
1963
|
+
} catch (e: Exception) {
|
|
1964
|
+
sess.lock.lock()
|
|
1965
|
+
let stopping = sess.stopSubRequested
|
|
1966
|
+
sess.lock.unlock()
|
|
1967
|
+
if (stopping) {
|
|
1968
|
+
alive = false
|
|
1969
|
+
} else {
|
|
1970
|
+
failed = true
|
|
1971
|
+
redisFailSession(sess, "订阅连接读取失败:${e.message}")
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
if (failed) {
|
|
1975
|
+
alive = false
|
|
1976
|
+
} else if (got > 0) {
|
|
1977
|
+
sess.lock.lock()
|
|
1978
|
+
sess.subReader.feed(buf[0..got])
|
|
1979
|
+
sess.lastRxAt = DateTime.now()
|
|
1980
|
+
sess.lock.unlock()
|
|
1981
|
+
redisSubDrain(sess)
|
|
1982
|
+
} else if (got == 0) {
|
|
1983
|
+
sess.lock.lock()
|
|
1984
|
+
let stopping = sess.stopSubRequested
|
|
1985
|
+
sess.lock.unlock()
|
|
1986
|
+
if (stopping) {
|
|
1987
|
+
alive = false
|
|
1988
|
+
} else {
|
|
1989
|
+
redisFailSession(sess, "订阅连接被对端关闭")
|
|
1990
|
+
alive = false
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
// got < 0(读超时):没数据而已,回头查旗标
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
sess.lock.lock()
|
|
1998
|
+
sess.subAlive = false
|
|
1999
|
+
sess.subPendingAcks = 0
|
|
2000
|
+
sess.lock.unlock()
|
|
2001
|
+
eprintln("[cj-tauri] redis sub: 读线程结束(订阅连接已收)")
|
|
2002
|
+
redisEmitState(sess)
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
/**
|
|
2006
|
+
* 把读线程刚喂进解析器的字节尽量读干:每条完整回复按「回执 / 推送」分流。
|
|
2007
|
+
*
|
|
2008
|
+
* 解析器是**增量**的(`respTryRead` 三态),所以这里可以一次收多条,也可以只收半条留着等下一批。
|
|
2009
|
+
* 畸形只落一行 stderr 并**停掉这条连接**:RESP2 下收到不符合语法的字节说明双方已经不同步,
|
|
2010
|
+
* 继续猜下去只会把后面的每一帧都认错(不猜意图,与 MQTT 解码层同款)。
|
|
2011
|
+
*/
|
|
2012
|
+
func redisSubDrain(sess: RedisSession): Unit {
|
|
2013
|
+
var running = true
|
|
2014
|
+
while (running) {
|
|
2015
|
+
sess.lock.lock()
|
|
2016
|
+
let r = sess.subReader.respTryRead()
|
|
2017
|
+
sess.lock.unlock()
|
|
2018
|
+
match (r) {
|
|
2019
|
+
case RespRead.Value(v) => redisSubDispatch(sess, v)
|
|
2020
|
+
case RespRead.Malformed(m) =>
|
|
2021
|
+
redisFailSession(sess, "订阅连接收到不符合 RESP2 的内容:${m}")
|
|
2022
|
+
running = false
|
|
2023
|
+
case RespRead.Incomplete => running = false
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
/**
|
|
2029
|
+
* 一条订阅推送 / 回执的分流。RESP2 下就这几种形状:
|
|
2030
|
+
* `[message, 频道, 正文]` / `[pmessage, 模式, 频道, 正文]`(推送)
|
|
2031
|
+
* `[subscribe|unsubscribe|psubscribe|punsubscribe, 名字, 当前订阅数]`(回执)
|
|
2032
|
+
*
|
|
2033
|
+
* 认不出的形状**只落一行 stderr**:对端可能只是多说了句话(例如新版本的额外通知),
|
|
2034
|
+
* 为这个把会话判坏,代价比少显示一条消息大得多。
|
|
2035
|
+
*/
|
|
2036
|
+
func redisSubDispatch(sess: RedisSession, v: RespValue): Unit {
|
|
2037
|
+
let items = respItemsOrEmpty(v)
|
|
2038
|
+
if (items.size == 0) {
|
|
2039
|
+
eprintln("[cj-tauri] redis sub: 收到空的推送数组(未处理)")
|
|
2040
|
+
return
|
|
2041
|
+
}
|
|
2042
|
+
let kind = respValueText(items[0])
|
|
2043
|
+
if (kind == "pmessage" && items.size >= 4) {
|
|
2044
|
+
redisSubMessage(sess, respValueText(items[1]), respValueText(items[2]), redisPushBytes(items[3]))
|
|
2045
|
+
} else if (kind == "message" && items.size >= 3) {
|
|
2046
|
+
redisSubMessage(sess, "", respValueText(items[1]), redisPushBytes(items[2]))
|
|
2047
|
+
} else if (kind == "subscribe" || kind == "psubscribe" ||
|
|
2048
|
+
kind == "unsubscribe" || kind == "punsubscribe") {
|
|
2049
|
+
sess.lock.lock()
|
|
2050
|
+
if (sess.subPendingAcks > 0) {
|
|
2051
|
+
sess.subPendingAcks -= 1
|
|
2052
|
+
}
|
|
2053
|
+
let pending = sess.subPendingAcks
|
|
2054
|
+
sess.lock.unlock()
|
|
2055
|
+
var count = "?"
|
|
2056
|
+
var who = ""
|
|
2057
|
+
if (items.size >= 2) {
|
|
2058
|
+
who = respValueText(items[1])
|
|
2059
|
+
}
|
|
2060
|
+
if (items.size >= 3) {
|
|
2061
|
+
count = respValueText(items[2])
|
|
2062
|
+
}
|
|
2063
|
+
eprintln("[cj-tauri] redis sub: ${kind} ${who}(该连接当前订阅数=${count}, 待确认=${pending})")
|
|
2064
|
+
} else {
|
|
2065
|
+
eprintln("[cj-tauri] redis sub: 收到认不出的推送形状 ${respBrief(v)}(未处理)")
|
|
2066
|
+
}
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
/** 推送里的正文按**字节**取(binary 安全):bulk 给原字节,其它形状退回它的文本字节 */
|
|
2070
|
+
func redisPushBytes(v: RespValue): Array<UInt8> {
|
|
2071
|
+
match (v) {
|
|
2072
|
+
case Bulk(Some(b)) => return b
|
|
2073
|
+
case _ => return respValueText(v).toArray()
|
|
2074
|
+
}
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
/**
|
|
2078
|
+
* 记一条推送:正文超上限先截断(进缓冲与进事件的是**同一份**,页面两条通道看到必须一致),
|
|
2079
|
+
* 再交给 `redisRecordMessage` 完成「seq → 缓冲 → 事件」。正文另附 hex 供页面原样回写。
|
|
2080
|
+
*/
|
|
2081
|
+
func redisSubMessage(sess: RedisSession, pattern: String, channel: String, raw: Array<UInt8>): Unit {
|
|
2082
|
+
let total = Int64(raw.size)
|
|
2083
|
+
let cut = redisClampInt(total, 0, REDIS_RECENT_BODY_LIMIT)
|
|
2084
|
+
let shown = raw[0..cut]
|
|
2085
|
+
let obj = JsonObject()
|
|
2086
|
+
obj.put("pattern", JsonString(pattern))
|
|
2087
|
+
obj.put("channel", JsonString(channel))
|
|
2088
|
+
obj.put("payload", JsonString(respSafeText(shown)))
|
|
2089
|
+
obj.put("payloadHex", JsonString(respHexOfBytes(shown)))
|
|
2090
|
+
obj.put("bytes", JsonInt(total))
|
|
2091
|
+
obj.put("truncated", JsonBool(cut < total))
|
|
2092
|
+
eprintln("[cj-tauri] redis message: channel=${channel} pattern=${redisClip(pattern, 32)}" +
|
|
2093
|
+
" bytes=${total} truncated=${cut < total}")
|
|
2094
|
+
redisRecordMessage(sess, obj)
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2097
|
+
// ------------------------------------------------------------ 顶层助手
|
|
2098
|
+
|
|
2099
|
+
/**
|
|
2100
|
+
* 在字符串里找某个 ASCII 字节的位置(返回**字节下标**,找不到回 -1)。
|
|
2101
|
+
*
|
|
2102
|
+
* 自己写而不是用现成查找:仓颉的 String 迭代得到的是 `UInt8`、下标也是字节下标(AGENTS §4 实测),
|
|
2103
|
+
* 用库函数一旦给的是码点下标,切字符串就会切在半个汉字上——这个不一致只能靠统一字节口径避免。
|
|
2104
|
+
*/
|
|
2105
|
+
func redisIndexOfByte(s: String, target: UInt8): Int64 {
|
|
2106
|
+
var i: Int64 = 0
|
|
2107
|
+
for (b in s) {
|
|
2108
|
+
if (b == target) {
|
|
2109
|
+
return i
|
|
2110
|
+
}
|
|
2111
|
+
i += 1
|
|
2112
|
+
}
|
|
2113
|
+
return -1
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
/** 去掉行尾的 `\r`(INFO 正文常见 CRLF;不剥掉的话每个值的尾巴都挂个不可见字符,页面与断言都难对) */
|
|
2117
|
+
func redisChompCr(line: String): String {
|
|
2118
|
+
if (line.size > 0 && line[line.size - 1] == 0x0Du8) {
|
|
2119
|
+
return line[0..line.size - 1]
|
|
2120
|
+
}
|
|
2121
|
+
return line
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
/** 是不是「空白类」ASCII 字节(空格 / 制表 / CR) */
|
|
2125
|
+
func redisIsSpaceByte(b: UInt8): Bool {
|
|
2126
|
+
return b == 0x20u8 || b == 0x09u8 || b == 0x0Du8
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
/** 去掉首尾空白(只认 ASCII 空白;INFO 的行首会有空格) */
|
|
2130
|
+
func redisTrim(s: String): String {
|
|
2131
|
+
var start: Int64 = 0
|
|
2132
|
+
var end = s.size
|
|
2133
|
+
while (start < end && redisIsSpaceByte(s[start])) {
|
|
2134
|
+
start += 1
|
|
2135
|
+
}
|
|
2136
|
+
while (end > start && redisIsSpaceByte(s[end - 1])) {
|
|
2137
|
+
end -= 1
|
|
2138
|
+
}
|
|
2139
|
+
return s[start..end]
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
/**
|
|
2143
|
+
* INFO 的整段文本 → 分节结构(`# Section` 开一节,`k:v` 进当前节)。
|
|
2144
|
+
*
|
|
2145
|
+
* 为什么不让页面自己切:INFO 正文里 `\r\n` 与 `\n` 混着、还夹着空行,切法一旦散到前端,
|
|
2146
|
+
* 有多少个页面就有多少份实现;这里切一次,页面与 E2E 断言看**同一份**结构。
|
|
2147
|
+
* 认不出的行收进该节的 `other`(**不丢**)——新版本多一行就说「没有」是最难查的那种错。
|
|
2148
|
+
*/
|
|
2149
|
+
func redisInfoParse(text: String): JsonValue {
|
|
2150
|
+
let sections = ArrayList<JsonValue>()
|
|
2151
|
+
var fields = ArrayList<JsonValue>()
|
|
2152
|
+
var others = ArrayList<JsonValue>()
|
|
2153
|
+
var name = ""
|
|
2154
|
+
for (rawLine in text.split("\n")) {
|
|
2155
|
+
let trimmed = redisTrim(redisChompCr(rawLine))
|
|
2156
|
+
if (trimmed.size == 0) {
|
|
2157
|
+
continue
|
|
2158
|
+
}
|
|
2159
|
+
if (trimmed.startsWith("#")) {
|
|
2160
|
+
if (name.size > 0) {
|
|
2161
|
+
sections.add(redisInfoSection(name, fields, others))
|
|
2162
|
+
}
|
|
2163
|
+
name = redisTrim(trimmed[1..trimmed.size])
|
|
2164
|
+
fields = ArrayList<JsonValue>()
|
|
2165
|
+
others = ArrayList<JsonValue>()
|
|
2166
|
+
} else {
|
|
2167
|
+
let sep = redisIndexOfByte(trimmed, 0x3Au8)
|
|
2168
|
+
if (sep >= 0) {
|
|
2169
|
+
let f = JsonObject()
|
|
2170
|
+
f.put("key", JsonString(trimmed[0..sep]))
|
|
2171
|
+
f.put("value", JsonString(trimmed[sep + 1..trimmed.size]))
|
|
2172
|
+
fields.add(f)
|
|
2173
|
+
} else {
|
|
2174
|
+
others.add(JsonString(trimmed))
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
if (name.size > 0) {
|
|
2179
|
+
sections.add(redisInfoSection(name, fields, others))
|
|
2180
|
+
}
|
|
2181
|
+
JsonArray(sections)
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
/** INFO 的一节 → 对象(`# Section` 那段) */
|
|
2185
|
+
func redisInfoSection(name: String, fields: ArrayList<JsonValue>, others: ArrayList<JsonValue>): JsonValue {
|
|
2186
|
+
let o = JsonObject()
|
|
2187
|
+
o.put("name", JsonString(name))
|
|
2188
|
+
o.put("count", JsonInt(Int64(fields.size)))
|
|
2189
|
+
o.put("fields", JsonArray(fields))
|
|
2190
|
+
if (others.size > 0) {
|
|
2191
|
+
o.put("other", JsonArray(others))
|
|
2192
|
+
}
|
|
2193
|
+
o
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
/**
|
|
2197
|
+
* 一条慢日志条目 → 对象:`[id, 时间戳, 耗时微秒, [参数...], 客户端地址, 客户端名]`。
|
|
2198
|
+
*
|
|
2199
|
+
* 尾部字段**按长度判着取**:不同版本的字段数不一样,直接按下标取会越界崩在这里
|
|
2200
|
+
* (宁可少给一个字段,也不要让「看慢日志」这条命令把整个诊断流程带崩)。
|
|
2201
|
+
*/
|
|
2202
|
+
func redisSlowlogEntry(it: RespValue): JsonValue {
|
|
2203
|
+
let items = respItemsOrEmpty(it)
|
|
2204
|
+
let o = JsonObject()
|
|
2205
|
+
var id: Int64 = -1
|
|
2206
|
+
var at: Int64 = 0
|
|
2207
|
+
var micros: Int64 = 0
|
|
2208
|
+
var client = ""
|
|
2209
|
+
if (items.size >= 1) {
|
|
2210
|
+
id = redisNumberOr(items[0], -1)
|
|
2211
|
+
}
|
|
2212
|
+
if (items.size >= 2) {
|
|
2213
|
+
at = redisNumberOr(items[1], 0)
|
|
2214
|
+
}
|
|
2215
|
+
if (items.size >= 3) {
|
|
2216
|
+
micros = redisNumberOr(items[2], 0)
|
|
2217
|
+
}
|
|
2218
|
+
let args = ArrayList<JsonValue>()
|
|
2219
|
+
if (items.size >= 4) {
|
|
2220
|
+
for (a in respItemsOrEmpty(items[3])) {
|
|
2221
|
+
args.add(JsonString(respValueText(a)))
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
if (items.size >= 5) {
|
|
2225
|
+
client = respValueText(items[4])
|
|
2226
|
+
}
|
|
2227
|
+
o.put("id", JsonInt(id))
|
|
2228
|
+
o.put("at", JsonInt(at))
|
|
2229
|
+
o.put("micros", JsonInt(micros))
|
|
2230
|
+
o.put("args", JsonArray(args))
|
|
2231
|
+
o.put("client", JsonString(client))
|
|
2232
|
+
o
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
/** 数值回复取值,非数值(nil / 出错 / 形状不对)回兜底——用于「取不到就别显示」的统计字段 */
|
|
2236
|
+
func redisNumberOr(v: RespValue, fallback: Int64): Int64 {
|
|
2237
|
+
match (v) {
|
|
2238
|
+
case Number(n) => return n
|
|
2239
|
+
case _ => return fallback
|
|
2240
|
+
}
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
/**
|
|
2244
|
+
* 一个键的「体积」:string 报**字节数**(STRLEN),容器报**元素个数**(HLEN/LLEN/SCARD/ZCARD/XLEN)。
|
|
2245
|
+
*
|
|
2246
|
+
* 两种口径混在一个字段里是有意的取舍:Redis 侧没有「按类型的字节数」这种 O(1) 回答
|
|
2247
|
+
* (`MEMORY USAGE` 要遍历采样,代价随键大小走),而页面这一栏要的是「这键有多大盘」。
|
|
2248
|
+
* 口径由 `redisKeySizeUnit` 在同一处写明(`sizeUnit` 字段),免得把元素个数当字节数读。
|
|
2249
|
+
*/
|
|
2250
|
+
func redisKeySize(sess: RedisSession, key: String, typeName: String): Int64 {
|
|
2251
|
+
if (typeName == REDIS_TYPE_STRING) {
|
|
2252
|
+
return redisNumberOr(redisCall(sess, ["STRLEN", key]), -1)
|
|
2253
|
+
}
|
|
2254
|
+
if (typeName == REDIS_TYPE_HASH) {
|
|
2255
|
+
return redisNumberOr(redisCall(sess, ["HLEN", key]), -1)
|
|
2256
|
+
}
|
|
2257
|
+
if (typeName == REDIS_TYPE_LIST) {
|
|
2258
|
+
return redisNumberOr(redisCall(sess, ["LLEN", key]), -1)
|
|
2259
|
+
}
|
|
2260
|
+
if (typeName == REDIS_TYPE_SET) {
|
|
2261
|
+
return redisNumberOr(redisCall(sess, ["SCARD", key]), -1)
|
|
2262
|
+
}
|
|
2263
|
+
if (typeName == REDIS_TYPE_ZSET) {
|
|
2264
|
+
return redisNumberOr(redisCall(sess, ["ZCARD", key]), -1)
|
|
2265
|
+
}
|
|
2266
|
+
if (typeName == REDIS_TYPE_STREAM) {
|
|
2267
|
+
return redisNumberOr(redisCall(sess, ["XLEN", key]), -1)
|
|
2268
|
+
}
|
|
2269
|
+
return -1
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
/** `redisKeySize` 的数字是什么口径(`keyInfo` 的 `sizeUnit` 字段) */
|
|
2273
|
+
func redisKeySizeUnit(typeName: String): String {
|
|
2274
|
+
if (typeName == REDIS_TYPE_STRING) {
|
|
2275
|
+
return "bytes"
|
|
2276
|
+
}
|
|
2277
|
+
return "elements"
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
/** 字符串 → 组帧用的原始参数(Redis 的文本参数就是它的 UTF-8 字节) */
|
|
2281
|
+
func redisRaw(s: String): Array<UInt8> {
|
|
2282
|
+
return s.toArray()
|
|
2283
|
+
}
|
|
2284
|
+
|
|
2285
|
+
/** 表里有没有这个字符串(订阅去重用);线性扫是有意的——这两张表向来只有几个到几十个元素 */
|
|
2286
|
+
func redisListHas(items: ArrayList<String>, target: String): Bool {
|
|
2287
|
+
for (it in items) {
|
|
2288
|
+
if (it == target) {
|
|
2289
|
+
return true
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
2292
|
+
return false
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
/**
|
|
2296
|
+
* 一段值字节 → 页面可显示的形状:`text` 尽力给文本(不可打印时退化成 `0x…`),`hex` 无损给全份,
|
|
2297
|
+
* 超展示上限只给前缀并置 `truncated`(**原文长度照实报**,不假装键只有这么长)。
|
|
2298
|
+
*
|
|
2299
|
+
* 两个都给是有意的:页面既能直接看文本,也能把 hex 原样回写,二进制安全的值不会在往返里被改。
|
|
2300
|
+
*/
|
|
2301
|
+
func redisValueDisplay(raw: Array<UInt8>): JsonValue {
|
|
2302
|
+
let total = Int64(raw.size)
|
|
2303
|
+
let cut = redisClampInt(total, 0, REDIS_VALUE_TEXT_LIMIT)
|
|
2304
|
+
let shown = raw[0..cut]
|
|
2305
|
+
let o = JsonObject()
|
|
2306
|
+
o.put("bytes", JsonInt(total))
|
|
2307
|
+
o.put("truncated", JsonBool(cut < total))
|
|
2308
|
+
o.put("text", JsonString(respSafeText(shown)))
|
|
2309
|
+
o.put("hex", JsonString(respHexOfBytes(shown)))
|
|
2310
|
+
o
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
// ------------------------------------------------------------ 插件装配
|
|
2314
|
+
|
|
2315
|
+
/**
|
|
2316
|
+
* redis 插件:注册命令 + 声明事件与权限集。
|
|
2317
|
+
*
|
|
2318
|
+
* 命令全名 = `redis:<短名>`(与内置 `system:*` 同形,分发与能力校验零改动);
|
|
2319
|
+
* 事件短名同理,框架自动加前缀 → 前端监听 `redis:message` / `redis:state`。
|
|
2320
|
+
* 权限集(v1.1):`default` = 全部命令,`readonly` = 只观不控的那些(浏览 / 诊断 / 订阅,改数据的一个都不给)。
|
|
2321
|
+
* **集声明不等于放行**(框架铁律):应用要在自己的 capabilities 里引用 `"redis:default"`
|
|
2322
|
+
* 或逐条写明文命令,缺一边就用不了。
|
|
2323
|
+
*/
|
|
2324
|
+
public class RedisPlugin <: Plugin {
|
|
2325
|
+
private let handler = RedisCommands()
|
|
2326
|
+
|
|
2327
|
+
public init() {}
|
|
2328
|
+
|
|
2329
|
+
public func name(): String {
|
|
2330
|
+
return "redis"
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
public func commands(): HashMap<String, CommandHandler> {
|
|
2334
|
+
let map = HashMap<String, CommandHandler>()
|
|
2335
|
+
// 键必须是**短名**:框架的 registerPluginCommands 会补 `<插件名>:` 前缀,
|
|
2336
|
+
// 这里再写一次就得到 `redis:redis:connect`(实机日志一眼可见,且能力清单怎么配都对不上)
|
|
2337
|
+
for (c in REDIS_PLUGIN_COMMANDS) {
|
|
2338
|
+
map.add(c, this.handler)
|
|
2339
|
+
}
|
|
2340
|
+
map
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2343
|
+
/** 事件短名(框架加 `redis:` 前缀);未在能力清单 `events` 声明时事件被丢弃,拉取通道仍可用 */
|
|
2344
|
+
public func events(): ArrayList<String> {
|
|
2345
|
+
let names = ArrayList<String>()
|
|
2346
|
+
names.add("message")
|
|
2347
|
+
names.add("state")
|
|
2348
|
+
names
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
public func setup(app: TauriApp): Unit {
|
|
2352
|
+
this.handler.attachApp(app)
|
|
2353
|
+
}
|
|
2354
|
+
|
|
2355
|
+
/** 命名权限集:短集名 → 成员(写短名,框架自动补 `redis:` 前缀) */
|
|
2356
|
+
public func permissions(): HashMap<String, ArrayList<String>> {
|
|
2357
|
+
let sets = HashMap<String, ArrayList<String>>()
|
|
2358
|
+
let all = ArrayList<String>()
|
|
2359
|
+
for (c in REDIS_PLUGIN_COMMANDS) {
|
|
2360
|
+
all.add(c)
|
|
2361
|
+
}
|
|
2362
|
+
let readonly = ArrayList<String>()
|
|
2363
|
+
for (c in REDIS_READONLY_COMMANDS) {
|
|
2364
|
+
readonly.add(c)
|
|
2365
|
+
}
|
|
2366
|
+
sets.add("default", all)
|
|
2367
|
+
sets.add("readonly", readonly)
|
|
2368
|
+
sets
|
|
2369
|
+
}
|
|
2370
|
+
|
|
2371
|
+
public func jsShim(): String {
|
|
2372
|
+
return redisJsShim()
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
|
|
2376
|
+
/** shim 里要暴露的命令名(JS 数组字面量):**从唯一那份命令表生成**,加命令时不会漏一处 */
|
|
2377
|
+
func redisShimNamesJs(): String {
|
|
2378
|
+
let out = ArrayList<UInt8>()
|
|
2379
|
+
respAscii(out, "[")
|
|
2380
|
+
var i: Int64 = 0
|
|
2381
|
+
for (c in REDIS_PLUGIN_COMMANDS) {
|
|
2382
|
+
if (i > 0) {
|
|
2383
|
+
respAscii(out, ",")
|
|
2384
|
+
}
|
|
2385
|
+
respAscii(out, "'" + c + "'")
|
|
2386
|
+
i += 1
|
|
2387
|
+
}
|
|
2388
|
+
respAscii(out, "]")
|
|
2389
|
+
return String.fromUtf8(out.toArray())
|
|
2390
|
+
}
|
|
2391
|
+
|
|
2392
|
+
/**
|
|
2393
|
+
* 前端 shim:`window.__CJ_TAURI__.redis.<短名>(args)` → `invoke("redis:<短名>", args)`。
|
|
2394
|
+
*
|
|
2395
|
+
* 用普通字符串拼(**不用三引号**):三引号会处理反斜杠转义(AGENTS §4),而这里的 JS 故意一个反斜杠都不写,
|
|
2396
|
+
* 免得同一份 shim 在不同文件里被转义规则咬出两种行为。桥没就位时留空实现,页面自己 waitForBridge 后再用。
|
|
2397
|
+
*/
|
|
2398
|
+
// 做成**函数**而不是顶层 `let`:顶层 `let` 按声明顺序初始化,而 `jsShim()`(在声明之前)要读它,
|
|
2399
|
+
// 仓颉直接判 `variable 'REDIS_JS_SHIM' is used before being defined`;函数可以前向引用,没有这个次序坑。
|
|
2400
|
+
func redisJsShim(): String {
|
|
2401
|
+
return "(function(){" +
|
|
2402
|
+
"var cj=window.__CJ_TAURI__;if(!cj||!cj.invoke){return;}" +
|
|
2403
|
+
"var names=" + redisShimNamesJs() + ";" +
|
|
2404
|
+
"var ns={};" +
|
|
2405
|
+
"names.forEach(function(n){ns[n]=function(a){return cj.invoke('redis:'+n,a||{});};});" +
|
|
2406
|
+
"ns.help=function(){return names.slice();};" +
|
|
2407
|
+
"cj.redis=ns;" +
|
|
2408
|
+
"})();"
|
|
2409
|
+
}
|