从零实现浏览器端 Redis 在线查询:WebSocket 隧道
一、要解决什么问题
做一个在线 Redis 查询工具,技术挑战分两层:
第一层:浏览器怎么连 Redis?
浏览器 JS 只能发 HTTP/WebSocket,不能直接建立 TCP 连接到 Redis 的 6379 端口。必须有一个中间层做协议转换。
第二层:用户 Redis 在内网怎么办?
公司内网的192.168.25.71:6379,云端服务器根本访问不到。
二、整体架构
┌──────────┐ WebSocket ┌────────────────┐ WebSocket ┌──────────────┐ TCP:6379 ┌────────┐ │ 浏览器 │ ◄──────────► │ Spring Boot │ ◄──────────► │ Python Agent │ ◄────────► │ Redis │ │ Vue 2 │ │ (消息转发) │ │ (redis-py) │ │ (内网) │ └──────────┘ └────────────────┘ └──────────────┘ └────────┘- 浏览器:WebSocket 客户端,发 JSON 命令
- Spring Boot:纯消息转发,不解析 Redis 协议,不存密码
- Agent:收到 JSON → 调用 redis-py 执行 → 返回结果
- Nginx:WebSocket Upgrade 代理
三、Spring Boot 端:纯透明转发
3.1 会话配对
@ComponentpublicclassSessionManager{// sessionId → { agentSession, browserSession }privatefinalConcurrentHashMap<String,SessionPair>pairs=newConcurrentHashMap<>();// Agent 连接 → 生成 8 位 sessionIdpublicStringregisterAgent(WebSocketSessionagentSession){StringsessionId=UUID.randomUUID().toString().replace("-","").substring(0,8);SessionPairpair=newSessionPair(sessionId);pair.agentSession=agentSession;pairs.put(sessionId,pair);returnsessionId;}// 浏览器连接 → 和 Agent 配对publicbooleanpairBrowser(StringsessionId,WebSocketSessionbrowserSession){SessionPairpair=pairs.get(sessionId);if(pair==null)returnfalse;pair.browserSession=browserSession;returntrue;}// 消息转发:Browser → AgentpublicvoidrelayToAgent(StringsessionId,Stringmessage){SessionPairpair=pairs.get(sessionId);if(pair!=null&&pair.agentSession!=null&&pair.agentSession.isOpen()){pair.agentSession.sendMessage(newTextMessage(message));}}}3.2 两个 WebSocket 端点
@Configuration@EnableWebSocketpublicclassWebSocketConfigimplementsWebSocketConfigurer{@OverridepublicvoidregisterWebSocketHandlers(WebSocketHandlerRegistryregistry){registry.addHandler(agentHandler,"/ws/agent");// Agent 连这里registry.addHandler(browserHandler,"/ws/browser/*");// 浏览器连这里}}AgentWebSocketHandler在 Agent 连接时生成 sessionId 返回,之后收到的每条消息都 relay 给浏览器。
BrowserWebSocketHandler从 URL 路径/ws/browser/{sessionId}提取 ID,配对后收到的每条消息 relay 给 Agent。
3.3 关键坑:AuthFilter 拦截 WebSocket 握手
Spring Boot 的Filter先于 WebSocket 处理器执行。ApiAuthFilter拦截了/ws/**,握手阶段的 HTTP Upgrade 请求被 401 拦截。加一行白名单即可:
if(apiUrl.startsWith("/api/pub/")||apiUrl.startsWith("/ws/")){filterChain.doFilter(servletRequest,servletResponse);return;}四、Python Agent:协议转换核心
4.1 消息协议
所有通信走 JSON,清晰可调试:
// 连接 Redis浏览器 → Agent:{"type":"connect","host":"192.168.25.71","port":6379,"password":"xxx","db":0}Agent → 浏览器:{"type":"connected","msg":"192.168.25.71:6379 DB0 - PONG"}// 执行命令浏览器 → Agent:{"type":"query","command":"HGETALL user:1001"}Agent → 浏览器:{"type":"result","result":{"name":"Alice","age":"25"},"resultType":"map"}// 错误Agent → 浏览器:{"type":"error","message":"Connection refused"}4.2 Redis 连接与命令执行
importredisasredis_libdefconnect_redis(ws,msg):globaldb db=redis_lib.Redis(host=msg.get("host"),port=msg.get("port",6379),password=msg.get("password")orNone,db=msg.get("db",0),socket_connect_timeout=5,socket_timeout=5,decode_responses=True,protocol=2,# ← 关键:强制 RESP2,兼容 Redis < 6.0)db.ping()defquery_redis(ws,msg):parts=shlex.split(msg["command"])# "HGETALL user:1001" → ["HGETALL", "user:1001"]cmd,args=parts[0].upper(),parts[1:]ifcmdnotinREDIS_READ_CMDS:# 白名单校验returnsend_error(ws,f"Command '{cmd}' not allowed")result=db.execute_command(cmd,*args)# 底层调用,比反射更可靠send(ws,{"type":"result","result":display,"resultType":rtype})4.3 兼容旧版 Redis
redis-py8.x 默认用 RESP3 协议,连接时先发HELLO 3协商。Redis < 6.0 不支持这个命令,直接报错:
unknown command 'HELLO', with args beginning with: '3'解决办法:连接时强制指定 RESP2。
db=redis.Redis(...,protocol=2)一行搞定,兼容所有 Redis 版本。
4.4 连接稳定性
浏览器 ──ping/15s──► 服务器 ──relay──► Agent ← 应用层心跳 ▲ │ └── pong ────────────┘ ← 服务端 echo 回执 Agent 断开 → 保留 sessionId → 带旧 ID 重连 ← 浏览器无感恢复 浏览器断开 → 指数退避重连(2s/4s/8s,最多3次) ← 超过3次提示检查 AgentAgent 主循环用while+WebSocketApp每次重连带?sessionId=旧ID:
whileshould_run[0]:ws=WebSocketApp(build_url(),...)ws.run_forever(ping_interval=0)delay=min(delay*2,30)time.sleep(delay)五、前端:Vue 2 实现
5.1 双模式切换
<el-radio-groupv-model="mode"><el-radio-buttonlabel="direct">直连模式</el-radio-button><el-radio-buttonlabel="agent">Agent 模式</el-radio-button></el-radio-group>- 直连模式:HTTP POST 给后端,后端用 Jedis 直连 Redis(同网段场景)
- Agent 模式:WebSocket 发到 Agent,Agent 在本地执行后返回
5.2 WebSocket 连接管理
connectViaAgent(){constwsUrl=`${location.protocol==='https:'?'wss:':'ws:'}//${location.host}/ws/browser/${this.conn.sessionId}`this.ws=newWebSocket(wsUrl)this.ws.onopen=()=>{this.startHeartbeat()// 15s 间隔 ping/pongthis.ws.send(JSON.stringify({type:'connect',host,port,password,db}))}this.ws.onmessage=(e)=>{constmsg=JSON.parse(e.data)if(msg.type==='connected'){this.connected=true// 连接成功,显示命令输入区}elseif(msg.type==='result'){this.result=msg// 查询结果,停止 loadingthis.loading=false}}}5.3 结果智能渲染
根据resultType自动选渲染方式:
<!-- string --><divv-if="resType==='string'"><divclass="meta">{{ resSize }}</div><prev-if="isJson">{{ resJson }}</pre><!-- JSON 自动格式化 --><spanv-else>{{ resStr }}</span><!-- 长文本折叠 --><button@click="copyResult">复制</button></div><!-- list --><divv-else-if="resType==='list'"><divclass="meta">{{ resList.length }} items</div><divv-for="(v,i) in pagedList":class="{stripe: i%2}"><span>{{ i }}</span><code>{{ v }}</code></div><button@click="loadMore">显示更多</button><!-- 分页加载 --></div><!-- map --><divv-else-if="resType==='map'"><inputv-model="filter"placeholder="筛选字段..."/><!-- 搜索过滤 --><table><trv-for="k in filteredKeys"><td><code>{{ k }}</code></td><td><code>{{ resMap[k] }}</code></td></tr></table></div><!-- nil --><divv-else-if="resStr==='(nil)'"class="nil-tip">(键不存在)</div>每种类型的渲染方式不同:string 展示字节数 + 折叠,list 斑马纹 + 分批加载,map 搜索框 + 隔行变色,nil 独立卡片。
六、部署架构
6.1 Nginx WebSocket 代理
location /ws/ { proxy_pass http://127.0.0.1:8000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 3600s; proxy_send_timeout 3600s; }6.2 Agent 打包
pipinstallpyinstaller pyinstaller--onefile--nameagent--icon=agent.ico agent.py# dist/agent.exe,12MB,单文件可分发七、结果展示
八、总结
核心设计思路就三条:
- 协议转换下沉到 Agent:服务器不碰 Redis 协议,只做消息转发。Agent 用原生 redis-py 连接,不受浏览器限制
- Agent 主动出站:WebSocket 从内网往外连,天然穿透 NAT/防火墙,用户零网络配置
在线体验:https://onltool.site
