shizhongqi 11 ماه پیش
کامیت
be3f1a6b2f
13فایلهای تغییر یافته به همراه795 افزوده شده و 0 حذف شده
  1. 3 0
      .env.example
  2. 15 0
      .gitignore
  3. 7 0
      config.env.example
  4. 14 0
      go.mod
  5. 14 0
      go.sum
  6. 114 0
      main.go
  7. 116 0
      static/css/styles.css
  8. 59 0
      static/js/chat.js
  9. 19 0
      templates/chat.html
  10. 18 0
      templates/index.html
  11. 84 0
      wsClient/client.go
  12. 147 0
      wsClient/redis.go
  13. 185 0
      wsClient/room.go

+ 3 - 0
.env.example

@@ -0,0 +1,3 @@
+REDIS_ADDR=118.178.193.23:6379
+REDIS_PASSWORD=byt6gc1w0aed2q7h
+REDIS_DB=0

+ 15 - 0
.gitignore

@@ -0,0 +1,15 @@
+# 环境配置文件
+.env
+
+# 输出的二进制文件(以你的模块名命名)
+chatapp
+
+# IDE 文件
+.vscode/
+.idea/
+
+# 操作系统生成的文件
+.DS_Store
+
+# 日志文件
+*.log

+ 7 - 0
config.env.example

@@ -0,0 +1,7 @@
+# Redis 配置
+REDIS_ADDR=localhost:6379
+REDIS_PASSWORD=
+REDIS_DB=0
+
+# 服务器配置
+SERVER_ADDR=:8080

+ 14 - 0
go.mod

@@ -0,0 +1,14 @@
+module chatapp
+
+go 1.24.6
+
+require (
+	github.com/gorilla/websocket v1.5.3
+	github.com/joho/godotenv v1.5.1
+	github.com/redis/go-redis/v9 v9.4.0
+)
+
+require (
+	github.com/cespare/xxhash/v2 v2.2.0 // indirect
+	github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
+)

+ 14 - 0
go.sum

@@ -0,0 +1,14 @@
+github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
+github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
+github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
+github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
+github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
+github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
+github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
+github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
+github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
+github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
+github.com/redis/go-redis/v9 v9.4.0 h1:Yzoz33UZw9I/mFhx4MNrB6Fk+XHO1VukNcCa1+lwyKk=
+github.com/redis/go-redis/v9 v9.4.0/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=

+ 114 - 0
main.go

@@ -0,0 +1,114 @@
+// WebSocket 聊天应用主程序
+// 提供基本的 HTTP 服务器和 WebSocket 连接处理
+package main
+
+import (
+	"chatapp/wsClient"
+	"flag"
+	"log"
+	"net/http"
+	"os"
+	"path/filepath"
+	"strconv"
+	"sync"
+	"text/template"
+
+	"github.com/joho/godotenv"
+)
+
+// templateHandler 负责处理 HTML 模板的渲染
+// 使用 sync.Once 确保模板只被解析一次,提高性能
+type templateHandler struct {
+	once     sync.Once         // 确保模板只被解析一次
+	filename string            // 模板文件名
+	templ    *template.Template // 解析后的模板对象
+}
+
+// ServeHTTP 处理 HTTP 请求并渲染模板
+// 实现了 http.Handler 接口
+func (t *templateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	// 使用 sync.Once 确保模板只被解析一次,避免重复解析带来的性能开销
+	t.once.Do(func() {
+		// 解析模板文件,如果失败会 panic
+		t.templ = template.Must(template.ParseFiles(filepath.Join("templates", t.filename)))
+	})
+
+	// 执行模板渲染,将请求对象作为数据传递给模板
+	t.templ.Execute(w, r)
+}
+
+func main() {
+	// makes every randomly generated number unique
+	//rand.Seed(time.Now().UnixNano()) //从 Go 1.20 开始,全局随机数生成器会自动使用一个随机种子,所以不再需要手动调用 Seed
+	
+	// 加载 .env 文件,如果文件不存在则忽略错误
+	if err := godotenv.Load(); err != nil {
+		log.Println("没有找到 .env 文件,将使用系统环境变量和默认值")
+	}
+	
+	var addr = flag.String("addr", ":8080", "Addr of the App")
+	flag.Parse()
+
+	// 检查是否有服务器地址的环境变量设置
+	if serverAddr := os.Getenv("SERVER_ADDR"); serverAddr != "" && *addr == ":8080" {
+		*addr = serverAddr
+	}
+
+	// 初始化Redis连接
+	// 从环境变量获取Redis配置,如果没有设置则使用默认值
+	redisAddr := os.Getenv("REDIS_ADDR")
+	if redisAddr == "" {
+		redisAddr = "localhost:6379" // 默认Redis地址
+	}
+	
+	redisPassword := os.Getenv("REDIS_PASSWORD") // 默认为空
+	
+	redisDB := 0 // 默认数据库
+	if dbStr := os.Getenv("REDIS_DB"); dbStr != "" {
+		if db, err := strconv.Atoi(dbStr); err == nil {
+			redisDB = db
+		}
+	}
+
+	// 初始化Redis连接
+	if err := wsClient.InitRedis(redisAddr, redisPassword, redisDB); err != nil {
+		log.Printf("Redis初始化失败: %v", err)
+		log.Println("将继续运行,但不会存储消息到Redis")
+	} else {
+		// 确保程序退出时关闭Redis连接
+		defer wsClient.CloseRedis()
+	}
+
+	// 设置静态文件服务器,处理 CSS、JS 等静态资源
+	// StripPrefix 会移除 URL 前缀 "/static/",然后从 "static" 目录提供文件
+	http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
+	
+	// 主页路由,渲染 index.html 模板
+	http.Handle("/", &templateHandler{filename: "index.html"})
+	
+	// 聊天页面路由,渲染 chat.html 模板
+	http.Handle("/chat", &templateHandler{filename: "chat.html"})
+
+	// Handle all websocket connections for chat rooms dynamically
+	// 处理 WebSocket 连接的路由
+	// 支持动态创建和管理聊天室
+	http.HandleFunc("/room", func(w http.ResponseWriter, r *http.Request) {
+		// 获取房间名称,并处理URL中的特殊字符(如+号)
+		roomName := r.URL.Query().Get("room")
+		if roomName == "" {
+			http.Error(w, "Room name required", http.StatusBadRequest)
+			return
+		}
+		// 获取或创建指定名称的房间,并启动房间的消息处理协程
+		room := wsClient.GetRoomAndRun(roomName)
+		// 将 WebSocket 连接交给房间处理
+		room.ServeHTTP(w, r)
+	})
+
+	// 启动 HTTP 服务器
+	log.Println("正在启动 Web 服务器,监听地址:", *addr)
+	// 开始监听并处理 HTTP 请求,如果启动失败则退出程序
+	if err := http.ListenAndServe(*addr, nil); err != nil {
+		log.Fatal("服务器启动失败:", err)
+	}
+}

+ 116 - 0
static/css/styles.css

@@ -0,0 +1,116 @@
+/* General page styles */
+body {
+  font-family: Arial, sans-serif;
+  background-color: #f1f1f1;
+  margin: 0;
+  padding: 0;
+}
+
+/* Header for chat.html */
+header {
+  background-color: #333;
+  color: #fff;
+  padding: 10px;
+  text-align: center;
+  font-size: 24px;
+}
+
+/* Message display */
+#messages {
+  height: 80vh;
+  overflow-y: auto;
+  padding: 10px;
+}
+
+.message-container {
+  margin-bottom: 15px;
+}
+
+.username {
+  font-weight: bold;
+  margin-bottom: 3px;
+  color: #333;
+}
+
+.message {
+  background-color: #e0e0e0;
+  padding: 10px;
+  border-radius: 5px;
+  display: inline-block;
+  max-width: 70%;
+  word-wrap: break-word;
+}
+
+/* Chat input section */
+.chat-input {
+  display: flex;
+  padding: 10px;
+  background-color: #fff;
+  border-top: 1px solid #ccc;
+}
+
+.chat-input input[type="text"] {
+  flex: 1;
+  padding: 10px;
+  font-size: 16px;
+  border: 1px solid #ccc;
+  border-radius: 4px;
+}
+
+.chat-input button {
+  padding: 10px 20px;
+  margin-left: 10px;
+  font-size: 16px;
+  background-color: #333;
+  color: #fff;
+  border: none;
+  border-radius: 4px;
+  cursor: pointer;
+}
+
+.chat-input button:hover {
+  background-color: #555;
+}
+
+/* Styles specific to index.html */
+.index-body {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  height: 100vh;
+}
+
+.index-body h1 {
+  margin-bottom: 20px;
+  color: #333;
+}
+
+.index-body form {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+
+.index-body input[type="text"] {
+  padding: 10px;
+  font-size: 16px;
+  width: 250px;
+  margin-bottom: 10px;
+  border: 1px solid #ccc;
+  border-radius: 4px;
+}
+
+.index-body button {
+  padding: 10px 20px;
+  font-size: 16px;
+  background-color: #333;
+  color: #fff;
+  border: none;
+  border-radius: 4px;
+  cursor: pointer;
+}
+
+.index-body button:hover {
+  background-color: #555;
+}

+ 59 - 0
static/js/chat.js

@@ -0,0 +1,59 @@
+const params = new URLSearchParams(window.location.search);
+const room = params.get("room");
+
+if (!room) {
+  alert("No room specified. Redirecting to homepage...");
+  window.location.href = "/";
+}
+
+const socket = new WebSocket(`ws://${location.host}/room?room=${room}`);
+
+socket.onmessage = (event) => {
+  try {
+    const data = JSON.parse(event.data);
+
+    // Create the container div
+    const msgContainer = document.createElement("div");
+    msgContainer.classList.add("message-container");
+
+    // Create the username div
+    const usernameDiv = document.createElement("div");
+    usernameDiv.classList.add("username");
+    usernameDiv.textContent = data.name;
+
+    // Create the message div
+    const messageDiv = document.createElement("div");
+    messageDiv.classList.add("message");
+    messageDiv.textContent = data.message;
+
+    // Append username and message in correct order
+    msgContainer.appendChild(usernameDiv);
+    msgContainer.appendChild(messageDiv);
+
+    // Append the whole message container to the messages div
+    document.getElementById("messages").appendChild(msgContainer);
+
+    // Auto-scroll
+    const messagesDiv = document.getElementById("messages");
+    messagesDiv.scrollTop = messagesDiv.scrollHeight;
+
+  } catch (err) {
+    console.error("Invalid JSON received:", event.data);
+  }
+};
+
+function sendMessage() {
+  const input = document.getElementById("msg");
+  if (input.value.trim() !== "") {
+    socket.send(input.value);
+    input.value = "";
+  }
+}
+
+document.getElementById("sendBtn").addEventListener("click", sendMessage);
+
+document.getElementById("msg").addEventListener("keyup", function (event) {
+  if (event.key === "Enter") {
+    sendMessage();
+  }
+});

+ 19 - 0
templates/chat.html

@@ -0,0 +1,19 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8" />
+  <title>Chat Room</title>
+  <link rel="stylesheet" href="/static/css/styles.css" />
+</head>
+<body class="chat-body">
+  <header>Chat Room</header>
+  <div id="messages"></div>
+
+  <div class="chat-input">
+    <input id="msg" type="text" placeholder="Type a message..." />
+    <button id="sendBtn">Send</button>
+  </div>
+
+  <script src="/static/js/chat.js"></script>
+</body>
+</html>

+ 18 - 0
templates/index.html

@@ -0,0 +1,18 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8" />
+  <title>Select a Chat Room</title>
+  <link rel="stylesheet" href="/static/css/styles.css" />
+</head>
+<body class="index-body">
+  <h1>Join a Chat Room</h1>
+  <form action="/chat" method="get">
+    <input type="text" name="room" placeholder="Enter channel name..." required />
+    <button type="submit">Join</button>
+  </form>
+
+  <!-- Optional: index.js if you want any client JS here -->
+  <script src="/static/js/index.js"></script>
+</body>
+</html>

+ 84 - 0
wsClient/client.go

@@ -0,0 +1,84 @@
+// 客户端管理模块
+// 处理单个 WebSocket 客户端的连接、消息读取和发送
+package wsClient
+
+import (
+	"encoding/json"
+	"fmt"
+
+	"github.com/gorilla/websocket" // WebSocket 库
+)
+
+// Client represents connection to a single chatting user
+// Client 表示一个聊天用户的连接
+// 每个 WebSocket 连接对应一个 Client 实例
+type Client struct {
+	// a socket is the web socket for this uer
+	// WebSocket 连接对象,用于与浏览器端进行双向通信
+	socket *websocket.Conn
+
+	//receive is a channel to receive messages from other clients
+	// 用于接收来自其他客户端的消息的 channel
+	// 房间会将广播的消息发送到这个 channel
+	receive chan []byte
+
+	//room is where the client is chatting in
+	// 客户端所在的聊天房间引用
+	// 用于将发送的消息转发给房间
+	room *Room
+
+	// 客户端的显示名称(用户名)
+	// 在消息中用于标识消息发送者
+	name string
+}
+
+// read 从 WebSocket 连接读取消息并转发给房间 (原作者说: Used to send messages)
+// 这个方法会阻塞运行直到连接断开或出现错误
+func (c *Client) read() {
+	// close the connection when we are done
+	defer c.socket.Close()
+	
+	// endlessly read messages from input
+	for {
+		// 从 WebSocket 连接读取消息
+		// _ 表示忽略消息类型,msg 是消息内容,err 是错误
+		_, msg, err := c.socket.ReadMessage()
+		// break if there is an error
+		if err != nil {
+			return
+		}
+
+		// 构造要发送的消息对象,包含用户名和消息内容
+		outgoing := map[string]string{
+			"name":    c.name,         // 发送者用户名
+			"message": string(msg),   // 消息内容(转换为字符串)
+		}
+
+		// 将消息对象编码为 JSON 格式
+		jsMessage, err := json.Marshal(outgoing)
+		if err != nil {
+			fmt.Println("Enconding failed!")
+			continue
+		}
+
+		// 将编码后的消息和发送者信息一起发送到房间的 forward channel
+		c.room.forward <- &message{
+			content:   jsMessage, // 消息内容
+			sender: c,         // 发送者(当前客户端)
+		}// 房间会将这条消息分发给除发送者外的所有其他客户端(在 room.go 中 run() 方法 case msg := <-r.forward 中)
+	}
+}
+
+// write 从 receive channel 读取消息并发送给客户端
+// 在独立的 goroutine 中运行,处理向客户端发送消息的任务
+func (c *Client) write() {
+	defer c.socket.Close()
+
+	// 循环从 receive channel 读取消息
+	for msg := range c.receive {
+		err := c.socket.WriteMessage(websocket.TextMessage, msg)
+		if err != nil {
+			return
+		}
+	}
+}

+ 147 - 0
wsClient/redis.go

@@ -0,0 +1,147 @@
+// Redis模块 - 负责Redis连接管理和消息存储
+package wsClient
+
+import (
+	"context"
+	"encoding/json"
+	"log"
+	"time"
+
+	"github.com/redis/go-redis/v9"
+)
+
+// Redis客户端实例,全局变量
+var redisClient *redis.Client
+
+// Redis上下文
+var ctx = context.Background()
+
+// ChatMessage 表示存储在Redis中的聊天消息结构
+type ChatMessage struct {
+	Content   string    `json:"content"`   // 原始消息内容(JSON字符串)
+	Timestamp time.Time `json:"timestamp"` // 存储时间戳
+	RoomName  string    `json:"room_name"` // 房间名称
+}
+
+// InitRedis 初始化Redis连接
+func InitRedis(addr, password string, db int) error {
+	redisClient = redis.NewClient(&redis.Options{
+		Addr:     addr,     // Redis服务器地址 (例如: "localhost:6379")
+		Password: password, // Redis密码 (如果没有密码则为空字符串)
+		DB:       db,       // Redis数据库编号 (0-15)
+	})
+
+	// 测试Redis连接
+	pong, err := redisClient.Ping(ctx).Result()
+	if err != nil {
+		log.Printf("Redis连接失败: %v", err)
+		return err
+	}
+	log.Printf("Redis连接成功: %s", pong)
+	return nil
+}
+
+// StoreRawMessage 将原始消息内容存储到Redis中
+// 使用房间名作为Redis列表的键,存储消息历史记录
+func StoreRawMessage(roomName string, rawMessage []byte) error {
+	if redisClient == nil {
+		log.Println("Redis客户端未初始化")
+		return nil
+	}
+
+	// 创建消息对象,包含原始消息内容和时间戳
+	chatMsg := ChatMessage{
+		Content:   string(rawMessage), // 直接存储原始JSON字符串
+		Timestamp: time.Now(),
+		RoomName:  roomName,
+	}
+
+	// 将消息序列化为JSON
+	msgBytes, err := json.Marshal(chatMsg)
+	if err != nil {
+		log.Printf("消息序列化失败: %v", err)
+		return err
+	}
+
+	// 使用房间名作为Redis键,将消息添加到列表的右端
+	// 键格式: "room_messages:{房间名}"
+	key := "room_messages:" + roomName
+	err = redisClient.RPush(ctx, key, msgBytes).Err()
+	if err != nil {
+		log.Printf("消息存储到Redis失败: %v", err)
+		return err
+	}
+
+	// 设置过期时间(可选)- 7天后自动删除
+	redisClient.Expire(ctx, key, 7*24*time.Hour)
+
+	log.Printf("消息已存储到Redis - 房间: %s", roomName)
+	return nil
+}
+
+// StoreMessage 将消息存储到Redis中(保留旧接口以兼容)
+// 使用房间名作为Redis列表的键,存储消息历史记录
+func StoreMessage(roomName, username, message string) error {
+	if redisClient == nil {
+		log.Println("Redis客户端未初始化")
+		return nil
+	}
+
+	// 构建与新格式兼容的消息结构
+	msgContent := map[string]interface{}{
+		"name":    username,
+		"message": message,
+	}
+	msgBytes, _ := json.Marshal(msgContent)
+	
+	return StoreRawMessage(roomName, msgBytes)
+}
+
+// GetRoomMessages 获取房间的历史消息
+// 返回指定数量的最新消息
+func GetRoomMessages(roomName string, count int64) ([]ChatMessage, error) {
+	if redisClient == nil {
+		log.Println("Redis客户端未初始化")
+		return nil, nil
+	}
+
+	key := "room_messages:" + roomName
+	
+	// 获取列表中的最新消息(从右端开始,即最新的消息)
+	// -count 表示从列表末尾开始的count个元素
+	result, err := redisClient.LRange(ctx, key, -count, -1).Result()
+	if err != nil {
+		log.Printf("从Redis获取消息失败: %v", err)
+		return nil, err
+	}
+
+	var messages []ChatMessage
+	for _, msgStr := range result {
+		var msg ChatMessage
+		if err := json.Unmarshal([]byte(msgStr), &msg); err != nil {
+			log.Printf("消息反序列化失败: %v", err)
+			continue
+		}
+		messages = append(messages, msg)
+	}
+
+	return messages, nil
+}
+
+// GetRoomMessageCount 获取房间的消息总数
+func GetRoomMessageCount(roomName string) (int64, error) {
+	if redisClient == nil {
+		return 0, nil
+	}
+
+	key := "room_messages:" + roomName
+	return redisClient.LLen(ctx, key).Result()
+}
+
+// CloseRedis 关闭Redis连接
+func CloseRedis() error {
+	if redisClient != nil {
+		return redisClient.Close()
+	}
+	return nil
+}

+ 185 - 0
wsClient/room.go

@@ -0,0 +1,185 @@
+// 房间管理模块 -- 负责创建、管理聊天房间,处理客户端的加入、离开和消息转发
+package wsClient
+
+import (
+	"fmt"
+	"log"
+	"math/rand"
+	"net/http"
+	"sync"
+
+	"github.com/gorilla/websocket"
+)
+
+// message 包含要转发的消息内容和发送者信息
+// 用于在转发时排除发送者自己
+type message struct {
+	content   []byte  // 消息内容
+	sender *Client // 发送者
+}
+
+// Room 表示一个聊天房间
+type Room struct {
+	// holds all current clientCons in the room
+	// 保存房间中所有当前活跃的客户端连接
+	clientCons map[*Client]bool
+
+	// join is a channel for all clients wishing to join the room
+	// 用于接收希望加入房间的客户端。新客户端通过这个 channel 请求加入
+	join chan *Client
+
+	// leave is a channel for all clients wishing to leave the room
+	// 用于接收希望离开房间的客户端。断开连接的客户端通过这个 channel 通知离开
+	leave chan *Client
+
+	// forward is a channel that holds incoming messages that should be forwarded to the other clients.
+	// 用于接收需要转发给其他客户端的消息
+	// 所有向此房间发送的消息都会通过这个 channel 进行分发
+	// 现在包含消息内容和发送者信息,以便排除发送者自己
+	forward chan *message
+
+	// name 房间名称,用作Redis存储的键
+	name string
+}
+
+// 全局房间 map,存储所有创建的房间
+// key: 房间名称,value: 房间对象指针
+var rooms = make(map[string]*Room)
+// 全局互斥锁,用于保护 rooms 字典的并发访问,确保多个 goroutine 同时访问 rooms 时的线程安全
+var mu sync.Mutex
+
+
+
+// newRoom 创建并返回一个新的房间实例
+// 初始化所有必要的 channel 和 map
+func newRoom(name string) *Room {
+	return &Room{
+		clientCons: make(map[*Client]bool),
+		join:    make(chan *Client),
+		leave:   make(chan *Client),
+		forward: make(chan *message), // 现在传输 message 结构体
+		name:    name,                // 设置房间名称
+	}
+}
+
+// GetRoomAndRun 获取指定名称的房间,如果不存在则创建新房间并启动
+// 这是线程安全的函数,使用互斥锁保护全局房间字典
+func GetRoomAndRun(name string) *Room {
+	mu.Lock()
+	defer mu.Unlock()
+	
+	if r, ok := rooms[name]; ok {
+		return r
+	}
+	r := newRoom(name) // 传入房间名称
+	rooms[name] = r
+	// 启动房间的消息处理循环(在新的 goroutine 中)
+	go r.run()
+	return r
+}
+
+// getRoom 获取指定名称的房间,如果不存在则创建新房间但不启动
+// 注意:这个函数只创建房间但不启动 run() 方法,可能导致房间无法处理消息
+// 建议使用 getRoomAndRun() 替代
+func GetRoom(roomName string) *Room {
+	mu.Lock()
+	defer mu.Unlock()
+
+	if r, ok := rooms[roomName]; ok {
+		return r
+	}
+	nr := newRoom(roomName) // 传入房间名称
+	rooms[roomName] = nr
+	// 注意:这里没有调用 go nr.run(),房间不会处理消息
+	return nr
+}
+
+// run 是房间的核心消息处理循环
+// 在独立的 goroutine 中运行,使用 select 语句处理三种类型的事件
+func (r *Room) run() {
+	for {
+		select {
+		// 处理客户端加入事件
+		case client := <-r.join:
+			// 将新客户端添加到房间的客户端集合中
+			r.clientCons[client] = true // 使用 true 作为值,实际上只使用 key(client 指针)
+			
+		// 处理客户端离开事件
+		case client := <-r.leave:
+			// 从房间的客户端集合中删除客户端
+			delete(r.clientCons, client)
+			// 关闭客户端的接收 channel,通知客户端的 write() 方法退出
+			close(client.receive)
+			
+		// 处理消息转发事件
+		case msg := <-r.forward:
+			// 打印消息内容与发送者信息
+			log.Printf("Received message from %v: --- %s", msg.sender, string(msg.content))
+
+			// 直接存储原始消息内容到Redis,使用房间名作为键
+			go StoreRawMessage(r.name, msg.content) // 异步存储,不阻塞消息转发
+			
+			// 将消息发送给房间中的所有客户端,但排除发送者自己
+			// 使用指针比较是最高效的方法,因为每个客户端都有唯一的内存地址
+			for client := range r.clientCons {
+				if client != msg.sender { // 高效的指针比较,排除发送者
+					// 将消息发送到每个客户端的接收 channel
+					client.receive <- msg.content // 只发送消息内容,不包含发送者信息
+				}
+			}
+		}
+	}
+}
+
+// WebSocket 相关常量定义
+const (
+	// WebSocket 连接的读写缓冲区大小(字节)
+	socketBufferSize  = 1024
+	// 每个客户端消息接收 channel 的缓冲区大小
+	messageBufferSize = 256
+)
+
+// WebSocket 升级器,用于将 HTTP 连接升级为 WebSocket 连接
+// 设置读写缓冲区大小以优化性能
+var upgrader = &websocket.Upgrader{
+	ReadBufferSize:  socketBufferSize,  // 读缓冲区大小
+	WriteBufferSize: socketBufferSize,  // 写缓冲区大小
+}
+
+// ServeHTTP 处理 WebSocket 连接请求
+// 实现了 http.Handler 接口,负责将 HTTP 连接升级为 WebSocket 连接
+// 并创建新的客户端加入当前房间
+func (r *Room) ServeHTTP(w http.ResponseWriter, req *http.Request) {
+	// -------- 使用当前房间实例(接收者 r),而不是重新获取房间 -------- 这是才正确的面向对象设计
+	// 从 URL 查询参数中获取房间名称
+    // roomName := req.URL.Query().Get("room")
+    // if roomName == "" {
+    //     http.Error(w, "Room name required", http.StatusBadRequest)
+    //     return
+    // }
+    // room := GetRoomAndRun(roomName)
+
+	// 将 HTTP 连接升级为 WebSocket 连接
+	socket, err := upgrader.Upgrade(w, req, nil)
+	if err != nil {
+		log.Println("Upgrade error:", err)
+		return
+	}
+
+	// 创建新的客户端对象
+	client := &Client{
+		socket:  socket,                                    // WebSocket 连接
+		receive: make(chan []byte, messageBufferSize),      // 接收消息的缓冲 channel
+		room:    r,                                         // 使用当前房间实例
+		name:    fmt.Sprintf("user_%d", rand.Intn(1000)),   // 生成随机用户名
+	}
+
+	// 将客户端加入当前房间
+	r.join <- client
+	defer func() { r.leave <- client }()
+	
+	// 启动客户端的写消息 goroutine
+	go client.write()
+	// 在当前 goroutine 中处理客户端的读消息(阻塞直到连接断开)
+	client.read()
+}