WebSocket
核心认知:WebSocket 只传字节
这是使用 WebSocket 时最重要的一点,必须先理清:
WebSocket 本身只负责传输字节(帧),它不关心、也不知道你在传什么。请求和响应的 JSON 形状是应用层协议,必须由提供服务的后端先定下来,客户端再按同一套字段去编解码。
具体来说:
- 传输层:WebSocket 只保证「把一段字节从 A 送到 B」,并且是全双工、长连接。它既不规定消息是 JSON 还是 protobuf,也不规定字段叫什么。
- 应用层协议:
{"type":"req","id":"...","method":"...","params":{...}}这样的字段结构,是服务端自己定义的一套约定(本例中就是网关的 RPC 协议)。服务端怎么定,客户端就必须怎么写——字段名、类型、含义全都要对齐,写错一个字母就解不出来。 - 实践推论:对接任何 WebSocket 服务时,第一件事是拿到服务端的协议文档(或源码里的结构体定义),照着写客户端的结构体
tag。不要凭猜想去试字段名。 - 对比 JSON-RPC:以太坊的
eth_subscribe、eth_call走的是同一套思路——传输层可以是 HTTP 也可以是 WebSocket,但jsonrpc/id/method/params这套应用层格式是固定的,与传输方式无关。
因此下面的示例可以拆成两层看:帧的收发(WriteJSON / ReadJSON,通用)和协议结构体(requestFrame / responseFrame,本服务专属)。
协议定义(应用层)
先定义服务端约定好的请求/响应结构,这就是「应用层协议」的落地:
type requestFrame struct {
Type string `json:"type"`
ID string `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
type responseFrame struct {
Type string `json:"type"`
ID string `json:"id"`
OK bool `json:"ok"`
Payload any `json:"payload,omitempty"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error,omitempty"`
}
要点:
Params json.RawMessage表示「原样保留、暂不解析」的 JSON 片段,因为不同method的参数结构不同,客户端框架层不需要知道细节,直接透传即可。Payload any同理——响应的业务数据形状由具体method决定,用any接住再二次解析。omitempty表示字段为空时不序列化,避免发出null让服务端解析出问题。Error用指针,是为了区分「没出错」(nil)和「出错了但错误信息为零值」。
完整示例
流程是:连接 → 发 connect 握手 → 读握手响应 → 发业务 RPC → 循环读响应直到读到匹配的 id。
package main
import (
"encoding/json"
"fmt"
"os"
"strings"
"time"
"github.com/gorilla/websocket"
)
const protocolVersion = 3
type requestFrame struct {
Type string `json:"type"`
ID string `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
type responseFrame struct {
Type string `json:"type"`
ID string `json:"id"`
OK bool `json:"ok"`
Payload any `json:"payload,omitempty"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error,omitempty"`
}
func main() {
wsURL := gatewayWSURL()
token := os.Getenv("GOCLAW_GATEWAY_TOKEN")
fmt.Printf("Dial %s\n", wsURL)
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
fmt.Printf("dial: %v\n", err)
os.Exit(1)
}
defer conn.Close()
// 1) connect 握手
connectParams, _ := json.Marshal(map[string]any{
"token": token,
"protocol": protocolVersion,
})
if err := conn.WriteJSON(requestFrame{
Type: "req",
ID: "cli-connect",
Method: "connect",
Params: connectParams,
}); err != nil {
fmt.Printf("send connect: %v\n", err)
os.Exit(1)
}
// 只约束「读 connect 回复」:5s 内 ReadJSON 完不成 → i/o timeout,随后 defer Close
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
var connectResp responseFrame
if err := conn.ReadJSON(&connectResp); err != nil {
fmt.Printf("read connect response: %v\n", err)
os.Exit(1)
}
if !connectResp.OK {
msg := "unknown error"
if connectResp.Error != nil {
msg = connectResp.Error.Message
}
fmt.Printf("connect failed: %s\n", msg)
os.Exit(1)
}
fmt.Println("connect OK")
// 2) 业务 RPC:这里用 health,pairing CLI 则是 pairing.list / approve
if err := conn.WriteJSON(requestFrame{
Type: "req",
ID: "cli-rpc",
Method: "health",
}); err != nil {
fmt.Printf("send health: %v\n", err)
os.Exit(1)
}
conn.SetReadDeadline(time.Now().Add(10 * time.Second))
for {
_, msg, err := conn.ReadMessage()
if err != nil {
fmt.Printf("read response: %v\n", err)
os.Exit(1)
}
var peek struct {
Type string `json:"type"`
}
if json.Unmarshal(msg, &peek) != nil {
continue
}
if peek.Type == "event" {
continue // 跳过服务端主动推送的 event
}
var resp responseFrame
if err := json.Unmarshal(msg, &resp); err != nil {
fmt.Printf("parse response: %v\n", err)
os.Exit(1)
}
if resp.ID != "cli-rpc" {
continue
}
out, _ := json.MarshalIndent(resp, "", " ")
fmt.Println(string(out))
return
}
}
func gatewayWSURL() string {
base := os.Getenv("GOCLAW_GATEWAY_URL")
if base == "" {
base = "http://127.0.0.1:18790"
}
base = strings.TrimRight(base, "/")
switch {
case strings.HasPrefix(base, "https://"):
return "wss://" + strings.TrimPrefix(base, "https://") + "/ws"
case strings.HasPrefix(base, "http://"):
return "ws://" + strings.TrimPrefix(base, "http://") + "/ws"
case strings.HasPrefix(base, "ws://") || strings.HasPrefix(base, "wss://"):
if strings.HasSuffix(base, "/ws") {
return base
}
return base + "/ws"
default:
return "ws://" + base + "/ws"
}
}
运行方式(依赖 gorilla/websocket):
go get github.com/gorilla/websocket
export GOCLAW_GATEWAY_TOKEN=...
go run .
代码逐段解析
1. URL 转换 gatewayWSURL
WebSocket 的地址由 HTTP 地址派生:http:// → ws://,https:// → wss://(带 TLS)。这个函数做了健壮性处理——无论环境变量里填的是 http://、ws:// 还是裸 host:port,都能归一化成正确的 ws 地址并补上 /ws 路径。这类小函数值得抽出来,避免调用方各自拼字符串出错。
2. 拨号与连接关闭
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
defer conn.Close()
Dial 完成的就是 HTTP Upgrade 握手(发 Upgrade: websocket 头,服务端回 101 Switching Protocols)。握手成功后连接就转为 WebSocket 长连接。defer conn.Close() 必须写——否则连接泄漏,服务端会一直持有。
3. 握手帧 connect
WebSocket 协议本身没有认证机制,所以「认证」属于应用层协议的一部分:这里服务端约定,连接建立后第一条消息必须是 method: "connect" 且携带 token 和 protocol 版本号。服务端据此校验身份、协商协议版本,不匹配就拒绝。
4. 读超时 SetReadDeadline
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
这是本示例最值得学习的细节之一。WebSocket 是长连接,服务端可能迟迟不回,ReadJSON 会一直阻塞导致程序挂死。设置读截止时间的含义是:只对「这一次读取」生效——5 秒内没读到就返回 i/o timeout 错误,然后 defer Close 兜底关闭连接。
注意它是一次性的、针对读操作的绝对时间点,所以每次关键读取前都要重新设一次(示例第二步又设了 10s)。在 Web3 场景监听链上事件时同样必须设置,否则 RPC 节点无响应会让程序永久卡住。
5. 循环读取与消息过滤
for {
_, msg, err := conn.ReadMessage()
...
if peek.Type == "event" {
continue // 跳过服务端主动推送的 event
}
...
if resp.ID != "cli-rpc" {
continue
}
...
return
}
这里体现了全双工带来的一个现实问题:你可能收到不是你要的消息。服务端会主动推送 event(通知类消息),也可能因为异步返回顺序错乱而先收到别的请求的响应。所以必须做两轮过滤:
- 先用一个轻量结构体
peek只解出type字段(这就是「先窥一眼再决定怎么处理」的常用技巧,避免对无关消息做完整解析),是event就跳过; - 再完整解码,比对
ID是否等于自己发出的cli-rpc,不是就继续等。
这正是「请求带 id」这一应用层设计的价值——请求和响应靠 id 配对,而不是靠顺序。
小结
- WebSocket 是字节管道,JSON 形状是应用层协议,字段由服务端定义、客户端照抄,二者不可混淆
- 用
id做请求-响应配对,用type区分响应与服务端推送事件 - 每次关键读取都要
SetReadDeadline,避免长连接把程序挂死 defer conn.Close()+omitempty+ 指针Error是这类代码的固定套路