简介:面向Java初、中级开发者的Socket编程实战示例,解决在分布式或网络应用中服务端与客户端传输文件的需求。源码包共9个文件,以3个Java源文件与3个编译后的class文件为核心,直观展示ServerSocket监听、accept()建立连接、输入/输出流读写文件等关键步骤;另含Eclipse工程配置文件(.project、.classpath、.prefs),导入IDE即可运行。压缩包仅9KB,结构精简,适合快速学习;目前已有679人学习/下载。资源价值在于给出完整的文件传输示例,涵盖字节流与缓冲流配合使用、文件大小预通知、read()判断传输结束,以及try-catch-finally异常处理和防资源泄露的规范写法;代码还涉及NIO与线程池的优化方向,可帮助读者理解单线程通信到并发处理的扩展思路。通过阅读和调试这份代码,能掌握Java文件传输的基础框架,为今后在真实项目中加入加密、身份验证等安全机制打下基础。 前阵子做一个内部小工具,需要在两台Linux服务器之间传一批配置文件和日志,环境比较干净,不想为一次临时需求去搭FTP、起Nginx,就顺手用Java写了个极简的Socket文件传输demo。整套代码走下来,发现从协议设计到粘包处理都有不少可以抠的细节,今天把这套实现和踩坑经历整理出来,希望对正在研究Java服务端和客户端传输文件的朋友有一点帮助。
这篇文章不会讲太虚的理论,核心就是围绕“Java中实现服务端和客户端传输文件”这一件事,把消息边界、代码实现、并发处理、常见坑位一次说清楚。如果你只是临时传文件,可以直接用我给的代码;如果你想自己封装一个文件传输工具,那我建议把协议设计和踩坑记录看两遍。
1. 先搞清楚需求,再选传输方案
1.1 不只是“传文件”,先看你的场景是什么
很多人在搜索引擎里输入“Java传输文件”,往往是因为遇到了某个具体场景:给客户端推送升级包、从服务器拉日志、内网机器之间同步配置、或者做一个教学演示。不同场景对传输的要求差别很大。
我自己的场景是内网两台机器之间传几个几十MB的文件,偶尔还有日志增量同步,不需要常驻服务,也不想依赖外部组件。这时候如果搭FTP,需要配置用户和目录权限;如果用HTTP,还要起个Web容器;用现成的scp,又要确认两边有没有装OpenSSH,而且不好在Java程序里集成控制。所以我最后选了Java原生Socket,服务端和客户端都是同一个Jar包里的两个入口,一条命令启动,传完就退,干净利落。
如果你要长期服务大量客户端,那建议直接走Netty或者成熟的HTTP文件服务,别用我这种简易实现。但如果目标是快速落地一个可控的小工具,或者想弄懂TCP传输文件的底层逻辑,原生Socket是性价比最高的选择。
1.2 主流方案对比,选型前先看这张表
关于“Java服务端和客户端传输文件”,市面常见方案无非这几种,优缺点其实都很明显:
| 方案 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|
| FTP / SFTP | 成熟稳定,支持断点续传、权限控制 | 需要额外搭服务,依赖外部组件 | 长期固定文件交换 |
| HTTP文件上传下载 | 能过防火墙,天然支持浏览器交互 | 需要Web容器,协议重量 | Web系统内嵌文件功能 |
| Java原生Socket | 依赖最少,协议可以自己定义,最容易理解 | 需要处理粘包、半包、断线等细节 | 轻量工具、教学、内网传输 |
| Netty | 高性能、高并发,内置编解码器 | 学习曲线陡,代码量相对大 | 生产级大文件、高并发传输 |
我最终选原生Socket,核心原因只有一条:我要传输的文件大小、数量、频次都可控,而且我想完全掌握协议内容。自己写的协议虽然简单,但出了问题我能直接定位到字节级,不用去翻别人的源码。
2. 协议边界问题:粘包半包怎么破
2.1 TCP是“水管”,不是“信封”
刚开始写Socket传输文件的同学,十有八九会踩这个坑。你以为客户端write一次、服务端就能read一次,两边数据是“一一对应”的,实际上完全不是这么回事。
TCP是字节流协议,它就像一根水管,你倒进去多少水(字节数据),接收端拧开水龙头接水时,接到的水量和你倒进去的水量并不一定完全一致。可能你倒了两次水,对方一次就全接走了,这叫粘包;也可能你倒了一次水,对方慢慢接,分两次接完,这叫半包。
放到文件传输里就更明显了。如果你直接把文件名和文件内容都塞进Socket流里,服务端拿着read()傻等,很容易出现文件名长度不对、内容被截断、甚至把下一个文件的数据都读进来。
2.2 解决方式:先定边界,再传数据
业内解决粘包半包的常见方式有三类:固定消息长度、分隔符分隔、长度字段头。文件传输场景最合理的是“长度字段头”方案,因为你本来就要传文件名和文件大小,顺手就把边界定了。
我的协议设计非常简单,一共就四步:
- 客户端发送文件名长度,占用4个字节(int)。
- 客户端发送文件名的UTF-8字节数组。
- 客户端发送文件大小,占用8个字节(long)。
- 客户端发送文件内容,长度就是上面传的fileSize。
服务端接收时反过来读:先readInt()拿到文件名长度,再按这个长度读文件名字节,接着readLong()拿到文件大小,最后按照这个大小循环读取内容。这样每一步该读多少字节,双方约定得明明白白,不会乱。
2.3 为什么不建议用writeUTF传文件名
DataOutputStream确实有个writeUTF(),可以一次性写入字符串和它的长度,读取时用readUTF()直接还原,很多教程都这么教。但它有一个隐藏限制:writeUTF内部用UTF-8的变体编码,字符串长度上限是65535字节。
普通文件名当然不会超过这个数,但我设计协议时会想一个问题:哪天我想传文件路径、文件MD5、附加JSON元数据,这些都可能很短也可能很长,如果依赖writeUTF,等于把协议卡死在一个早期选择上。所以更稳妥的办法是:writeInt(length) + write(bytes),自解释又不受长度限制。
提示:自己定义协议时,尽量把“长度字段”和“数据内容”分开,别把所有东西绑在一个API里,后面扩展会舒服很多。
3. 服务端完整实现:接收文件、落盘、并发处理
3.1 ServerSocket监听与线程池接入
服务端核心就两件事:监听端口、为每个连接分配处理线程。直接上代码,我加了详细注释。
import java.io.*; import java.net.*; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.concurrent.*; public class FileServer { private static final int PORT = 9000; private static final ExecutorService POOL = Executors.newFixedThreadPool(8); public static void main(String[] args) throws IOException { ServerSocket serverSocket = new ServerSocket(PORT); System.out.println("服务端已启动,监听端口:" + PORT); while (true) { Socket socket = serverSocket.accept(); POOL.execute(() -> handle(socket)); } } private static void handle(Socket socket) { String saveDir = "/tmp/files"; try { Path dir = Paths.get(saveDir); if (!Files.exists(dir)) { Files.createDirectories(dir); } DataInputStream in = new DataInputStream(socket.getInputStream()); // 1. 读取文件名长度和文件名 int nameLen = in.readInt(); byte[] nameBytes = new byte[nameLen]; in.readFully(nameBytes); String fileName = new String(nameBytes, StandardCharsets.UTF_8); // 安全过滤:去掉路径分隔符,防止目录穿越 fileName = Paths.get(fileName).getFileName().toString(); // 2. 读取文件大小 long fileSize = in.readLong(); System.out.println("接收文件:" + fileName + ",大小:" + fileSize + " 字节"); // 3. 循环读取文件内容并落盘 Path target = dir.resolve(fileName); try (FileOutputStream fos = new FileOutputStream(target.toFile())) { byte[] buffer = new byte[8192]; long remaining = fileSize; int read; while (remaining > 0) { read = in.read(buffer, 0, (int) Math.min(buffer.length, remaining)); if (read == -1) { throw new IOException("文件流提前关闭,传输不完整"); } fos.write(buffer, 0, read); remaining -= read; } fos.flush(); } System.out.println("文件接收完成:" + target); } catch (Exception e) { e.printStackTrace(); } finally { try { socket.close(); } catch (IOException ignored) { } } } }你可能会问:为什么读取文件内容时不用readFully?因为readFully会一次性把byte[]填满,但文件大小可能不是8192的整数倍,最后一部分会凑不满buffer。所以我用“剩余字节数remaining”来限制每次最多读多少,读到最后只剩100字节时,就new一个100字节大小的read长度,避免多读下一个数据包的内容。这就是协议边界带来的好处。
3.2 服务端并发控制与安全校验
我刚才用的是FixedThreadPool(8),也就是固定8个线程处理客户端连接。这是故意为之,原因很简单:如果不限制线程数,服务端每accept一个连接就new一个Thread,一旦几十个客户端同时连进来,机器会直接被线程开销拖垮。
安全方面有两个点值得说。第一,文件名做了Paths.get(fileName).getFileName()过滤,把路径分隔符去掉,防止有人搞../../etc/passwd这种目录穿越。第二,实际生产里一定要在协议里加一个文件大小上限校验,比如如果fileSize超过5GB就直接拒绝,否则恶意客户端可以发一个假的超长fileSize,让服务端一直傻等。
3.3 关于Socket连接不关闭导致的问题
服务端处理完一个文件后,最好主动关闭Socket。如果不关闭,客户端可能一直在等服务端响应,连接也不会释放,时间长了会把端口和句柄耗尽。上面的代码在finally里做了close,这是必须的。
4. 客户端设计与编码细节
4.1 客户端连接与发送文件元数据
客户端的重点在于发协议头部、发文件内容、实时打印进度。下面是我整理后的完整客户端代码:
import java.io.*; import java.net.*; import java.nio.charset.StandardCharsets; public class FileClient { public static void main(String[] args) throws Exception { String host = "127.0.0.1"; int port = 9000; File file = new File("/tmp/input/config.yaml"); try (Socket socket = new Socket()) { socket.connect(new InetSocketAddress(host, port), 5000); socket.setSoTimeout(10000); DataOutputStream out = new DataOutputStream(socket.getOutputStream()); FileInputStream fis = new FileInputStream(file); // 1. 发送文件名长度 + 文件名 byte[] nameBytes = file.getName().getBytes(StandardCharsets.UTF_8); out.writeInt(nameBytes.length); out.write(nameBytes); // 2. 发送文件大小 long fileSize = file.length(); out.writeLong(fileSize); // 3. 发送文件内容 byte[] buffer = new byte[8192]; int read; long total = 0; while ((read = fis.read(buffer)) != -1) { out.write(buffer, 0, read); total += read; long percent = total * 100 / fileSize; System.out.printf("\r上传进度:%d%% (%d/%d)", percent, total, fileSize); } out.flush(); System.out.println("\n文件上传完成"); } } }4.2 超时设置与传输中断的兜底
这里的connect超时设置5秒,setSoTimeout设置10秒,意思是客户端向服务端写数据时,如果10秒内没有任何数据成功发送出去,就会抛SocketTimeoutException,避免程序挂在那边不动。这对网络抖动频繁的跨机房传输很有用。
注意一个细节:setSoTimeout对BIO的write也有影响,但更关键的是读超时。我们这个场景只发不收,所以平时一般不会触发,但如果以后要加“服务端回执确认”机制,一定要把读超时时间调合理,否则服务端稍微慢一点,客户端就会误判失败。
4.3 进度条打印的体验优化
打印进度这里,我不是每一轮都打印,而是通过\r回车符在同一行刷新百分比。因为文件循环可能很快,如果每读一次就println一行,十几万行日志就能把控制台刷瘫痪。你可以根据文件大小调整打印策略,比如每传输2%打印一次,或者每传输1MB打印一次。
5. 常见问题排查与调优经验
5.1 常见问题速查表
写网络程序,排查问题的时间往往比写代码的时间长。我把实际遇到的典型问题整理成了表格,你可以直接对照着看:
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| 服务端启动报Address already in use | 端口被占用 | 换端口,或用`netstat -anp |
| 客户端连接超时或拒绝 | 服务端没启动、防火墙拦截、IP端口错误 | 先telnet IP 端口测连通性,再查防火墙 |
| 接收到的文件名乱码 | 客户端和服务端编码不一致 | 统一用UTF-8,不要依赖系统默认编码 |
| 文件接收不完整或比原文件大 | 粘包/半包没处理干净 | 严格按协议读:固定长度的头部 + 已知大小的内容 |
| 传大文件时内存溢出 | 用了Files.readAllBytes()一次性读入内存 | 改成BufferedInputStream循环读写,缓冲区8KB到64KB |
| 传输速度很慢 | 缓冲区太小、多次小包传输 | 调整buffer大小,服务端和客户端都用32KB以上缓冲区 |
| 连接数一多服务端就卡死 | 每连接一个Thread导致线程爆炸 | 用线程池限制并发连接数 |
5.2 性能优化:缓冲区到底设多大合适
缓冲区的大小不是越大越好。8KB是一次文件系统IO的常见页大小,很多基础教程都这么写,但在万兆网卡下就能明显感觉到还有优化空间。我自己测试下来,32KB到64KB之间通常是个甜点区,网络吞吐和内存占用比较平衡;再往上,比如1MB,对单文件传输提升有限,反而让内存峰值变高。
如果你追求极致性能,可以改用FileChannel的transferTo()做零拷贝传输。零拷贝意味着数据在内核态直接搬运,减少一次用户态到内核态的拷贝。下面是发送端用零拷贝的简单思路:
try (SocketChannel channel = SocketChannel.open(new InetSocketAddress(host, port)); FileChannel fileChannel = FileChannel.open(Paths.get("/tmp/input/config.yaml"))) { // 这里仍然要先写协议头,只是文件内容改用transferTo fileChannel.transferTo(0, fileChannel.size(), channel); }实际上transferTo的目标是WritableByteChannel,SocketChannel正好满足。但要注意,transferTo一次可能传不完所有字节,需要循环调用,直到返回0。
5.3 断点续传的思路,别一上来就做
很多人一开口就要断点续传。想法很好,但断点续传并不适合在第一步就做,因为它要求双方维护一个“传输状态”:已经传输了多少字节,文件MD5是什么,是否校验过。这些一旦做起来,代码量立马翻倍。
如果真要做,协议可以这样扩展:客户端先发送一个“传输请求”,内容包括文件名、文件总大小、文件已存在的最后偏移量offset。服务端收到后,打开文件并seek到offset位置,然后客户端从offset继续读文件发送。整个流程有点像HTTP的Range请求,逻辑上并不复杂,但要对文件读写异常做更多处理。
我的建议是先把最简单的“整文件传输”跑通,再考虑断点续传。因为它的核心难点不在续传,而在程序怎么感知“上次传到哪里了”,这本身就需要持久化状态,通常直接存到一个临时文件记录偏移量即可。
5.4 关于测试环境的一点提醒
测试时最好在本地用127.0.0.1模拟一遍,再换到真实机器之间测。本地回环网络几乎不会丢包,能帮你先排除网络因素;换到真实机器后,再观察传输速度、超时设置是否合理。我自己踩过的一次坑是:在测试环境一切正常,上了生产环境就频繁连接超时,最后发现是防火墙对非标准端口做了限制,换到服务端配置放行就解决了。
6. 这次实践下来,我最想强调的东西
代码写到这儿,其实核心逻辑也就两百来行。如果让我给新人一句话总结,那就是:先把协议边界定义清楚,再写功能代码。文件名长度、文件大小、谁来读多少个字节,这些在设计阶段就定死,后面所有问题都会少很多。别把文件名和内容一股脑write出去,然后对面read就乱了,这是绝大多数Socket文件传输bug的根源。
另外,如果只是临时传个小文件,原生Socket足够;但如果这个传输工具要长期维护,还要支持断点续传、并发多开、断线重试,我就比较推荐换Netty了。Netty自带LengthFieldBasedFrameDecoder这类解码器,能优雅地处理长度字段,你就不用自己跟readFully和缓冲区死磕了。不过底层原理还是今天讲的这套,把原生Socket跑通了,再去看Netty,你会觉得豁然开朗。
最后再分享一个小经验:写网络传输程序时,日志一定要把“每次write了多少字节、每次read了多少字节”打出来,哪怕只在调试时打印。很多时候问题不是逻辑错,而是某个瞬间的字节数对不上,有这些日志,你一眼就能定位是客户端发少了,还是服务端读多了。
本文还有配套的精品资源,点击获取