1035 lines
23 KiB
Markdown
1035 lines
23 KiB
Markdown
# 扩展 WebSocket 协议开发指南
|
||
|
||
> 本文档详细介绍如何在 Tinode 中扩展 WebSocket 协议,包括添加新消息类型、新端点、修改现有行为等。
|
||
|
||
---
|
||
|
||
## 目录
|
||
|
||
- [概述](#概述)
|
||
- [场景一:添加新消息类型](#场景一添加新消息类型)
|
||
- [场景二:添加新 WebSocket 端点](#场景二添加新-websocket-端点)
|
||
- [场景三:修改现有消息处理](#场景三修改现有消息处理)
|
||
- [场景四:添加新的认证方式](#场景四添加新的认证方式)
|
||
- [测试指南](#测试指南)
|
||
- [调试技巧](#调试技巧)
|
||
|
||
---
|
||
|
||
## 概述
|
||
|
||
### WebSocket 协议扩展的几种方式
|
||
|
||
| 方式 | 用途 | 复杂度 |
|
||
|------|------|--------|
|
||
| 添加新消息类型 | 新增业务功能(如撤回消息、输入状态) | 中 |
|
||
| 添加新端点 | 独立的服务入口(如管理后台) | 高 |
|
||
| 修改现有消息 | 增强现有功能(如消息增加字段) | 低 |
|
||
| 添加认证方式 | 支持新的登录方式(如 OAuth) | 中 |
|
||
|
||
### 核心文件
|
||
|
||
| 文件 | 说明 |
|
||
|------|------|
|
||
| `server/datamodel.go` | 消息结构定义 |
|
||
| `server/session.go` | 消息路由和分发 |
|
||
| `server/hdl_websock.go` | WebSocket 连接处理 |
|
||
| `server/hub.go` | 会话和话题管理 |
|
||
| `pbx/model.proto` | gRPC 协议定义(如需同步支持) |
|
||
|
||
---
|
||
|
||
## 场景一:添加新消息类型
|
||
|
||
### 示例:添加消息撤回功能
|
||
|
||
假设我们要添加一个 `{recall}` 消息类型,用于撤回已发送的消息。
|
||
|
||
### 步骤 1:定义消息结构
|
||
|
||
**文件**: `server/datamodel.go`
|
||
|
||
```go
|
||
// 在文件末尾添加
|
||
|
||
// MsgClientRecall 是撤回消息的请求 {recall}
|
||
type MsgClientRecall struct {
|
||
// 消息 ID(用于响应匹配)
|
||
Id string `json:"id,omitempty"`
|
||
// 话题名称
|
||
Topic string `json:"topic"`
|
||
// 要撤回的消息 ID 列表
|
||
SeqList []int `json:"seq"`
|
||
// 是否硬删除(对所有人生效)
|
||
Hard bool `json:"hard,omitempty"`
|
||
}
|
||
|
||
// MsgServerRecall 是撤回消息的响应
|
||
type MsgServerRecall struct {
|
||
// 原请求 ID
|
||
Id string `json:"id,omitempty"`
|
||
// 话题名称
|
||
Topic string `json:"topic,omitempty"`
|
||
// 已撤回的消息 ID 列表
|
||
SeqList []int `json:"seq,omitempty"`
|
||
// 撤回时间
|
||
Deleted *time.Time `json:"deleted,omitempty"`
|
||
}
|
||
```
|
||
|
||
### 步骤 2:添加到 ClientComMessage
|
||
|
||
**文件**: `server/datamodel.go`
|
||
|
||
```go
|
||
// ClientComMessage 是客户端消息的包装器
|
||
type ClientComMessage struct {
|
||
Hi *MsgClientHi `json:"hi"`
|
||
Acc *MsgClientAcc `json:"acc"`
|
||
Login *MsgClientLogin `json:"login"`
|
||
Sub *MsgClientSub `json:"sub"`
|
||
Leave *MsgClientLeave `json:"leave"`
|
||
Pub *MsgClientPub `json:"pub"`
|
||
Get *MsgClientGet `json:"get"`
|
||
Set *MsgClientSet `json:"set"`
|
||
Del *MsgClientDel `json:"del"`
|
||
Note *MsgClientNote `json:"note"`
|
||
Recall *MsgClientRecall `json:"recall"` // ← 添加这一行
|
||
|
||
// ... 其他字段
|
||
}
|
||
```
|
||
|
||
### 步骤 3:添加到 ServerComMessage
|
||
|
||
**文件**: `server/datamodel.go`
|
||
|
||
```go
|
||
// ServerComMessage 是服务器消息的包装器
|
||
type ServerComMessage struct {
|
||
Ctrl *MsgServerCtrl `json:"ctrl,omitempty"`
|
||
Data *MsgServerData `json:"data,omitempty"`
|
||
Meta *MsgServerMeta `json:"meta,omitempty"`
|
||
Pres *MsgServerPres `json:"pres,omitempty"`
|
||
Info *MsgServerInfo `json:"info,omitempty"`
|
||
Recall *MsgServerRecall `json:"recall,omitempty"` // ← 添加这一行
|
||
|
||
// ... 其他字段
|
||
}
|
||
```
|
||
|
||
### 步骤 4:实现消息处理器
|
||
|
||
**文件**: `server/session.go`
|
||
|
||
```go
|
||
// 在文件末尾添加
|
||
|
||
// recall 处理消息撤回请求
|
||
func (s *Session) recall(msg *ClientComMessage) {
|
||
// 1. 检查是否已认证
|
||
if msg.AsUser == "" {
|
||
s.queueOut(ErrAuthRequiredReply(msg, msg.Timestamp))
|
||
return
|
||
}
|
||
|
||
// 2. 获取话题
|
||
topic := msg.Original
|
||
t := globals.hub.topicGet(topic)
|
||
if t == nil {
|
||
s.queueOut(ErrTopicNotFoundReply(msg, msg.Timestamp))
|
||
return
|
||
}
|
||
|
||
// 3. 检查权限
|
||
modeWant, modeGiven := t.getPerUserAcs(types.ParseUserId(msg.AsUser))
|
||
if !(modeGiven & modeWant).IsWriter() {
|
||
s.queueOut(ErrPermissionDeniedReply(msg, msg.Timestamp))
|
||
return
|
||
}
|
||
|
||
// 4. 调用话题处理撤回
|
||
t.clientMsg <- msg
|
||
}
|
||
```
|
||
|
||
### 步骤 5:注册消息路由
|
||
|
||
**文件**: `server/session.go`
|
||
|
||
找到 `dispatch` 函数,在 switch 语句中添加新的 case:
|
||
|
||
```go
|
||
func (s *Session) dispatch(msg *ClientComMessage) {
|
||
// ... 前面的代码保持不变
|
||
|
||
switch {
|
||
case msg.Pub != nil:
|
||
handler = checkVers(checkUser(s.publish))
|
||
msg.Id = msg.Pub.Id
|
||
msg.Original = msg.Pub.Topic
|
||
|
||
// ... 其他 case
|
||
|
||
case msg.Recall != nil: // ← 添加这个 case
|
||
handler = checkVers(checkUser(s.recall))
|
||
msg.Id = msg.Recall.Id
|
||
msg.Original = msg.Recall.Topic
|
||
|
||
default:
|
||
// 未知消息
|
||
s.queueOut(ErrMalformed("", "", msg.Timestamp))
|
||
logs.Warn.Println("s.dispatch: unknown message", s.sid)
|
||
return
|
||
}
|
||
|
||
// ... 后续代码
|
||
}
|
||
```
|
||
|
||
### 步骤 6:在 Topic 中实现业务逻辑
|
||
|
||
**文件**: `server/topic.go`
|
||
|
||
```go
|
||
// 在 Topic 的消息处理循环中添加
|
||
func (t *Topic) runLocal(hub *Hub) {
|
||
for {
|
||
select {
|
||
case msg := <-t.clientMsg:
|
||
switch {
|
||
case msg.Recall != nil:
|
||
t.handleRecall(msg)
|
||
// ... 其他消息类型
|
||
}
|
||
|
||
// ... 其他 case
|
||
}
|
||
}
|
||
}
|
||
|
||
// handleRecall 处理消息撤回
|
||
func (t *Topic) handleRecall(msg *ClientComMessage) {
|
||
// 1. 验证消息所有权
|
||
uid := types.ParseUserId(msg.AsUser)
|
||
|
||
// 2. 从数据库删除消息
|
||
var deletedSeqs []int
|
||
for _, seq := range msg.Recall.SeqList {
|
||
// 检查是否是自己发送的消息
|
||
if t.isMessageOwner(seq, uid) || msg.Recall.Hard {
|
||
err := store.Messages.Delete(t.name, seq, msg.Recall.Hard)
|
||
if err == nil {
|
||
deletedSeqs = append(deletedSeqs, seq)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 3. 更新 delID
|
||
if len(deletedSeqs) > 0 {
|
||
t.delID++
|
||
store.Topics.Update(t.name, map[string]any{"delId": t.delID})
|
||
}
|
||
|
||
// 4. 广播给所有订阅者
|
||
now := types.TimeNow()
|
||
resp := &ServerComMessage{
|
||
Recall: &MsgServerRecall{
|
||
Id: msg.Id,
|
||
Topic: t.name,
|
||
SeqList: deletedSeqs,
|
||
Deleted: &now,
|
||
},
|
||
}
|
||
|
||
for sess := range t.sessions {
|
||
sess.queueOut(resp)
|
||
}
|
||
}
|
||
```
|
||
|
||
### 步骤 7:同步 gRPC 支持(可选)
|
||
|
||
如果需要 gRPC 客户端也支持,需要修改:
|
||
|
||
**文件**: `pbx/model.proto`
|
||
|
||
```protobuf
|
||
// 添加新的消息定义
|
||
message ClientRecall {
|
||
string id = 1;
|
||
string topic = 2;
|
||
repeated int32 seq = 3;
|
||
bool hard = 4;
|
||
}
|
||
|
||
message ServerRecall {
|
||
string id = 1;
|
||
string topic = 2;
|
||
repeated int32 seq = 3;
|
||
int64 deleted = 4;
|
||
}
|
||
|
||
// 添加到 ClientMsg
|
||
message ClientMsg {
|
||
oneof Message {
|
||
// ... 现有字段
|
||
ClientRecall recall = 14; // 使用新的字段号
|
||
}
|
||
ClientExtra extra = 13;
|
||
}
|
||
|
||
// 添加到 ServerMsg
|
||
message ServerMsg {
|
||
oneof Message {
|
||
// ... 现有字段
|
||
ServerRecall recall = 6;
|
||
}
|
||
}
|
||
```
|
||
|
||
然后生成 Go 代码:
|
||
|
||
```bash
|
||
# 在 pbx 目录执行
|
||
protoc --go_out=plugins=grpc:. model.proto
|
||
```
|
||
|
||
**文件**: `server/pbconverter.go`
|
||
|
||
添加序列化和反序列化函数:
|
||
|
||
```go
|
||
func pbCliSerialize(msg *ClientComMessage) *pbx.ClientMsg {
|
||
// ... 现有代码
|
||
|
||
case msg.Recall != nil:
|
||
pkt.Message = &pbx.ClientMsg_Recall{
|
||
Recall: &pbx.ClientRecall{
|
||
Id: msg.Recall.Id,
|
||
Topic: msg.Recall.Topic,
|
||
Seq: msg.Recall.SeqList,
|
||
Hard: msg.Recall.Hard,
|
||
},
|
||
}
|
||
}
|
||
|
||
func pbCliDeserialize(pkt *pbx.ClientMsg) *ClientComMessage {
|
||
// ... 现有代码
|
||
|
||
if recall := pkt.GetRecall(); recall != nil {
|
||
msg.Recall = &MsgClientRecall{
|
||
Id: recall.GetId(),
|
||
Topic: recall.GetTopic(),
|
||
SeqList: recall.GetSeq(),
|
||
Hard: recall.GetHard(),
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### 步骤 8:编写单元测试
|
||
|
||
**文件**: `server/session_test.go`
|
||
|
||
```go
|
||
func TestRecallMessage(t *testing.T) {
|
||
// 1. 创建测试会话
|
||
sess := createTestSession()
|
||
defer sess.cleanUp(false)
|
||
|
||
// 2. 发送登录
|
||
sess.dispatchRaw([]byte(`{
|
||
"login": {
|
||
"id": "login1",
|
||
"scheme": "basic",
|
||
"secret": "dXNlcjpwYXNz"
|
||
}
|
||
}`))
|
||
|
||
// 3. 订阅话题
|
||
sess.dispatchRaw([]byte(`{
|
||
"sub": {
|
||
"id": "sub1",
|
||
"topic": "test-topic"
|
||
}
|
||
}`))
|
||
|
||
// 4. 发送消息
|
||
sess.dispatchRaw([]byte(`{
|
||
"pub": {
|
||
"id": "pub1",
|
||
"topic": "test-topic",
|
||
"content": "test message"
|
||
}
|
||
}`))
|
||
|
||
// 5. 撤回消息
|
||
sess.dispatchRaw([]byte(`{
|
||
"recall": {
|
||
"id": "recall1",
|
||
"topic": "test-topic",
|
||
"seq": [1]
|
||
}
|
||
}`))
|
||
|
||
// 6. 验证响应
|
||
// ... 检查撤回是否成功
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 场景二:添加新 WebSocket 端点
|
||
|
||
### 示例:添加管理后台专用端点
|
||
|
||
假设我们要添加一个 `/admin/channels` 端点,用于管理后台的 WebSocket 连接。
|
||
|
||
### 步骤 1:定义新的处理器
|
||
|
||
**文件**: `server/hdl_websock.go`
|
||
|
||
```go
|
||
// Admin WebSocket 升级器
|
||
var adminUpgrader = websocket.Upgrader{
|
||
ReadBufferSize: 1024,
|
||
WriteBufferSize: 1024,
|
||
EnableCompression: true,
|
||
CheckOrigin: func(r *http.Request) bool {
|
||
// 只允许特定来源
|
||
origin := r.Header.Get("Origin")
|
||
return origin == "https://admin.example.com"
|
||
},
|
||
}
|
||
|
||
// serveAdminWebSocket 处理管理后台 WebSocket 连接
|
||
func serveAdminWebSocket(wrt http.ResponseWriter, req *http.Request) {
|
||
now := types.TimeNow()
|
||
|
||
// 1. 验证 API Key(管理后台专用)
|
||
apiKey := getAPIKey(req)
|
||
if !isAdminAPIKey(apiKey) {
|
||
wrt.WriteHeader(http.StatusForbidden)
|
||
json.NewEncoder(wrt).Encode(ErrAPIKeyRequired(now))
|
||
return
|
||
}
|
||
|
||
// 2. 验证管理员令牌
|
||
token := req.Header.Get("X-Admin-Token")
|
||
if !validateAdminToken(token) {
|
||
wrt.WriteHeader(http.StatusUnauthorized)
|
||
json.NewEncoder(wrt).Encode(ErrPermissionDenied("", "", now))
|
||
return
|
||
}
|
||
|
||
// 3. 升级 WebSocket
|
||
ws, err := adminUpgrader.Upgrade(wrt, req, nil)
|
||
if err != nil {
|
||
logs.Err.Println("admin ws: failed to Upgrade", err)
|
||
return
|
||
}
|
||
|
||
// 4. 创建管理员会话
|
||
sess, count := globals.sessionStore.NewSession(ws, "admin")
|
||
sess.isAdmin = true // 标记为管理员会话
|
||
sess.remoteAddr = getRemoteAddr(req)
|
||
|
||
logs.Info.Println("admin ws: session started", sess.sid, count)
|
||
|
||
// 5. 启动读写循环
|
||
go sess.writeLoop()
|
||
go sess.readLoop()
|
||
}
|
||
|
||
// isAdminAPIKey 检查是否为管理 API Key
|
||
func isAdminAPIKey(apiKey string) bool {
|
||
// 检查配置中的管理 API Key
|
||
return apiKey == globals.adminAPIKey
|
||
}
|
||
|
||
// validateAdminToken 验证管理员令牌
|
||
func validateAdminToken(token string) bool {
|
||
// JWT 验证或其他验证逻辑
|
||
return token != ""
|
||
}
|
||
```
|
||
|
||
### 步骤 2:注册路由
|
||
|
||
**文件**: `server/http.go`
|
||
|
||
```go
|
||
func setupHTTPHandlers() {
|
||
// ... 现有路由
|
||
|
||
// 添加管理后台 WebSocket 路由
|
||
http.HandleFunc("/admin/channels", serveAdminWebSocket)
|
||
}
|
||
```
|
||
|
||
### 步骤 3:扩展 Session 结构
|
||
|
||
**文件**: `server/session.go`
|
||
|
||
```go
|
||
type Session struct {
|
||
// ... 现有字段
|
||
|
||
// 是否为管理员会话
|
||
isAdmin bool
|
||
}
|
||
```
|
||
|
||
### 步骤 4:添加管理员专用消息
|
||
|
||
可以在 dispatch 函数中添加管理员专用的消息处理:
|
||
|
||
```go
|
||
func (s *Session) dispatch(msg *ClientComMessage) {
|
||
// 管理员专用消息检查
|
||
if msg.AdminCmd != nil {
|
||
if !s.isAdmin {
|
||
s.queueOut(ErrPermissionDenied("", "", now))
|
||
return
|
||
}
|
||
handler = s.handleAdminCommand
|
||
msg.Id = msg.AdminCmd.Id
|
||
}
|
||
|
||
// ... 其他消息处理
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 场景三:修改现有消息处理
|
||
|
||
### 示例:为 {pub} 消息添加过期时间
|
||
|
||
假设我们要为消息添加自动过期功能。
|
||
|
||
### 步骤 1:扩展消息结构
|
||
|
||
**文件**: `server/datamodel.go`
|
||
|
||
```go
|
||
// MsgClientPub 是客户端发布消息的请求
|
||
type MsgClientPub struct {
|
||
Id string `json:"id,omitempty"`
|
||
Topic string `json:"topic"`
|
||
NoEcho bool `json:"noecho,omitempty"`
|
||
Head map[string]any `json:"head,omitempty"`
|
||
Content any `json:"content"`
|
||
|
||
// 新增:消息过期时间(秒)
|
||
ExpiresAfter int `json:"expires_after,omitempty"`
|
||
}
|
||
```
|
||
|
||
### 步骤 2:修改 Topic 处理逻辑
|
||
|
||
**文件**: `server/topic.go`
|
||
|
||
```go
|
||
func (t *Topic) handlePublish(msg *ClientComMessage) {
|
||
// ... 现有逻辑
|
||
|
||
// 处理过期时间
|
||
var expiresAt *time.Time
|
||
if msg.Pub.ExpiresAfter > 0 {
|
||
exp := time.Now().Add(time.Duration(msg.Pub.ExpiresAfter) * time.Second)
|
||
expiresAt = &exp
|
||
}
|
||
|
||
// 存储消息时包含过期时间
|
||
err := store.Messages.Create(&types.Message{
|
||
// ... 其他字段
|
||
ExpiresAt: expiresAt,
|
||
})
|
||
}
|
||
```
|
||
|
||
### 步骤 3:添加过期清理任务
|
||
|
||
**文件**: `server/main.go`
|
||
|
||
```go
|
||
// 启动消息过期清理
|
||
func startMessageExpirationCleaner() {
|
||
go func() {
|
||
ticker := time.NewTicker(5 * time.Minute)
|
||
for range ticker.C {
|
||
store.Messages.DeleteExpired()
|
||
}
|
||
}()
|
||
}
|
||
```
|
||
|
||
### 步骤 4:数据库支持
|
||
|
||
**文件**: `server/db/mysql/messages.go`
|
||
|
||
```go
|
||
// Create 创建消息
|
||
func (m *MessageAdapter) Create(msg *types.Message) error {
|
||
_, err := m.db.Exec(`
|
||
INSERT INTO messages (id, topic, ` + "`from`" + `, head, content, expires_at)
|
||
VALUES (?, ?, ?, ?, ?, ?)
|
||
`, msg.Id, msg.Topic, msg.From, msg.Head, msg.Content, msg.ExpiresAt)
|
||
return err
|
||
}
|
||
|
||
// DeleteExpired 删除过期消息
|
||
func (m *MessageAdapter) DeleteExpired() error {
|
||
_, err := m.db.Exec(`
|
||
DELETE FROM messages
|
||
WHERE expires_at IS NOT NULL AND expires_at < NOW()
|
||
`)
|
||
return err
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 场景四:添加新的认证方式
|
||
|
||
### 示例:添加 OAuth 认证
|
||
|
||
### 步骤 1:实现认证处理器
|
||
|
||
**文件**: `server/auth/oauth.go`
|
||
|
||
```go
|
||
package auth
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"net/http"
|
||
"net/url"
|
||
|
||
"golang.org/x/oauth2"
|
||
"golang.org/x/oauth2/google"
|
||
)
|
||
|
||
// OAuth 认证处理器
|
||
type OAuthAuthenticator struct {
|
||
oauthConfig *oauth2.Config
|
||
}
|
||
|
||
func NewOAuthAuthenticator(clientID, clientSecret, redirectURL string) *OAuthAuthenticator {
|
||
return &OAuthAuthenticator{
|
||
oauthConfig: &oauth2.Config{
|
||
ClientID: clientID,
|
||
ClientSecret: clientSecret,
|
||
RedirectURL: redirectURL,
|
||
Scopes: []string{"email", "profile"},
|
||
Endpoint: google.Endpoint,
|
||
},
|
||
}
|
||
}
|
||
|
||
// Authenticate 验证 OAuth 令牌
|
||
func (o *OAuthAuthenticator) Authenticate(secret []byte) (Uid, []byte, time.Duration, error) {
|
||
// secret 是 OAuth authorization code
|
||
code := string(secret)
|
||
|
||
// 交换令牌
|
||
token, err := o.oauthConfig.Exchange(context.Background(), code)
|
||
if err != nil {
|
||
return ZeroUid, nil, 0, err
|
||
}
|
||
|
||
// 获取用户信息
|
||
client := o.oauthConfig.Client(context.Background(), token)
|
||
resp, err := client.Get("https://www.googleapis.com/oauth2/v2/userinfo")
|
||
if err != nil {
|
||
return ZeroUid, nil, 0, err
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
var userInfo struct {
|
||
Email string `json:"email"`
|
||
Name string `json:"name"`
|
||
}
|
||
json.NewDecoder(resp.Body).Decode(&userInfo)
|
||
|
||
// 查找或创建用户
|
||
uid := findOrCreateUserByEmail(userInfo.Email, userInfo.Name)
|
||
|
||
return uid, nil, 0, nil
|
||
}
|
||
```
|
||
|
||
### 步骤 2:注册认证器
|
||
|
||
**文件**: `server/auth/auth.go`
|
||
|
||
```go
|
||
func init() {
|
||
// 注册 OAuth 认证器
|
||
Register("oauth", func(params map[string]any) (Authenticator, error) {
|
||
clientID, _ := params["client_id"].(string)
|
||
clientSecret, _ := params["client_secret"].(string)
|
||
redirectURL, _ := params["redirect_url"].(string)
|
||
return NewOAuthAuthenticator(clientID, clientSecret, redirectURL), nil
|
||
})
|
||
}
|
||
```
|
||
|
||
### 步骤 3:配置文件支持
|
||
|
||
**文件**: `server/tinode.conf`
|
||
|
||
```json
|
||
{
|
||
"auth": {
|
||
"oauth": {
|
||
"enabled": true,
|
||
"client_id": "your-client-id",
|
||
"client_secret": "your-client-secret",
|
||
"redirect_url": "https://your-app.com/oauth/callback"
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### 步骤 4:前端使用
|
||
|
||
```javascript
|
||
// 前端发送登录请求
|
||
ws.send(JSON.stringify({
|
||
login: {
|
||
id: "login1",
|
||
scheme: "oauth",
|
||
secret: "authorization_code_from_provider"
|
||
}
|
||
}));
|
||
```
|
||
|
||
---
|
||
|
||
## 测试指南
|
||
|
||
### 单元测试
|
||
|
||
```go
|
||
// server/session_test.go
|
||
func TestNewMessageType(t *testing.T) {
|
||
tests := []struct {
|
||
name string
|
||
input string
|
||
expected *ClientComMessage
|
||
}{
|
||
{
|
||
name: "recall message",
|
||
input: `{"recall": {"id": "1", "topic": "test", "seq": [1, 2]}}`,
|
||
expected: &ClientComMessage{
|
||
Recall: &MsgClientRecall{
|
||
Id: "1",
|
||
Topic: "test",
|
||
SeqList: []int{1, 2},
|
||
},
|
||
},
|
||
},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
var msg ClientComMessage
|
||
err := json.Unmarshal([]byte(tt.input), &msg)
|
||
assert.NoError(t, err)
|
||
assert.Equal(t, tt.expected.Recall, msg.Recall)
|
||
})
|
||
}
|
||
}
|
||
```
|
||
|
||
### 集成测试
|
||
|
||
```go
|
||
// server/integration_test.go
|
||
func TestWebSocketProtocol(t *testing.T) {
|
||
// 1. 启动测试服务器
|
||
srv := startTestServer()
|
||
defer srv.Close()
|
||
|
||
// 2. 连接 WebSocket
|
||
ws := connectWebSocket(t, srv.URL)
|
||
defer ws.Close()
|
||
|
||
// 3. 测试消息流程
|
||
testFullFlow(t, ws)
|
||
}
|
||
|
||
func testFullFlow(t *testing.T, ws *websocket.Conn) {
|
||
// 发送 hi
|
||
sendAndExpect(t, ws,
|
||
`{"hi": {"id": "hi1", "ver": "0.22", "ua": "test"}}`,
|
||
func(resp map[string]any) {
|
||
assert.Equal(t, 200, int(resp["ctrl"].(map[string]any)["code"].(float64)))
|
||
})
|
||
|
||
// 发送 login
|
||
sendAndExpect(t, ws,
|
||
`{"login": {"id": "login1", "scheme": "basic", "secret": "dXNlcjpwYXNz"}}`,
|
||
func(resp map[string]any) {
|
||
assert.Equal(t, 200, int(resp["ctrl"].(map[string]any)["code"].(float64)))
|
||
})
|
||
}
|
||
```
|
||
|
||
### 压力测试
|
||
|
||
```go
|
||
// server/benchmark_test.go
|
||
func BenchmarkMessageDispatch(b *testing.B) {
|
||
sess := createTestSession()
|
||
defer sess.cleanUp(false)
|
||
|
||
raw := []byte(`{"pub": {"id": "1", "topic": "test", "content": "hello"}}`)
|
||
|
||
b.ResetTimer()
|
||
for i := 0; i < b.N; i++ {
|
||
sess.dispatchRaw(raw)
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 调试技巧
|
||
|
||
### 1. 启用详细日志
|
||
|
||
```go
|
||
// 在 tinode.conf 中
|
||
{
|
||
"log_level": "debug",
|
||
"log_file": "/var/log/tinode/debug.log"
|
||
}
|
||
```
|
||
|
||
### 2. WebSocket 抓包
|
||
|
||
使用 Wireshark 或浏览器开发者工具:
|
||
|
||
```javascript
|
||
// 浏览器控制台
|
||
const ws = new WebSocket('ws://localhost:6060/v0/channels');
|
||
|
||
ws.onopen = () => {
|
||
console.log('Connected');
|
||
ws.send(JSON.stringify({hi: {ver: "0.22", ua: "debug"}}));
|
||
};
|
||
|
||
ws.onmessage = (e) => {
|
||
console.log('Received:', JSON.parse(e.data));
|
||
};
|
||
|
||
// 发送测试消息
|
||
ws.send(JSON.stringify({
|
||
recall: {id: "1", topic: "test", seq: [1]}
|
||
}));
|
||
```
|
||
|
||
### 3. 使用 wscat 工具
|
||
|
||
```bash
|
||
# 安装
|
||
npm install -g wscat
|
||
|
||
# 连接
|
||
wscat -c ws://localhost:6060/v0/channels
|
||
|
||
# 发送消息
|
||
> {"hi": {"ver": "0.22", "ua": "wscat"}}
|
||
< {"ctrl":{"code":200,"text":"ok",...}}
|
||
|
||
> {"recall": {"id": "1", "topic": "test", "seq": [1]}}
|
||
< {"ctrl":{"code":200,"text":"ok",...}}
|
||
```
|
||
|
||
### 4. 断点调试
|
||
|
||
在 VSCode 中设置断点:
|
||
|
||
```go
|
||
// 在 dispatchRaw 函数设置断点
|
||
func (s *Session) dispatchRaw(raw []byte) {
|
||
logs.Debug.Printf("Received: %s", string(raw)) // 添加日志
|
||
// ...
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 最佳实践
|
||
|
||
### 1. 向后兼容
|
||
|
||
添加新字段时使用 `omitempty`:
|
||
|
||
```go
|
||
type MsgClientPub struct {
|
||
// ... 现有字段
|
||
ExpiresAfter int `json:"expires_after,omitempty"` // 可选字段
|
||
}
|
||
```
|
||
|
||
### 2. 版本检查
|
||
|
||
对于需要特定版本的功能:
|
||
|
||
```go
|
||
func (s *Session) recall(msg *ClientComMessage) {
|
||
if s.ver < minVersionForRecall {
|
||
s.queueOut(ErrUnsupportedVersion(msg.Id, msg.Original, msg.Timestamp))
|
||
return
|
||
}
|
||
// ...
|
||
}
|
||
```
|
||
|
||
### 3. 权限控制
|
||
|
||
始终检查用户权限:
|
||
|
||
```go
|
||
func (s *Session) recall(msg *ClientComMessage) {
|
||
// 检查用户是否已认证
|
||
if msg.AsUser == "" {
|
||
s.queueOut(ErrAuthRequiredReply(msg, msg.Timestamp))
|
||
return
|
||
}
|
||
|
||
// 检查话题权限
|
||
modeWant, modeGiven := t.getPerUserAcs(uid)
|
||
if !(modeGiven & modeWant).IsWriter() {
|
||
s.queueOut(ErrPermissionDeniedReply(msg, msg.Timestamp))
|
||
return
|
||
}
|
||
}
|
||
```
|
||
|
||
### 4. 错误处理
|
||
|
||
提供有意义的错误信息:
|
||
|
||
```go
|
||
func (s *Session) recall(msg *ClientComMessage) {
|
||
if len(msg.Recall.SeqList) == 0 {
|
||
s.queueOut(&ServerComMessage{
|
||
Ctrl: &MsgServerCtrl{
|
||
Id: msg.Id,
|
||
Code: 400,
|
||
Text: "No message IDs provided",
|
||
Topic: msg.Original,
|
||
},
|
||
})
|
||
return
|
||
}
|
||
}
|
||
```
|
||
|
||
### 5. 单元测试覆盖
|
||
|
||
确保新功能有测试覆盖:
|
||
|
||
```go
|
||
func TestRecallValidation(t *testing.T) {
|
||
tests := []struct {
|
||
name string
|
||
msg *MsgClientRecall
|
||
wantErr bool
|
||
}{
|
||
{"empty seq list", &MsgClientRecall{SeqList: []int{}}, true},
|
||
{"valid seq list", &MsgClientRecall{SeqList: []int{1, 2}}, false},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
err := validateRecallMessage(tt.msg)
|
||
if tt.wantErr {
|
||
assert.Error(t, err)
|
||
} else {
|
||
assert.NoError(t, err)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 常见问题
|
||
|
||
### Q1: 如何处理大消息?
|
||
|
||
使用消息分片或文件上传:
|
||
|
||
```go
|
||
// 大消息应该使用文件上传
|
||
if len(content) > maxInlineMessageSize {
|
||
// 上传到文件服务
|
||
url, err := uploadFile(content)
|
||
// 发送文件引用
|
||
sendFileReference(url)
|
||
}
|
||
```
|
||
|
||
### Q2: 如何支持消息压缩?
|
||
|
||
启用 WebSocket 压缩:
|
||
|
||
```go
|
||
var upgrader = websocket.Upgrader{
|
||
EnableCompression: true, // 启用压缩
|
||
}
|
||
```
|
||
|
||
### Q3: 如何处理客户端断线重连?
|
||
|
||
使用会话恢复:
|
||
|
||
```javascript
|
||
// 客户端重连时提供之前的 sid
|
||
ws.send(JSON.stringify({
|
||
hi: {
|
||
ver: "0.22",
|
||
ua: "client",
|
||
device_id: "unique-device-id" // 用于恢复会话
|
||
}
|
||
}));
|
||
```
|
||
|
||
### Q4: 如何支持集群部署?
|
||
|
||
使用 Hub 的集群功能:
|
||
|
||
```go
|
||
// 配置集群
|
||
{
|
||
"cluster": {
|
||
"enabled": true,
|
||
"nodes": ["node1:6060", "node2:6060"]
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 总结
|
||
|
||
扩展 WebSocket 协议的核心步骤:
|
||
|
||
1. **定义数据结构** - 在 `datamodel.go` 中添加
|
||
2. **实现处理逻辑** - 在 `session.go` 中添加处理器
|
||
3. **注册消息路由** - 在 `dispatch` 函数中注册
|
||
4. **实现业务逻辑** - 在 `topic.go` 中实现
|
||
5. **同步 gRPC** - 如果需要,更新 `model.proto`
|
||
6. **编写测试** - 确保功能正确
|
||
7. **文档更新** - 更新 API 文档
|
||
|
||
遵循这些步骤,可以安全地扩展 Tinode 的 WebSocket 协议,同时保持向后兼容和系统稳定性。 |