第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"`
}
💪 练习题
- 实现"正在输入..."提示功能
- 添加消息撤回功能(2分钟内可撤回)
- 实现@某人功能
- 添加表情包支持
💡 参考答案
// "正在输入"功能示例
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文档 了解更多
