简介:广工计算机网络课程设计项目:基于P2P的局域网即时通信系统,面向需要完成类似课设的高校计算机专业学生,解决局域网内用户发现、消息发送与文件传输的完整实现问题。系统以Java构建图形界面,具备用户注册、在线对等方扫描(3333端口)、TCP连接后消息与文件发送,并能展示对等方列表、聊天记录及传输进度,覆盖了课设要求的主要功能点。压缩包共158个文件,以.java源码、.class字节码、XML/Properties配置文件、Eclipse工程文件及界面资源为主,整包约20.77MB,目录结构清晰,适合直接导入开发环境进行阅读和二次修改。目前已有424人学习下载。通过研读源码与工程配置,可掌握P2P通信模型、Socket编程、多线程处理和GUI事件驱动设计等关键技能,无论是作为课设参考还是网络编程入门实践,都具有较强的借鉴价值。
1. 基于P2P的局域网即时通信系统:计网课设到底在考你什么
广工的计网课设要求在局域网里实现一个基于P2P的即时通信系统,服务端口明确写成3333。你千万别把它当成一个普通聊天软件来写,它真正考核的是计算机网络里两个最核心的动作:对等方如何发现彼此,以及两个节点之间如何可靠地传消息和文件。整个系统没有中心服务器,每个节点既要主动发请求,又要被动处理别人的请求,所以程序本身既是服务器又是客户。适合正在做计网课设、对着题目不知道先从哪下手的人参考。我拆这份资源的时候,把UDP广播、TCP长连接、Swing多线程这几个点按课设验收顺序过了一遍,下面从发现协议开始讲。
2. 对等方发现机制:UDP广播扫描与3333端口的应答协议
2.1 为什么选UDP广播而不是组播或手动添加
P2P系统里第一步要解决的问题是:新启动的节点怎么知道局域网里有哪些对等方在线。课设题目原话是“扫描网段中在线的对等方(3333端口打开)”,这本质上是一个自动发现机制。常见做法是用UDP广播包发到255.255.255.255:3333,所有监听3333端口的节点都能收到注册消息。选UDP广播有三个理由:局域网范围有限,广播包不会跨路由器泛滥,物理上可控;实现代码只有几行,DatagramSocket发出去就行;所有节点都监听同一个端口,不需要额外维护主机清单。
组播看起来更“专业”,但课设环境里要额外考虑组播地址、TTL和路由器IGMP行为,反而容易把答辩时间耗在配置上。手动添加主机表虽然能绕开网络编程,但缺失了计网课设最想考察的“对等方发现”环节。用UDP广播还有一个隐形好处:广播包是UDP,天然不可靠,这正好可以和后面TCP文本、文件传输形成对比,答辩时老师问“为什么消息用TCP而发现用UDP”,你能答出可靠性与开销的权衡。
需要提醒的是,本机如果有多个网卡,比如笔记本同时开着无线和有线,直接new DatagramSocket()发送广播包可能从错误的网卡出去,导致同网段的其他机器收不到。我一般会遍历NetworkInterface获取第一个siteLocal地址,然后显式绑定到这个地址上再发广播。单网卡桌面机不用管这一点,但虚拟机和物理机混着调试时,这条经验往往能让广播立刻恢复正常。
2.2 注册消息的格式设计与广播发送
发现机制的核心不是发送,而是消息格式。课设允许自己定义格式,但至少要包含用户名和IP。我拆到的项目里恰好有SendIPtoLAN.class和ReceiveIP$GetPacket.class,前者管广播发送,后者管接收解析,说明资源和这个机会点正好对上了。
消息格式我用一行文本,竖线分隔字段,类型在前:
String regMsg = "REG|" + userName + "|" + groupName + "|" + myIP + "|3333"; byte[] payload = regMsg.getBytes("UTF-8"); DatagramPacket packet = new DatagramPacket(payload, payload.length, InetAddress.getByName("255.255.255.255"), 3333); socket.send(packet);REG代表这是一个注册请求,紧接着是用户名、所在组、本机IP、服务端口3333。接收端收到后按竖线拆字段,就能把发送方加入自己的对等方列表。之所以把端口也写进消息里,是为后续扩展留余地;当前课设服务端口固定3333,不加也可以,但加了能让协议更完整,答辩时也多一个可讲的点。
解析时有一个细节容易翻车:split("|")在Java里是正则表达式,直接写会拆不对,必须写成split("\|")。如果用户名里带竖线,消息格式就会错乱,所以我在注册界面会限制用户名不能包含|字符。这属于“不写不知道,一写就报错”的典型问题。另一个值得注意的地方是编码,所有收发都用UTF-8,避免中文用户名在跨平台传输时变成乱码。
发送广播包之前,还有两个Socket参数需要设置。第一个是setBroadcast(true),不设置的话发送广播包会被系统丢弃;第二个是setSoTimeout(3000),让接收线程在收不到ACK时最多等3秒,不会永远阻塞。收ACK的过程我一般放在发送端同一个线程里,限时3秒:
socket.setSoTimeout(3000); long deadline = System.currentTimeMillis() + 3000; while (System.currentTimeMillis() < deadline) { DatagramPacket resp = new DatagramPacket(new byte[1024], 1024); socket.receive(resp); String text = new String(resp.getData(), 0, resp.getLength(), "UTF-8"); if (text.startsWith("ACK|") && !text.contains(myIP)) { addPeer(resp.getAddress().getHostAddress(), text); } }这里收到ACK后,不能直接使用DatagramPacket里携带的源地址作为对等方IP。虽然大多数情况下源地址就是对方IP,但广播包经过某些特殊网络环境时,源IP可能被改写。更稳妥的办法是从ACK消息内容里再读取一次IP字段,或者至少对比一下resp.getAddress()和内容里的IP是否一致。课设环境下两者通常一致,但我在编写时还是习惯以消息体里的IP为准。
2.3 接收端应答:单播ACK避免广播风暴
接收端收到REG包后,要把发送方加入用户列表,再回一个ACK。这里最常见的大坑是:为了省事,把ACK也广播回255.255.255.255。一旦两端都这么写,A广播REG,B广播ACK,A收到ACK又广播一个什么消息,B再广播……消息成指数增长,局域网瞬间被无用包塞满,表现为所有聊天窗口卡顿、消息延迟暴涨。
正确做法是从REG包的源地址里取出对方地址,用单播把ACK发回去:
DatagramPacket req = new DatagramPacket(new byte[1024], 1024); socket.receive(req); String text = new String(req.getData(), 0, req.getLength(), "UTF-8"); if (text.startsWith("REG|")) { String[] parts = text.split("\\|"); // parts[0]=REG, parts[1]=用户名, parts[2]=组名, parts[3]=IP String ackMsg = "ACK|" + userName + "|" + groupName + "|" + myIP; byte[] ackBytes = ackMsg.getBytes("UTF-8"); DatagramPacket ackPacket = new DatagramPacket(ackBytes, ackBytes.length, req.getSocketAddress()); socket.send(ackPacket); }req.getSocketAddress()会保留请求方的IP和端口,比手动InetSocketAddress拼接可靠得多。这样ACK只发给发起请求的节点,不会干扰网络上其他机器。接收方收到ACK后,需要过滤掉自己:如果ACK里的IP和本机IP相同,说明这是网卡回环或广播包被本机协议栈带回,应当丢弃。我之前的代码用text.contains(myIP)做粗过滤,多网卡下可能误判,但课设单网卡演示足够。
UDP发现协议到这里就可以跑通了。为了增加成功率,我还会在连续广播两次之间间隔500毫秒,总扫描时长控制在3秒内。不要用死循环发广播,否则同一台机器上多个实例会互相干扰。资源里的ReceiveIP$GetPacket.class、SendIPtoLAN.class基本就是这套逻辑的类级划分,拿着源码和下面的表对照,很快能理顺字段含义。
| 字段位置 | 字段名 | 示例值 | 用途 |
|---|---|---|---|
| 第1字段 | 消息类型 | REG/ACK | 区分注册请求与应答 |
| 第2字段 | 用户名 | ZhangSan | 显示在好友列表 |
| 第3字段 | 所在组 | 计网课设组 | 分组显示 |
| 第4字段 | IP地址 | 192.168.1.23 | 后续建立TCP连接 |
| 第5字段 | 服务端口 | 3333 | 后续连接端口 |
3. 用户列表与聊天窗口:Swing界面与TCP连接怎么配合
3.1 主窗口组件划分和双击打开聊天窗口
课设界面上需要三个区域:对等方列表、消息显示列表、消息输入框,再加上文件传输显示和操作按钮。用Java Swing实现时,我建议把主窗口设计成左右结构,左侧是JList显示对等方列表,右侧放聊天相关的Tab页或单独弹窗。资源里有个类名叫friendlist_doubleclick.class,说明这个项目给对等方列表加了双击事件,双击用户时新建ChatWindow,而不是把消息都堆在主窗口里。这样每个聊天对象独立,后续文件传输入口和进度条也有地方放。
列表数据建议用DefaultListModel来管理,它自带线程安全的内容更新方法,比直接操作数组省心不少:
DefaultListModel<String> peerModel = new DefaultListModel<>(); JList<String> peerList = new JList<>(peerModel); peerList.addMouseListener(new MouseAdapter() { public void mouseClicked(MouseEvent e) { if (e.getClickCount() == 2) { String item = peerList.getSelectedValue(); if (item != null) { openChatWindow(item); } } } });鼠标事件里判断getClickCount() == 2是为了避免单击和双击都触发打开窗口。openChatWindow可以根据选中的字符串解析出IP,然后传入ChatWindow的构造方法。常见做法是把用户ID或IP作为聊天窗口的唯一标识,避免同一个用户被双击两次时打开多个窗口。你可以用一个HashMap维护“用户IP -> 聊天窗口”的映射,已有窗口时直接setVisible(true),没有时才new ChatWindow。
3.2 聊天窗口的TCP连接:Object流与发送顺序
聊天窗口创建时,需要和对方建立一条TCP连接。连接细节上,每次聊天窗口都新建一个Socket,用完关闭,不搞长连接池。对单个课设来说,每条聊天独立连接即可,结构清晰,也方便排查问题。
使用Java对象流传输消息是常见做法。在连接建立后,先创建ObjectOutputStream,再创建ObjectInputStream,这个顺序不能反。ObjectOutputStream的构造方法会先写一个序列化头,如果接收端先创建ObjectInputStream,会卡在读序列化头上。正确姿势是两端都先out后in,或者配合flush()强制写出头信息。
聊天消息至少要有类型、发送者、内容三个信息。我一般定义一个Message类:
public class Message implements Serializable { public int type; // 0=文本,1=文件头,2=文件块 public String sender; // 发送者用户名 public String content; // 文本内容或文件名 public byte[] data; // 文件块数据 public long length; // 文件总长度或块长度 }Message必须实现Serializable,否则writeObject直接抛NotSerializableException。字段全部public是为了课设代码简洁,生产环境会加私有字段和getter,但计网课设更看重协议设计,不看重封装技巧。
发送文本消息时,从输入框取内容,trim去首尾空格,空消息直接return。然后填type=0、sender为本机用户名、content为输入内容,通过ObjectOutputStream发出去。发送完成后清空输入框,焦点还给输入框,方便连续输入。接收线程收到type=0的消息后,用SwingUtilities.invokeLater更新聊天窗口文本区。
3.3 UI更新必须在事件调度线程里做
Swing的线程模型是单线程UI模型,所有组件更新都必须在事件调度线程EDT上执行。Socket接收线程在后台跑,如果直接把收到的内容追加到JTextArea,轻则不刷新,重则抛ConcurrentModificationException或界面卡死。我一般把所有更新都包在invokeLater里:
SwingUtilities.invokeLater(() -> { chatArea.append(msg.sender + ": " + msg.content + "\n"); chatArea.setCaretPosition(chatArea.getDocument().getLength()); });append后面跟上setCaretPosition,让滚动条自动滚到最新一行,否则消息多了以后,用户需要手动往下翻才能看到新消息。这个细节不难,但很影响演示体验。在界面初始化时,最好也把聊天记录区设置为不可编辑,只通过append追加内容,否则用户不小心按到Backspace会删掉聊天记录。
关于“对等方列表为什么有时候不刷新”,我排查过很多次,绝大多数不是因为Socket没收到数据,而是更新model时没进EDT。比如UDP接收线程直接调用peerModel.addElement(),界面过一会儿才更新,或者干脆不更新。正确做法同样是invokeLater。把网络接收和UI更新彻底分开,这份耐心能让课设的界面稳定性上一个档次。
4. 消息与文件传输:从线程模型到文件保存的边界条件
4.1 既是服务器又是客户:TCP监听线程与线程池
程序既要主动连接别人,又要被动接受连接。被动接收需要一个ServerSocket监听3333端口,每accept一个连接就交给一个线程处理。资源里testSever$ServerThread.class就是干这件事的,每个连接一个ServerThread,负责读取对方发来的消息并响应。
监听线程的骨架:
ServerSocket serverSocket = new ServerSocket(3333); while (!closed) { Socket socket = serverSocket.accept(); new Thread(() -> handleClient(socket)).start(); }accept()是阻塞的,所以ServerSocket的accept循环必须放在后台线程里,不能在Swing事件线程里跑。每个连接用新线程处理,是为了防止一个慢速文件传输拖垮其他聊天连接。如果同一时间有很多对等方连接,我建议把new Thread换成一个固定大小线程池:
ExecutorService pool = Executors.newFixedThreadPool(10); while (!closed) { Socket socket = serverSocket.accept(); pool.submit(() -> handleClient(socket)); }线程池大小设为10,对课设场景够用,也避免几百个连接时创建出几百个线程。如果收到大量连接导致线程池排队,至少不会让整个程序内存爆炸。handleClient里要处理两种消息:文本消息和文件块消息。文件块消息会占用较长时间读取,如果和其他文本消息混在同一个线程池里顺序执行,后面发来的文本消息会排队。所以在handleClient开头先读一个消息,如果type是文件块,就交给一个单独的FileSave线程去写盘,当前线程继续读下一个消息。
4.2 文件头的设计与分块传输的边界
文件传输不能直接writeObject(File),File对象里只有路径,对方拿不到内容。正确做法是先传文件头,再循环传文件块。文件头里至少包含文件名和总长度,文件块里包含块数据和实际长度。发送端代码见下:
File file = new File(filePath); FileInputStream fis = new FileInputStream(file); byte[] buffer = new byte[65536]; Message header = new Message(); header.type = 1; header.content = file.getName(); header.length = file.length(); out.writeObject(header); int len; while ((len = fis.read(buffer)) != -1) { byte[] sent = Arrays.copyOf(buffer, len); Message chunk = new Message(); chunk.type = 2; chunk.data = sent; chunk.length = len; out.writeObject(chunk); } fis.close();最容易被忽略的是Arrays.copyOf(buffer, len)。read方法的返回值是实际字节数,最后一次读取可能只有几百字节,如果不复制有效长度,整个64KB的buffer会连残留脏数据一起发过去,接收端落盘后文件就会多出一截。把实际读取长度存进chunk.length,接收端只写length个字节,才能保证文件MD5一致。我把发送缓冲区设成64KB,是考虑到ObjectOutputStream序列化byte[]会额外带数组长度和类描述信息,块太大增加内存压力,块太小IO次数太多。课程设计这个量级,64KB是稳定值。
接收端收到type=1的文件头后,先创建文件保存线程,文件名用时间戳加原名拼接,避免重名覆盖。接收端处理文件块的代码会写入FileOutputStream,并在写完最后一个块后flush、close。如果接收过程中Socket断开,FileSave线程需要在异常里关闭输出流并删除半成品文件,否则磁盘上会留下一个不完整的破损文件。
4.3 进度条:用真实字节数更新,不用sleep模拟
资源里的ProgressBar.class和sendFilebt_adapter.class说明,项目把文件传输按钮和进度显示做了适配。进度条最忌讳的做法是在发送循环里用Thread.sleep+固定增量模拟进度,不仅不准,还会卡UI。正确做法是每次写块后累加真实已发送字节数,然后通过SwingWorker的publish/progress机制更新。
我一般把整个文件发送过程封装成一个SwingWorker:
SwingWorker<Void, Integer> worker = new SwingWorker<>() { @Override protected Void doInBackground() { FileInputStream fis = new FileInputStream(file); byte[] buffer = new byte[65536]; int len; long sentTotal = 0; while ((len = fis.read(buffer)) != -1) { byte[] sent = Arrays.copyOf(buffer, len); // 发送chunk out.writeObject(new Message(2, sent, len)); sentTotal += len; publish((int) (sentTotal * 100 / file.length())); } fis.close(); return null; } @Override protected void process(List<Integer> chunks) { int percent = chunks.get(chunks.size() - 1); progressBar.setValue(percent); } }; worker.execute();publish的值是进度百分比,process里只取最后一个值,避免中间值覆盖掉最终进度。文件传输按钮在点击后要setEnabled(false),防止用户重复点击导致同一文件并发发送。传输完成后再恢复按钮,并把“已完成”写进聊天窗口。这一套做下来,进度条基本不会翻车。
5. 计网课设避坑指南:四个最容易翻车的点在哪儿
5.1 现象:A能发现B,B却永远发现不了A
调试时最典型的场景是两台机器同一网段,A的广播能到达B,B也回了ACK,但A列表里没有B,B列表里反而有A;或者两边都互相发现不了。原因通常是两个:一是发送ACK时用了广播地址而不是req.getSocketAddress(),ACK变成了新一轮广播,被其他节点当无效包丢弃;二是接收ACK后没有过滤本机IP,导致广播包在协议栈回环时把自己加进了列表,污染了在线用户数据。
解决办法分两步:接收REG后从DatagramPacket里取getSocketAddress()并单播回ACK;在解析ACK时判断里面的IP是否等于本机IP,相等就丢弃。如果还是发现不了,就把防火墙临时关掉,看是不是系统防火墙拦截了UDP 3333端口的入站包。Windows默认防火墙会拦UDP入站,特别是Java程序第一次运行时弹窗点成“不允许”了,后面再改就要去高级安全规则里加放行。
5.2 现象:列表里能看到人,但双击聊天就是连不上
对等方列表有数据,证明UDP发现成功;但双击聊天提示Connection refused或超时,说明TCP连接建立失败。我遇到的最常见原因是主动连接方的Socket被bind到了3333端口。很多同学为了“端口保持一致”,在new Socket之前调socket.bind(new InetSocketAddress(3333)),结果本地3333端口已经被ServerSocket占用,直接抛Address already in use;就算不抛异常,connect时也会因为端口冲突走不通。
局域网内做TCP连接,主动方不需要也没必要绑定固定端口。直接把目标IP和端口传给Socket构造方法,系统会自动分配一个空闲的临时端口。服务端ServerSocket只要监听3333就行,OS会自动处理从临时端口过来的连接。所以聊天连接代码写new Socket(peerIP, 3333)就对了,千万不要手动bind。排查这个问题时可以用netstat -ano看3333端口状态,如果是LISTENING,再开另一个socket bind就会冲突。
5.3 现象:传大文件传着传着界面就白屏
文件传输过程中聊天窗口无响应,关闭按钮点不动,标题栏显示“未响应”。这是Swing事件线程被阻塞的典型表现。产生原因要么是把文件读取和网络IO写在了按钮的actionPerformed里,要么是在进度更新循环里用了Thread.sleep,两者都会阻塞EDT,导致界面重绘停摆。
解决思路是让耗时操作完全离开EDT。把文件发送丢进SwingWorker或普通Thread,进度条更新使用publish/process机制。还有一点很容易被遗漏:文件读取循环里如果调用了out.writeObject,而ObjectOutputStream内部缓冲区已满,写操作可能阻塞等待网络消费,这不是IO出问题,而是背压导致。遇到这种情况,可以给Socket设置setSendBufferSize和setReceiveBufferSize,例如64KB,让缓冲区和发送块大小匹配,减少阻塞概率。但最根本的还是让发送逻辑跑在独立线程里,UI永远不被网络读写拖住。
5.4 现象:接收到的文件多了几百个字节,MD5对不上
文件传完了,长度比原始文件大,用MD5校验时结果不一致。这个坑我在前面代码里埋了伏笔:发送时如果直接writeObject(buffer),而buffer是复用的大数组,最后一次读取不足数组长度时,数组尾部还残留着上一次读取的旧数据。ObjectOutputStream会把整个数组序列化过去,接收端写入文件后,文件末尾就多了残留字节。
解决方法是发送前复制有效长度:byte[] sent = Arrays.copyOf(buffer, len); 然后把sent作为Message.data。接收端写文件时只写message.length个字节,而不是写data.length。另外还要注意Message类中的data字段类型必须声明为byte[],不能是固定大小的数组,否则序列化会把整个数组都传过去,即使只有前len个有效。这是文件传输里最常见的“玄学”,其实原理就一条:通信协议里必须显式传长度,不能让接收端靠数组长度猜。
还有一个隐藏坑:文件接收端用message.content直接作为文件路径,如果对方传了一个含路径分隔符的文件名,可能会写到预期之外的目录。我习惯在保存前对文件名做一次replaceAll("[\\/:*?"<>|]", "_"),把非法字符替换掉。课设演示通常不会传恶意文件名,但这个防护能避免演示现场写出一个奇怪路径,省得答辩时手忙脚乱。
6. 验证与进阶:从能跑通到从容答辩
系统能跑起来只是第一步,答辩时最怕老师说“你真机试过吗”。我通常用三条路径验证:先在本机开两个实例,一个用真实IP,一个用127.0.0.1,测UDP发现和TCP聊天;再到物理机和虚拟机之间测文件传输,传一个10MB以上的随机文件,用certutil -hashfile对比MD5;最后用Wireshark抓包,过滤udp.port==3333,看REG和ACK是否成对出现,这比代码日志有说服力。
进阶方向上,我推荐给协议加一个消息序号。REG消息带自增ID,ACK回带这个ID,这样能明确对应请求和应答;TCP消息里也带序号,接收端收到乱序包可以提示“消息顺序异常”,虽然局域网通常不会乱序,但答辩时能把这个设计讲出来,老师会认为你真的理解了P2P协议设计。再进一步,把5秒广播一次改成周期心跳,连续两次没收到心跳的节点就从对等方列表移除,这样在线列表是动态的,而不是点一下扫描一次,更贴近真实P2P系统。
我印象最深的是,答辩前我把端口3333和UDP广播的话术背得很熟,结果老师第一问不是“TCP和UDP的区别”,而是“你的文件传完了,接收端怎么知道的”。我一时语塞,因为我的代码里没有传END消息,接收端是靠输入流读到-1才关闭文件,这个逻辑讲不清。后来我把协议改成:文件头发文件名和总长度,中间文件块带块序号,最后再传一条type=3的END消息,接收端收到END才做文件close和MD5校验。从那以后我每次写通信协议,都强制走一遍“消息类型、长度、边界、终止条件”四要素。这份资源里正好有对应这些要点的类:SendIPtoLAN做广播,ReceiveIP做UDP收包,testSever下的ServerThread和FileSave处理TCP连接与落盘,ChatWindow负责界面,sendFilebt_adapter和ProgressBar负责文件发送与进度显示。代码量不大,但覆盖了计网课设所有验收点。如果你也在写广工计网课设,建议先把自己的版本跑通,再拿这份资源对照差异,能省不少排查时间,希望帮到你。
本文还有配套的精品资源,点击获取