简介:Chat是一款基于Java开发的局域网聊天软件,面向具备Java基础、希望理解网络通信与桌面应用整合的开发者与学习者,用于在本地网络环境中实现文字交流与文件传输。压缩包共7个文件,约14KB,以Java源码文件为核心,辅以Eclipse工程配置(.project、.classpath、.settings下的prefs)、.gitignore、LICENSE与README说明,工程结构轻量,便于直接导入IDE阅读与运行。已有238人学习下载,适合作为小型实战案例参考。代码围绕Socket与ServerSocket建立客户端与服务器连接,借助多线程处理并发聊天与文件传输,使用Swing或JavaFX构建聊天窗口与文件选择对话框,并通过java.io流完成文件读写,同时涉及事件监听、对象序列化、异常处理与日志记录等知识点。读者可从中梳理局域网通信的完整链路,理解消息收发与文件传输的实现思路,并借鉴其工程组织方式,作为Java网络编程与GUI练习的起点。
1. 局域网聊天工具:一个 Java 桌面项目能解决什么实际问题
办公室内网断了外网,微信登不上,钉钉消息发不出去,但同事就在隔壁工位——这种场景下,一个能在局域网里直接跑起来的聊天工具就是刚需。这个项目用 Java 写了一个局域网聊天软件,核心能力两条:文字聊天和文件传输。没有服务器依赖,没有云端账号,启动即用,同一网段内互相发现、互相通信。适合谁?一是需要在内网环境做即时沟通的小团队,二是想拿一个完整 Socket 编程 + Swing 界面 + 文件 IO 的 Java 实战项目来练手的开发者。它不复杂,但五脏俱全:网络通信、多线程、GUI、文件分块传输,该有的都有。下面从架构到代码到改造,把这个项目拆开讲清楚。
2. 架构与目录:一个桌面聊天工具的模块划分
2.1 技术选型与整体架构
这个项目的技术栈很朴素:Java SE 标准库,没有引入 Netty、Spring 这类框架。通信层用java.net.Socket和ServerSocket做 TCP 长连接,界面层用 Swing(JFrame、JTextArea、JList等),文件传输走同一条 TCP 通道做分块读写,多线程用Thread或ExecutorService处理并发连接。选 TCP 而不是 UDP 的原因很直接:文字消息不能丢,文件传输更不能丢包,TCP 的可靠传输省去了自己实现重传和排序的麻烦。
整体架构是典型的 C/S 混合模式——每个客户端既是客户端也是服务端。启动时先开一个监听端口等待别人连接,同时可以主动去连别人的 IP。这样不需要一个中心服务器,任意两个节点之间都能直连。常见做法是维护一个在线用户列表,每个连接对应一个Socket和一个读线程,发消息时遍历目标连接写入数据。
LanChat/ ├── src/ │ ├── main/ │ │ ├── ChatApp.java // 程序入口,启动界面和监听 │ │ ├── ui/ │ │ │ ├── MainFrame.java // 主窗口 │ │ │ └── ChatPanel.java // 聊天面板 │ │ ├── net/ │ │ │ ├── ChatServer.java // 监听端口、接受连接 │ │ │ ├── ChatClient.java // 主动连接、发送消息 │ │ │ └── MessageHandler.java // 消息解析与分发 │ │ ├── file/ │ │ │ └── FileTransfer.java // 文件分块收发 │ │ └── model/ │ │ ├── User.java // 用户信息 │ │ └── Message.java // 消息实体 │ └── resources/ │ └── config.properties // 端口、昵称等配置 └── pom.xml / build.gradle这个目录结构是我按常见 Java 桌面项目习惯整理的,实际项目可能略有差异,但模块职责基本对应。net包管通信,ui包管界面,file包管文件传输,model包放数据实体,职责清晰,改起来不会牵一发动全身。
2.2 通信协议设计:消息怎么区分类型
文字和文件走同一条 TCP 流,必须有个协议头来区分。最简单可靠的做法是自定义一个定长头部:前 4 个字节存消息类型(1=文字,2=文件开始,3=文件数据,4=文件结束),接下来 4 个字节存数据长度,后面跟实际内容。这样接收端先读 8 字节头,解析出类型和长度,再按长度读正文,不会出现粘包问题。
// 消息协议常量 public class Protocol { public static final int TYPE_TEXT = 1; // 文字消息 public static final int TYPE_FILE_START = 2; // 文件传输开始 public static final int TYPE_FILE_DATA = 3; // 文件数据块 public static final int TYPE_FILE_END = 4; // 文件传输结束 public static final int HEADER_SIZE = 8; // 4字节类型 + 4字节长度 } // 发送一条文字消息 public void sendText(String text) throws IOException { byte[] content = text.getBytes(StandardCharsets.UTF_8); ByteBuffer buffer = ByteBuffer.allocate(Protocol.HEADER_SIZE + content.length); buffer.putInt(Protocol.TYPE_TEXT); // 写入类型 buffer.putInt(content.length); // 写入长度 buffer.put(content); // 写入正文 outputStream.write(buffer.array()); outputStream.flush(); }ByteBuffer在这里比DataOutputStream更顺手,因为可以一次性拼好头部和正文再写出,减少系统调用次数。putInt默认大端序,收发两端保持一致就行。参数上注意content.length是字节数不是字符数,中文按 UTF-8 编码后一个字符占 3 字节,用String.length()会算错,必须用getBytes().length。
2.3 多线程模型:每个连接一个读线程
界面不能卡,所以网络读写必须放在后台线程。常见做法是每接受一个连接就起一个读线程,循环阻塞读消息,读到后通过SwingUtilities.invokeLater切回 EDT 更新界面。发送操作可以在调用线程直接写,但要注意多个线程同时写同一个OutputStream会串数据,得加锁或者用单线程发送队列。
// 每个连接对应一个读线程 class ReaderTask implements Runnable { private final Socket socket; private final MessageHandler handler; ReaderTask(Socket socket, MessageHandler handler) { this.socket = socket; this.handler = handler; } @Override public void run() { try (DataInputStream in = new DataInputStream(socket.getInputStream())) { while (!socket.isClosed()) { int type = in.readInt(); // 阻塞读类型 int length = in.readInt(); // 阻塞读长度 byte[] data = new byte[length]; in.readFully(data); // 确保读满 handler.dispatch(type, data); // 分发处理 } } catch (IOException e) { // 连接断开,从在线列表移除 handler.onDisconnect(socket); } } }readFully是关键,普通read(byte[])可能只读到一部分就返回,文件传输时会导致数据错位。dispatch里根据类型分派:文字消息直接拼到聊天区,文件数据块写入临时文件。onDisconnect负责清理资源、刷新用户列表。这里有个细节——socket.isClosed()在对方异常断开时不一定立刻为 true,更稳的判断是捕获EOFException或SocketException后退出循环。
3. 跑起来:从零启动两个客户端互发消息和文件
3.1 环境准备与编译运行
需要 JDK 8 及以上,项目如果是 Maven 结构,直接mvn clean package打出 jar;如果是普通 Java 项目,用javac编译后java运行。Swing 界面在无头环境跑不了,必须在有图形界面的机器上启动。两台机器要在同一局域网,防火墙放行监听端口(默认常见是 8888 或 9999,以配置文件为准)。
# Maven 项目打包 mvn clean package -DskipTests # 启动第一个客户端(监听 8888) java -jar target/lan-chat.jar --port 8888 --name Alice # 启动第二个客户端(监听 8889,连接第一个) java -jar target/lan-chat.jar --port 8889 --name Bob --connect 192.168.1.10:8888参数含义:--port是本机监听端口,同一台机器跑两个实例必须不同;--name是显示昵称;--connect是主动连接的目标地址。如果项目没有命令行参数解析,就在config.properties里改端口和昵称,效果一样。启动后主窗口会显示在线用户列表,选中用户即可发消息。
3.2 文件传输的完整流程
文件传输分三步:发送方先发TYPE_FILE_START,携带文件名和文件大小;然后循环读文件块,每块发TYPE_FILE_DATA;最后发TYPE_FILE_END。接收方收到 START 时创建临时文件,收到 DATA 时追加写入,收到 END 时关闭流并重命名。块大小一般取 4KB 到 64KB,太小系统调用频繁,太大占内存,8KB 是个稳妥的默认值。
// 发送文件 public void sendFile(File file) throws IOException { // 1. 发送文件开始头:文件名|文件大小 String meta = file.getName() + "|" + file.length(); byte[] metaBytes = meta.getBytes(StandardCharsets.UTF_8); sendFrame(Protocol.TYPE_FILE_START, metaBytes); // 2. 分块发送文件内容 byte[] chunk = new byte[8192]; try (FileInputStream fis = new FileInputStream(file)) { int read; while ((read = fis.read(chunk)) != -1) { byte[] data = Arrays.copyOf(chunk, read); // 最后一块可能不满 sendFrame(Protocol.TYPE_FILE_DATA, data); } } // 3. 发送结束标记 sendFrame(Protocol.TYPE_FILE_END, new byte[0]); } private void sendFrame(int type, byte[] data) throws IOException { ByteBuffer buf = ByteBuffer.allocate(8 + data.length); buf.putInt(type); buf.putInt(data.length); buf.put(data); synchronized (outputStream) { // 防止多线程写串 outputStream.write(buf.array()); outputStream.flush(); } }Arrays.copyOf处理最后一块不满 8KB 的情况,否则会把缓冲区里的脏数据也发出去。synchronized (outputStream)保证文字消息和文件块不会交叉写入——如果用户一边传文件一边打字,没有这把锁,接收端解析头部就会错位,这是血泪经验。接收端对应逻辑是:START 时用FileOutputStream打开临时文件,DATA 时write(data),END 时close()并提示用户保存位置。
3.3 界面与消息刷新的线程安全
Swing 的所有组件更新必须在事件调度线程(EDT)上做。网络线程读到消息后直接textArea.append()是典型的翻车点——偶尔正常,压力一大就界面卡死或抛异常。正确做法是包一层SwingUtilities.invokeLater。
// 网络线程中收到文字消息后更新界面 public void onTextReceived(String from, String text) { SwingUtilities.invokeLater(() -> { chatArea.append("[" + from + "] " + text + "\n"); chatArea.setCaretPosition(chatArea.getDocument().getLength()); // 自动滚到底 }); }setCaretPosition让滚动条自动跟到最新消息,不加的话新消息来了用户还得手动往下拉。这个细节虽小,但直接影响使用体验。用户列表的刷新同理,DefaultListModel的增删也要在 EDT 上操作。
4. 避坑与排查:局域网聊天项目最容易翻车的几个点
4.1 连不上:先查防火墙再查网段
现象是输入对方 IP 点连接,一直超时或拒绝。原因通常两个:一是目标机器防火墙没放行监听端口,Windows 默认会弹窗询问,点了拒绝就静默拦截;二是两台机器不在同一网段,比如一个连公司 WiFi 一个插网线,网关不同。解决办法是先ping通再telnet 目标IP 端口测端口,不通就检查防火墙入站规则,把 Java 程序或对应端口加白名单。跨网段的情况需要路由器支持,普通局域网工具不处理 NAT 穿透。
4.2 中文乱码:编码两端必须一致
现象是发出去的中文对方收到变成问号或方块。原因是发送端用平台默认编码(Windows 中文版是 GBK),接收端按 UTF-8 解。解决很简单,所有getBytes和new String显式指定StandardCharsets.UTF_8,Swing 的JTextArea本身用 Unicode 存储不受影响,问题只出在网络字节流这一层。文件名的传输同理,meta字符串编码要统一。
4.3 文件传一半卡住:缓冲区没 flush 或读不满
现象是大文件传到某个位置双方都卡死。原因一是发送端没flush,数据积在缓冲区;二是接收端用read(byte[])没循环读满就解析下一帧,导致头部错位。解决是发送端每帧写完flush,接收端用DataInputStream.readFully读定长头部和正文。另外文件超过 2GB 时int长度会溢出,需要改用long存文件大小,这是大文件场景必须提前考虑的。
4.4 关窗口后进程不退:线程没设守护或没关 Socket
现象是点了关闭按钮,界面没了但任务管理器里 Java 进程还在。原因是监听线程和读线程还在阻塞等待,非守护线程会阻止 JVM 退出。解决办法是在窗口关闭事件里遍历所有 Socket 调close(),或者把网络线程设为setDaemon(true)。更规范的做法是加一个shutdown()方法统一释放资源,在WindowListener.windowClosing里调用。
5. 二次开发:从能用到好用,几个改造方向
拿到这个项目后,最值得先改的是消息协议的可扩展性。现在的 8 字节头只有类型和长度,想加发送者 ID、时间戳、消息序号就得改协议。我一般会把头部扩成固定 JSON 或者加一个扩展字段长度,这样后续加"已读回执""消息撤回"不用再动底层读写逻辑。改的时候注意新旧版本兼容——如果两端版本不一致,老客户端解析新头部会错位,稳妥做法是头部第一个字节放协议版本号,收到不认识的版本直接提示升级。
第二个改造点是用户发现。现在靠手动输 IP,体验很差。局域网内可以用 UDP 广播实现自动发现:每个客户端启动时向255.255.255.255的约定端口发一个广播包,携带自己的昵称和 TCP 监听端口,其他客户端收到后自动加入列表。UDP 广播只在同一网段有效,正好匹配这个工具的使用场景。代码上就是开一个DatagramSocket监听广播端口,收到包解析后更新DefaultListModel,注意去重和超时剔除。
第三个方向是消息持久化。现在聊天记录关了就没了,加一个本地 SQLite 或者简单的按天文本文件追加,启动时加载最近记录。表结构不用复杂,messages(id, from_user, to_user, content, type, timestamp)一张表够用,文件传输只存路径和元信息,不存二进制。查询时按时间倒序取最近 200 条渲染到界面,避免一次性加载太多卡顿。
验证改造是否成功,我的习惯是开三个客户端做交叉测试:A 发文字给 B、B 传文件给 C、C 同时给 A 发消息,观察是否有串包、乱序、界面卡顿。压力测试可以写个脚本模拟连续发 1000 条消息和 10 个并发文件传输,看内存和线程数是否稳定。改造过程中每改一个模块就单独跑一遍双端联调,别攒一堆改动一起测,出了问题定位成本翻倍。
从那以后我每次动网络协议相关的代码,都强制先跑一遍"文字+文件混合并发"的冒烟测试再提交,这个习惯帮我省了不少回滚的麻烦。希望帮到你。
本文还有配套的精品资源,点击获取