Watermill库-go iris + Vue3 + WebSocket应用示例

83 阅读9分钟

Watermill库- go iris + Vue3 + WebSocket应用示例

Watermill是一个用于构建事件驱动应用程序的Go库,它提供了一种简单而强大的方式来处理消息传递。本文将详细介绍Watermill库的使用方法,并通过一个完整的示例展示如何在Go服务端使用Iris框架结合Watermill进行消息处理,以及前端如何通过Vue3和WebSocket进行消息发送与接收。

1. Watermill库详细介绍

1.1 Watermill简介

Watermill是ThreeDotsLabs开发的一个Go语言事件驱动框架,它提供了统一的接口来处理各种消息传递系统。Watermill的主要特点包括:

  • 统一的接口:支持多种消息传递后端(如Kafka、RabbitMQ、Google Cloud Pub/Sub等)
  • 灵活的消息路由:可以轻松地在不同的消息处理函数之间路由消息
  • 可靠的消息处理:提供消息确认机制,确保消息不会丢失
  • 易于测试:提供了测试工具,便于编写单元测试

1.2 下载、安装与配置

Watermill可以通过Go modules进行安装。在项目目录中执行以下命令:

go mod init your-project-name
go get github.com/ThreeDotsLabs/watermill

Watermill还提供了多种消息传递后端的支持,可以根据需要安装相应的驱动:

# 例如安装Kafka支持
go get github.com/ThreeDotsLabs/watermill-kafka/v2

# 安装RabbitMQ支持
go get github.com/ThreeDotsLabs/watermill-amqp/v2

在本示例中,我们将使用自定义的内存消息代理,因此不需要额外安装其他依赖。

2. Go服务端使用Iris框架和Watermill库

2.1 项目结构与依赖

我们的项目使用了以下主要依赖:

import (
  "context"
  "encoding/json"
  "log"
  "sync"

  "github.com/ThreeDotsLabs/watermill"
  "github.com/ThreeDotsLabs/watermill/message"

  "github.com/kataras/iris/v12"
  "github.com/kataras/iris/v12/websocket"
  "github.com/kataras/neffos"
  "gorm.io/driver/sqlite"
  "gorm.io/gorm"
)

2.2 前后端通信流程

以下流程图展示了前后端之间完整的通信过程:

sequenceDiagram
    participant C as 客户端 (浏览器)
    participant S as 服务端 (Iris)
    participant W as Watermill
    participant D as 数据库 (SQLite)

    C->>S: 1. WebSocket连接请求 (/ws)
    S->>C: 2. 建立WebSocket连接
    
    C->>S: 3. 发送消息 (POST /send-message)
    S->>D: 4. 保存消息到数据库
    S->>W: 5. 通过Watermill发布消息
    W->>S: 6. 消息被订阅者接收
    S->>C: 7. 通过WebSocket推送消息给所有客户端
    
    Note over C,S: 重复步骤3-7实现消息广播

2.3 消息模型定义

首先,我们定义了一个短消息模型,用于存储消息内容和接收者信息:

// 短消息模型
type ShortMessage struct {
  gorm.Model
  Content  string `json:"content"`  // 消息内容
  Receiver string `json:"receiver"` // 接收者(如用户ID)
}

2.4 自定义内存消息代理

为了演示Watermill的使用,我们实现了一个自定义的内存消息代理:

// 自定义内存 Broker
type MemoryBroker struct {
  topics map[string][]chan *message.Message // 主题 -> 消息通道列表(每个订阅者一个通道)
  mu     sync.RWMutex                      // 保护 topics 的锁
  logger watermill.LoggerAdapter
}

// NewMemoryBroker 创建自定义内存 Broker
func NewMemoryBroker(logger watermill.LoggerAdapter) *MemoryBroker {
  if logger == nil {
    logger = watermill.NopLogger{}
  }
  return &MemoryBroker{
    topics: make(map[string][]chan *message.Message),
    logger: logger,
  }
}

// Publish 实现 Publisher 接口:向指定主题发布消息
func (b *MemoryBroker) Publish(topic string, messages ...*message.Message) error {
  b.mu.RLock()
  defer b.mu.RUnlock()

  // 遍历该主题的所有订阅者通道,发送消息
  for _, ch := range b.topics[topic] {
    for _, msg := range messages {
      // 复制消息(避免多个订阅者共享同一消息实例导致的 Ack 冲突)
      msgCopy := message.NewMessage(msg.UUID, msg.Payload)
      // 手动复制 Metadata
      for k, v := range msg.Metadata {
        msgCopy.Metadata[k] = v
      }

      select {
      case ch <- msgCopy:
        b.logger.Debug("消息已发送到通道", watermill.LogFields{"topic": topic, "msg_uuid": msg.UUID})
      default:
        // 通道满时直接丢弃(简单处理,生产环境可增加缓冲或阻塞)
        b.logger.Error("通道缓冲区满,消息丢弃", nil, watermill.LogFields{"topic": topic, "msg_uuid": msg.UUID})
      }
    }
  }
  return nil
}

// Subscribe 实现 Subscriber 接口:订阅主题,返回消息通道
func (b *MemoryBroker) Subscribe(ctx context.Context, topic string) (<-chan *message.Message, error) {
  // 创建消息通道(带缓冲,避免阻塞)
  msgCh := make(chan *message.Message, 100)

  // 将通道注册到主题中
  b.mu.Lock()
  b.topics[topic] = append(b.topics[topic], msgCh)
  b.mu.Unlock()

  b.logger.Debug("已订阅主题", watermill.LogFields{"topic": topic})

  // 当上下文关闭时,从主题中移除通道
  go func() {
    <-ctx.Done()
    b.mu.Lock()
    defer b.mu.Unlock()

    // 从主题的通道列表中删除当前通道
    channels := b.topics[topic]
    for i, ch := range channels {
      if ch == msgCh {
        // 移除元素(保持顺序)
        b.topics[topic] = append(channels[:i], channels[i+1:]...)
        break
      }
    }
    close(msgCh)
    b.logger.Debug("已取消订阅主题", watermill.LogFields{"topic": topic})
  }()

  return msgCh, nil
}

// Start 启动 Broker(空实现,内存 Broker 无需额外启动逻辑)
func (b *MemoryBroker) Start(ctx context.Context) error {
  return nil
}

// Stop 停止 Broker(关闭所有通道)
func (b *MemoryBroker) Stop(ctx context.Context) error {
  b.mu.Lock()
  defer b.mu.Unlock()

  for topic, channels := range b.topics {
    for _, ch := range channels {
      close(ch)
    }
    delete(b.topics, topic)
    b.logger.Debug("已关闭主题", watermill.LogFields{"topic": topic})
  }
  return nil
}

// Close 实现 Subscriber 接口,用于优雅关闭订阅者
func (b *MemoryBroker) Close() error {
  return nil
}

2.5 主函数与路由配置

主函数负责初始化数据库、创建消息代理、设置路由和启动服务:

// 全局变量:WebSocket 连接管理器(用于广播消息)
var (
  wsConnections = make(map[string]*neffos.Conn) // 连接ID -> 连接实例
  wsMu          sync.Mutex                      // 保护连接 map 的互斥锁
)

func main() {
   // 1. 初始化数据库(不变)
  db, err := gorm.Open(sqlite.Open("short_messages.db"), &gorm.Config{})
  if err != nil {
    log.Fatalf("数据库初始化失败: %v", err)
  }
  db.AutoMigrate(&ShortMessage{})

  // 2. 初始化自定义内存 Broker(替换原来的 inmemory Broker)
  logger := watermill.NewStdLogger(true, true) // 启用日志
  broker := NewMemoryBroker(logger)
  if err := broker.Start(context.Background()); err != nil {
    log.Fatalf("Broker 启动失败: %v", err)
  }
  defer broker.Stop(context.Background())

  // 3. 后续逻辑(发布者、订阅者、路由等)完全不变
  publisher := broker
  subscriber := broker
  startMessageSubscriber(subscriber)

  app := iris.New()
  setupRoutes(app, db, publisher)
  app.Run(iris.Addr(":8080"))
}

2.6 路由配置

路由配置包括前端页面提供、消息发送接口和WebSocket连接处理:

// 2.2 配置路由(消息提交、WebSocket、前端页面)
// setupRoutes 配置所有路由
func setupRoutes(app *iris.Application, db *gorm.DB, publisher message.Publisher) {
  // 1. 提供前端页面(包含 Vue3)
  app.Get("/", func(ctx iris.Context) {
    ctx.ServeFile("index.html") // 前端页面放在项目根目录
  })
// 2. 接收短消息提交(保存到数据库并发布消息)
  app.Post("/send-message", func(ctx iris.Context) {
    var req struct {
      Content  string `json:"content"`
      Receiver string `json:"receiver"`
    }
    if err := ctx.ReadJSON(&req); err != nil {
      ctx.StatusCode(iris.StatusBadRequest)
      ctx.JSON(iris.Map{"error": "无效请求"})
      return
    }

    // 保存消息到数据库
    msg := ShortMessage{Content: req.Content, Receiver: req.Receiver}
    if err := db.Create(&msg).Error; err != nil {
      ctx.StatusCode(iris.StatusInternalServerError)
      ctx.JSON(iris.Map{"error": "保存消息失败"})
      return
    }

    // 通过 Watermill 发布消息(推送给订阅者)
    payload, _ := json.Marshal(msg)
    watermillMsg := message.NewMessage(watermill.NewUUID(), payload)
    if err := publisher.Publish("short_messages", watermillMsg); err != nil {
      log.Printf("发布消息失败: %v", err)
    }

    ctx.JSON(iris.Map{"status": "success", "message": "消息发送成功"})
  })

  // 3. WebSocket 路由(用于向前端推送消息)
  setupWebSocketRoute(app)
}

2.7 WebSocket配置

WebSocket配置使用Neffos库处理连接和断开事件:

// 2.3 WebSocket 配置(推送消息给前端)
// setupWebSocketRoute 配置 WebSocket 路由
func setupWebSocketRoute(app *iris.Application) {
  // 配置 WebSocket
  ws := neffos.New(websocket.DefaultGorillaUpgrader, neffos.Namespaces{
    "default": neffos.Events{
      neffos.OnNamespaceConnected: func(ns *neffos.NSConn, msg neffos.Message) error {
        // 连接建立时:记录连接
        connID := ns.Conn.ID()
        wsMu.Lock()
        wsConnections[connID] = ns.Conn
        wsMu.Unlock()
        log.Printf("新的 WebSocket 连接: %s", connID)
        return nil
      },
      neffos.OnNamespaceDisconnect: func(ns *neffos.NSConn, msg neffos.Message) error {
        // 连接关闭时:移除连接
        connID := ns.Conn.ID()
        wsMu.Lock()
        delete(wsConnections, connID)
        wsMu.Unlock()
        log.Printf("WebSocket 连接关闭: %s", connID)
        return nil
      },
    },
  })

  // 注册 WebSocket 路由
  app.Get("/ws", websocket.Handler(ws))
}

2.8 Watermill消息订阅者

Watermill订阅者负责接收消息并通过WebSocket推送给所有连接的客户端:

// 2.4 Watermill 订阅者(接收消息并推送给 WebSocket)
// startMessageSubscriber 启动 Watermill 订阅者,接收消息后通过 WebSocket 推送
func startMessageSubscriber(subscriber message.Subscriber) {
  // 订阅 "short_messages" 主题
  messages, err := subscriber.Subscribe(context.Background(), "short_messages")
  if err != nil {
    log.Fatalf("订阅消息失败: %v", err)
  }

  // 启动协程处理消息
  go func() {
    for msg := range messages {
      log.Printf("收到消息: %s, 内容: %s", msg.UUID, string(msg.Payload))

      // 通过 WebSocket 广播消息给所有前端连接
      wsMu.Lock()
      for connID, conn := range wsConnections {
        // 直接使用Socket发送文本消息
        err := conn.Socket().WriteText(msg.Payload, 0)
        if err != nil {
          log.Printf("WebSocket 推送失败 (连接 %s): %v", connID, err)
        }
      }
      wsMu.Unlock()

      // 确认消息处理完成
      msg.Ack()
    }
  }()
}

3. 前端Vue3通过WebSocket进行消息发送与接收

3.1 HTML结构与样式

前端使用Vue3和Neffos.js库实现WebSocket通信:

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>短消息系统</title>
  <!-- 引入 Vue3 -->
  <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
  <!-- 移除外部neffos库,使用原生WebSocket API -->
  <style>
    .message-form { margin: 20px; }
    input { padding: 8px; width: 300px; }
    button { padding: 8px 16px; margin-left: 10px; }
    .message-list {
      margin: 20px;
      border: 1px solid #ccc;
      padding: 10px;
      max-height: 400px;
      overflow-y: auto;
    }
    .message-item {
      padding: 5px;
      border-bottom: 1px solid #eee;
    }
    .message-item:last-child {
      border-bottom: none;
    }
  </style>
</head>
<body>
  <div id="app">
    <h1>短消息系统</h1>
    <!-- 发送消息表单 -->
    <div class="message-form">
      <input 
        type="text" 
        v-model="messageContent" 
        placeholder="输入消息内容"
      >
      <button @click="sendMessage">发送消息</button>
    </div>

    <!-- 显示服务端推送的消息 -->
    <div class="message-list">
      <h2>收到的消息</h2>
      <div v-if="messages.length === 0">暂无消息</div>
      <div v-else>
        <div class="message-item" v-for="msg in messages" :key="msg.ID">
          <strong>消息内容:</strong> {{ msg.Content }}<br>
          <strong>接收者:</strong> {{ msg.Receiver }}<br>
          <strong>时间:</strong> {{ new Date(msg.CreatedAt).toLocaleString() }}
        </div>
      </div>
    </div>
  </div>

3.2 Vue3 Composition API实现

使用Vue3的Composition API实现消息发送和接收功能:

 <script>
    const { createApp, ref, onMounted, onUnmounted } = Vue;

    createApp({
      setup() {
        const messageContent = ref('');
        const messages = ref([]); // 存储收到的消息
        const ws = ref(null); // WebSocket 实例
        const reconnectAttempts = ref(0);
        const maxReconnectAttempts = 5;
        const connected = ref(false);

        // 添加系统消息
        const addSystemMessage = (content) => {
          console.log(`[系统] ${content}`);
          messages.value.unshift({
            ID: Date.now(),
            Content: content,
            Receiver: "系统",
            CreatedAt: new Date().toISOString(),
            Type: "system"
          });
        };
        
        // 初始化 WebSocket
        const initWebSocket = () => {
          console.log(`开始初始化WebSocket连接... (尝试次数: ${reconnectAttempts.value + 1})`);
          
          try {
            // 先检查是否已经有连接,如果有,先关闭
            if (ws.value && (ws.value.readyState === WebSocket.OPEN || ws.value.readyState === WebSocket.CONNECTING)) {
              console.log("关闭现有WebSocket连接");
              ws.value.close();
            }

            // 使用动态协议和主机名,更好地适应部署环境
            const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
            const wsUrl = `${wsProtocol}//${window.location.host}/ws`;
            console.log(`正在连接到WebSocket服务: ${wsUrl}`);
            ws.value = new WebSocket(wsUrl);

            // 连接建立
            ws.value.onopen = () => {
              console.log("WebSocket连接已建立(readyState:", ws.value.readyState, ")");
              connected.value = true;
              reconnectAttempts.value = 0; // 重置重连次数
               
              // 对于neffos服务端,我们需要发送正确的命名空间连接消息
              const connectMsg = JSON.stringify({
                n: "default", // namespace
                m: 0,         // message type: 0 for connect
                b: ""
              });
              console.log("发送命名空间连接消息:", connectMsg);
              ws.value.send(connectMsg);
               
              // 立即显示连接成功的消息,用于调试
              addSystemMessage("WebSocket连接成功,等待接收消息...");
            };

            // 接收消息 - 增强版,更健壮的消息处理
            ws.value.onmessage = (event) => {
              console.log("收到WebSocket消息:", event.data);
               
              try {
                // 尝试直接解析为JSON对象
                const data = JSON.parse(event.data);
                console.log("成功解析JSON消息:", JSON.stringify(data, null, 2)); // 美化输出便于调试
                
                // 检查是否包含消息的必要字段(更宽松的检查)
                if ((data.ID || data.id) && (data.Content || data.content || data.message || (data.b && data.b.content))) {
                  // 格式化消息对象,处理不同的消息结构
                  let formattedMessage;
                  
                  // 处理不同的消息结构
                  if (data.b) {
                    // 格式1: {"b": {"ID": 1, "Content": "..."}}
                    formattedMessage = {
                      ID: data.b.ID || data.b.id || Date.now(),
                      Content: data.b.Content || data.b.content || data.b.message || '无内容',
                      Receiver: data.b.Receiver || data.b.receiver || '未知',
                      CreatedAt: data.b.CreatedAt || data.b.created_at || new Date().toISOString(),
                      Type: "received"
                    };
                  } else if (data.Payload) {
                    // 格式2: {"Payload": {...}}
                    formattedMessage = {
                      ID: data.Payload.ID || data.Payload.id || Date.now(),
                      Content: data.Payload.Content || data.Payload.content || data.Payload.message || '无内容',
                      Receiver: data.Payload.Receiver || data.Payload.receiver || '未知',
                      CreatedAt: data.Payload.CreatedAt || data.Payload.created_at || new Date().toISOString(),
                      Type: "received"
                    };
                  } else {
                    // 格式3: {"ID": 1, "Content": "..."}
                    formattedMessage = {
                      ID: data.ID || data.id || Date.now(),
                      Content: data.Content || data.content || data.message || '无内容',
                      Receiver: data.Receiver || data.receiver || '未知',
                      CreatedAt: data.CreatedAt || data.created_at || new Date().toISOString(),
                      Type: "received"
                    };
                  }
                  
                  console.log("添加到消息列表:", formattedMessage);
                  messages.value.unshift(formattedMessage);
                } else if (data.n && data.m !== undefined) {
                  // Neffos协议控制消息
                  console.log(`收到Neffos控制消息: namespace=${data.n}, method=${data.m}`);
                  
                  // 如果是连接成功的消息,可以添加系统提示
                  if (data.m === 0 || data.m === "OnConnect") {
                    addSystemMessage(`成功连接到命名空间: ${data.n}`);
                  }
                } else {
                  // 未识别的消息结构,但仍添加到界面以便调试
                  console.log("收到未识别结构的消息,但添加到界面:", data);
                  messages.value.unshift({
                    ID: Date.now(),
                    Content: "收到未识别结构的消息: " + JSON.stringify(data),
                    Receiver: "系统",
                    CreatedAt: new Date().toISOString(),
                    Type: "debug"
                  });
                }
              } catch (e) {
                console.error("解析JSON失败:", e);
                // 即使解析失败,也将原始消息显示出来,便于调试
                messages.value.unshift({
                  ID: Date.now(),
                  Content: "收到无法解析的消息: " + event.data,
                  Receiver: "系统",
                  CreatedAt: new Date().toISOString(),
                  Type: "error"
                });
              }
            };

            // 连接关闭 - 实现指数退避重连策略
            ws.value.onclose = (event) => {
              console.log(`WebSocket连接已关闭(代码:${event.code}, 原因:${event.reason})`);
              connected.value = false;
              addSystemMessage(`WebSocket连接已关闭: ${event.reason || '未知原因'} (代码: ${event.code})`);
              
              // 尝试重连,但限制重连次数
              if (reconnectAttempts.value < maxReconnectAttempts) {
                reconnectAttempts.value++;
                const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.value), 30000); // 指数退避,最大30秒
                console.log(`尝试重新连接... (${reconnectAttempts.value}/${maxReconnectAttempts}),延迟${delay}ms`);
                
                addSystemMessage(`将在 ${delay/1000} 秒后尝试第 ${reconnectAttempts.value} 次重连...`);
                setTimeout(() => initWebSocket(), delay);
              } else {
                console.log('已达到最大重连次数,停止尝试');
                addSystemMessage('已达到最大重连次数,请刷新页面重试');
              }
            };

            // 连接错误
            ws.value.onerror = (error) => {
              console.error("WebSocket连接错误:", error);
              addSystemMessage(`WebSocket连接错误: ${error.message || '未知错误'}`);
            };
          } catch (error) {
            console.error("WebSocket初始化异常:", error);
            addSystemMessage(`WebSocket初始化失败: ${error.message || '未知错误'}`);
            
            // 初始化失败也计入重连次数
            if (reconnectAttempts.value < maxReconnectAttempts) {
              reconnectAttempts.value++;
              setTimeout(() => initWebSocket(), 3000);
            }
          }
        };

        // 发送消息到后端
        const sendMessage = () => {
          if (!messageContent.value.trim()) {
            alert('请输入消息内容');
            return;
          }

          // 调用后端 API 提交消息
          fetch('/send-message', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
              content: messageContent.value,
              receiver: 'user123' // 示例接收者ID
            })
          })
          .then(res => {
            console.log('发送消息响应状态:', res.status);
            return res.json()
          })
          .then(data => {
            console.log('发送结果:', data);
            
            // 将发送的消息添加到界面,方便用户查看
            messages.value.unshift({
              ID: Date.now(),
              Content: messageContent.value,
              Receiver: 'user123',
              CreatedAt: new Date().toISOString(),
              Type: "sent"
            });
            
            messageContent.value = ''; // 清空输入框
          })
          .catch(error => {
            console.error('发送消息失败:', error);
            addSystemMessage(`发送消息失败: ${error.message || '网络错误'}`);
          });
        };

        // 组件挂载后初始化WebSocket
        onMounted(() => {
          console.log('组件已挂载,初始化WebSocket...');
          initWebSocket();
        });

        // 组件卸载前清理资源
        onUnmounted(() => {
          if (ws.value) {
            ws.value.close();
          }
        });

        // 暴露给模板
        return {
          messageContent,
          messages,
          sendMessage
        };
      }
    }).mount('#app');
  </script>
</body>
</html>

3. 运行效果

示例中的代码包含以下功能:

  1. 前端通过Vue3构建一个界面,用户输入消息并点击发送按钮,将消息发送给后端并接收后端推送回的消息。

html.png 2. 服务端通过Watermill库将消息存储在自定义内存消息代理中。 PS D:\goWatermillTest> go run . [watermill] 2025/10/29 13:18:26.201213 MemoryBroker.go:66: level=DEBUG msg="已订阅主题" topic=short_messages Iris Version: 12.2.11

Now listening on:

Network: http://192.168.4.22:8080 Network: http://10.168.1.108:8080 Network: http://192.168.16.1:8080 Local: http://localhost:8080 Application started. Press CTRL+C to shut down. [watermill] 2025/10/29 13:19:12.104199 MemoryBroker.go:46: level=DEBUG msg="消息已发送到通道" msg_uuid=83f64f48-b825-491e-82b3-a1ca97ddacdf topic=short_messages 2025/10/29 13:19:12 收到消息: 83f64f48-b825-491e-82b3-a1ca97ddacdf, 内容: {"ID":41,"CreatedAt":"2025-10-29T13:19:12.0858855+08:00","UpdatedAt":"2025-10-29T13:19:12.0858855+08:00","DeletedAt":null,"content":"国庆节快乐!","receiver":"user123"}
2025/10/29 13:19:25 收到消息: e76d9298-6546-47a3-b201-4de5aaa32527, 内容: {"ID":42,"CreatedAt":"2025-10-29T13:19:25.1785528+08:00","UpdatedAt":"2025-10-29T13:19:25.1785528+08:00","DeletedAt":null,"content":"春节快乐!","receiver":"user123"}
[watermill] 2025/10/29 13:19:25.197243 MemoryBroker.go:46: level=DEBUG msg="消息已发送到通道" msg_uuid=e76d9298-6546-47a3-b201-4de5aaa32527 topic=short_messages

4. 总结

通过以上示例,我们展示了如何使用Watermill库构建一个完整的事件驱动消息系统。整个系统包括:

  1. 使用Watermill库创建自定义内存消息代理
  2. 使用Iris框架处理HTTP请求和WebSocket连接
  3. 使用Vue3构建前端界面,通过WebSocket与后端通信

Watermill的强大之处在于它提供了一套统一的接口来处理消息传递,使得开发者可以轻松地在不同的消息传递系统之间切换,而无需修改业务逻辑代码。在本示例中,我们使用了自定义的内存消息代理,但在生产环境中,可以轻松替换为Kafka、RabbitMQ等更强大的消息队列系统。