ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java Socket文件传输实战:从协议设计到MD5校验的完整落地

Java Socket文件传输实战:从协议设计到MD5校验的完整落地 简介面向Java初学者的套接字编程文件传输示例基于服务端与客户端两类套接字实现文件点对点发送适用于分布式系统、网络应用或课程设计等教学场景。资源包共9个文件包含3个Java源码、3个已编译的class文件以及Eclipse工程配置整体仅9KB代码精简便于快速通读和运行。目前已有679人学习下载。示例完整演示了服务端通过accept等待连接客户端发起连接后用文件输入输出流读取本地文件并通过网络字节流发送接收端边读边写入磁盘传输前先发送文件大小信息便于接收端预分配通过read方法返回-1判断结束并使用缓冲区减少I/O次数。异常处理采用try-catch-finally关闭流避免资源泄漏同时还给出NIO与线程池优化思路可帮助读者从基础实现走向并发网络通信代码结构清晰适合初学者对照练习也可改造为小型文件传输工具或用于面试复习。1. 从 Socket 到落盘Java 文件传输为什么值得自己写一遍某个内部对接场景里数据提供方只给了一个端口号要求把一批报表文件按时传过去并落盘传完还要确认成功与否。第一反应是上 HTTP 接口结果发现 multipart 解析、进度回执、批量重试都要自己绕绕到最后反而发现Java 自带的 Socket 方案最顺手服务端开一个 ServerSocket 监听端口客户端用一个 Socket 发起连接中间是 InputStream 和 OutputStream 两条流像读写本地文件一样读写网络。很多人以为这只是学校实验实际上内部离线包分发、日志收集、报表传递都能直接套这套代码。这篇按真实落地顺序来讲先设计文件协议再写服务端接收和客户端发送最后列出我踩过的几个坑以及能直接用的校验和压缩方案。2. 先想清楚协议再写代码Java 文件传输的四个基础选择2.1 用 TCP Socket 而不是 HTTPJava 文件传输的选型理由要写服务端和客户端文件传输我建议先别急着上框架把传输通道选定。两个 Java 程序之间传文件最可控的方式就是 TCP SocketJava 把 TCP 封装成 Socket拿到的是干净的 InputStream 和 OutputStream读网络数据就像读文件写网络数据就像写文件中间没有框架强加的报文格式。HTTP 虽然也基于 TCP但请求行、Header、Body、连接管理等规则已经固定你在服务端通过 Servlet 或 WebFlux 接收文件时实际上是在和一个框架打交道。文件命名、断点续传、限速、校验和这些细节要么被框架的路由规则挡住要么得额外写很多钩子方法。另一个常见候选是 UDP但如果走 UDP报文会乱序、丢失、重复而文件传输要求整包完整你必须自己实现确认与重发等于在应用层重新造一个 TCP。所以在服务端和客户端文件传输这个场景里TCP Socket 是性价比最高的选择。适用场景很明确内网里让两个 Java 进程之间传日志、离线安装包、报表、配置文件文件数量多、单个文件大而且需要自己掌控超时、进度和错误分类。当然它不适合公网大流量分发也不适合替代 HTTPS 那套安全层安全传输建议在程序外部加加密隧道或者直接选用现成的文件服务组件。2.2 先定协议文件名、文件大小与分块边界TCP 是流协议服务端和客户端之间只有连续的字节没有“文件名在哪结束、文件体在哪开始”的天然边界。如果只把文件内容 write 进 Socket接收方拿到数据后不知道文件名也无法确认什么时候收完。因此动手写收发代码之前必须先定义应用层协议。我在做这类传输时最常用的格式是“文件头 文件体”文件头就三个字段文件名字节长度、文件名 UTF-8 字节、文件大小加起来通常是 9 加 N 个字节。// 发送端先写文件头再写文件体 byte[] nameBytes fileName.getBytes(StandardCharsets.UTF_8); out.writeByte(nameBytes.length); // 1 字节最大 255 out.write(nameBytes); // 文件名 UTF-8 字节 out.writeLong(fileSize); // 8 字节long 大端序 out.flush();// 接收端先读文件头再读文件体 int nameLen in.readByte() 0xFF; byte[] nameBytes new byte[nameLen]; in.readFully(nameBytes); String fileName new String(nameBytes, StandardCharsets.UTF_8); long fileSize in.readLong(); System.out.printf(接收文件: %s, %d 字节%n, fileName, fileSize);这里为什么自己拼文件名长度而不直接用 writeUTF 和 readUTFwriteUTF 用的是 modified UTF-8自带一个 2 字节长度头看起来省事但它和文件二进制内容混在一起时容易让人误会边界长度也被限制在 65535 字节内。自己用 1 字节长度加原始 UTF-8 字节协议更直白其他语言写客户端对接时也更容易照着实现。还有个细节容易被忽略readByte() 返回的是有符号 byte如果文件名长度超过 127 字节中文文件名很容易读出来的值就成了负数必须用 0xFF 还原成 0 到 255 的无符号整数。readFully 则保证把 nameBytes 读满不会读了一半就继续往下执行。文件大小用 long 不用 int单个文件超过 2GB 时 int 会溢出成负数这是给离线数据包传大文件时最容易踩的雷。字段占用说明文件名字节数1 字节取值范围 0-255接收端做无符号处理文件名内容N 字节UTF-8 编码不含结尾符文件大小8 字节总字节数long 类型把文件长度放在第三个字段是有原因的接收端先知道文件叫什么再知道要收多大后面才能用剩余字节数控制循环。如果顺序反了读取逻辑会绕一大圈。2.3 连接建立与参数服务端和客户端握手时要想好的三件事文件协议定了之后还要决定 Socket 的建立方式和超时参数。常见做法是服务端绑定端口客户端用无参构造再显式 connect如果写成 new Socket(host, port)连接建立超时完全由操作系统默认控制网络异常时线程可能卡住很久。无参构造加 connect(host, port, timeout) 可以把超时限定在 5 秒内超时立刻抛异常用户端就能快速提示“目标不可达”。另一个容易翻车的参数是 ServerSocket 的 backlog。new ServerSocket(port) 底层默认队列长度较小连接请求多时即使端口正常也会出现 connect 被拒绝。我在一个每分钟会涌来上百个任务的调度场景里把 backlog 调到 64 后客户端连接成功率立刻恢复正常。读写超时则由 socket.setSoTimeout(30000) 控制它约束的是 accept 之后 read 和 write 的阻塞时间对端连上但不发数据时30 秒后抛 SocketTimeoutException服务端线程不会无限挂起。代码这样写ServerSocket serverSocket new ServerSocket(9000, 64); // backlog64 Socket clientSocket serverSocket.accept(); clientSocket.setSoTimeout(30_000); Socket socket new Socket(); socket.connect(new InetSocketAddress(127.0.0.1, 9000), 5000); socket.setTcpNoDelay(true);setTcpNoDelay(true) 关闭的是 Nagle 算法文件头、ACK 这类小包在局域网传输时延迟会明显降低。如果走公网或弱网传输又希望把包尽可能合并可以不开这个开关。这里没有标准答案但内网传文件我一般开着进度反馈会来得更快。参数建议值作用connect 超时3000-5000ms让客户端快速发现不可达backlog50-128等待队列长度防突发连接被拒SO_TIMEOUT30s读写阻塞上限防线程挂死TcpNoDelaytrue关闭 Nagle降低小包延迟3. 服务端接收文件的完整实现监听端口与按连接落盘3.1 服务端监听骨架ServerSocket 与每连接一个线程服务端要做的事情一句话概括开端口、循环 accept、每个连接独立处理。很多人会写一个单线程循环在处理方法里把文件收完再去 accept 下一个。这样做的风险是第一个文件传输慢时第二个客户端连接只能在队列里等表现出来就是连上了但迟迟没有响应。常见做法是 accept 后直接丢给一个线程或线程池处理。public class FileServer { private static final int PORT 9000; private static final String SAVE_DIR ./uploads; private static volatile boolean running true; public static void main(String[] args) throws IOException { Files.createDirectories(Paths.get(SAVE_DIR)); ServerSocket serverSocket new ServerSocket(PORT, 64); while (running) { try { Socket socket serverSocket.accept(); new Thread(() - handleClient(socket), file-transfer-handler).start(); } catch (IOException e) { if (!running) { break; } } } serverSocket.close(); } }accept 在 running 为 true 时阻塞只有收到连接或出现异常时才返回。每连接一个线程代码最直观适合文件数量不多、并发有限的服务。如果并发高把 new Thread 换成固定线程池更稳比如 Executors.newFixedThreadPool(8)避免无限新建线程拖垮系统。running 用 volatile是为了在需要停服时修改标志位让循环退出而不是去调用已经废弃的 Thread.stop()。catch 里的 running 判断也很关键主动关闭 ServerSocket 会触发 accept 抛异常这时不该继续循环打印堆栈。3.2 接收数据落盘从 InputStream 循环读到 FileOutputStream文件头解析之后进入文件体接收。最关键的写法不是“直接 read 到 EOF”而是按期望的剩余字节数控制循环不能多读一个字节。如果连接里只传一个文件多读一点问题不大如果一条连接要连续传多个文件多读的字节就是下一个文件头的一部分协议立刻乱掉。private static void handleClient(Socket socket) { try (Socket s socket; DataInputStream in new DataInputStream(s.getInputStream())) { int nameLen in.readByte() 0xFF; byte[] nameBytes new byte[nameLen]; in.readFully(nameBytes); String fileName new String(nameBytes, StandardCharsets.UTF_8); long fileSize in.readLong(); String target Paths.get(SAVE_DIR, fileName).toString(); try (FileOutputStream fos new FileOutputStream(target)) { byte[] buffer new byte[64 * 1024]; long remaining fileSize; int len; while (remaining 0 (len in.read(buffer, 0, (int) Math.min(buffer.length, remaining))) ! -1) { fos.write(buffer, 0, len); remaining - len; } if (remaining ! 0) { System.err.println(文件不完整: 期望 fileSize 字节, 实际少 remaining); } } } catch (IOException e) { e.printStackTrace(); } }这里有几个地方要重点解释。第一Math.min(buffer.length, remaining) 的作用是防止越过文件边界。假设 buffer 是 64KB文件体只剩 10KB如果直接 read(buffer)可能把这 10KB 以及后续下一个文件头的字节一起读进来。用 Math.min 限制本次读取长度确保不会多读。第二fileSize 是 long转成 int 前先用 Math.min 压在 buffer 长度以内所以不会溢出也就不用担心大文件长度转换出错。第三循环退出后要检查 remaining大于 0 说明连接提前断开文件没传完小于 0 说明读多了这是协议实现有 bug需要回头查接收方的循环条件。try-with-resources 能保证 Socket 和输入流一定关闭。文件输出流的 try 单独拆开是为了让文件写坏时输出流也能关闭不影响外层连接的释放。我第一次写这段时把它们混在一个 try 里文件写异常后 Socket 没有正常关闭服务端 fd 泄漏排查了很久才发现原因。3.3 服务端三个必调参数缓冲区、超时和并发数服务端的传输效率其实由三个参数决定缓冲区大小、超时时间、并发模型。缓冲区建议在 8KB 到 64KB 之间。太小比如 1KB系统调用次数多CPU 占用高太大比如 4MB在普通内网没有明显收益还会让每条连接占用的内存放大。64KB 在局域网里已经足够稳定。超时前面说了 SO_TIMEOUT这里再强调它只管读不管写。Java 标准库对写阻塞没有直接的超时控制生产环境里要尽量通过对端及时读取来避免写阻塞如果对端不读发送方的 write 会一直卡住这是 TCP 背压的正常表现不能被误判为网络断开。并发模型上每连接一个线程适合偶发任务文件多、并发高时改用固定线程池更合适。我给一个最小替换写法ExecutorService pool Executors.newFixedThreadPool(8); pool.execute(() - handleClient(socket));参数建议值影响bufferSize64 * 1024单次 read/write 的数据量SO_TIMEOUT30_000 msread 卡住时的最大等待时间线程池大小8 或 CPU 核数 * 2可同时处理的连接上限如果是常驻服务我一般还会给 ServerSocket 加一个看护线程定期检查 accept 是否还在阻塞防止某个异常状态让监听线程无声退出。脚本式 Demo 不需要但常驻服务值得加。4. 客户端发送端连接复用、进度反馈与断点续传边界4.1 客户端连接与文件信息发送先报告名字和大小客户端做的事情跟服务端对称建立连接、写文件头、然后写文件体。连接部分要用无参构造加 connect 超时避免“目标不可达”时长时间卡死。文件头部分要注意文件名取 Path.getFileName().toString()不要带前面的目录。如果客户端传的是 /data/tmp/report.zip而服务端直接把完整路径拼在保存目录后面落盘时可能生成多层目录甚至因为路径非法而写入失败。public class FileClient { public static void main(String[] args) { String host 127.0.0.1; int port 9000; Path filePath Paths.get(./data/report.zip); try (Socket socket new Socket()) { socket.connect(new InetSocketAddress(host, port), 5000); DataOutputStream out new DataOutputStream(socket.getOutputStream()); byte[] nameBytes filePath.getFileName().toString().getBytes(StandardCharsets.UTF_8); out.writeByte(nameBytes.length); out.write(nameBytes); out.writeLong(Files.size(filePath)); out.flush(); sendBody(socket, filePath); } catch (IOException e) { System.err.println(发送失败: e.getMessage()); } } }Files.size(filePath) 返回的是 longwriteLong 和 readLong 在 Java 两端默认走大端序不用额外约定。文件头写完后先 flush 一次再发文件体。如果不 flush这个文件头可能和第一块文件内容合并成一个 TCP 段发出对端也能解析但服务端要等第一个数据块到达后才能开始读文件头交互上多了一次小包聚合调试时也更难判断到底是哪段数据出了问题。4.2 发送循环与进度反馈边读边发的正确节奏文件体发送的核心是一个读取本地文件、写出到 Socket 的循环。这里最容易犯的错是把整个文件读进 byte[] 再一次写出。对小文件问题不大对大文件就是内存炸弹。正确做法是用缓冲区边读边发同时算进度百分比。private static void sendBody(Socket socket, Path filePath) throws IOException { long total Files.size(filePath); try (DataOutputStream out new DataOutputStream(socket.getOutputStream()); InputStream in Files.newInputStream(filePath)) { byte[] buffer new byte[64 * 1024]; long sent 0; long lastPercent -1; int len; while ((len in.read(buffer)) ! -1) { out.write(buffer, 0, len); sent len; long percent sent * 100 / total; if (percent ! lastPercent) { System.out.printf(已发送 %d%% (%d/%d 字节)%n, percent, sent, total); lastPercent percent; } } out.flush(); System.out.println(发送完成总字节数 sent); } }这版代码有几个细节要说明。第一用 Files.newInputStream 而不是 new FileInputStream写法更简洁也兼容 Path。第二read 返回的 len 可能小于 buffer 长度尤其是文件末尾write 时必须写 len 而不是整个 buffer否则会把上一次读到的残留数据重复发出去。第三进度百分比用 long 计算避免中间相乘溢出如果文件本身是 0 字节total 为 0 会导致除零发送空文件的场景要单独判断可以在文件头里直接声明 0 字节然后结束。第四flush 只在全部数据写完后调用一次不要在循环里每块都 flush否则会把一次次小包立刻推给内核吞吐量明显下降。4.3 断点续传的边界偏移量放哪儿、怎么接着传断点续传是最容易想当然的功能。真正难点不是“从第几字节继续写”而是双方要有共同认可的偏移量并且要处理断线期间文件被修改的情况。最简单实现是客户端重连后把已发送的字节数 offset 放在协议里传给服务端服务端用 RandomAccessFile 打开目标文件seek(offset) 后继续写剩余部分。long offset getRemoteOffset(); // 从服务端查询已写入长度 RandomAccessFile raf new RandomAccessFile(targetFile, rw); raf.seek(offset); byte[] buffer new byte[64 * 1024]; int len; while ((len in.read(buffer)) ! -1) { raf.write(buffer, 0, len); } raf.close();这样写有两个隐形坑。一是断线期间文件如果被修改过seek 和现有内容对不上续传结果是一份错位文件。二是没有对 offset 做合法性校验服务端要结合实际文件大小来判断如果 offset 大于文件大小说明客户端和服务端记录的版本不一致应该直接拒绝续传并重新全量传输。更稳的方案是把偏移量定义成“服务端已落盘完整字节数”续传前重新发一次文件头声明 startOffset服务端先 truncate 到 offset 再继续写。这个功能我建议放在第二阶段做。第一版先把全量传输跑通日志打清楚再考虑断点续传一上来就把断点逻辑混进主流程一旦出错你很难判断是传输本身的问题还是偏移量计算的问题。5. 避坑指南文件传输中最容易翻车的五个场景5.1 大文件 OOM把整个文件 readAllBytes 的后果现象客户端传一个 2GB 的压缩包刚启动几秒堆内存快速占满还没发多少数据就 OOM。原因代码里用了 Files.readAllBytes() 或者 IOUtils.toByteArray()整个文件被一次性装进 byte[]。文件多大内存就要多大有两个客户端同时传直接翻倍。解决改成固定缓冲区的边读边写循环服务端和客户端都严格限制单次读写大小。缓冲区 64KB 足够在高速内网上也不会成为瓶颈。弱网条件下大缓冲区反而会让单次 write 等待时间变长客户端不好判断进度。文件传完后堆内存里的临时数据就只有缓冲区那么大这是最直观的收益。5.2 中文文件名乱码writeUTF 与编码不一致的坑现象客户端在中文 Windows 上传“考核表.pdf”服务端落盘成了“è€ƒæ ¸è¡¨.pdf”或者一串问号。原因String.getBytes() 不带编码参数时使用系统默认编码Windows 通常是 GBKLinux 是 UTF-8。协议没有把文件名编码固定两端各按各的默认值解码结果自然对不上。解决协议层直接规定文件名一律用 UTF-8 字节。发送端写上 StandardCharsets.UTF_8接收端 new String(bytes, StandardCharsets.UTF_8) 也显式指定不要用无参构造。这个坑在跨 Windows 和 Linux 平台对接时几乎每次都会遇到提前在协议里写死编码能省很多事。5.3 连接发送到一半断开把资源释放和半成品处理做好现象文件只传到 30% 网络闪断服务端线程抛 SocketException: Connection reset工作目录里留下一个 30% 大小的残废文件。原因对端关闭时 read 会返回 -1 或直接抛 IOException代码只打印了异常没有清理已经写入一半的文件。解决接收时先写临时文件比如 xxx.part全部接收完成后再改名成正式文件。这样进程崩溃或断线时目录里只剩一个 part 文件重试任务可以把它当垃圾清理不会误用半成品。Path tmp Paths.get(SAVE_DIR, fileName .part); Path target Paths.get(SAVE_DIR, fileName); // 全部收完且校验通过后 Files.move(tmp, target, StandardCopyOption.ATOMIC_MOVE);ATOMIC_MOVE 在大部分本地文件系统上都支持能保证改名是一个原子操作如果你用的是不支持原子移动的远程存储至少也要用 REPLACE_EXISTING 加 try-catch避免改名失败时把原文件弄丢。5.4 同一条连接传多个文件粘包导致后续文件解析错乱现象一个连接里连续传两个文件第二个文件的文件名变成乱码文件大小变成一个天文数字服务端 readLong 读到负数长度后卡死。原因TCP 是流协议没有天然的文件边界。如果接收方用一次 read 就当做一个文件甚至把后面文件的字节提前读走后续解析就全乱了。解决严格按“文件头 文件体长度”处理。文件体只读 fileSize 个字节剩下的继续留给下一个文件头解析。只要循环条件是 remaining 0read 第三个参数用 Math.min(buffer.length, remaining)就不会把下一个文件头部吃进来。这就是我在接收端强调“按剩余字节数控制循环”的原因。5.5 发送顺序写反服务端解析卡死是另一个常见翻车现象服务端先读文件头结果读到的第一个字节是文件内容readLong 读出一个巨大负数随后循环里的 read 一直等到超时客户端却停在发送阶段两边互相等。原因客户端没有把文件名和文件大小写在前面或者写了文件头但没有 flush而文件体又很快发出来实际字节顺序已经乱了。解决协议顺序必须在代码里唯一化先 writeByte(nameLen)、write(nameBytes)、writeLong(fileSize)然后 flush再发送文件正文。文件头 flush 是个好习惯能确保对端先拿到元信息提前做创目录、查重名、开输出流等准备。注意服务端 readLong 得到负数时多半不是文件大小算错而是读取位置已经跑到了文件内容里。优先检查发送端是不是把头和正文的顺序搞反了。6. 让传输更稳的进阶写法MD5 校验、压缩传输与生产落地习惯6.1 加一层校验发送完文件先算 MD5字节数一致不代表内容一致文件传完至少要加一层摘要校验。客户端在发送循环里用同一个 byte[] 边读边更新 MessageDigest文件发送完后再把摘要写到流末尾服务端读完文件后自己再算一遍摘要一致才算成功。大文件不想二次读盘可以在写文件时同步更新摘要。校验失败就删掉临时文件不要留一份坏数据在线上目录里。MessageDigest md MessageDigest.getInstance(MD5); while ((len in.read(buffer)) ! -1) { fos.write(buffer, 0, len); md.update(buffer, 0, len); } String localHex HexFormat.of().formatHex(md.digest()); String remoteHex in.readUTF(); if (!localHex.equalsIgnoreCase(remoteHex)) { Files.deleteIfExists(tmpPath); throw new IOException(MD5 校验失败); }这段代码我习惯放在服务端循环之后先校验、后改名。校验失败直接删掉 part 文件下次重传还是干净的。只比较文件大小会漏掉内容被篡改的情况生产环境别省这一步。6.2 压缩传输和临时文件命名一个可以立刻用起来的生产习惯大多数文件都有可压缩空间传输前用 GZIP 包一层能省不少带宽。发送端用 GZIPOutputStream 包住 Socket 输出流接收端用 GZIPInputStream 包住输入流两边一对就完事。要注意 GZIPOutputStream 的数据必须显式调用 finish()它会把压缩流末尾的完整字节写出去如果只靠 close() 触发外层流的关闭顺序容易被 try-with-resources 搞乱尾部数据没刷出去对端就解压不完整。我最后一个习惯是所有落盘先写 .part全部收完且校验通过后再 rename 成正式文件名。有一次某个离线任务传库文件服务端写了一半被重启留下一个没有后缀的临时文件第二次重推因为先检查“文件是否存在”而直接跳过结果线上一直跑着旧数据。后来我规定收完必须改名判断文件是否有效必须看“大小加 MD5”而不是只看文件在不在。这个习惯救了我后续很多次也希望这篇 Java 文件传输的落地笔记能帮到你少踩一点我当年踩过的坑。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进