简介:Chat是一款基于Java开发的局域网聊天软件源码包,供Java网络编程初学者与轻量内网通信需求者使用,重点解决局域网内文字聊天和文件传输两个核心场景。源码充分利用Java跨平台能力,在单一Chat.java中整合了Socket连接、ServerSocket监听、多线程并发处理、Swing界面组件、文件输入输出流以及事件监听等关键技术,可以直观看到事件驱动与阻塞式网络调用是如何协同工作的。资源包仅14KB,共7个文件,核心为src下的Chat.java源码,另有Eclipse工程配置(.project、.classpath)、Java编译器选项(org.eclipse.jdt.core.prefs)、README说明、LICENSE许可证和.gitignore等辅助文件,结构紧凑。已有236人学习下载,轻量程度非常适合快速通读和实验。通过阅读和运行这套源码,读者不仅能掌握聊天室的基本流程,还可以进一步尝试加入消息序列化、SSL加密和异常日志处理等增强设计,为后续开发完整的企业级通信工具打下扎实基础。
1. 局域网聊天软件还在被需要:为什么这个方向值得写
在没有外网或网络受限的内网环境里,想给团队发个文件、说句话,折腾公网聊天工具往往是死路一条。自己用Java写一个局域网聊天软件,听起来像上个世纪的课程设计,但真的遇到「机房断网、内部资料不能出内网」的场景时,它反而是最可靠的工具。我用Java把文字聊天和文件传输跑通之后最直观的感受是:这套东西原理不复杂,但想稳定不翻车,细节比想象的多得多。这篇文章会沿着技术选型、文字聊天、文件传输到坑点排查的顺序,把一个可复现的局域网聊天方案完整拆开。适合需要在内网做小工具的你,也适合想搞清楚Java网络编程到底怎么落地的开发者。
2. 先想清楚再动手:Java局域网聊天的技术选型与消息协议设计
动手写代码之前,先把技术选型和通信模型定下来,否则写到一半会反复推倒重来。标题里明确了「Java写的局域网聊天软件」,语言定了,剩下的关键选择是:用TCP还是UDP、界面用Swing还是JavaFX、服务端和客户端怎么组织、文件传输走哪条通道。这几个决定直接影响代码结构和踩坑面。
2.1 TCP还是UDP、单端口还是双端口:网络模型怎么选
局域网聊天最常见的方案是TCP,原因很直接:文字消息不能丢,文件传输更不能丢。UDP虽然快,但丢包、乱序都要自己补,处理成本比TCP高不少。TCP自带重传和按序到达,服务端和客户端各自维护一个Socket,代码可以写得更线性。
网络模型上有两种做法:一种是在同一个TCP连接里用消息类型区分文字和文件,另一种是文字聊天和文件传输分别建立连接。常见做法是HTTP/HTTPS协议,但局域网内自建服务直接使用TCP Socket效率更高、可控性更强。
| 维度 | 单连接复用 | 双连接分离 |
|---|---|---|
| 端口占用 | 1个 | 2个 |
| 实现复杂度 | 低,但要自己定义帧格式 | 高,文件连接要单独管理 |
| 文字/文件互相影响 | 大文件会阻塞文字消息 | 互不干扰 |
| 适用规模 | 小工具够用 | 多人并发更稳 |
我一般会选择双连接:文字走5000端口,文件传输走5001端口。原因很简单——文件传输的缓冲区和线程模型跟文字完全不同,硬塞在一条连接里,大文件传输时文字消息要排队等,体验一落千丈。
2.2 界面选型与线程模型:Swing的局限和替代
Java做桌面界面无非Swing和JavaFX两个方向。JavaFX界面现代但打包体积大,部署在内网机器上还要额外装运行环境;Swing虽然老,但JDK自带,双击jar就能跑,对局域网工具来说这是最大的优势。所以我的选择是Swing。
Swing有个必须记住的线程纪律:所有界面刷新必须在事件调度线程(EDT)上做,网络收消息的子线程不能直接调setText()或append(),否则界面要么闪屏闪烁,要么直接卡死。正确做法是用SwingUtilities.invokeLater()把界面更新扔回EDT。
线程模型上,服务端每个客户端连接开一个读线程,文件连接再单独开一个读线程。不要用一连接一线程以外的花哨模型,这个规模下BIO足够,NIO和Netty反而把问题复杂化。
2.3 消息协议设计:从一条JSON说起
文字聊天消息的格式必须自己定义,常见做法是JSON。每次消息包含类型、发送者、内容、时间戳。如果是文件消息,则额外带上文件名、文件大小和文件通道端口。
{ "type": "text", "sender": "192.168.1.100", "content": "你好,文件收到了吗?", "ts": 1734567890123 }{ "type": "file_offer", "sender": "192.168.1.100", "fileName": "需求文档.pdf", "fileSize": 2048576, "filePort": 5001, "ts": 1734567890456 }消息与消息之间用换行符分隔,每行一条JSON。实测下来这个协议最省心——不需要像二进制协议那样手工拼字节,也不容易出现粘包问题,因为JSON里不会出现裸换行(需要转义,所以JSON库会自动处理成\n)。
3. 把文字聊天跑通:Server与Client的最小可用代码
这一章直接给最小可运行的代码。目标是:一台机器跑服务端,另外两台机器跑客户端,输入IP和端口就能互相发文字消息。先把这条链路跑通,再在上面加文件传输。
3.1 服务端:监听连接、分发消息的骨架
服务端做的事只有三件:监听端口、维护客户端Socket列表、把消息广播给所有人。核心逻辑如下:
public class ChatServer { private static final int PORT = 5000; private static List<PrintWriter> clients = new CopyOnWriteArrayList<>(); public static void main(String[] args) throws IOException { ServerSocket serverSocket = new ServerSocket(PORT); System.out.println("聊天服务端已启动,端口 " + PORT); while (true) { Socket socket = serverSocket.accept(); System.out.println("新客户端接入: " + socket.getInetAddress()); PrintWriter out = new PrintWriter(socket.getOutputStream(), true); clients.add(out); new Thread(() -> handleClient(socket, out)).start(); } } private static void handleClient(Socket socket, PrintWriter out) { try (BufferedReader in = new BufferedReader( new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8))) { String line; while ((line = in.readLine()) != null) { System.out.println("收到消息: " + line); for (PrintWriter writer : clients) { writer.println(line); // 广播给所有客户端 } } } catch (IOException e) { System.out.println("客户端断开: " + socket.getInetAddress()); } finally { clients.remove(out); } } }CopyOnWriteArrayList在这里是必要的——广播时会遍历列表,如果某个客户端断开并触发remove,用普通ArrayList会抛出并发修改异常。PrintWriter的第二个参数true表示自动刷新,每一行都立即发送,避免消息积压缓冲区。
这段代码是标准的BIO广播模型,所有客户端在同一个聊天室里。注意readLine()会阻塞,所以每个客户端必须有独立线程,这也是为什么上面用new Thread(...).start()。消息内容是纯文本JSON,直接被广播,客户端拿到后自行解析。
3.2 客户端:连接服务器、收发与界面刷新
客户端的难度在于界面和网络不在同一个线程,要在回调里安全更新UI。下面这段是客户端收发消息的核心骨架:
public class ChatClient { private Socket socket; private PrintWriter out; private JTextArea messageArea; public void connect(String serverIp, int port) throws IOException { socket = new Socket(serverIp, port); out = new PrintWriter(socket.getOutputStream(), true); new Thread(() -> receiveLoop()).start(); } private void receiveLoop() { try (BufferedReader in = new BufferedReader( new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8))) { String line; while ((line = in.readLine()) != null) { final String msg = line; SwingUtilities.invokeLater(() -> messageArea.append(msg + "\n")); } } catch (IOException e) { SwingUtilities.invokeLater(() -> messageArea.append("连接断开\n")); } } public void sendMessage(String content) { String json = "{\"type\":\"text\",\"sender\":\"我\",\"content\":\"" + content.replace("\"", "\\\"") + "\",\"ts\":" + System.currentTimeMillis() + "}"; out.println(json); } }注意sendMessage里面做的转义处理——JSON里如果内容包含英文双引号,不转义会导致服务端广播后其他客户端解析失败。这里只是简单示意,实际项目建议直接用Jackson或Gson生成JSON,不要手工拼字符串。
SwingUtilities.invokeLater()这行是Swing的生命线。网络收消息的线程不是EDT,直接调messageArea.append()轻则界面闪烁,重则整个窗口假死。把所有UI操作都扔给EDT,这是Swing开发中不能省的成本。
3.3 消息编解码与参数说明
客户端解析收到消息时,用JSON库反序列化成对象,然后根据type字段决定是文字还是文件通知。解析代码如下:
ObjectMapper mapper = new ObjectMapper(); JsonNode node = mapper.readTree(json); String type = node.get("type").asText(); if ("text".equals(type)) { String sender = node.get("sender").asText(); String content = node.get("content").asText(); messageArea.append("[" + sender + "] " + content + "\n"); } else if ("file_offer".equals(type)) { String fileName = node.get("fileName").asText(); long fileSize = node.get("fileSize").asLong(); int filePort = node.get("filePort").asInt(); // 提示用户是否接收文件 }这里有一个必须面对的坑:网络传输的缓冲区大小和消息长度。BufferedReader.readLine()读的是以换行为分隔的一整行,对于文字聊天完全够用。但如果有人在消息里输入了一个特别长的文本(比如粘贴几万字的文档),行缓冲会溢出,需要调大Socket的接收缓冲区,或者改用read(char[], offset, len)配合自定义帧长度前缀。小工具场景一般不用处理,但你要知道边界在哪。
端口和超时参数方面:服务端连接握手超时建议设3秒左右,socket.setSoTimeout(3000)只在「等待对端响应」时生效,不影响已建立的连接。生产级工具还会做心跳,后面避坑章节会展开。
4. 文件传输:在聊天之外开一条专用数据通道
文字聊天跑通后,文件传输就是另一个独立模块。标题把「文件传输」和「文字聊天」并列,说明这是核心功能而不是附加功能。文件传输用独立TCP连接,分别监听两个端口,这个设计在前面已定下来。本章展开传输协议、发送端和服务端的实现。
4.1 文件传输协议的总体设计
| 阶段 | 方向 | 内容 | 说明 |
|---|---|---|---|
| 文件协商 | 发送端 → 服务端 | 文件名、大小、目标端口 | 走文字通道(5000) |
| 传输准备 | 发送端 → 接收端 | 连接文件端口(5001) | 接收端主动连接 |
| 元数据 | 发送端 → 接收端 | 文件名、大小、校验信息 | 文件通道第一行 |
| 文件体 | 发送端 → 接收端 | 原始字节流 | 分块读取写入 |
| 完成确认 | 接收端 → 发送端 | 收到字节数 | 校验用 |
这个协议的核心思路是:控制信息走文字通道,文件数据走文件通道。文件通道的连接方向是接收端主动连发送端,而不是发送端主动连接收端。实际场景中发送方和服务端通常在同一个机房,接收方可能是外网或跨网段的机器,让接收方主动连接更符合内网穿透习惯。
4.2 客户端发送端实现
文件发送端的代码要分两步:先在文字通道广播一个file_offer,然后启动文件通道服务等待接收端连接。核心代码如下:
public void sendFile(File file, int filePort) throws IOException { // 1. 广播文件协商消息 String offer = "{\"type\":\"file_offer\",\"fileName\":\"" + file.getName() + "\",\"fileSize\":" + file.length() + ",\"filePort\":" + filePort + "}"; out.println(offer); // 2. 等待接收端连接文件端口 ServerSocket fileServer = new ServerSocket(filePort); fileServer.setSoTimeout(30000); // 30秒没人连就取消 try (Socket fileSocket = fileServer.accept(); DataOutputStream dos = new DataOutputStream(fileSocket.getOutputStream()); FileInputStream fis = new FileInputStream(file)) { // 发送元数据 dos.writeUTF(file.getName()); dos.writeLong(file.length()); // 分块发送文件内容 byte[] buffer = new byte[8192]; int bytesRead; long totalSent = 0; while ((bytesRead = fis.read(buffer)) != -1) { dos.write(buffer, 0, bytesRead); totalSent += bytesRead; } dos.flush(); System.out.println("文件发送完成,共 " + totalSent + " 字节"); } }DataOutputStream.writeUTF()写入的是UTF-8编码的字符串,自带长度前缀,读取时不需要担心粘包。writeLong()同理。这两行保证了元数据解析不会出错,是这个方案里最稳的一步。
缓冲区大小8192是经过实测的折中值。太小(比如1024)会导致系统调用次数过多,CPU占用高;太大(比如64KB以上)在某些老旧交换机上反而触发TCP分片和重组,传输速度不升反降。8KB适用于绝大多数内网环境,如果你要传大文件(超过1GB),可以改成16KB再对比测试。
4.3 服务端接收与转发实现
服务端收到文件字节流后,要做的是把流同时写到本地缓存并从文字通道通知所有客户端。如果只是两个客户端点对点,服务端直接转发没问题;如果将来做多人群聊,文件需要落盘一份再分发。以下代码是接收端(文件接收方客户端)的实现:
public void receiveFile(int filePort, String fileName, long fileSize) throws IOException { try (Socket socket = new Socket(serverIp, filePort); DataInputStream dis = new DataInputStream(socket.getInputStream()); FileOutputStream fos = new FileOutputStream(saveDir + fileName)) { String remoteName = dis.readUTF(); long remoteSize = dis.readLong(); byte[] buffer = new byte[8192]; long remaining = remoteSize; int bytesRead; while (remaining > 0 && (bytesRead = dis.read(buffer, 0, (int) Math.min(buffer.length, remaining))) != -1) { fos.write(buffer, 0, bytesRead); remaining -= bytesRead; progressBar.setValue((int) ((remoteSize - remaining) * 100 / remoteSize)); } if (remaining == 0) { System.out.println("文件接收完成: " + remoteName); } else { System.out.println("文件接收不完整,缺 " + remaining + " 字节"); } } }关键点是remaining变量:文件大小是已知的,循环里必须严格按照剩余字节数来读,不能多读也不能少读。Math.min(buffer.length, remaining)保证最后一小块不会越界读入下一段数据。因为文件通道是专用连接,关掉后连接就断了,所以不能用read() != -1作为循环条件——容易在文件末尾卡住或者多读脏数据。
4.4 传输参数与常见误区
误区一:文件传输必须用对象流ObjectOutputStream。很多人图省事直接writeObject(fileBytes),序列化整个文件到内存再发给对端。这个方案在小文件(几MB)时没问题,但传一个几百MB的文件会直接内存溢出,而且序列化开销远大于原始字节流。
误区二:文件很快传完,不用关心进度。实测1GB文件在内网千兆环境下大约需要10秒左右,但客户端界面如果直接在主线程写文件,这10秒界面会完全卡死。文件读写必须在独立线程,进度条通过SwingUtilities.invokeLater更新。
误区三:两个端口同时监听,防火墙只放行了一个。文字能通但文件永远连不上,大概率是防火墙只放行了5000端口。部署前一定要确认netsh advfirewall firewall add rule或图形界面里把5001端口也加入白名单。
5. 局域网聊天开发避坑:连接失败、中文乱码与内存溢出
写到这一章,说明你已经把基础版本跑起来了。下面这些坑是我实际开发中踩过的,每条都是现象、原因、解决三步。文件传输和文字聊天各自的坑不一样,分开列,避免混淆。
5.1 客户端连不上服务器:防火墙、端口占用与IP写错
现象:客户端点击连接后一直转圈,报java.net.ConnectException: Connection refused。原因:最常见的是服务端机器防火墙拦截了TCP入站连接,其次是端口被其他进程占用,还有一种低级错误——客户端填写的服务器IP写错或填成了127.0.0.1(除非服务端和客户端在同一台机器,否则这个地址永远指向本机)。解决:先用netstat -ano | findstr 5000确认端口在监听;再在客户端机器上ping 服务器IP看网络通不通;最后检查防火墙入站规则,放行TCP 5000和5001端口。如果这三步都过了还不行,抓包看TCP握手是否完成,tcpdump或 Wireshark 过滤tcp.port == 5000。
5.2 中文乱码:字节流与字符流的编码不一致
现象:聊天消息里中文全部变成???或乱码,文件传输的文件名显示成乱码。原因:服务端或客户端某一侧用了平台默认编码(Windows中文系统默认是GBK),而另一侧用UTF-8,两边不一致导致解码错乱。在InputStreamReader和OutputStreamWriter里没有显式指定字符集时,JDK会按操作系统默认值来处理,这是典型的隐蔽问题。解决:所有字符流的转换处强制指定StandardCharsets.UTF_8,包括new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)和new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8)。JSON字符串本身是UTF-8,读写两侧统一用UTF-8即可。如果是从数据库或配置文件读的发送者昵称,也要确保源头不是GBK。
5.3 大文件传输把内存撑爆:readAllBytes是隐形炸弹
现象:传一个200MB的文件,客户端卡死,日志报OutOfMemoryError: Java heap space。原因:代码里用了Files.readAllBytes(path)或类似的一次性读入整文件方式,文件多大内存就要多大。200MB文件,堆内存默认256MB直接爆掉。解决:改成上面第4章的流式读写,固定8KB缓冲区循环读写。如果确实需要内存映射,用FileChannel.map()配合MappedByteBuffer,但普通文件传输场景用流式已经足够,不要过度设计。真的要在小内存机器上传超大文件,考虑加一个文件传输限速或者分片传输的机制。
5.4 心跳与接收线程互相阻塞:readLine卡死
现象:客户端闲置5分钟后再发消息,消息发不出去,界面看起来还能操作,但收不到任何回复。原因:服务端和客户端之间没有心跳机制。服务端维护的客户端Socket实际已经死了(比如客户端电脑休眠、网线被拔),但TCP没有及时感知。当客户端重新发消息时,服务端的readLine()线程还阻塞在旧连接上,消息发出去没人在读。解决:客户端每隔30秒发一个{"type":"ping"}心跳消息,服务端对超过90秒没有心跳的客户端执行socket.close()并从列表移除。客户端这边还要在receiveLoop里捕获SocketException——当服务端关闭了旧连接,readLine()会抛异常,此时要自动重连而不是静默失败。
5.5 界面卡死:EDT上做了耗时操作
现象:接收文件时,整个窗口转圈,无法拖动,点击任何按钮都没反应。原因:文件读写逻辑写在了EDT上——比如按钮的ActionListener里直接调用了receiveFile(),这个方法是阻塞的,会锁住事件分发线程。解决:耗时操作全部放进new Thread(() -> {...}).start(),进度更新用SwingUtilities.invokeLater()交给EDT。一个更稳妥的做法是使用SwingWorker,它有现成的publish()和process()机制来更新UI,避免自己管理线程和EDT的切换。
6. 进阶:从「能用」到「好用」——大文件续传与在线状态检测
基础版跑通后,下一步是解决真实场景里的两个硬需求:传一半断了怎么办,以及怎么知道对方还在线。
文件续传的实现思路是在文件通道的元数据阶段增加一个offset字段。接收端本地检查同名文件是否已存在部分字节,如果存在,就在元数据里带上已接收的字节数。发送端读到这个字段后,用FileChannel对文件进行position(offset)偏移,跳过前段字节,只发送剩余部分。这样断线重传时就不需要重新传整个文件了。注意这个方案要求文件通道连接不能沿用上一章的短连接模式,需要改成可重连且带状态确认的机制。
在线状态检测除了心跳,还要在服务端保存每个客户端的时间戳。界面侧把用户列表做成定时刷新,每5秒检查一次。服务端主动断开长时间无心跳的客户端后,应当广播一条{"type":"offline","sender":"IP"}消息,其他客户端收到后把该用户置灰。这个时机要和心跳定时错开,避免两套定时任务挤在同一切面。
如果要把单聊升级成群聊,JTextArea的滚动定位、消息去重、离线消息补发就是三个绕不开的点。消息去重可以在服务端给每条消息加自增ID,客户端维护最近收到的ID集合,重复的ID直接丢弃。离线消息补发则需要服务端在客户端重新连接后,从内存队列里把积压消息推给新连接,注意控制消息条数上限,避免无限制堆积。
我自己的项目做到断点续传就停了,没有再加更多功能。原因很简单:一个内网工具做到「文件能发、消息能收、大文件不断线、对方在线状态可感知」,已经覆盖了90%的使用场景。再往上的体验优化和分布式扩展,需要投入的时间和复杂度会指数上升。如果你的目标只是解决一个具体问题,不妨就以断点续传和心跳为终点。希望这些经验能帮你在动手前避开我当年趟过的坑,用两个晚上把第一版跑起来,然后按自己的节奏完善。
本文还有配套的精品资源,点击获取