
简介一份面向计算机专业课程设计的《仿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 MapString, 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|senderalice|receiverbob|time1712345678901|msg你好在吗 LOGIN|usernamealice|time1712345678901 LOGOUT|usernamealice|time1712345678901 PING|time1712345678901每条消息的第一个字段是消息类型后面是键值对参数。键值对的好处是扩展字段时不需要像位置参数那样把所有调用点都改一遍。比如以后要加消息 ID只需在末尾追加|msgIdxxx老客户端忽略这个字段即可。服务端解析时先用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, passwordLOGIN_OK服务端到客户端登录成功响应friendListLOGIN_FAIL服务端到客户端密码错误或账号不存在reasonPRIVATE双向一对一私聊sender, receiver, msg, timePING客户端到服务端心跳保活timePONG服务端到客户端心跳响应timeKICK服务端到客户端账号异地登录被踢reason4. 仿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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;密码字段存的是加盐后的哈希而不是明文。即使是课程设计也不应该用明文密码表交作业。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 中的参数很有讲究建议固定写上useSSLfalse和serverTimezoneAsia/Shanghai前者避免本地开发环境证书校验报错后者解决 MySQL 8.0 默认时区参数导致的日期时间错乱。public class DbUtil { private static final String URL jdbc:mysql://localhost:3306/qq_chat?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8; 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 时手动开线程是最直接的方式。好友列表的刷新也需要线程切换。登录成功后的全量好友列表、其他用户上线通知、其他人下线通知这三类消息都应该走同一个刷新方法避免逻辑分散。我习惯把好友列表设计成DefaultListModelString它的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), timeout5) login_frame fLOGIN|usernameuser{user_id}|time{int(time.time() * 1000)} sock.sendall((login_frame \n).encode(utf-8)) time.sleep(2) sock.sendall((fPING|time{int(time.time() * 1000)}\n).encode(utf-8)) time.sleep(1) sock.close() except Exception as e: print(fuser{user_id} error: {e}) threads [threading.Thread(targetclient_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 次注销数量一致即通过验证。参数方面timeout5是 socket 连接超时防止某个客户端连接卡死导致整个模拟脚本无法结束USER_COUNT按课程设计要求的规模调整一般在 50 以内就足够说明问题。5.2 为每条消息加序号解决乱序与重发TCP 保证字节流按序到达但应用层经过线程池分发后消息顺序不一定严格等于发送顺序尤其是服务端用多线程处理转发时两条消息可能被不同工作线程先后写出。微信、QQ 这类商业 IM 的做法是每条消息带一个递增序号接收端用序号做排序和去重。课程设计里做到“去重”这一层已经足够拔高。实现思路是给PRIVATE帧增加seq字段发送方本地维护一个自增计数器接收方在客户端用TreeMapLong, MessageFrame做乱序缓冲。收到消息先放入缓冲然后循环检查当前期望的nextSeq是否存在存在就上屏并删除该条目。private final TreeMapLong, 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聊天系统课程设计在功能完整性和工程规范性上已经明显超过大多数同题目的提交作品了。本文还有配套的精品资源点击获取