简介:这是一份面向Java初学者与网络编程入门者的Socket文件传输示例源码,围绕服务端与客户端之间的文件收发场景展开,帮助读者理解如何用ServerSocket监听端口、用Socket建立连接,并通过字节流完成二进制文件的可靠传输。压缩包共9个文件,约9KB,包含3个java源文件、3个class编译文件,以及project、prefs、classpath等Eclipse工程配置,便于直接导入IDE运行调试。源码覆盖服务端accept接收、客户端发送、缓冲区读写、文件大小预传、传输进度跟踪与异常处理等关键环节,并留有NIO与线程池优化的延伸思路,适合对照学习网络I/O与资源关闭规范。目前已有679人学习下载,可作为课程实验或分布式文件传输练手的参考实现。
1. 从一次文件传输翻车说起:这个 Java Socket 项目到底能干什么
有个做后端的朋友接了个内部需求,要把一台机器上的日志文件定时搬到另一台机器上。他第一反应是上 MQ、上对象存储,结果被运维一句“内网环境,别引入外部依赖”怼了回来。最后他用 Java 原生 Socket 写了个服务端和客户端,两百多行代码搞定,跑了大半年没出过问题。这件事说明一个道理:文件传输这种需求,不一定非要上重型中间件,把 Java socket 编程 的基本功打扎实,很多场景反而更轻、更可控。
这份资源要讲的就是这么一件事——用 Java 的 Socket API 实现一个服务端和客户端之间的文件传输通道。服务端监听端口,客户端连接上来之后把本地文件推过去,服务端接收并落盘。它适合谁?适合刚学完 Java IO 流、想找一个能跑通的完整例子练手的开发者;也适合手头有内网小工具需求、不想引入 Netty 或 FTP 组件的熟手。核心用到的是 ServerSocket、Socket、InputStream、OutputStream 这几样东西,没有框架,没有额外依赖,JDK 自带就能跑。
2. 架构拆解:一个文件从客户端到服务端经历了什么
2.1 整体通信模型
这个项目的结构非常朴素,就是经典的 C/S 模型。服务端启动后在一个固定端口上阻塞等待,客户端发起 TCP 连接,连接建立之后双方通过各自的输入输出流交换数据。文件传输的本质,是把文件拆成字节数组,一段一段从客户端的输出流写出去,服务端从输入流读进来,再写进本地文件。
这里有一个容易被忽略的点:TCP 是流式协议,它不保证你写一次对方就读一次。客户端 write 了 1024 个字节,服务端可能分两次读到,也可能一次读到 2048 个字节(如果客户端连续写了两次)。所以接收端必须循环读取,直到读到的字节数符合预期或者流结束。这个认知是后面所有代码的基础。
常见的做法是:客户端先发送文件名和文件长度,再发送文件内容。服务端先读文件名和长度,然后按长度循环接收,直到收满为止。这样服务端就知道什么时候该停,不会一直阻塞在那里等一个永远不会来的字节。
2.2 服务端职责与关键类
服务端的核心类是ServerSocket,它负责绑定端口和接受连接。每接受一个连接,就拿到一个Socket对象,从这个 Socket 上取InputStream用来读数据,取OutputStream用来回响应。
一个合格的服务端至少要处理三件事:第一,正确解析客户端发来的文件名和文件长度;第二,按长度精确接收文件内容并写入磁盘;第三,处理异常和连接关闭,避免文件句柄或 Socket 泄漏。我一般会把接收逻辑单独抽成一个方法,主循环只负责 accept,这样结构清晰,也方便后面加多线程。
// 服务端:接收文件的核心逻辑 public void receiveFile(Socket socket, String saveDir) throws IOException { DataInputStream dis = new DataInputStream(socket.getInputStream()); // 先读文件名长度和文件名,避免中文乱码用 UTF-8 int nameLen = dis.readInt(); byte[] nameBytes = new byte[nameLen]; dis.readFully(nameBytes); String fileName = new String(nameBytes, StandardCharsets.UTF_8); // 再读文件长度 long fileLen = dis.readLong(); File target = new File(saveDir, fileName); try (FileOutputStream fos = new FileOutputStream(target)) { byte[] buf = new byte[4096]; long received = 0; while (received < fileLen) { int toRead = (int) Math.min(buf.length, fileLen - received); int n = dis.read(buf, 0, toRead); if (n == -1) break; // 对端提前关闭 fos.write(buf, 0, n); received += n; } } }这段代码里,DataInputStream是对InputStream的包装,提供了readInt()和readLong()这种按类型读的方法,省得自己手动拼字节。readFully保证把指定长度的字节全部读满,不会出现读一半的情况。接收循环里用fileLen - received算出剩余需要读的字节数,再和缓冲区大小取最小值,这样最后一次读取不会多读。received变量是判断是否收完的唯一依据,不能靠read返回 -1,因为对端正常关闭时也会返回 -1,但那时候可能还没收完。
2.3 客户端职责与关键类
客户端用Socket连接服务端地址和端口,拿到OutputStream之后,先把文件名长度、文件名、文件长度写过去,再循环写文件内容。这里同样要用DataOutputStream来写,保证和服务端的读取顺序一致。
// 客户端:发送文件的核心逻辑 public void sendFile(String host, int port, File file) throws IOException { try (Socket socket = new Socket(host, port); DataOutputStream dos = new DataOutputStream(socket.getOutputStream()); FileInputStream fis = new FileInputStream(file)) { byte[] nameBytes = file.getName().getBytes(StandardCharsets.UTF_8); dos.writeInt(nameBytes.length); // 文件名长度 dos.write(nameBytes); // 文件名 dos.writeLong(file.length()); // 文件长度 dos.flush(); byte[] buf = new byte[4096]; int n; while ((n = fis.read(buf)) != -1) { dos.write(buf, 0, n); } dos.flush(); } }客户端的顺序必须和服务端严格对应:先writeInt写文件名长度,再write写文件名,再writeLong写文件长度,最后写内容。任何顺序错位都会导致服务端解析出错误的文件名或长度,进而卡死或者写出损坏的文件。flush()在关闭流之前调用一次,确保缓冲区里的数据全部发出去。用 try-with-resources 管理 Socket 和流,方法结束时自动关闭,不用手动写 finally。
2.4 目录结构建议
这个项目不需要复杂的工程结构,但建议至少分成三个文件:FileServer.java负责监听和接收,FileClient.java负责连接和发送,FileTransferTest.java作为入口分别启动两端。如果要做成可配置的,可以加一个config.properties放端口号和保存目录。常见做法是把服务端和客户端放在同一个包下,方便调试时直接在一个 IDE 里跑两个 main 方法。
3. 跑起来:从零到一次完整传输的操作步骤
3.1 环境准备与编译
这个项目对 JDK 版本没有特殊要求,JDK 8 及以上都能跑。不需要 Maven 或 Gradle,直接用javac编译就行。如果你习惯用 IDE,新建一个普通 Java 项目,把三个类放进去即可。
# 假设源码在 src 目录下 javac -d out src/com/example/transfer/*.java编译完成后,out目录下会有对应的 class 文件。如果代码里用了StandardCharsets,确认 JDK 是 7 以上,JDK 8 肯定没问题。
3.2 启动服务端
服务端需要先启动,因为它要绑定端口并进入监听状态。启动命令里传入端口号和保存目录两个参数。
java -cp out com.example.transfer.FileServer 9000 /data/received服务端启动后会打印一行日志,类似“服务端已启动,监听端口 9000,保存目录 /data/received”。这时候它阻塞在accept()上,等待客户端连接。如果端口被占用,会抛出BindException,换一个端口就行。保存目录如果不存在,代码里应该先mkdirs()创建,否则写文件时会报FileNotFoundException。
3.3 启动客户端并发送文件
客户端在另一个终端启动,传入服务端地址、端口和要发送的文件路径。
java -cp out com.example.transfer.FileClient 127.0.0.1 9000 ./test.log客户端连接成功后开始发送,发送完毕关闭连接。服务端收到文件后写入指定目录,然后继续等待下一个连接。你可以在服务端目录下用ls -l确认文件大小和源文件一致。如果文件比较大,比如几百 MB,传输过程中可以用watch -n 1 ls -l观察文件大小增长,确认数据在持续写入。
3.4 验证传输完整性
传输完成后,最直接的验证方式是对比文件大小和 MD5。在客户端机器上算一次 MD5,在服务端机器上算一次,两个值一致就说明传输没有丢字节。
# 客户端 md5sum ./test.log # 服务端 md5sum /data/received/test.log如果 MD5 不一致,大概率是接收端没有按长度精确接收,或者发送端在写文件名和长度之后没有 flush 就写内容,导致服务端读到的长度字段是脏数据。这种情况在本地回环地址上不容易复现,因为本地传输太快,缓冲区不会积压;一旦跨机器,问题就暴露了。
提示:测试时先用小文件(几 KB)验证流程,再用大文件(几百 MB)验证稳定性和内存占用。大文件传输时注意观察 JVM 内存,如果代码里是一次性把文件读进 byte 数组,大文件会直接 OOM。
4. 避坑指南:文件传输里最容易翻车的几个点
4.1 文件名乱码
现象:服务端收到的文件名是乱码,比如“测试.log”这种。原因:客户端用file.getName().getBytes()默认平台编码,服务端用new String(bytes)默认平台编码,两边编码不一致时就乱了。解决:两端统一用StandardCharsets.UTF_8,发送端getBytes(UTF_8),接收端new String(bytes, UTF_8)。不要依赖默认编码,那是个玄学。
4.2 接收端提前退出循环
现象:文件传了一半就停了,服务端文件大小比源文件小。原因:接收循环里用while ((n = dis.read(buf)) != -1)作为条件,但 TCP 连接在数据传输过程中可能因为网络抖动触发一次 read 返回 -1,实际上对端还在发。解决:不要用 -1 作为唯一判断,而是用已接收字节数和文件总长度比较,收满fileLen才退出。read返回 -1 只在对端真正关闭连接时出现,但为了健壮性,还是按长度控制更可靠。
4.3 大文件导致内存溢出
现象:传一个 500MB 的文件,客户端或服务端抛OutOfMemoryError。原因:代码里用了Files.readAllBytes()或者一次性把整个文件读进byte[]。解决:永远用固定大小的缓冲区循环读写,比如 4096 或 8192 字节。缓冲区大小不是越大越好,4KB 到 8KB 在大多数场景下足够,再大对吞吐量提升有限,反而占内存。
4.4 端口被占用或防火墙拦截
现象:客户端连接时报Connection refused或ConnectException。原因:服务端没启动、端口写错、或者中间有防火墙。解决:先用telnet 服务端IP 端口确认端口通不通,不通就检查服务端进程是否在跑、端口是否被其他程序占用(netstat -tlnp | grep 端口)。如果是跨机器,确认防火墙规则允许该端口入站。
4.5 传输完成后连接没有关闭
现象:服务端处理完一个文件后,客户端进程不退出,或者服务端 accept 阻塞住不再接受新连接。原因:Socket 没有关闭,或者流没有关闭导致底层连接保持。解决:用 try-with-resources 包裹 Socket 和所有流,方法结束自动关闭。服务端在处理完一个连接后,显式调用socket.close(),然后回到 accept 循环。不要在一个连接上处理多个文件,除非你设计了明确的分隔协议。
5. 二次开发:把这个 Demo 改成能用的工具
5.1 加多线程支持并发传输
现在的服务端是单线程的,一次只能处理一个客户端。如果多个客户端同时连上来,后面的会排队等待。改造方式很简单:在 accept 之后,把处理逻辑丢给一个线程池。
ExecutorService pool = Executors.newFixedThreadPool(10); while (true) { Socket socket = serverSocket.accept(); pool.submit(() -> { try { receiveFile(socket, saveDir); } catch (IOException e) { e.printStackTrace(); } finally { try { socket.close(); } catch (IOException ignored) {} } }); }线程池大小根据实际并发量调整,10 到 50 都算常见。注意每个线程里要单独处理异常和关闭 Socket,不能让一个连接的异常影响主循环。
5.2 加进度显示和断点续传
进度显示比较简单,客户端每写一块就累加已发送字节数,除以文件总长度得到百分比,打印到控制台。断点续传要复杂一些:客户端在发送前先问服务端“这个文件你已经收了多少”,服务端返回已接收的字节数,客户端从那个偏移量开始续传。这需要设计一个简单的请求-响应协议,比如客户端先发一个命令字,服务端回一个 long 值。
// 断点续传的协议设计示例 // 客户端发送:CMD_RESUME(1字节) + 文件名长度(4字节) + 文件名 // 服务端返回:已接收字节数(8字节) // 客户端根据返回值决定从哪个位置开始读文件这个改造的关键是服务端要记录每个文件的接收进度,可以用一个Map<String, Long>存在内存里,也可以落盘到一个进度文件。注意并发情况下要加锁,否则两个客户端同时续传同一个文件会写乱。
5.3 加校验和重传
传输完成后,客户端把源文件的 MD5 发给服务端,服务端对比本地文件的 MD5,不一致就通知客户端重传。这个机制在不可靠网络下很有用。实现上就是在文件内容发送完之后,再发一个 16 字节的 MD5 值,服务端接收后计算本地文件 MD5 并比较。如果不一致,服务端回一个失败标记,客户端重新发送整个文件。虽然简单粗暴,但在内网环境下足够用。
5.4 我踩过的一个坑
早期我在客户端写完文件内容后直接关闭了 Socket,没有调用flush()。本地测试一切正常,因为数据量小,缓冲区自动刷出去了。后来传一个 200MB 的文件,服务端收到的文件总是少最后几 KB。排查了半天才意识到,DataOutputStream的缓冲区在close()时会 flush,但如果代码里先关了FileInputStream再关 Socket,顺序不对就可能丢数据。从那以后我每次写完数据都强制flush()一次,再关闭流。这个习惯看起来多余,但能省掉很多莫名其妙的“文件损坏”问题。希望帮到你。
本文还有配套的精品资源,点击获取