简介:这是一份基于Java的即时通信软件毕业设计文档,面向计算机相关专业毕业生、Java初学者及需要完成课程设计的开发者,可用于快速梳理即时通信系统的选题思路与开发全流程。文档从课题研究背景与关键技术切入,详细介绍了Socket网络编程、多线程并发处理、TCP/IP协议及数据加密等实现要点,并给出完整的系统需求分析、C/S架构设计、异步通信方式以及数据库概要设计,涵盖用户表、好友表、在线状态表、离线信息表等核心结构。同时,文档对系统设计进行了深入拆解,包括服务器端与客户端的功能模块划分、注册登录模块、消息协议定义、离线消息存储与转发机制,以及线程池优化、敏感信息加密等实际开发中的注意事项;全文章节结构完整,从绪论、需求分析到详细设计与实现均有涉及。资源包为单个doc文件,共1个文档,压缩包大小638KB,内容结构清晰、目录完整,便于直接查阅和二次编辑。目前已有110人学习,适合正在准备毕业设计或希望系统掌握即时通信软件设计方法的人参考。
1. 从局域网聊天的需求说起:为什么选 Java C/S 而不是 B/S
即时通信软件听起来门槛不高,但真要从零实现一套可用的局域网聊天工具,技术点并不少:连接管理、并发读写、消息协议、离线存储,任何一个环节没想透,联调时都会回来找你。这个项目用纯 Java 实现了完整的 C/S 架构即时通信系统,服务端负责用户认证、好友关系维护和消息中转,客户端负责界面展示和点对点直连。它没有用 WebSocket,也没有引入 Netty,所有通信都建立在 Java Socket 之上,对理解 TCP 编程和线程模型非常有帮助。如果你正在做 Java 网络编程相关的课程设计,或者想把多线程、数据库、GUI 开发串成一个大作业,这个项目拆解下来的方案可以反复套用。
2. 通信骨架:Socket、线程模型与消息协议设计
2.1 登录信令与好友列表的拉取流程
系统采用经典的 C/S 双端结构,服务器启动后监听固定端口,客户端通过 Socket 发起 TCP 连接。登录流程不是一次性请求/响应,而是拆成两段:客户端先发送封装好的账号密码,服务器验证成功后,再把该用户的好友列表、在线好友 IP 和端口号一次性回传。这里的「好友列表」并不直接从数据库灌给客户端,而是通过ArrayList<FriendModel>组装成模型对象,再经SendModel.sendFriList()序列化发送。
之所以这样设计,是为了让客户端不需要直接访问数据库,所有数据访问都收敛在服务器端。常见做法是让LoginListener线程专门接收登录包,验证通过后同时执行三件事:写入登录表、刷新服务器端表格、推送好友列表。你可以把它想象成一次「握手 + 资源下发」,后续聊天不再经过这个入口。
// 伪代码示意:登录成功后的资源下发 LoginModel lm = parseLoginPacket(inputStream); UserModel user = loginDao.findByNumAndPass(lm.getUserNum(), lm.getPass()); if (user != null) { win.getDao().addLoginUser(lm); // 写入服务器端登录表 ArrayList<FriendModel> friList = win.getDao().getFriendsList(lm.getUserNum()); SendModel.sendFriList(lm, friList); // 下发好友列表 }这里的addLoginUser负责记录当前在线会话,getFriendsList通过用户编号查出好友及在线状态,sendFriList把列表数据写入输出流。注意登录包解析时不要直接在业务线程里做复杂 SQL,否则多个用户同时登录时会出现明显的卡顿。
2.2 在线直连与服务器中转的两条消息通道
聊天消息的发送路径有两条:在线直接通讯和服务器代理通讯。在线直接通讯利用登录阶段拿到的对方 IP 和端口,客户端直接用新 Socket 把消息发到对端 PC,服务器不参与消息内容转发,这种方式延迟低,适合局域网内网环境。但是,如果两台机器之间有防火墙限制,或者点对点连接长时间建不起来,就自动降级为服务器中转,也就是所有消息先发给服务端,再由服务端转发给接收方。
这个设计思路其实和早期的 QQ 非常相似,核心价值在于容错。实现时需要注意,点对点直连使用的是客户端之间约定的 TCP 端口,这个端口必须与服务器监听端口区分开,否则会冲突。判断是否直连成功,可以设置连接超时,超时后立刻走中转通道,保证用户无感知。
# 查看端口监听状态,确认服务端与点对点端口不冲突 netstat -an | grep -E "LISTEN|ESTABLISHED"Linux 下用该命令可以看到当前所有 TCP 连接状态,如果你在 Windows 上调试,把grep换成findstr。正常运行时,服务器端口和点对点端口应该同时处于监听或已建立连接的状态。如果只有服务器端口在监听,说明所有消息都走了中转通道,需要检查客户端是否拿到了好友的真实 IP。
2.3 消息协议字段设计:从地址到消息标识
项目原文对消息协议提出了明确要求:消息格式必须包含「让接收者可以回消息的地址、发信者和即时收件箱的标识」,并且允许对信息有效负载进行编码和鉴别。落到实现上,我一般会用自定义的文本协议,每一行是一个字段,用分隔符切分,避免引入复杂的序列化框架。常见的协议包可以这样组织:
| 字段名 | 类型 | 含义 | 示例 |
|---|---|---|---|
| type | int | 消息类型:1 登录,2 文本,3 好友列表 | 2 |
| fromUserId | int | 发送方账号 | 10001 |
| toUserId | int | 接收方账号 | 10002 |
| ip | String | 发送方 IP,用于回包 | 192.168.1.10 |
| port | int | 点对点监听端口 | 9000 |
| content | String | 消息正文 | hello |
| timestamp | long | 发送时间戳,用于防重放 | 1712345678901 |
// 发送消息时组装协议包 String packet = String.join("|", "2", String.valueOf(fromUserId), String.valueOf(toUserId), ip, String.valueOf(port), content, String.valueOf(System.currentTimeMillis()) ); outputStream.write(packet.getBytes("UTF-8"));字段之间用|分隔,接收端按split("\\|")解析。注意content里如果包含|需要做转义或使用更长的分隔符,否则解析会错位。时间戳字段的意义在于幂等——客户端收到消息后可以根据时间戳去重,避免重复消息弹出多个窗口。
3. 数据库与后端落地:Oracle 表结构与服务器端线程实现
3.1 五张核心表设计:用户、好友、在线状态、登录记录、离线消息
数据库选用 Oracle 10g,客户端工具是 PLSQL Developer。表设计遵循第三范式,把用户、好友关系、在线状态、登录会话、离线消息拆成五张表。其中用户表是主表,好友表通过外键关联用户编号,在线状态表用typeid区分在线、离线和隐身,登录表记录每次会话的 IP 和时间,离线信息表则用于存储用户不在线时别人发来的消息。
-- 用户表 CREATE TABLE user_info ( user_num NUMBER(10) PRIMARY KEY, user_name VARCHAR2(20) NOT NULL, pass VARCHAR2(20) NOT NULL, desc_info VARCHAR2(100), sex NUMBER(1), birthday DATE, olpic BLOB, ofpic BLOB, mespic BLOB ); -- 好友表 CREATE TABLE friends ( friid NUMBER(10) PRIMARY KEY, user_num NUMBER(10) NOT NULL, frinum NUMBER(10) NOT NULL, CONSTRAINT fk_friends_user FOREIGN KEY (user_num) REFERENCES user_info(user_num) );这里的user_num作为用户编号主键,通过序列sq_user.nextval自动生成。好友表用friid做主键,user_num和frinum分别表示主人和好友的账号,外键约束确保不会引用不存在的用户。头像字段使用BLOB类型存储图片二进制数据,这在课程设计中很常见,但生产环境更推荐只存文件路径,把图片放到文件服务器或对象存储,避免数据库膨胀。
3.2 ServerSocket 与双监听线程:LoginListener 和 MesListener
服务器端的实现基于ServerSocket,主线程负责侦听客户端连接,每接受一个连接就new ServerThread(socket)创建一个线程。原文中提到了LoginListener和MesListener两个线程类,前者处理登录逻辑,后者处理消息中转和离线存储。这种按职责拆线程的方式比单一线程处理所有包更容易维护。
ServerSocket server = new ServerSocket(8888); while (true) { Socket socket = server.accept(); new Thread(() -> { try { InputStream in = socket.getInputStream(); OutputStream out = socket.getOutputStream(); // 根据首包类型分发给 LoginListener 或 MesListener } catch (IOException e) { e.printStackTrace(); } }).start(); }上面的代码展示了一个最朴素的「每连接一线程」模型,适合并发量不大的场景。注意accept()是阻塞调用,程序会停留在这一行直到有客户端连接。每个客户端连接进来后,需要先读首包判断是登录请求还是消息请求,再交给你定义的 handler 处理。实际使用中不要直接在匿名线程里写大段业务逻辑,最好封装成ServerThread类,便于扩展。
3.3 注册和登录的 SQL 实现与注入边界
用户注册模块的核心逻辑是先把注册信息封装成对象,再插入数据库。原文给了一个很典型的LoginId方法,直接拼接字符串执行 SQL。这种做法在毕业设计里能跑通,但存在 SQL 注入风险,面试时常常会被问到。
public void LoginId(String userName, String pass, String desc, int sex, String birthday) { String sql = "insert into user_info values(sq_user.nextval, '" + userName + "','" + pass + "','" + desc + "','" + sex + "'," + "to_date('" + birthday + "','yyyy-mm-dd'),null,null,null)"; dbutil.executeDML(sql); }这里把用户名、密码、签名、性别、生日直接拼进 SQL,如果userName里包含了单引号,比如admin' --,就会破坏 SQL 结构,甚至被用来绕过验证。更稳妥的做法是用PreparedStatement加占位符,或者至少对单引号做转义。登录验证模块的逻辑是查询账号密码是否匹配,同样建议参数化查询。你可以把这一点写在文档的「安全性分析」里,作为与普通课程设计拉开差距的亮点。
4. 客户端实现:界面事件、好友状态与聊天收发
4.1 登录按钮背后的监听逻辑
客户端用JFrame构建登录窗口,按钮事件通过LoginFrameHandler实现ActionListener接口。用户点击登录时,监听器从输入框取账号、密码和在线状态,封装成LoginModel,然后启动一个线程把登录包发给服务器。为什么不能用主线程发送?因为网络阻塞会让界面卡死,用户点击后窗口会无响应。
public class LoginFrameHandler implements ActionListener { public void actionPerformed(ActionEvent e) { String num = userField.getText(); String pass = passField.getText(); // 封装登录模型,经 Socket 发送 LoginModel lm = new LoginModel(num, pass, 12); new Thread(() -> socketManager.sendLogin(lm)).start(); } }代码里用new Thread(...).start()将网络发送放到独立线程,这样 UI 线程能立刻返回,界面不会卡住。12对应在线状态表的「在线」,10是离线,11是隐身。这个状态值是前后端约定好的常量,在客户端和服务器端都要定义一份,避免魔法数字散落在业务逻辑中。
4.2 好友列表与头像状态更新
登录成功后,服务器返回好友列表,客户端把这些数据填充进 JTable 或 JList。为了体现在线状态,客户端需要根据每个好友的在线标识动态切换头像图标。常见做法是提前把离线头像和在线头像加载到内存 Map 里,渲染时直接查表取图片,避免每次重绘都做磁盘 IO。
// 根据在线状态选择头像 ImageIcon icon = onlineMap.get(friend.getUserNum()); if (icon == null) { icon = offlineIcon; } listCellRenderer.setIcon(icon);这里的onlineMap是本地缓存,键是好友编号。如果服务器下发好友列表时携带了 IP 和端口,客户端还可以维护一张「可直连好友」列表,双击头像时优先尝试点对点连接。头像闪动效果可以通过一个定时器周期性切换消息头像和普通头像实现,注意定时器线程结束后必须释放,否则关掉聊天窗口后仍然在闪。
4.3 聊天窗口的发送、接收与离线补拉
聊天窗口的核心是发送和接收两个动作。发送时先判断好友是否在线、是否可直连,如果可直连则直接用点对点 Socket 发送,否则发给服务器中转。接收方收到消息后,要判断当前用户是否在聊天界面中,如果在则直接打印到文本区,否则只在好友条目上闪动头像。离线消息的处理是收到登录成功通知后,主动从服务器拉取离线表里的留言。
// 接收消息并处理 String line = bufferedReader.readLine(); String[] parts = line.split("\\|"); if ("2".equals(parts[0])) { String content = parts[5]; chatArea.append("好友说: " + content + "\n"); }这段代码放在客户端的消息接收线程里,每次读到一行就解析,按type字段分发。注意readLine()是阻塞的,所以接收线程通常用while (true)循环持续读。如果服务器端没有发送换行符,readLine()会一直等不到数据,这就是常见的「客户端收不到消息」的坑,排查时要先用 TCP 调试工具确认服务器确实发出了\n。
5. 验证与进阶:从课程设计到可工程化的即时通信
5.1 用测试案例验证登录与消息闭环
系统测试不能只靠手工点界面。至少要准备三类用例:正常登录、错误密码登录、离线消息补拉。正常登录要验证好友列表数量正确,错误密码要验证返回「登录失败」且服务器端登录表不增加记录。离线消息的测试比较隐蔽,建议这样操作:先用 A 账号登录,B 账号不登录,用 A 给 B 发消息,再登录 B,检查是否收到离线留言。
// 测试离线消息补拉 @Test public void testOfflineMessage() throws Exception { // 模拟 B 不在线 sendMessage(10001, 10002, "Hello, offline"); // B 登录后应收到该消息 List<String> messages = getOfflineMessages(10002); assertTrue(messages.contains("Hello, offline")); }这个测试的核心是验证服务器端在转发失败时把消息写入了离线信息表,并在用户登录后清空该表。实际项目中可以用 JUnit 写类似的集成测试,启动服务器后通过客户端 API 直接操作,避免依赖 GUI 自动化。
5.2 线程池替代每连接一线程,面试里会追问的细节
原代码的new ServerThread(socket)在低并发下没问题,但每个连接占用一个线程,Java 默认线程栈大小约 1MB,连接数一多内存就会吃紧。更合理的做法是用线程池,配合Callable或Runnable接口管理异步任务。下面是改造后的服务器主循环:
ExecutorService pool = Executors.newFixedThreadPool(16); while (true) { Socket socket = server.accept(); pool.submit(new ClientHandler(socket)); }线程池大小不是随便定的,一般按CPU 核心数 + 1作为最小值。IO 密集型任务可以适当加大线程数,但要注意队列积压会带来延迟。面试官如果追问「线程池拒绝策略」,你要能答出CallerRunsPolicy和AbortPolicy的区别。这里只是把连接处理放到了池子里,心跳检测和消息推送仍然需要你自己实现。
5.3 心跳机制与半包处理:最后一个必须掌握的坑
原系统没有心跳机制,如果客户端断电退出,服务器无法及时感知连接断开,数据库的登录表里会残留僵尸记录。常见做法是每隔 30 秒发送一个type=0的心跳包,服务器 3 次没收到就主动断开并清理登录状态。另外,TCP 是流式协议,readLine()可能读完半个消息,所以在消息边界处理上,自定义协议最好加上\r\n结尾,并在解析时按分隔符切割完整包。
private String readPacket(BufferedReader reader) throws IOException { StringBuilder sb = new StringBuilder(); String line; while ((line = reader.readLine()) != null) { sb.append(line); if (line.endsWith("</msg>")) { // 自定义结束标记 break; } } return sb.toString(); }这段代码是半包处理的简易示例,读完一行后检查是否包含结束标记。如果你的消息内容本身可能包含标记,就要在协议层增加长度字段,先读固定的 4 字节长度,再读对应字节数。这个技术点在 Java 网络编程面试中几乎必考,也是从「能跑」走向「可用」的关键分水岭。把这几个点补进设计文档里,整个项目的完成度会明显上一个台阶。
本文还有配套的精品资源,点击获取