[WebSocket/Python] 基于 python + websocket 实现简易的Web版聊天室

0 序

  • 在开发 MCP Tool 时,了解到 基于 HTTP SSE 通信模式下 MCP Server/Tool;而与 HTTP SSE 通信模式较为接近的是 WebSocket 通信模式。
  • 为此,对 WebSocket 进行一个简单的试验、实践。

1 基于 python + websocket 实现简易的Web版聊天室

  • 创建一个基于 WebSocket + Python 的简易 Web 版聊天室。这个实现将包含完整的后端和前端代码,支持多人实时聊天、用户加入/离开通知等功能。

1 源码实现

chat.html - WebSocket 聊天室

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>WebSocket 聊天室</title>
    <style>
        * {
            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;
            justify-content: center;
            align-items: center;
        }

        .chat-container {
            width: 90%;
            max-width: 800px;
            height: 90vh;
            background: rgba(255, 255, 255, 0.95);
            border-radius: 20px;
            box-shadow: 0 20px 60px rgba(0,0,0,0.3);
            display: flex;
            flex-direction: column;
            overflow: hidden;
        }

        .chat-header {
            background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
            color: white;
            padding: 20px;
            text-align: center;
            position: relative;
        }

        .chat-header h1 {
            font-size: 24px;
            margin-bottom: 5px;
        }

        .online-count {
            font-size: 14px;
            opacity: 0.9;
        }

        .connection-status {
            position: absolute;
            right: 20px;
            top: 50%;
            transform: translateY(-50%);
            width: 12px;
            height: 12px;
            border-radius: 50%;
            background: #ff4444;
            transition: background 0.3s;
        }

        .connection-status.connected {
            background: #00ff88;
            box-shadow: 0 0 10px #00ff88;
        }

        .chat-messages {
            flex: 1;
            overflow-y: auto;
            padding: 20px;
            background: #f8f9fa;
        }

        .message {
            margin-bottom: 15px;
            animation: slideIn 0.3s ease-out;
        }

        @keyframes slideIn {
            from {
                opacity: 0;
                transform: translateY(10px);
            }
            to {
                opacity: 1;
                transform: translateY(0);
            }
        }

        .message-header {
            display: flex;
            align-items: center;
            margin-bottom: 5px;
        }

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

        .timestamp {
            font-size: 12px;
            color: #999;
        }

        .message-content {
            background: white;
            padding: 12px 16px;
            border-radius: 15px;
            box-shadow: 0 2px 5px rgba(0,0,0,0.05);
            display: inline-block;
            max-width: 80%;
            word-wrap: break-word;
        }

        .message.own .message-header {
            flex-direction: row-reverse;
        }

        .message.own .username {
            color: #764ba2;
            margin-right: 0;
            margin-left: 10px;
        }

        .message.own .message-content {
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            color: white;
            margin-left: auto;
        }

        .system-message {
            text-align: center;
            color: #888;
            font-style: italic;
            margin: 10px 0;
            font-size: 14px;
        }

        .typing-indicator {
            display: none;
            padding: 10px 20px;
            color: #999;
            font-style: italic;
            font-size: 14px;
        }

        .typing-indicator.active {
            display: block;
        }

        .chat-input-area {
            padding: 20px;
            background: white;
            border-top: 1px solid #eee;
        }

        .input-wrapper {
            display: flex;
            gap: 10px;
        }

        #messageInput {
            flex: 1;
            padding: 12px 20px;
            border: 2px solid #e0e0e0;
            border-radius: 25px;
            outline: none;
            font-size: 16px;
            transition: border-color 0.3s;
        }

        #messageInput:focus {
            border-color: #667eea;
        }

        #sendBtn {
            padding: 12px 30px;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            color: white;
            border: none;
            border-radius: 25px;
            cursor: pointer;
            font-size: 16px;
            transition: transform 0.2s, box-shadow 0.2s;
        }

        #sendBtn:hover {
            transform: translateY(-2px);
            box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
        }

        #sendBtn:active {
            transform: translateY(0);
        }

        .login-modal {
            position: fixed;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            background: rgba(0,0,0,0.8);
            display: flex;
            justify-content: center;
            align-items: center;
            z-index: 1000;
        }

        .login-box {
            background: white;
            padding: 40px;
            border-radius: 20px;
            text-align: center;
            animation: popIn 0.3s ease-out;
        }

        @keyframes popIn {
            from {
                transform: scale(0.8);
                opacity: 0;
            }
            to {
                transform: scale(1);
                opacity: 1;
            }
        }

        .login-box h2 {
            margin-bottom: 20px;
            color: #333;
        }

        #usernameInput {
            width: 100%;
            padding: 15px;
            margin-bottom: 20px;
            border: 2px solid #e0e0e0;
            border-radius: 10px;
            font-size: 16px;
            outline: none;
        }

        #usernameInput:focus {
            border-color: #667eea;
        }

        #joinBtn {
            width: 100%;
            padding: 15px;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            color: white;
            border: none;
            border-radius: 10px;
            font-size: 16px;
            cursor: pointer;
            transition: transform 0.2s;
        }

        #joinBtn:hover {
            transform: scale(1.05);
        }

        .hidden {
            display: none !important;
        }

        /* 滚动条样式 */
        .chat-messages::-webkit-scrollbar {
            width: 8px;
        }

        .chat-messages::-webkit-scrollbar-track {
            background: #f1f1f1;
        }

        .chat-messages::-webkit-scrollbar-thumb {
            background: #888;
            border-radius: 4px;
        }

        .chat-messages::-webkit-scrollbar-thumb:hover {
            background: #555;
        }
    </style>
</head>
<body>
    <!-- 登录界面 -->
    <div id="loginModal" class="login-modal">
        <div class="login-box">
            <h2>🚀 加入聊天室</h2>
            <input type="text" id="usernameInput" placeholder="请输入你的昵称" maxlength="20">
            <button id="joinBtn">进入聊天室</button>
        </div>
    </div>

    <!-- 聊天界面 -->
    <div class="chat-container hidden" id="chatContainer">
        <div class="chat-header">
            <h1>💬 WebSocket 聊天室</h1>
            <div class="online-count">在线人数: <span id="onlineCount">0</span></div>
            <div class="connection-status" id="connectionStatus"></div>
        </div>
        
        <div class="chat-messages" id="chatMessages">
            <!-- 消息将在这里动态添加 -->
        </div>
        
        <div class="typing-indicator" id="typingIndicator">
            有人正在输入...
        </div>
        
        <div class="chat-input-area">
            <div class="input-wrapper">
                <input type="text" id="messageInput" placeholder="输入消息..." maxlength="500">
                <button id="sendBtn">发送</button>
            </div>
        </div>
    </div>

    <script>
        let ws;
        let username;
        let reconnectAttempts = 0;
        const maxReconnectAttempts = 5;

        // DOM 元素
        const loginModal = document.getElementById('loginModal');
        const chatContainer = document.getElementById('chatContainer');
        const usernameInput = document.getElementById('usernameInput');
        const joinBtn = document.getElementById('joinBtn');
        const messageInput = document.getElementById('messageInput');
        const sendBtn = document.getElementById('sendBtn');
        const chatMessages = document.getElementById('chatMessages');
        const onlineCount = document.getElementById('onlineCount');
        const connectionStatus = document.getElementById('connectionStatus');

        // 加入聊天室
        joinBtn.addEventListener('click', joinChat);
        usernameInput.addEventListener('keypress', (e) => {
            if (e.key === 'Enter') joinChat();
        });

        function joinChat() {
            username = usernameInput.value.trim();
            if (!username) {
                alert('请输入昵称');
                return;
            }
            
            loginModal.classList.add('hidden');
            chatContainer.classList.remove('hidden');
            connectWebSocket();
        }

        // 连接 WebSocket
        function connectWebSocket() {
            // 使用当前主机和端口,或指定服务器地址
            const wsUrl = `ws://${window.location.hostname}:8765`;
            ws = new WebSocket(wsUrl);

            ws.onopen = () => {
                console.log('WebSocket 连接成功');
                connectionStatus.classList.add('connected');
                reconnectAttempts = 0;
                
                // 发送加入消息
                ws.send(JSON.stringify({
                    type: 'join',
                    username: username,
                    timestamp: new Date().toISOString()
                }));
            };

            ws.onmessage = (event) => {
                const data = JSON.parse(event.data);
                handleMessage(data);
            };

            ws.onclose = () => {
                console.log('WebSocket 连接关闭');
                connectionStatus.classList.remove('connected');
                attemptReconnect();
            };

            ws.onerror = (error) => {
                console.error('WebSocket 错误:', error);
            };
        }

        // 重连机制
        function attemptReconnect() {
            if (reconnectAttempts < maxReconnectAttempts) {
                reconnectAttempts++;
                console.log(`尝试重连... (${reconnectAttempts}/${maxReconnectAttempts})`);
                setTimeout(connectWebSocket, 3000);
            } else {
                addSystemMessage('连接已断开,请刷新页面重试');
            }
        }

        // 处理收到的消息
        function handleMessage(data) {
            switch(data.type) {
                case 'message':
                    addMessage(data.username, data.content, data.timestamp, data.username === username);
                    break;
                case 'system':
                    addSystemMessage(data.content);
                    break;
                case 'user_count':
                    onlineCount.textContent = data.count;
                    break;
                case 'history':
                    // 加载历史消息
                    data.messages.forEach(msg => {
                        addMessage(msg.username, msg.content, msg.timestamp, msg.username === username);
                    });
                    break;
            }
        }

        // 添加普通消息
        function addMessage(user, content, timestamp, isOwn = false) {
            const messageDiv = document.createElement('div');
            messageDiv.className = `message ${isOwn ? 'own' : ''}`;
            
            const time = new Date(timestamp).toLocaleTimeString('zh-CN', {
                hour: '2-digit',
                minute: '2-digit'
            });
            
            messageDiv.innerHTML = `
                <div class="message-header">
                    <span class="username">${escapeHtml(user)}</span>
                    <span class="timestamp">${time}</span>
                </div>
                <div class="message-content">${escapeHtml(content)}</div>
            `;
            
            chatMessages.appendChild(messageDiv);
            scrollToBottom();
        }

        // 添加系统消息
        function addSystemMessage(content) {
            const div = document.createElement('div');
            div.className = 'system-message';
            div.textContent = content;
            chatMessages.appendChild(div);
            scrollToBottom();
        }

        // 滚动到底部
        function scrollToBottom() {
            chatMessages.scrollTop = chatMessages.scrollHeight;
        }

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

        // 发送消息
        function sendMessage() {
            const content = messageInput.value.trim();
            if (!content || !ws || ws.readyState !== WebSocket.OPEN) return;

            ws.send(JSON.stringify({
                type: 'message',
                username: username,
                content: content,
                timestamp: new Date().toISOString()
            }));

            messageInput.value = '';
            messageInput.focus();
        }

        sendBtn.addEventListener('click', sendMessage);
        messageInput.addEventListener('keypress', (e) => {
            if (e.key === 'Enter') sendMessage();
        });

        // 页面关闭时发送离开消息
        window.addEventListener('beforeunload', () => {
            if (ws && ws.readyState === WebSocket.OPEN) {
                ws.send(JSON.stringify({
                    type: 'leave',
                    username: username
                }));
            }
        });
    </script>
</body>
</html>

server.py - WebSocket 聊天室服务端

# server.py - WebSocket 聊天室服务端
import asyncio
import websockets
import json
import logging
from datetime import datetime
from collections import defaultdict

# 配置日志
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)

class ChatServer:
    def __init__(self):
        # 存储所有连接的客户端 {websocket: username}
        self.clients = {}
        # 消息历史记录(保留最近100条)
        self.message_history = []
        self.max_history = 100
        
    async def register(self, websocket, username):
        """注册新客户端"""
        self.clients[websocket] = username
        logger.info(f"用户 {username} 加入聊天室,当前在线: {len(self.clients)}人")
        
        # 发送历史消息给新用户
        await self.send_history(websocket)
        
        # 广播用户加入消息
        await self.broadcast({
            "type": "system",
            "content": f"👋 {username} 加入了聊天室"
        })
        
        # 更新在线人数
        await self.broadcast_user_count()
        
    async def unregister(self, websocket):
        """注销客户端"""
        if websocket in self.clients:
            username = self.clients[websocket]
            del self.clients[websocket]
            logger.info(f"用户 {username} 离开聊天室,当前在线: {len(self.clients)}人")
            
            # 广播用户离开消息
            await self.broadcast({
                "type": "system",
                "content": f"👋 {username} 离开了聊天室"
            })
            
            # 更新在线人数
            await self.broadcast_user_count()
            
    async def send_history(self, websocket):
        """发送历史消息给新连接的用户"""
        if self.message_history:
            await websocket.send(json.dumps({
                "type": "history",
                "messages": self.message_history
            }))
            
    async def broadcast_user_count(self):
        """广播当前在线人数"""
        await self.broadcast({
            "type": "user_count",
            "count": len(self.clients)
        })
        
    async def broadcast(self, message, exclude=None):
        """广播消息给所有客户端"""
        if self.clients:
            message_str = json.dumps(message)
            # 创建发送任务列表
            tasks = []
            for client in self.clients:
                if client != exclude and client.open:
                    tasks.append(asyncio.create_task(self.safe_send(client, message_str)))
            
            if tasks:
                await asyncio.gather(*tasks, return_exceptions=True)
                
    async def safe_send(self, websocket, message):
        """安全发送消息(捕获异常)"""
        try:
            await websocket.send(message)
        except Exception as e:
            logger.error(f"发送消息失败: {e}")
            
    async def handle_message(self, websocket, data):
        """处理收到的消息"""
        msg_type = data.get("type")
        
        if msg_type == "join":
            username = data.get("username")
            await self.register(websocket, username)
            
        elif msg_type == "message":
            username = data.get("username")
            content = data.get("content", "").strip()
            
            if not content:
                return
                
            # 保存到历史记录
            message_data = {
                "username": username,
                "content": content,
                "timestamp": data.get("timestamp", datetime.now().isoformat())
            }
            self.message_history.append(message_data)
            
            # 限制历史记录长度
            if len(self.message_history) > self.max_history:
                self.message_history.pop(0)
            
            # 广播消息
            await self.broadcast({
                "type": "message",
                **message_data
            })
            
        elif msg_type == "leave":
            await self.unregister(websocket)
            
    async def handler(self, websocket, path):
        """WebSocket 连接处理器"""
        try:
            async for message in websocket:
                try:
                    data = json.loads(message)
                    await self.handle_message(websocket, data)
                except json.JSONDecodeError:
                    logger.error("收到无效的 JSON 数据")
                except Exception as e:
                    logger.error(f"处理消息时出错: {e}")
                    
        except websockets.exceptions.ConnectionClosed:
            logger.info("客户端连接关闭")
        finally:
            await self.unregister(websocket)

# 创建服务器实例
chat_server = ChatServer()

async def main():
    """启动 WebSocket 服务器"""
    host = "0.0.0.0"
    port = 8765
    
    logger.info(f"启动聊天室服务器于 ws://{host}:{port}")
    
    async with websockets.serve(chat_server.handler, host, port):
        await asyncio.Future()  # 永久运行

if __name__ == "__main__":
    try:
        asyncio.run(main())
    except KeyboardInterrupt:
        logger.info("服务器已停止")

2 使用说明

2.1 安装依赖

pip install websockets

2.2 启动服务端

python server.py

启动运行日志

(ai-env) PS D:\Workspace\xxx\xxx\websocket_chat> python server.py
2026-03-27 09:31:39,782 - INFO - 启动聊天室服务器于 ws://0.0.0.0:8765
2026-03-27 09:31:39,876 - INFO - server listening on 0.0.0.0:8765
2026-03-27 09:33:49,209 - INFO - connection open
2026-03-27 09:33:49,212 - INFO - 用户 johnny 加入聊天室,当前在线: 1人
2026-03-27 09:34:30,394 - INFO - connection open
2026-03-27 09:34:30,399 - INFO - 用户 jack 加入聊天室,当前在线: 2人
2026-03-27 09:41:05,575 - INFO - server closing
2026-03-27 09:41:05,577 - INFO - 用户 jack 离开聊天室,当前在线: 1人
2026-03-27 09:41:05,578 - INFO - connection closed
2026-03-27 09:41:05,578 - INFO - 用户 johnny 离开聊天室,当前在线: 0人
2026-03-27 09:41:05,579 - INFO - connection closed
2026-03-27 09:41:05,579 - INFO - server closed
2026-03-27 09:41:05,580 - INFO - 服务器已停止

2.3 运行客户端

  • 直接在浏览器中打开 HTML 文件,或 使用 Python 简单 HTTP 服务器
# 在 HTML 文件所在目录运行
python -m http.server 8080
# 然后访问 http://localhost:8080

启动/运行日志:

$ python -m http.server 8080
Serving HTTP on :: port 8080 (http://[::]:8080/) ...
::1 - - [27/Mar/2026 09:33:27] "GET / HTTP/1.1" 200 -
::1 - - [27/Mar/2026 09:33:27] code 404, message File not found
::1 - - [27/Mar/2026 09:33:27] "GET /favicon.ico HTTP/1.1" 404 -
::1 - - [27/Mar/2026 09:33:30] "GET /chat.html HTTP/1.1" 200 -
::1 - - [27/Mar/2026 09:34:08] "GET /chat.html HTTP/1.1" 304 -

2.4 体验/使用

image

3 功能特性

功能 说明
实时通信 基于 WebSocket 的全双工通信
多人聊天 支持多个客户端同时在线
用户系统 昵称登录,加入/离开通知
消息历史 新用户加入可看到最近100条消息
在线人数 实时显示当前在线用户数量
重连机制 客户端自动重连(最多5次)
XSS 防护 自动转义 HTML 特殊字符
响应式设计 适配移动端和桌面端

4 架构说明

┌─────────────┐         WebSocket          ┌──────────────┐
│   浏览器     │  ◄──────────────────────►  │  Python 服务端 │
│  (HTML/JS)  │      ws://localhost:8765    │  websockets   │
└─────────────┘                             └──────────────┘
       ▲                                            │
       │                                            │
       └────────────── 广播消息 ◄────────────────────┘
  • 这个实现非常轻量,核心代码不到 200 行,但包含了生产环境所需的基本功能如异常处理、心跳检测(通过自动重连)、历史消息等。你可以基于此扩展更多功能如私聊、表情包、文件传输等。

Z FAQ for WebSocket/聊天室

Y 推荐文献

X 参考文献

posted @ 2026-03-27 09:50  千千寰宇  阅读(6)  评论(0)    收藏  举报