Go语言快速入门Go语言快速入门
首页
基础篇
进阶篇
高阶篇
实战篇
Go官方网站
编程指南
首页
基础篇
进阶篇
高阶篇
实战篇
Go官方网站
编程指南
  • 实战篇

    • 🚀 实战篇
    • 第1个项目 - 命令行文件管理器
    • 第2个项目 - RESTful API Todo服务
    • 第3个项目 - Web爬虫 新闻采集器
    • 第4个项目 - 实时聊天室 WebSocket
    • 第5个项目 - URL短链接服务
    • 第6个项目 - 完整博客系统

第4个项目 - 实时聊天室 WebSocket

嘿,朋友们!我是长安。

今天我们要做的第四个实战项目是实时聊天室。这是一个基于WebSocket的实时通信应用,用户可以实时发送和接收消息。

说实话,实时通信是现代Web应用的核心功能之一!无论是聊天工具、在线客服、实时协作,还是游戏应用,都离不开WebSocket。我第一次接触WebSocket的时候觉得特别神奇,原来浏览器还能这样双向通信!Go的并发特性让它特别适合做实时应用!

🎯 项目目标

实现一个功能完整的聊天室,支持以下功能:

功能说明技术点
用户加入/离开进入和退出聊天室WebSocket连接管理
实时消息发送和接收消息广播机制
在线用户列表显示当前在线用户状态同步
私聊功能一对一私密聊天点对点通信
历史消息显示最近的聊天记录消息存储

📁 项目结构

chat-room/
├── main.go              # 程序入口
├── hub/
│   └── hub.go          # 连接中心(管理所有连接)
├── client/
│   └── client.go       # 客户端连接
├── models/
│   └── message.go      # 消息数据模型
├── static/
│   ├── index.html      # 前端页面
│   ├── css/
│   │   └── style.css   # 样式文件
│   └── js/
│       └── app.js      # 前端逻辑
└── go.mod

🚀 第一步:项目初始化

# 创建项目目录
mkdir chat-room
cd chat-room

# 初始化Go模块
go mod init github.com/yourusername/chat-room

# 安装依赖
go get -u github.com/gorilla/websocket
go get -u github.com/gin-gonic/gin

# 创建目录结构
mkdir hub client models static
mkdir static/css static/js

📦 第二步:定义消息模型

文件: models/message.go

package models

import "time"

// MessageType 消息类型
type MessageType string

const (
	MessageTypeJoin    MessageType = "join"     // 加入聊天室
	MessageTypeLeave   MessageType = "leave"    // 离开聊天室
	MessageTypeChat    MessageType = "chat"     // 聊天消息
	MessageTypePrivate MessageType = "private"  // 私聊消息
	MessageTypeUsers   MessageType = "users"    // 用户列表
)

// Message 消息结构体
type Message struct {
	Type      MessageType `json:"type"`
	Username  string      `json:"username"`
	Content   string      `json:"content"`
	To        string      `json:"to,omitempty"`        // 私聊目标用户
	Timestamp time.Time   `json:"timestamp"`
	Users     []string    `json:"users,omitempty"`     // 在线用户列表
}

// NewMessage 创建新消息
func NewMessage(msgType MessageType, username, content string) *Message {
	return &Message{
		Type:      msgType,
		Username:  username,
		Content:   content,
		Timestamp: time.Now(),
	}
}

// NewPrivateMessage 创建私聊消息
func NewPrivateMessage(from, to, content string) *Message {
	return &Message{
		Type:      MessageTypePrivate,
		Username:  from,
		To:        to,
		Content:   content,
		Timestamp: time.Now(),
	}
}

👤 第三步:实现客户端连接

文件: client/client.go

package client

import (
	"encoding/json"
	"log"
	"time"

	"github.com/gorilla/websocket"
	"github.com/yourusername/chat-room/models"
)

const (
	// 写入超时时间
	writeWait = 10 * time.Second
	// 读取超时时间
	pongWait = 60 * time.Second
	// ping周期(必须小于pongWait)
	pingPeriod = (pongWait * 9) / 10
	// 最大消息大小
	maxMessageSize = 512
)

// Client 客户端连接
type Client struct {
	Hub      Hub
	Conn     *websocket.Conn
	Send     chan *models.Message
	Username string
}

// Hub接口(避免循环依赖)
type Hub interface {
	Register(client *Client)
	Unregister(client *Client)
	Broadcast(message *models.Message)
	SendPrivate(message *models.Message)
	GetOnlineUsers() []string
}

// ReadPump 从WebSocket读取消息
func (c *Client) ReadPump() {
	defer func() {
		c.Hub.Unregister(c)
		c.Conn.Close()
	}()

	c.Conn.SetReadDeadline(time.Now().Add(pongWait))
	c.Conn.SetPongHandler(func(string) error {
		c.Conn.SetReadDeadline(time.Now().Add(pongWait))
		return nil
	})

	for {
		_, messageData, err := c.Conn.ReadMessage()
		if err != nil {
			if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
				log.Printf("错误: %v", err)
			}
			break
		}

		// 解析消息
		var msg models.Message
		if err := json.Unmarshal(messageData, &msg); err != nil {
			log.Printf("解析消息失败: %v", err)
			continue
		}

		// 设置用户名和时间戳
		msg.Username = c.Username
		msg.Timestamp = time.Now()

		// 根据消息类型处理
		switch msg.Type {
		case models.MessageTypeChat:
			// 广播消息
			c.Hub.Broadcast(&msg)
		case models.MessageTypePrivate:
			// 私聊消息
			c.Hub.SendPrivate(&msg)
		}
	}
}

// WritePump 向WebSocket写入消息
func (c *Client) WritePump() {
	ticker := time.NewTicker(pingPeriod)
	defer func() {
		ticker.Stop()
		c.Conn.Close()
	}()

	for {
		select {
		case message, ok := <-c.Send:
			c.Conn.SetWriteDeadline(time.Now().Add(writeWait))
			if !ok {
				// Hub关闭了通道
				c.Conn.WriteMessage(websocket.CloseMessage, []byte{})
				return
			}

			// 序列化消息
			messageData, err := json.Marshal(message)
			if err != nil {
				log.Printf("序列化消息失败: %v", err)
				continue
			}

			// 发送消息
			if err := c.Conn.WriteMessage(websocket.TextMessage, messageData); err != nil {
				return
			}

		case <-ticker.C:
			c.Conn.SetWriteDeadline(time.Now().Add(writeWait))
			if err := c.Conn.WriteMessage(websocket.PingMessage, nil); err != nil {
				return
			}
		}
	}
}

🔄 第四步:实现连接中心(Hub)

文件: hub/hub.go

package hub

import (
	"log"
	"sync"

	"github.com/yourusername/chat-room/client"
	"github.com/yourusername/chat-room/models"
)

// Hub 管理所有客户端连接
type Hub struct {
	// 已注册的客户端
	clients map[*client.Client]bool

	// 用户名到客户端的映射
	userClients map[string]*client.Client

	// 广播消息的通道
	broadcast chan *models.Message

	// 注册客户端的通道
	register chan *client.Client

	// 注销客户端的通道
	unregister chan *client.Client

	// 历史消息(最多保存100条)
	history []*models.Message

	// 互斥锁
	mu sync.RWMutex
}

// NewHub 创建新的Hub
func NewHub() *Hub {
	return &Hub{
		clients:     make(map[*client.Client]bool),
		userClients: make(map[string]*client.Client),
		broadcast:   make(chan *models.Message),
		register:    make(chan *client.Client),
		unregister:  make(chan *client.Client),
		history:     make([]*models.Message, 0, 100),
	}
}

// Run 启动Hub
func (h *Hub) Run() {
	for {
		select {
		case client := <-h.register:
			// 注册新客户端
			h.mu.Lock()
			h.clients[client] = true
			h.userClients[client.Username] = client
			h.mu.Unlock()

			// 发送历史消息给新用户
			h.sendHistory(client)

			// 广播用户加入消息
			joinMsg := models.NewMessage(models.MessageTypeJoin, client.Username, "加入了聊天室")
			h.addHistory(joinMsg)
			h.broadcastMessage(joinMsg)

			// 广播更新的用户列表
			h.broadcastUserList()

			log.Printf("用户 %s 加入聊天室, 当前在线: %d", client.Username, len(h.clients))

		case client := <-h.unregister:
			// 注销客户端
			if _, ok := h.clients[client]; ok {
				h.mu.Lock()
				delete(h.clients, client)
				delete(h.userClients, client.Username)
				close(client.Send)
				h.mu.Unlock()

				// 广播用户离开消息
				leaveMsg := models.NewMessage(models.MessageTypeLeave, client.Username, "离开了聊天室")
				h.addHistory(leaveMsg)
				h.broadcastMessage(leaveMsg)

				// 广播更新的用户列表
				h.broadcastUserList()

				log.Printf("用户 %s 离开聊天室, 当前在线: %d", client.Username, len(h.clients))
			}

		case message := <-h.broadcast:
			// 广播消息
			h.addHistory(message)
			h.broadcastMessage(message)
		}
	}
}

// Register 注册客户端
func (h *Hub) Register(c *client.Client) {
	h.register <- c
}

// Unregister 注销客户端
func (h *Hub) Unregister(c *client.Client) {
	h.unregister <- c
}

// Broadcast 广播消息
func (h *Hub) Broadcast(message *models.Message) {
	h.broadcast <- message
}

// SendPrivate 发送私聊消息
func (h *Hub) SendPrivate(message *models.Message) {
	h.mu.RLock()
	targetClient, exists := h.userClients[message.To]
	h.mu.RUnlock()

	if !exists {
		// 目标用户不在线
		return
	}

	// 发送给目标用户
	select {
	case targetClient.Send <- message:
	default:
		// 发送失败,关闭连接
		h.Unregister(targetClient)
	}
}

// GetOnlineUsers 获取在线用户列表
func (h *Hub) GetOnlineUsers() []string {
	h.mu.RLock()
	defer h.mu.RUnlock()

	users := make([]string, 0, len(h.clients))
	for client := range h.clients {
		users = append(users, client.Username)
	}
	return users
}

// broadcastMessage 广播消息给所有客户端
func (h *Hub) broadcastMessage(message *models.Message) {
	h.mu.RLock()
	defer h.mu.RUnlock()

	for client := range h.clients {
		select {
		case client.Send <- message:
		default:
			// 发送失败,关闭连接
			close(client.Send)
			delete(h.clients, client)
			delete(h.userClients, client.Username)
		}
	}
}

// broadcastUserList 广播用户列表
func (h *Hub) broadcastUserList() {
	users := h.GetOnlineUsers()
	message := &models.Message{
		Type:  models.MessageTypeUsers,
		Users: users,
	}
	h.broadcastMessage(message)
}

// addHistory 添加到历史记录
func (h *Hub) addHistory(message *models.Message) {
	h.mu.Lock()
	defer h.mu.Unlock()

	h.history = append(h.history, message)
	// 只保留最近100条
	if len(h.history) > 100 {
		h.history = h.history[1:]
	}
}

// sendHistory 发送历史消息给新用户
func (h *Hub) sendHistory(c *client.Client) {
	h.mu.RLock()
	defer h.mu.RUnlock()

	for _, msg := range h.history {
		select {
		case c.Send <- msg:
		default:
			return
		}
	}
}

🌐 第五步:实现HTTP服务

文件: main.go

package main

import (
	"log"
	"net/http"

	"github.com/gin-gonic/gin"
	"github.com/gorilla/websocket"
	"github.com/yourusername/chat-room/client"
	"github.com/yourusername/chat-room/hub"
)

var upgrader = websocket.Upgrader{
	ReadBufferSize:  1024,
	WriteBufferSize: 1024,
	// 允许所有来源(生产环境应该限制)
	CheckOrigin: func(r *http.Request) bool {
		return true
	},
}

func main() {
	// 创建Hub并启动
	h := hub.NewHub()
	go h.Run()

	// 创建Gin引擎
	router := gin.Default()

	// 静态文件
	router.Static("/static", "./static")
	router.StaticFile("/", "./static/index.html")

	// WebSocket端点
	router.GET("/ws", func(c *gin.Context) {
		handleWebSocket(h, c.Writer, c.Request)
	})

	// 启动服务器
	log.Println("🚀 聊天室启动在 http://localhost:8080")
	if err := router.Run(":8080"); err != nil {
		log.Fatal(err)
	}
}

func handleWebSocket(h *hub.Hub, w http.ResponseWriter, r *http.Request) {
	// 获取用户名
	username := r.URL.Query().Get("username")
	if username == "" {
		http.Error(w, "缺少用户名", http.StatusBadRequest)
		return
	}

	// 升级到WebSocket
	conn, err := upgrader.Upgrade(w, r, nil)
	if err != nil {
		log.Printf("升级WebSocket失败: %v", err)
		return
	}

	// 创建客户端
	c := &client.Client{
		Hub:      h,
		Conn:     conn,
		Send:     make(chan *models.Message, 256),
		Username: username,
	}

	// 注册客户端
	h.Register(c)

	// 启动读写协程
	go c.WritePump()
	go c.ReadPump()
}

🎨 第六步:实现前端页面

文件: static/index.html

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>实时聊天室</title>
    <link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
    <div class="container">
        <!-- 登录界面 -->
        <div id="login-screen" class="screen">
            <h1>💬 实时聊天室</h1>
            <input type="text" id="username-input" placeholder="请输入你的昵称" maxlength="20">
            <button onclick="joinChat()">加入聊天</button>
        </div>

        <!-- 聊天界面 -->
        <div id="chat-screen" class="screen" style="display: none;">
            <div class="header">
                <h2>💬 聊天室</h2>
                <span id="online-count">在线: 0</span>
            </div>

            <div class="main">
                <!-- 左侧:在线用户列表 -->
                <div class="sidebar">
                    <h3>在线用户</h3>
                    <ul id="user-list"></ul>
                </div>

                <!-- 右侧:聊天区域 -->
                <div class="chat-area">
                    <div id="messages" class="messages"></div>
                    
                    <div class="input-area">
                        <input type="text" id="message-input" placeholder="输入消息..." onkeypress="handleKeyPress(event)">
                        <button onclick="sendMessage()">发送</button>
                    </div>
                </div>
            </div>
        </div>
    </div>

    <script src="/static/js/app.js"></script>
</body>
</html>

文件: static/css/style.css

* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
    height: 100vh;
    display: flex;
    align-items: center;
    justify-content: center;
}

.container {
    width: 100%;
    max-width: 1200px;
    height: 90vh;
    background: white;
    border-radius: 10px;
    box-shadow: 0 10px 40px rgba(0,0,0,0.2);
    overflow: hidden;
}

#login-screen {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    height: 100%;
    gap: 20px;
}

#login-screen h1 {
    font-size: 2.5rem;
    color: #667eea;
}

#login-screen input {
    width: 300px;
    padding: 15px;
    border: 2px solid #ddd;
    border-radius: 5px;
    font-size: 1rem;
}

#login-screen button {
    width: 300px;
    padding: 15px;
    background: #667eea;
    color: white;
    border: none;
    border-radius: 5px;
    font-size: 1rem;
    cursor: pointer;
}

#login-screen button:hover {
    background: #5568d3;
}

#chat-screen {
    display: flex;
    flex-direction: column;
    height: 100%;
}

.header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 20px;
    background: #667eea;
    color: white;
}

.main {
    display: flex;
    flex: 1;
    overflow: hidden;
}

.sidebar {
    width: 250px;
    background: #f7f7f7;
    padding: 20px;
    border-right: 1px solid #ddd;
    overflow-y: auto;
}

.sidebar h3 {
    margin-bottom: 15px;
    color: #333;
}

#user-list {
    list-style: none;
}

#user-list li {
    padding: 10px;
    margin-bottom: 5px;
    background: white;
    border-radius: 5px;
    cursor: pointer;
}

#user-list li:hover {
    background: #e0e0e0;
}

.chat-area {
    flex: 1;
    display: flex;
    flex-direction: column;
}

.messages {
    flex: 1;
    padding: 20px;
    overflow-y: auto;
}

.message {
    margin-bottom: 15px;
    padding: 10px;
    border-radius: 5px;
    background: #f0f0f0;
}

.message.join, .message.leave {
    text-align: center;
    background: #e3f2fd;
    color: #1976d2;
    font-style: italic;
}

.message .username {
    font-weight: bold;
    color: #667eea;
    margin-right: 10px;
}

.message .time {
    font-size: 0.8rem;
    color: #999;
    margin-left: 10px;
}

.input-area {
    display: flex;
    padding: 20px;
    border-top: 1px solid #ddd;
    gap: 10px;
}

.input-area input {
    flex: 1;
    padding: 12px;
    border: 2px solid #ddd;
    border-radius: 5px;
    font-size: 1rem;
}

.input-area button {
    padding: 12px 30px;
    background: #667eea;
    color: white;
    border: none;
    border-radius: 5px;
    cursor: pointer;
}

.input-area button:hover {
    background: #5568d3;
}

文件: static/js/app.js

let ws = null;
let username = '';

// 加入聊天
function joinChat() {
    const input = document.getElementById('username-input');
    username = input.value.trim();
    
    if (!username) {
        alert('请输入昵称');
        return;
    }
    
    // 连接WebSocket
    connectWebSocket();
    
    // 切换界面
    document.getElementById('login-screen').style.display = 'none';
    document.getElementById('chat-screen').style.display = 'flex';
}

// 连接WebSocket
function connectWebSocket() {
    const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
    const wsUrl = `${protocol}//${window.location.host}/ws?username=${encodeURIComponent(username)}`;
    
    ws = new WebSocket(wsUrl);
    
    ws.onopen = () => {
        console.log('WebSocket连接成功');
    };
    
    ws.onmessage = (event) => {
        const message = JSON.parse(event.data);
        handleMessage(message);
    };
    
    ws.onerror = (error) => {
        console.error('WebSocket错误:', error);
    };
    
    ws.onclose = () => {
        console.log('WebSocket连接关闭');
        setTimeout(connectWebSocket, 3000); // 3秒后重连
    };
}

// 处理接收到的消息
function handleMessage(message) {
    switch (message.type) {
        case 'join':
        case 'leave':
            displaySystemMessage(message);
            break;
        case 'chat':
            displayChatMessage(message);
            break;
        case 'users':
            updateUserList(message.users);
            break;
    }
}

// 显示系统消息
function displaySystemMessage(message) {
    const messagesDiv = document.getElementById('messages');
    const messageDiv = document.createElement('div');
    messageDiv.className = `message ${message.type}`;
    messageDiv.textContent = `${message.username} ${message.content}`;
    messagesDiv.appendChild(messageDiv);
    messagesDiv.scrollTop = messagesDiv.scrollHeight;
}

// 显示聊天消息
function displayChatMessage(message) {
    const messagesDiv = document.getElementById('messages');
    const messageDiv = document.createElement('div');
    messageDiv.className = 'message';
    
    const time = new Date(message.timestamp).toLocaleTimeString('zh-CN', {
        hour: '2-digit',
        minute: '2-digit'
    });
    
    messageDiv.innerHTML = `
        <span class="username">${message.username}:</span>
        <span class="content">${escapeHtml(message.content)}</span>
        <span class="time">${time}</span>
    `;
    
    messagesDiv.appendChild(messageDiv);
    messagesDiv.scrollTop = messagesDiv.scrollHeight;
}

// 更新用户列表
function updateUserList(users) {
    const userList = document.getElementById('user-list');
    const onlineCount = document.getElementById('online-count');
    
    userList.innerHTML = '';
    users.forEach(user => {
        const li = document.createElement('li');
        li.textContent = user;
        userList.appendChild(li);
    });
    
    onlineCount.textContent = `在线: ${users.length}`;
}

// 发送消息
function sendMessage() {
    const input = document.getElementById('message-input');
    const content = input.value.trim();
    
    if (!content) return;
    
    const message = {
        type: 'chat',
        content: content
    };
    
    ws.send(JSON.stringify(message));
    input.value = '';
}

// 处理键盘事件
function handleKeyPress(event) {
    if (event.key === 'Enter') {
        sendMessage();
    }
}

// 转义HTML
function escapeHtml(text) {
    const div = document.createElement('div');
    div.textContent = text;
    return div.innerHTML;
}

🧪 第七步:运行测试

# 运行程序
go run main.go

# 输出:
# 🚀 聊天室启动在 http://localhost:8080

打开浏览器访问 http://localhost:8080,可以打开多个浏览器标签测试多用户聊天。

💡 扩展思考

1. 添加数据库存储

使用MySQL存储用户和消息历史:

type ChatMessage struct {
	ID        uint      `gorm:"primaryKey"`
	Username  string    `gorm:"index"`
	Content   string
	CreatedAt time.Time
}

// 保存消息到数据库
db.Create(&ChatMessage{
	Username: msg.Username,
	Content:  msg.Content,
})

2. 用户认证

添加JWT认证:

go get -u github.com/golang-jwt/jwt/v5

3. 房间功能

支持多个聊天室:

type Room struct {
	ID      string
	Hub     *Hub
	Clients map[*Client]bool
}

4. 富文本消息

支持图片、表情、文件发送:

type Attachment struct {
	Type string `json:"type"` // image, file, emoji
	URL  string `json:"url"`
}

💪 练习题

  1. 实现"正在输入..."提示功能
  2. 添加消息撤回功能(2分钟内可撤回)
  3. 实现@某人功能
  4. 添加表情包支持
💡 参考答案
// "正在输入"功能示例
const MessageTypeTyping MessageType = "typing"

type TypingMessage struct {
	Type     MessageType `json:"type"`
	Username string      `json:"username"`
	IsTyping bool        `json:"is_typing"`
}

// 前端定时发送typing消息
let typingTimer;
messageInput.addEventListener('input', () => {
	clearTimeout(typingTimer);
	sendTypingStatus(true);
	
	typingTimer = setTimeout(() => {
		sendTypingStatus(false);
	}, 2000);
});

🎯 小结

恭喜你完成了第四个实战项目!通过这个项目,你学会了:

✅ WebSocket的使用和连接管理
✅ 实时消息的广播机制
✅ 并发安全的状态管理
✅ 前后端实时通信
✅ 完整的聊天应用架构

下一个项目我们将实现URL短链接服务,学习Redis和高并发场景!

下一步: 第5个项目 - URL短链接服务 →


💬 遇到问题了吗?

  • 确保WebSocket连接正常
  • 检查浏览器控制台的错误信息
  • 注意并发安全问题
  • 访问 gorilla/websocket文档 了解更多
最近更新: 2025/12/27 13:26
Contributors: 王长安
Prev
第3个项目 - Web爬虫 新闻采集器
Next
第5个项目 - URL短链接服务