简介:一份面向计算机专业课程设计的《仿QQ聊天系统课程设计》Word文档,适合需要完成网络通信或即时通讯类课程设计的在校学生参考。文档按完整课程设计流程组织,从绪论、需求分析入手,覆盖软件功能需求与安全需求;随后展开总体设计(含软件结构图、注册/登录/聊天功能描述、安全设计)、数据库设计(概念结构、逻辑结构、物理结构)以及详细设计(用户聊天模块流程图、服务端与客户端模块),并附编码、结论、学习体会与参考文献,结构完整,可直接对照梳理自己的设计思路。压缩包内含1个doc文档,包体大小1.04MB,无需解压复杂目录即可查阅。目前已有1391人学习,对希望快速搭建仿QQ聊天系统方案、理解聊天系统分层架构与数据库设计要点的读者有较高参考价值。
1. 仿QQ聊天系统课程设计:先从 C/S 架构说起
很多人拿到“仿QQ聊天系统课程设计”这个题目,第一反应是去搜一个现成的源码包,改个登录界面就交差。这个思路最大的问题不是抄袭,而是你把课程设计唯一的价值丢掉了:用一个足够具体、足够熟悉的业务场景,把计算机网络、操作系统、数据库、Java 面向对象设计这几门课串成一条线。
聊天系统之所以适合做课程设计,是因为它的功能边界非常清楚:客户端登录、好友列表加载、一对一私聊、离线消息、上下线状态通知。要做到这些,你必须自己处理 TCP 连接、多线程并发、自有协议设计、数据库读写和 GUI 线程安全,这些恰恰是面试和后续工作中最常见的基本功。而不是把 Netty 或者 EMQ X 引进来,让框架把一切遮住。
这篇文章围绕“仿QQ聊天系统课程设计.doc”这个题目,给出一个可以直接照着写、能跑通、能写进设计文档的完整方案。阅读顺序建议从服务端开始,先把协议和连接管理定下来,再补客户端和数据库,最后用模拟客户端做验证。文中的代码都是最小可运行版本,你可以在这个骨架上继续加文件传输、分组、聊天室等功能。
2. 仿QQ聊天系统服务端骨架:连接管理与线程模型
2.1 为什么课程设计不建议直接上 Netty
如果你打开招聘网站看即时通讯相关岗位,Netty 几乎是必提的关键词,但课程设计阶段直接引入 Netty 反而会让报告很难写。Netty 帮你把 Reactor 线程模型、ByteBuf 内存管理、ChannelPipeline 责任链全部封装好了,你在答辩时能讲清楚的只剩配置文件。而 Java 原生的ServerSocket+ 线程池方案虽然朴素,却能让你明确回答三个核心问题:连接是谁接受的、消息由哪个线程处理、在线用户状态存在哪里。
先看结论:对于“仿QQ聊天系统”这个量级的课程设计,我建议用每连接一线程模型,原因有三。
第一,代码量少。整个服务端核心代码控制在 200 行以内,任何一个有 Java SE 基础的人都能读懂。第二,调试直观。每个客户端连接对应一个独立的ClientHandler实例,出问题时单独看这个线程的堆栈就够了。第三,课程设计文档好写,你能画出清晰的线程交互图:主线程 accept、工作线程读消息、共享的在线用户 Map 做状态维护。
如果以后想扩展,再迁移到 NIO 或者 Netty 也不迟,因为你的业务处理和底层 IO 是解耦的。下面这个表格可以放进设计说明里,展示你考虑过这个问题。
| 模型 | 连接数承载力 | 代码复杂度 | 适合场景 |
|---|---|---|---|
| 每连接一线程 | 几十到几百 | 低 | 课程设计、小型内部系统 |
| Java NIO Selector | 几千 | 中 | 需要自己写消息编解码的场景 |
| Netty | 几万以上 | 高 | 生产级网关、IM 服务端 |
2.2 可跑通的最小服务端代码
服务端的主类只做两件事:创建ServerSocket监听端口,以及把每个新连接包装成任务交给线程池。这里用newCachedThreadPool()而不是newFixedThreadPool(),是因为聊天连接是长连接且空闲时间不均匀,固定线程池在线程数打满后会让新的聊天连接排队,这在课程设计里是不希望看到的行为。
public class ChatServer { // 在线用户表,key 为用户名,value 为对应的连接处理器 private final Map<String, ClientHandler> onlineUsers = new ConcurrentHashMap<>(); private final ExecutorService workerPool = Executors.newCachedThreadPool(); public void start(int port) throws IOException { try (ServerSocket serverSocket = new ServerSocket(port)) { System.out.println("ChatServer listening on port " + port); while (true) { Socket socket = serverSocket.accept(); ClientHandler handler = new ClientHandler(socket, this); workerPool.submit(handler); } } } public void register(String username, ClientHandler handler) { onlineUsers.put(username, handler); } public void unregister(String username) { onlineUsers.remove(username); } public ClientHandler getHandler(String username) { return onlineUsers.get(username); } }accept()是阻塞方法,主线程在这里等新连接。每来一个连接,就创建一个ClientHandler并交给线程池执行。这里把 handler 提交给ExecutorService后,主线程立刻回到accept()继续等待,不会被任何一个客户端的读写操作拖住。
onlineUsers使用ConcurrentHashMap是多线程环境下的硬性要求。register和unregister会被不同客户端连接线程同时调用,如果使用普通的HashMap,在扩容期间可能出现死循环或者数据丢失。课程设计答辩时,这个选择可以直接回答“你如何保证在线状态的线程安全”这类问题。
2.3 在线用户表:Map 还是数据库?
在线状态最禁忌的做法是把“是否在线”字段直接写到数据库的 user 表里,因为登录、掉线、心跳超时都会触发数据库写操作,高频写入会把 MySQL 变成瓶颈。更合理的做法是只在内存里维护在线状态,数据库只保存用户资料和离线消息。
我一般会在客户端登录时做这样几步:
- 客户端把用户名和密码发给服务端。
- 服务端查询数据库验证密码哈希。
- 验证成功后,服务端把用户名写入
onlineUsers。 - 服务端返回登录成功响应,附带全量好友列表。
如果同一个账号在另一台机器上重复登录,可以直接把前一个连接踢下线。实现思路:register方法里先查看onlineUsers是否已有同名的 handler,如果有就向旧连接发送一个KICK消息然后关闭,再用新连接覆盖旧值。这个逻辑放在register里做,比放在客户端判断要可靠得多。
ClientHandler里最核心的方法是run(),它负责循环读取客户端发来的每一行数据,然后交给消息分发器处理。这个循环判断不能写成while (true),因为客户端如果直接拔网线,readLine()可能一直阻塞。比较稳妥的判断条件是捕获 IOException 后主动 break,并调用unregister清理在线状态。
@Override public void run() { try (BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream())); PrintWriter writer = new PrintWriter(socket.getOutputStream(), true)) { this.reader = reader; this.writer = writer; String line; while ((line = reader.readLine()) != null) { dispatch(line); } } catch (IOException e) { // 客户端异常断开,清理在线状态 server.unregister(username); } }这里把reader和writer保存为成员变量,是为了让服务端在其他线程中也能主动向客户端推送消息。PrintWriter第二个参数true表示自动 flush,每调用一次println就会立刻把缓冲内容写到网络,减小消息延迟。
3. 聊天协议与帧边界设计:仿QQ聊天系统的消息格式
3.1 先定义文本帧而不是 Java 对象流
很多聊天系统课程设计会直接用ObjectOutputStream把 Java 对象整个序列化后往 socket 里写。这样做在联调阶段确实省事,但有两个隐藏问题:对象序列化后的字节流不可读,一旦消息格式对不上,排查只能靠比对 hex;如果客户端将来想用 Python 或者其他语言重写,Java 序列化协议就成了跨语言的障碍。
比较好的做法是自定义一个简单的文本帧。以“传输一行是一帧”为基础,每个字段用竖线分隔,这样既满足课程设计需要的“自定义应用层协议”要求,又能直接在终端里用telnet或者nc调试。消息格式可以这样定义:
PRIVATE|sender=alice|receiver=bob|time=1712345678901|msg=你好,在吗? LOGIN|username=alice|time=1712345678901 LOGOUT|username=alice|time=1712345678901 PING|time=1712345678901每条消息的第一个字段是消息类型,后面是键值对参数。键值对的好处是扩展字段时不需要像位置参数那样把所有调用点都改一遍。比如以后要加消息 ID,只需在末尾追加|msgId=xxx,老客户端忽略这个字段即可。
服务端解析时,先用split("\\|")把帧拆成数组,再按照类型交给不同方法处理。注意String.split的参数是正则表达式,竖线需要转义成\\|。
3.2 粘包与半包:用 readLine 划定业务消息边界
TCP 是字节流协议,它本身不保证一次write的数据会以一个完整的“包”到达对端。实际运行中经常出现两种情况:多个业务消息在一次 TCP 读取中一起到达,叫粘包;一条业务消息被拆成多次读取,叫半包。服务端如果用read(buff)直接读字节数组,就必须自己拼接和切割。
课程设计里最简单的规避方案是:消息以换行符作为边界,服务端统一使用BufferedReader.readLine()读取。readLine()内部会持续读取直到遇到换行符,所以它天然把半包拼装成了完整帧;而粘包情况下,readLine()每次只消费一行,剩余数据留在缓冲区里下次继续读取。这等于把“按行分帧”的协议底层细节交给了 JDK。
看似绕过了粘包问题,但代价是消息正文里不能出现换行符。解决办法是发送方在组装帧之前,把正文里的换行符替换为转义序列;接收方解析后再反转义。如果不做这层处理,聊天内容里包含换行时对方收到的消息会被截断。
如果想把方案做得更正式,我建议在文档中补充说明另一种方案:4 字节长度头 + 消息体。发送时先写长度,再写内容;接收时先读长度,再按长度读内容。这个方案更适合二进制协议或图片传输,文本聊天用行分隔符已经足够。
3.3 心跳保活与离线判定
QQ 的在线状态不是客户端自己上报“我在线”就算数,服务端要能感知客户端是否真的存活。如果客户端直接断网,TCP 连接可能几十秒甚至几分钟以后才通过 FIN 或 RST 探测到异常。这期间该用户仍然在好友列表里显示在线,其他用户给他发消息,服务端会尝试写 socket 但可能失败。
心跳机制的常见做法是:客户端每 20 到 30 秒发送一个PING帧;服务端在每次收到数据时记录当前时间;一个独立的后台线程每隔 10 秒扫描一次所有客户端,如果某个客户端超过 90 秒没有活跃记录,就判定为离线,清理在线状态并通知好友。
ScheduledExecutorService heartbeatScheduler = Executors.newSingleThreadScheduledExecutor(); heartbeatScheduler.scheduleAtFixedRate(() -> { long now = System.currentTimeMillis(); onlineUsers.forEach((username, handler) -> { if (now - handler.lastReceivedTime > 90000) { handler.forceLogout("心跳超时"); } }); }, 10, 10, TimeUnit.SECONDS);lastReceivedTime是ClientHandler里的一个volatile long字段,在每次readLine()成功后更新。用volatile是因为这个字段会被服务端后台线程读取、同时被连接工作线程写入,它们在不同线程中操作,需要保证可见性。forceLogout方法发送一个系统提示后关闭 socket,让客户端读取到null从而退出循环。
心跳间隔和超时阈值要注意区分。客户端发送心跳的间隔要远小于超时时间,否则网络稍微抖动就误杀连接。我习惯设置为:客户端 30 秒发送一次PING,服务端 90 秒无数据判定超时。如果你的课程设计演示环境有网络代理或者虚拟机网络不稳定,可以把超时放宽到 120 秒。
下面给出完整的消息类型定义表,可以放进课程设计文档的协议设计章节。
| 消息类型 | 方向 | 作用 | 关键字段 |
|---|---|---|---|
| LOGIN | 客户端到服务端 | 登录并注册在线状态 | username, password |
| LOGIN_OK | 服务端到客户端 | 登录成功响应 | friendList |
| LOGIN_FAIL | 服务端到客户端 | 密码错误或账号不存在 | reason |
| PRIVATE | 双向 | 一对一私聊 | sender, receiver, msg, time |
| PING | 客户端到服务端 | 心跳保活 | time |
| PONG | 服务端到客户端 | 心跳响应 | time |
| KICK | 服务端到客户端 | 账号异地登录被踢 | reason |
4. 仿QQ聊天系统的客户端与 MySQL 持久化
4.1 数据库表结构设计与离线消息
聊天系统的数据量不大,但表结构设计能体现你对关系型数据库的理解。用户表、好友关系表和离线消息表是三个最基础的表。用户表字段不要只做“能登录”的最小集,建议把昵称和头像字段一起加上,好友列表里显示的是昵称而不是用户名。
CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password_hash VARCHAR(64) NOT NULL, salt VARCHAR(32) NOT NULL, nickname VARCHAR(50) NOT NULL, avatar VARCHAR(255) DEFAULT '', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE friendship ( user_id INT NOT NULL, friend_id INT NOT NULL, remark VARCHAR(50) DEFAULT '', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (user_id, friend_id), KEY idx_friend_id (friend_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE offline_message ( id BIGINT PRIMARY KEY AUTO_INCREMENT, sender_username VARCHAR(50) NOT NULL, receiver_username VARCHAR(50) NOT NULL, content TEXT NOT NULL, msg_time BIGINT NOT NULL, status TINYINT DEFAULT 0, KEY idx_receiver_status (receiver_username, status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;密码字段存的是加盐后的哈希而不是明文。即使是课程设计,也不应该用明文密码表交作业。salt字段保存随机盐值,登录校验时把用户输入的密码加上盐再做 SHA-256,对比哈希结果。虽然 SHA-256 本身不是最安全的密码存储方案,但相比明文已经是质的区别,答辩时能解释清楚即可。
friendship表用(user_id, friend_id)双主键保证一对好友关系只存一行,查询某人的好友列表用SELECT friend_id FROM friendship WHERE user_id = ?。这里不冗余存储用户名和昵称,优点是好友昵称变更时无需批量更新关系表;代价是查询好友列表时要多一次 JOIN。对于课程设计的数据量来说,完全够用。
4.2 JDBC 落地与事务边界
JDBC 连接管理不要每次操作都写一遍DriverManager.getConnection,我一般用一个简单的DbUtil类做统一管理。注意连接 URL 中的参数很有讲究,建议固定写上useSSL=false和serverTimezone=Asia/Shanghai,前者避免本地开发环境证书校验报错,后者解决 MySQL 8.0 默认时区参数导致的日期时间错乱。
public class DbUtil { private static final String URL = "jdbc:mysql://localhost:3306/qq_chat?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8"; private static final String USER = "root"; private static final String PASSWORD = "123456"; public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } public static String sha256WithSalt(String password, String salt) { String input = password + salt; return DigestUtils.sha256Hex(input); } }发送私聊消息时,服务端不能只在内存里把消息转发给在线接收方,因为接收方如果已经掉线,消息就丢了。正确的顺序是:接收方在线则直接转发,同时把消息写入offline_message表作为备份;接收方离线则只写离线表,等对方登录后再拉取。
这两种路径下,写库操作和内存操作之间没有强一致性要求,不需要开启事务。因为即使数据库写入失败,内存消息也已经送达,不会影响用户体验。真正需要考虑事务的是“注册新用户”这种操作:插入用户表后紧接着初始化好友关系,两步要么都成功,要么都失败。
4.3 Swing 客户端的线程切换安全
Java Swing 是单线程模型,所有 UI 组件的创建、修改和销毁都必须发生在 EDT(事件调度线程)上。聊天客户端至少存在两个线程:一个是接收网络消息的线程,一个是处理用户点击事件的 EDT。如果网络线程直接调用chatArea.append(...),轻则界面刷新卡顿,重则抛出InterruptedException或ConcurrentModificationException。
安全的写法是用SwingUtilities.invokeLater()把 UI 更新操作提交到 EDT 队列中执行。因为网络消息可能连续到达,invokeLater 传入的 Runnable 会被依次执行,不会互相覆盖。
private void onMessageReceived(MessageFrame frame) { SwingUtilities.invokeLater(() -> { chatArea.append("[" + frame.getSender() + "] " + frame.getContent() + "\n"); chatArea.setCaretPosition(chatArea.getDocument().getLength()); }); }setCaretPosition是聊天窗口最容易被忽略的细节:如果消息很多,JTextArea 不会自动向下滚动,用户看到的是停留在中间区域的内容。把光标强制移动到末尾,才能保证新消息可见。
登录操作我建议放在子线程里执行,不要在 EDT 中直接发起网络请求,否则登录期间窗口会无响应。常见做法是点“登录”按钮后,在ActionListener中new Thread(this::doLogin).start(),登录完成后再用invokeLater切换回 EDT 更新界面。虽然 Java 11 以后的 HttpClient 支持异步,但课程设计用原生 Socket 时,手动开线程是最直接的方式。
好友列表的刷新也需要线程切换。登录成功后的全量好友列表、其他用户上线通知、其他人下线通知,这三类消息都应该走同一个刷新方法,避免逻辑分散。我习惯把好友列表设计成DefaultListModel<String>,它的addElement和removeElement方法会通知 JList 自动重绘,省去手动调用updateUI的麻烦。
5. 验证与进阶:并发模拟、消息序号与乱序缓冲
5.1 写一个并发客户端模拟器验证服务端
课程设计最尴尬的情况是答辩现场只开两个客户端,无法证明服务端“能同时处理多个连接”。我建议在验收前用一段简单的 Python 脚本模拟几十个客户端同时上线、互发消息,然后把服务端打印的在线数量截图放进设计文档。
import socket import threading import time HOST = "127.0.0.1" PORT = 9000 USER_COUNT = 30 BASE_MSG = "hello from user {}" def client_worker(user_id): try: sock = socket.create_connection((HOST, PORT), timeout=5) login_frame = f"LOGIN|username=user{user_id}|time={int(time.time() * 1000)}" sock.sendall((login_frame + "\n").encode("utf-8")) time.sleep(2) sock.sendall((f"PING|time={int(time.time() * 1000)}\n").encode("utf-8")) time.sleep(1) sock.close() except Exception as e: print(f"user{user_id} error: {e}") threads = [threading.Thread(target=client_worker, args=(i,)) for i in range(USER_COUNT)] for t in threads: t.start() for t in threads: t.join() print("simulation finished")脚本的核心思路很简单:每个线程负责一个模拟客户端,连接后发送登录帧,等待两秒后发送一个心跳帧,然后关闭连接。如果服务端的ClientHandler在readLine()返回null时正确执行了unregister,服务端日志里应看到 30 次注册和 30 次注销,数量一致即通过验证。
参数方面,timeout=5是 socket 连接超时,防止某个客户端连接卡死导致整个模拟脚本无法结束;USER_COUNT按课程设计要求的规模调整,一般在 50 以内就足够说明问题。
5.2 为每条消息加序号,解决乱序与重发
TCP 保证字节流按序到达,但应用层经过线程池分发后,消息顺序不一定严格等于发送顺序,尤其是服务端用多线程处理转发时,两条消息可能被不同工作线程先后写出。微信、QQ 这类商业 IM 的做法是每条消息带一个递增序号,接收端用序号做排序和去重。
课程设计里做到“去重”这一层已经足够拔高。实现思路是给PRIVATE帧增加seq字段,发送方本地维护一个自增计数器;接收方在客户端用TreeMap<Long, MessageFrame>做乱序缓冲。收到消息先放入缓冲,然后循环检查当前期望的nextSeq是否存在,存在就上屏并删除该条目。
private final TreeMap<Long, MessageFrame> pendingMessages = new TreeMap<>(); private long nextExpectedSeq = 1; public void onFrameReceived(MessageFrame frame) { long seq = frame.getSeq(); if (seq < nextExpectedSeq) { return; // 已处理过的重复消息,直接丢弃 } pendingMessages.put(seq, frame); while (pendingMessages.containsKey(nextExpectedSeq)) { MessageFrame ordered = pendingMessages.remove(nextExpectedSeq); SwingUtilities.invokeLater(() -> displayMessage(ordered)); nextExpectedSeq++; } }这个方案的代价是接收方要等待缺失的序号到达后才能显示后续消息,如果网络质量很差,聊天窗口会出现短暂停顿。课程设计环境下基本不会有严重丢包,所以这种等待是值得的,因为设计文档里可以明确写出“通过消息序号避免了网络重传导致的重复消息和顺序错乱”。
离线消息也建议沿用同一套seq逻辑:发送方把消息正文、消息序号和时间戳一起写入offline_message表,接收方登录后查询时按msg_time和seq排序后一次性加载。这样在线聊天和离线补拉两条路径采用了同一套消息编号规范,不会出现离线记录与在线记录排列顺序不一致的情况。实现到这一步,你的仿QQ聊天系统课程设计在功能完整性和工程规范性上,已经明显超过大多数同题目的提交作品了。
本文还有配套的精品资源,点击获取