WebSocket

核心认知:WebSocket 只传字节

这是使用 WebSocket 时最重要的一点,必须先理清:

WebSocket 本身只负责传输字节(帧),它不关心、也不知道你在传什么。请求和响应的 JSON 形状是应用层协议,必须由提供服务的后端先定下来,客户端再按同一套字段去编解码。

具体来说:

  1. 传输层:WebSocket 只保证「把一段字节从 A 送到 B」,并且是全双工、长连接。它既不规定消息是 JSON 还是 protobuf,也不规定字段叫什么。
  2. 应用层协议{"type":"req","id":"...","method":"...","params":{...}} 这样的字段结构,是服务端自己定义的一套约定(本例中就是网关的 RPC 协议)。服务端怎么定,客户端就必须怎么写——字段名、类型、含义全都要对齐,写错一个字母就解不出来。
  3. 实践推论:对接任何 WebSocket 服务时,第一件事是拿到服务端的协议文档(或源码里的结构体定义),照着写客户端的结构体 tag。不要凭猜想去试字段名。
  4. 对比 JSON-RPC:以太坊的 eth_subscribeeth_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" 且携带 tokenprotocol 版本号。服务端据此校验身份、协商协议版本,不匹配就拒绝。

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 是这类代码的固定套路