简介:这份源码资源面向Java网络编程学习者与分布式系统入门开发者,围绕UDP协议不可靠特性,给出了一套可运行的可靠通信系统完整实现,帮助读者理解序列号确认、超时重传、CRC校验与流量控制等核心机制。压缩包共132个文件,约1.13MB,以43个java源文件与63个class编译文件为主体,另含9个xml配置、3个jar依赖及少量gif、jpg等界面素材,Client与Server两端代码结构清晰,便于对照阅读。资源中客户端负责请求发起与响应处理,服务器端负责监听接收、确认应答与业务逻辑分发,涉及数据包序列化反序列化、确认机制与错误处理等关键环节。目前已有187人学习,适合希望从UDP底层出发掌握可靠传输设计思路、积累网络编程实战经验的读者参考。
1. 从一份 .rar 说起:UDP 不可靠,为什么还要用它做聊天系统
很多人第一次看到「基于 UDP 的可靠通讯系统」这个题目,第一反应是矛盾:UDP 本来就是不可靠的,既然要可靠,为什么不直接用 TCP?这个问题我在带课程设计时被问过不下二十次。答案其实很实在——UDP 没有连接握手、没有拥塞控制、没有内核级重传,延迟低、开销小,适合即时聊天、游戏同步、心跳广播这类场景;但业务层又确实需要「消息不丢、不乱序」,于是就得在应用层自己补一套确认与重传机制。这份java基于UDP协议的可靠通讯系统的设计与实现程序源码.rar拆开看,正是一个典型的 Java 课程设计级实现:客户端ChatClientFriendList、friendChat、otherOnline、delteFriend负责好友列表、单聊、在线状态和删好友,服务端ClientConnectionThread、ServerMessageThread负责连接维护与消息转发,DataPacket是自定义数据包,DAOImpl和XmlUtil负责持久化与配置解析。它适合正在做 Java 网络编程课程设计、想搞懂「UDP 上怎么自己造可靠性」的学生和初级工程师,也适合想拿一份能跑通的聊天 demo 改造成自己项目的开发者。下面我按「先讲清原理选型,再落到类结构和参数,最后说坑」的顺序拆一遍。
2. DataPacket 与确认重传:可靠层到底加在哪
2.1 为什么可靠逻辑必须收口到 DataPacket
UDP 的DatagramSocket.send()只保证「尽力发出去」,不保证到达、不保证顺序、不保证不重复。如果把这些逻辑散落在每个业务类里,friendChat要写一遍重传,otherOnline又要写一遍,维护成本直接爆炸。所以这份源码里DataPacket是整个可靠层的收口点——它把「业务数据」和「传输控制信息」打包成一个可序列化的对象,序列号、确认标志、重传次数、时间戳都挂在它身上。常见做法是让DataPacket实现Serializable,用ObjectOutputStream写进ByteArrayOutputStream,再塞进DatagramPacket发送。这样业务层只管调sendReliable(DataPacket),不用关心底层怎么重传。
一个合格的自定义包至少要有这几个字段,缺一个后面都会翻车:
| 字段 | 类型 | 作用 | 缺失后果 |
|---|---|---|---|
| seq | long | 发送序列号,唯一标识 | 无法去重、无法确认 |
| ack | long | 确认号,回带已收到的 seq | 发送端不知道对方收没收到 |
| flag | int | 数据/确认/心跳标志位 | 无法区分包类型 |
| timestamp | long | 发送时间,用于超时判断 | 无法计算 RTT、无法重传 |
| payload | byte[] | 真正的业务数据 | 没数据可传 |
| retryCount | int | 已重传次数 | 无限重传,死循环 |
2.2 序列号 + 确认 + 超时重传的最小实现
可靠性的核心就三件事:给每个包编号、收到就回确认、超时没确认就重发。下面是我按这份源码思路整理的一段可抄作业的核心逻辑,放在发送端线程里跑:
// 发送端:带确认与超时重传的可靠发送 public void sendReliable(DataPacket packet, DatagramSocket socket, InetAddress addr, int port) throws Exception { int maxRetry = 3; // 最大重传次数,超过就放弃并通知上层 int timeoutMs = 1000; // 单次等待确认的超时时间 packet.setTimestamp(System.currentTimeMillis()); for (int i = 0; i <= maxRetry; i++) { byte[] data = serialize(packet); // 序列化整个 DataPacket socket.send(new DatagramPacket(data, data.length, addr, port)); // 在超时窗口内等待对应 seq 的 ACK DataPacket ack = waitForAck(socket, packet.getSeq(), timeoutMs); if (ack != null && ack.getAck() == packet.getSeq()) { return; // 收到确认,发送成功 } packet.setRetryCount(i + 1); // 没收到,计数+1 后重发 } throw new IOException("packet " + packet.getSeq() + " 重传 " + maxRetry + " 次仍未确认"); }逻辑说明:serialize把DataPacket转成字节数组,waitForAck在timeoutMs内阻塞接收并比对ack字段是否等于自己发的seq。参数上,maxRetry设 3 是课程设计的常见折中——再多会让界面卡顿明显;timeoutMs设 1000ms 适合局域网,跨公网要放大到 2000~3000ms。这里有个关键点:waitForAck不能简单receive一次就返回,因为可能收到的是别人的包或重复 ACK,必须循环读到匹配的 seq 或超时为止,否则会出现「明明对方收到了却还在重传」的玄学现象。
2.3 接收端去重与 ACK 回送
接收端要做两件事:判断这个 seq 是不是已经处理过,没处理过就交给业务层并回 ACK,处理过就只回 ACK 不重复处理。源码里ClientConnectionThread和ServerMessageThread承担了这个职责。维护一个Set<Long> receivedSeqs或滑动窗口即可,简单场景用HashSet就够,但要注意它非线程安全,多线程接收时得用ConcurrentHashMap.newKeySet()。回 ACK 时把flag置为确认类型、ack设为刚收到的 seq,原路发回。这一步不做,发送端就会一直重传,最后抛异常,聊天窗口表现为「消息发出去了但对方没收到,自己这边还报错」。
3. 客户端与服务端类结构:从 ClientConnectionThread 到 friendChat
3.1 服务端:ClientConnectionThread 与 ServerMessageThread 的分工
服务端不是「一个 socket 收所有」,而是每个客户端连接对应一个ClientConnectionThread,这个线程负责持续receive该客户端发来的DataPacket,解析后交给ServerMessageThread做消息路由。ServerMessageThread更像一个消息总线:它持有所有在线客户端的连接引用(通常是一个Map<String, ClientConnectionThread>,key 是用户名或 IP:端口),收到friendChat类型的包就查目标用户,转发过去;收到otherOnline就更新在线列表并广播。这种「一连接一线程 + 一路由线程」的结构在课程设计里足够清晰,但要注意线程数量随在线人数线性增长,几十人没问题,上百人就要考虑线程池或 NIO。
启动服务端的骨架大概是这样:
// 服务端启动:绑定端口,循环接收新客户端 public class ChatServer { private static final int PORT = 8888; private static Map<String, ClientConnectionThread> onlineClients = new ConcurrentHashMap<>(); public static void main(String[] args) throws Exception { DatagramSocket serverSocket = new DatagramSocket(PORT); System.out.println("UDP 聊天服务端已启动,端口 " + PORT); while (true) { byte[] buf = new byte[4096]; // 缓冲区要够大,装得下序列化后的 DataPacket DatagramPacket raw = new DatagramPacket(buf, buf.length); serverSocket.receive(raw); // 阻塞等待任意客户端的数据 DataPacket packet = deserialize(raw.getData()); // 反序列化 String clientKey = raw.getAddress() + ":" + raw.getPort(); // 首次出现的客户端,创建专属处理线程 onlineClients.computeIfAbsent(clientKey, k -> { ClientConnectionThread t = new ClientConnectionThread(raw.getAddress(), raw.getPort(), serverSocket); t.start(); return t; }); ServerMessageThread.route(packet, clientKey); // 交给路由线程处理 } } }逻辑说明:buf设 4096 是经验值,序列化后的DataPacket含对象头通常几百字节,4096 能覆盖绝大多数文本消息;如果传文件或长消息要调到 8192 甚至更大,否则receive会截断数据导致反序列化失败。computeIfAbsent保证同一客户端只创建一个线程,避免重复连接。route里根据packet.getFlag()分派到不同业务处理,这是整个服务端的中枢。
3.2 客户端:ChatClientFriendList 与 friendChat 的协作
客户端侧,ChatClientFriendList是主界面和连接管理器,负责初始化DatagramSocket、启动接收线程、维护好友列表;friendChat是单个聊天窗口,发送消息时构造DataPacket调可靠发送,接收时由主接收线程根据来源分发到对应窗口。otherOnline处理在线状态刷新,delteFriend处理删除好友的本地和服务端同步。这里的关键设计是:客户端只有一个DatagramSocket,所有窗口共用,接收线程收到包后按flag和来源分发给不同窗口,而不是每个窗口各开一个 socket——后者会导致端口冲突和消息错乱。
发送一条聊天消息的典型流程:
// 客户端发送聊天消息 public void sendChatMessage(String toUser, String content) throws Exception { DataPacket packet = new DataPacket(); packet.setFlag(DataPacket.FLAG_CHAT); // 标记为聊天消息 packet.setSeq(seqGenerator.incrementAndGet()); // 全局自增序列号 packet.setFrom(currentUser); packet.setTo(toUser); packet.setPayload(content.getBytes(StandardCharsets.UTF_8)); InetAddress serverAddr = InetAddress.getByName(SERVER_IP); reliableSender.sendReliable(packet, socket, serverAddr, SERVER_PORT); }逻辑说明:seqGenerator用AtomicLong保证多窗口并发发送时序列号不重复,这是很多人会忽略的点——用普通long自增在并发下会产生重复 seq,接收端去重逻辑就会误判丢包。payload显式指定 UTF-8,避免中文在不同平台默认编码下变乱码。sendReliable复用第 2 章的可靠发送逻辑,业务层不感知重传细节。
3.3 DAOImpl 与 XmlUtil:持久化与配置别写死
DAOImpl负责好友关系、聊天记录、用户信息的存取,XmlUtil负责读写 XML 配置(比如服务器 IP、端口、数据库连接)。课程设计里常见做法是用 XML 存配置、用文件或嵌入式数据库存数据。这里要提醒的是:不要把服务器 IP 和端口硬编码在ChatClientFriendList里,否则换台机器就要重新编译。正确做法是启动时用XmlUtil读config.xml,把 IP、端口、超时时间、最大重传次数都做成可配置项。DAOImpl的接口设计要面向接口而非实现,方便后面从文件存储换成数据库存储时不改业务代码。
4. 避坑与排查:UDP 可靠通信最容易翻车的五个点
4.1 现象:消息偶尔丢失,重传也没用
原因:DatagramSocket的接收缓冲区太小,或者receive用的byte[]数组小于实际包大小,导致数据被截断,反序列化抛异常被 catch 后静默丢弃。解决:把接收缓冲区设到 8192 以上,并在deserialize失败时打印日志而不是吞掉异常;同时检查DatagramPacket构造时的长度参数是否用了data.length而非固定值。
4.2 现象:中文消息变乱码或问号
原因:发送端用平台默认编码getBytes(),接收端用另一种编码new String(),两边不一致。解决:收发两端统一显式指定StandardCharsets.UTF_8,序列化DataPacket时也用 UTF-8 写字符串字段。这个问题在 Windows 开发、Linux 部署时特别容易暴露,因为两边默认编码不同。
4.3 现象:重传风暴,一个包发了几十次
原因:waitForAck没有正确匹配 seq,或者 ACK 包本身也走了可靠发送导致递归重传。解决:ACK 包必须走「不可靠发送」,即直接socket.send()不等待确认;waitForAck要循环读取直到匹配 seq 或超时,不能读一次就判定失败。另外maxRetry要有上限,超过就通知上层而不是无限重试。
4.4 现象:多客户端在线时服务端 CPU 飙高
原因:每个ClientConnectionThread都在死循环receive,没有阻塞或休眠,空转消耗 CPU。解决:receive本身是阻塞的,正常不会空转;如果 CPU 高,检查是不是在循环里做了忙等待(比如while(!hasData){})。正确做法是让receive阻塞,收到数据才唤醒处理。另外线程数过多时考虑用线程池替代一连接一线程。
4.5 现象:好友列表刷新后在线状态不对
原因:otherOnline的广播包丢失后没有补偿机制,或者客户端本地缓存没更新。解决:在线状态这类「最终一致」的数据,除了实时广播,还要在客户端登录时主动拉一次全量在线列表;广播包丢失时下次心跳或拉取会纠正。不要指望单次 UDP 广播一定到达。
5. 进阶:把这份源码改成能扛住真实网络的版本
课程设计能跑通局域网,但放到真实网络环境,还有几处要加固。第一,超时时间要动态计算。固定 1000ms 在局域网够用,跨网络要基于 RTT 自适应,常见做法是维护一个平滑 RTT(类似 TCP 的 SRTT),超时设为SRTT * 2,这样网络抖动时不会频繁误重传。第二,序列号要用滑动窗口而非单个等待。现在的实现是「发一个等一个」,吞吐量低;改成窗口大小 N,可以连续发 N 个包再统一处理 ACK,吞吐能提升数倍。第三,加心跳保活。UDP 没有连接概念,NAT 映射会超时失效,客户端要定期发心跳包维持通道,服务端超过一定时间没收到心跳就标记离线。第四,DataPacket的序列化建议从 Java 原生序列化换成更紧凑的格式,原生序列化体积大、跨语言差,真实项目里常用 Protobuf 或自定义二进制协议。
验证改造是否成功,我一般会做三组测试:正常局域网下连续发 1000 条消息,统计丢失率和平均延迟;用工具模拟 10% 丢包和 200ms 延迟,看重传是否生效、消息是否最终全部到达;同时开 20 个客户端,观察服务端 CPU 和内存是否稳定。这三组过了,基本就能说明可靠层是有效的。
最后说个我自己的习惯:每次改完可靠层参数,我都会先把maxRetry临时设成 1、timeoutMs设成 200ms,人为制造重传,确认重传路径真的被触发、日志真的打出来,再改回正常值。因为可靠逻辑最怕的就是「看起来在跑,其实重传分支从来没进过」,等真丢包时才发现是黑匣子。从那以后我每次调网络参数都强制走一遍这个「先制造失败再验证恢复」的流程。希望帮到你。
本文还有配套的精品资源,点击获取