跳转至

实战项目8:聊天室程序

项目概述

使用 Python 标准库 socket 和 threading 实现一个基于 TCP 的多人在线聊天室。服务端负责管理连接和广播消息,客户端支持收发消息、修改昵称、私聊和查看在线用户。这是学习网络编程最经典的入门项目。

学习目标

  • 理解 TCP Socket 编程的基本流程
  • 掌握多线程并发处理多个客户端
  • 学习自定义应用层协议(消息格式)
  • 理解"一人读、一人写"的收发分离模型
  • 了解粘包问题及其简单处理方案

功能需求

核心功能

  • 多个客户端同时连接并聊天
  • 服务端把消息广播给所有在线用户
  • 用户进入/离开时通知全体
  • 修改昵称、查看在线列表
  • 私聊指定用户

项目结构

chat_room/
├── server.py    # 服务端
└── client.py    # 客户端

代码实现

1. 服务端(server.py)

import socket
import threading
import time

HOST, PORT = "0.0.0.0", 9000
clients = {}          # socket -> 昵称
lock = threading.Lock()


def broadcast(msg, sender=None):
    """把消息发给除发送者外的所有客户端。"""
    with lock:
        targets = list(clients.items())
    text = msg.encode("utf-8")
    for sock, _ in targets:
        if sock is sender:
            continue
        try:
            sock.sendall(text)
        except OSError:
            remove_client(sock)


def online_names():
    with lock:
        return list(clients.values())


def remove_client(sock):
    with lock:
        name = clients.pop(sock, None)
    if name:
        try:
            sock.close()
        except OSError:
            pass
        broadcast(f"【系统】{name} 离开了聊天室")


def handle(sock, addr):
    # 第一条消息作为昵称
    try:
        name = sock.recv(1024).decode("utf-8").strip() or f"用户{addr[1]}"
    except OSError:
        sock.close()
        return

    with lock:
        # 昵称去重
        base, i = name, 1
        while name in clients.values():
            i += 1
            name = f"{base}{i}"
        clients[sock] = name

    sock.sendall(f"【系统】欢迎你,{name}!当前在线:{', '.join(online_names())}".encode())
    broadcast(f"【系统】{name} 进入了聊天室", sender=sock)
    print(f"[{time.strftime('%H:%M:%S')}] {name} 已连接 ({addr[0]})")

    buffer = b""
    while True:
        try:
            data = sock.recv(1024)
            if not data:
                break
        except OSError:
            break
        buffer += data
        # 按换行分包,处理 TCP 粘包
        while b"\n" in buffer:
            line, buffer = buffer.split(b"\n", 1)
            text = line.decode("utf-8").strip()
            if not text:
                continue
            now = time.strftime("%H:%M:%S")
            if text == "/list":
                sock.sendall(f"在线用户:{', '.join(online_names())}".encode())
            elif text.startswith("/to "):
                # 私聊:/to 昵称 消息
                parts = text.split(maxsplit=2)
                if len(parts) < 3:
                    sock.sendall("用法:/to 昵称 消息".encode())
                    continue
                _, target, content = parts
                with lock:
                    target_sock = next(
                        (s for s, n in clients.items() if n == target), None)
                if target_sock:
                    target_sock.sendall(f"【私聊】{name}:{content}".encode())
                else:
                    sock.sendall(f"【系统】用户 {target} 不在线".encode())
            elif text == "/quit":
                buffer = b""
                break
            else:
                broadcast(f"[{now}] {name}:{text}", sender=sock)
        if not buffer and text == "/quit" if False else False:
            break

    print(f"{name} 已断开")
    remove_client(sock)


def main():
    server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    server.bind((HOST, PORT))
    server.listen()
    print(f"聊天室服务端已启动,监听 {HOST}:{PORT}")
    while True:
        sock, addr = server.accept()
        threading.Thread(target=handle, args=(sock, addr), daemon=True).start()


if __name__ == "__main__":
    main()

2. 客户端(client.py)

import socket
import threading

HOST, PORT = "127.0.0.1", 9000


def receive(sock):
    """独立线程:持续接收并打印服务端消息。"""
    while True:
        try:
            data = sock.recv(1024)
        except OSError:
            print("\n连接已断开")
            break
        if not data:
            print("\n服务端已关闭")
            break
        print("\r" + data.decode("utf-8") + "\n> ", end="", flush=True)


def main():
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.connect((HOST, PORT))

    name = input("请输入昵称:").strip()
    sock.sendall((name + "\n").encode())

    threading.Thread(target=receive, args=(sock,), daemon=True).start()
    print("输入消息回车发送,/list 查看在线,/to 昵称 消息 私聊,/quit 退出")

    while True:
        text = input("> ").strip()
        if not text:
            continue
        if text == "/quit":
            sock.sendall(b"/quit\n")
            break
        try:
            sock.sendall((text + "\n").encode("utf-8"))
        except OSError:
            print("发送失败,连接已断开")
            break
    sock.close()


if __name__ == "__main__":
    main()

运行指南

# 终端 1:启动服务端
python server.py

# 终端 2、3、4:分别启动客户端
python client.py

支持的特殊命令:

命令 作用
/list 查看在线用户
/to 昵称 消息 向指定用户私聊
/quit 退出聊天室

关键知识点

为什么要用多线程?

服务端要同时服务多个客户端。主线程负责 accept 新连接,每来一个连接就开一个新线程专门处理它的读写,互不阻塞。这是最朴素的并发模型,理解它之后再看 selectors、asyncio 会容易很多。

什么是 TCP 粘包?

TCP 是字节流协议,没有"消息边界":两条 send 的内容可能被合并成一次 recv 收到。解决办法是自定义边界,本项目用换行符分包(收到数据后按 \n 切分),生产环境更常用"消息头 + 长度字段"的方案。

广播为什么要加锁?

clients 字典会被多个线程同时读写(新连接插入、断开移除、广播遍历)。Python 的 dict 虽然单个操作是原子的,但"遍历期间有元素被删除"仍会出问题,所以用 threading.Lock 保护。

扩展功能建议

基础扩展

  • 消息加上颜色区分(客户端 ANSI 转义码)
  • 服务端保存聊天记录到文件
  • 限制最大在线人数

进阶扩展

  • 改用 selectors 实现单线程 I/O 多路复用版本
  • 用 asyncio 重写服务端,对比理解两种并发模型
  • 消息加入长度前缀的协议头,彻底解决粘包

高级扩展

  • 基于 WebSocket(websockets 库)实现网页版聊天室
  • 增加聊天房间(频道)功能
  • 消息端到端加密

常见问题

Q: 客户端发送和接收会互相阻塞吗?

A: 不会。客户端把"接收消息"放在独立线程里,主线程只负责读取用户输入并发送,两边互不干扰。

Q: 为什么服务端要捕获 OSError?

A: 客户端异常断线(如直接关闭窗口)时,服务端对该 socket 的读写会抛异常。捕获后统一走 remove_client 清理资源,避免线程崩溃。

Q: 如何测试多个客户端?

A: 在同一台机器开多个终端运行 client.py 即可,局域网内其他电脑把 HOST 改成服务端 IP 也能连入。

项目总结

本项目实现了一个功能完整的多人聊天室,涵盖了:

  1. Socket 编程:TCP 连接的建立、读写与关闭
  2. 并发模型:一线程一连接的经典结构
  3. 协议设计:换行分包 + 命令字(/list、/to)
  4. 共享状态管理:加锁保护在线用户表
  5. 健壮性处理:断线检测、昵称去重、异常清理

下一步


祝你开发愉快!