如果你学过一段时间的网络编程,大概率会碰到一个经典练习:用 TCP Socket 传文件。我当年第一次做这个案例时,以为就是把FileInputStream读出来的字节丢给Socket,结果被粘包、半包、中文文件名、服务器卡死、文件损坏这些问题轮番教育了一遍。后来在真实项目里维护过一套基于 TCP 的二进制传输组件,回头再看这个教学案例,才发现它并不“简单”,它把连接管理、字节流边界、阻塞 IO、异常处理、资源释放全部压缩在一个几十行的 Demo 里。
这篇文章我就以“TCP 通信综合案例——文件上传”为线索,把背后的协议设计、代码实现、踩坑经历完整拆开讲一遍。不管你是刚学 Socket 的初学者,还是想补网络编程底层细节的开发者,这套思路换到任何二进制协议场景都适用。
1. 案例全景:为什么文件上传是TCP绕不开的试金石
1.1 一个案例暴露的通病
文件上传的入门版本通常很朴素:客户端开一个Socket,把文件字节流全部写进去,服务端把字节流全部读出来,保存成文件。第一次能跑通的人都会很开心,但稍微换一个场景就崩:
- 文件名超过 255 字节,缓冲区装不下。
- 文件是二进制,不能按“行”读,不能按“字符”读。
- 客户端发送太快,服务端一次性读到两条数据,也就是“粘包”。
- 网络慢时,一次
read只读到半个文件,也就是“半包”。 - 服务端把所有字节一次性
readAllBytes,大文件直接内存溢出。 - 传输中途客户端异常断开,服务端并不知道文件是否完整。
你会发现,几乎每一个问题都不是“语法错误”,而是对 TCP 模型理解不深。搞明白这个案例,等于把 TCP 是个“可靠的字节流管道”这个概念从口号变成了肌肉记忆。
1.2 先把协议订明白,比写代码重要十倍
写文件上传最忌讳一上来就敲Socket代码。我现在的习惯是先在文档里把“线协议”写清楚,也就是定义字节流里面每个字节的含义。
一个最小可用的文件上传协议,至少要有这些字段:
| 字段 | 类型 | 作用 |
|---|---|---|
| 魔数 | 4 字节 int | 防止连接发错端口或收到垃圾数据 |
| 文件名长度 | 4 字节 int | 解决“文件名该读多长”的问题 |
| 文件名 | 变长字节 | 用 UTF-8 编码,兼容中文 |
| 文件大小 | 8 字节 long | 告诉服务端要读多少字节正文 |
| 文件内容 | 变长字节 | 真正的二进制数据 |
| 服务端状态 | 1 字节 | 0 表示失败,1 表示成功 |
| 状态消息长度 | 4 字节 int | 配合消息内容使用 |
| 状态消息 | 变长字节 | 给客户端可读的错误原因 |
这个协议可以继续加字段,比如 CRC 校验、分块序号、命令字,但核心思路不变:TCP 只保证数据按顺序到达,不保证一次读写就是一个完整消息,所以我们需要自己定义消息边界。
1.3 技术选型:原生Socket仍是默认答案
有人会问,现在都有 Netty、Mina,为什么还要手写原生 Socket?
我的看法是:Netty 是生产级框架,它帮你处理了粘包半包、线程模型、ByteBuf 池化,但如果不懂底层,出了问题根本无从排查。这个案例的价值在于亲手感受阻塞式 BIO 的细节,然后在框架里找到对应概念。
原生 Socket 在这个场景下的优点很明显:
- 几十行代码就能说明白整个流程。
- 可以直接看到
DataInputStream、DataOutputStream的行为。 - 性能足够支撑教学级和中小文件传输。
- 方便在本地抓包验证。
缺点也很明显:连接数多时线程模型会成瓶颈。但这个案例主要解决“如何正确传完一个文件”,所以原生 Socket 是合适的选择。
2. 核心细节拆解:字节流、粘包与流的正确姿势
2.1 TCP看不到“文件”,只有无边界的字节流
TCP 是流协议,它不保留应用层消息的边界。你调用一次write(byte[]),服务端可能一次read读完,也可能分两次读完;反过来,你调用两次write,服务端可能一次read读到两次的数据。这就是粘包和半包的根源。
很多人第一次看到服务端“文件名乱码、文件内容漂移”,第一反应是查编码,其实真正的问题是读取边界没有控制好。
解决边界问题的标准做法是“长度前置”:先发送固定字节数的长度信息,再发送变长数据。比如文件名,就是“4 字节长度 + N 字节内容”。服务端先严格读 4 字节,拿到 N,再严格读 N 字节,这样无论底层怎么粘包半包,应用层都能切出正确的消息。
在 Java 里,DataInputStream提供了readInt()、readFully(byte[])这类方法,它们会阻塞直到读满指定字节数,天然适合处理固定长度头部。但要注意,readFully是阻塞的,如果连接长期不发送,需要配合Socket.setSoTimeout使用,否则线程会挂住。
2.2 文件名和消息头怎么设计才不会踩坑
消息头里的每个字段都有讲究。先说魔数。魔数是一个固定值,比如0x23332333,客户端先发它,服务端读到了才能继续。如果服务端运行在公网环境,经常会收到一堆乱七八糟的扫描流量,有了魔数就可以快速拒绝无效连接。
文件名长度为什么要单独用 4 字节?因为文件名是变长的,如果不在前面告诉长度,服务端只能读到换行符或 EOF,但文件内容本身可能包含这些字节,整个解析就乱了。给文件名预留 4 字节,最长可以表示约 2GB 的字节数,正常文件名完全够用。
文件大小用 8 字节long是必须的。4 字节int最大只能表示 2GB,传稍大一点的视频文件就会溢出。我见过不少案例用int存文件大小,结果文件超过 2GB 时服务端永远读不完整。
文件名的编码建议统一用StandardCharsets.UTF_8。String.getBytes()不指定编码时依赖系统默认值,在 Windows 上可能是 GBK,在 Linux 上可能是 UTF-8,同一个客户端和服务端用不同系统就会乱码。
这里还有一个小陷阱:文件名可能会包含路径分隔符,比如../../etc/passwd。如果服务端直接new File(baseDir, fileName),就会造成目录穿越。正规做法是在服务端对文件名做白名单校验,或者只取Path的getFileName()部分。
2.3 文件内容的分块读写与进度控制
文件内容不能一次性读进内存。一个 2GB 文件,你用readAllBytes()会直接 OOM。正确姿势是分块:
- 设置一个固定大小的缓冲区,比如 8192 字节。
- 客户端从
FileInputStream读一块,写入Socket输出流。 - 服务端从
Socket输入流读一块,写入目标FileOutputStream。
这里有一个容易写错的地方:FileInputStream.read(byte[])返回 -1 表示文件结束;但网络流的read(byte[])返回 -1 表示连接关闭。在读文件时你依赖返回值正常结束;在写文件内容时,你不能把 -1 当作正常结束,因为服务端知道文件大小,应该用剩余字节数来控制循环。
我习惯这样写服务端读取逻辑:
long remaining = fileSize; byte[] buffer = new byte[8192]; while (remaining > 0) { int len = input.read(buffer, 0, (int) Math.min(buffer.length, remaining)); if (len == -1) { throw new EOFException("连接提前关闭,已接收 " + (fileSize - remaining) + " 字节"); } fileOutput.write(buffer, 0, len); remaining -= len; }每次读取的长度用Math.min(buffer.length, remaining)限制,保证不会读到下一个消息的数据。这里的read可能返回比remaining小的值,但没关系,循环会继续读,直到剩余字节数为 0。
进度控制也很简单。客户端每写入一块,累计sentBytes,再除以fileSize就是上传进度。输出进度时不要每块都打印,否则日志会被刷爆,建议每 1MB 或固定百分比打印一次。
2.4 确认消息与服务端状态码
在文件内容写完之后,服务端必须返回一个确认消息,否则客户端不知道是成功还是失败。
有些实现会在客户端写完文件后立刻关闭Socket,这会造成两个问题:
- 服务端的文件可能还没落盘,连接就断了。
- 客户端收不到失败原因,只能傻等超时。
正确顺序是:客户端发送完整文件字节后,继续保持Socket打开,等待服务端状态码;服务端写完目标文件、flush之后,发送状态码和消息,然后由服务端或客户端关闭连接。
状态码用一个字节就够了,比如0失败、1成功。成功时消息可以简化为"OK";失败时需要把异常信息通过协议传回客户端,方便排查。注意异常信息也可能很长,所以协议里要有“状态消息长度”字段。
3. 实操:从零写一个可复现的文件上传Demo
3.1 服务端代码骨架
服务端核心步骤:
- 创建
ServerSocket,绑定端口。 - 循环
accept()接收连接。 - 读取 4 字节魔数,校验。
- 读取文件名长度、文件名、文件大小。
- 按剩余字节循环读取文件内容,写入本地文件。
- 发送状态码和状态消息。
- 释放资源。
我用 Java 写了一个精简版,关键代码在注释里做了说明:
ServerSocket serverSocket = new ServerSocket(9000, 50); System.out.println("文件服务启动,监听 9000 端口"); while (!Thread.currentThread().isInterrupted()) { try (Socket socket = serverSocket.accept()) { socket.setSoTimeout(30000); DataInputStream in = new DataInputStream(socket.getInputStream()); DataOutputStream out = new DataOutputStream(socket.getOutputStream()); int magic = in.readInt(); if (magic != 0x23332333) { out.writeByte(0); out.writeInt(4); out.writeBytes("bad!"); continue; } int nameLen = in.readInt(); if (nameLen <= 0 || nameLen > 1024) { out.writeByte(0); out.writeInt(7); out.writeBytes("invalid"); continue; } byte[] nameBytes = new byte[nameLen]; in.readFully(nameBytes); String fileName = new String(nameBytes, StandardCharsets.UTF_8); long fileSize = in.readLong(); if (fileSize <= 0 || fileSize > 10L * 1024 * 1024 * 1024) { out.writeByte(0); out.writeInt(7); out.writeBytes("invalid"); continue; } Path targetPath = Paths.get(SAVE_DIR, fileName).normalize(); // 防止目录穿越:校验 normalize 后是否仍在 SAVE_DIR 内 if (!targetPath.startsWith(Paths.get(SAVE_DIR).toAbsolutePath())) { out.writeByte(0); out.writeInt(14); out.writeBytes("bad file name"); continue; } try (FileOutputStream fos = new FileOutputStream(targetPath.toFile())) { byte[] buffer = new byte[8192]; long remaining = fileSize; while (remaining > 0) { int len = in.read(buffer, 0, (int) Math.min(buffer.length, remaining)); if (len == -1) { throw new EOFException("客户端连接提前断开"); } fos.write(buffer, 0, len); remaining -= len; } fos.flush(); } out.writeByte(1); out.writeInt(2); out.writeBytes("OK"); } catch (Exception e) { e.printStackTrace(); } }注意try-with-resources的用法,Socket和文件流都实现了AutoCloseable,遇到异常会自动关闭。服务端判断文件名安全用的是Path.normalize()加startsWith(),这是我在真实项目里沿用下来的做法。
3.2 客户端代码骨架
客户端步骤:
- 读取本地文件基本信息。
- 连接服务端。
- 发送魔数。
- 发送文件名长度和文件名。
- 发送文件大小。
- 分块发送文件内容。
- 读取服务端状态码和状态消息。
- 根据状态码决定是否删除本地临时文件或提示重试。
File file = new File("/data/test.bin"); if (!file.exists()) { System.out.println("文件不存在"); return; } try (Socket socket = new Socket("127.0.0.1", 9000); DataInputStream in = new DataInputStream(socket.getInputStream()); DataOutputStream out = new DataOutputStream(socket.getOutputStream()); FileInputStream fis = new FileInputStream(file)) { // 设置超时,防止服务端不返回 socket.setSoTimeout(30000); // 发送头部 out.writeInt(0x23332333); byte[] nameBytes = file.getName().getBytes(StandardCharsets.UTF_8); out.writeInt(nameBytes.length); out.write(nameBytes); out.writeLong(file.length()); // 分块发送 byte[] buffer = new byte[8192]; long sent = 0; long total = file.length(); int n; while ((n = fis.read(buffer)) != -1) { out.write(buffer, 0, n); sent += n; if (System.currentTimeMillis() % 5000 == 0) { System.out.printf("已发送 %d / %d 字节%n", sent, total); } } out.flush(); // 读取状态码 int status = in.readByte(); int msgLen = in.readInt(); byte[] msgBytes = new byte[msgLen]; in.readFully(msgBytes); String message = new String(msgBytes, StandardCharsets.UTF_8); if (status == 1) { System.out.println("上传成功: " + message); } else { System.out.println("上传失败: " + message); } }客户端在发送完整文件后,不要着急关Socket,只要代码离开try块,Socket会自动关闭。这一版代码已经能处理绝大多数教学场景,而且很接近正式项目的写法。
3.3 启动顺序和关键参数
这个 Demo 的启动顺序很简单:先启动服务端,再启动客户端。如果客户端先启动,会立刻抛ConnectException,连接被拒绝。这一点看似基础,但很多人第一次跑的时候都会搞错。
我建议把关键参数整理成一张表,方便以后复用:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 服务端端口 | 9000 | 避开敏感端口,生产环境建议 1024 以上随机高位 |
| Socket 超时 | 30000 ms | 控制台交互场景够用,大文件可调大或动态调整 |
| 缓冲区大小 | 8192 字节 | 兼顾 IO 效率和内存占用 |
| 文件大小上限 | 10 GB | 防客户端伪造超大长度导致服务端死等 |
| 文件名长度上限 | 1024 字节 | 防恶意超长文件名字段刷内存 |
| 服务端接收队列 | 50 | ServerSocket的 backlog 参数 |
实际项目里可以根据带宽调整缓冲区。调试时 8KB 足够;局域网传大文件可以提高到 64KB,但注意不要超过 TCP 窗口大小,否则收益不大。
3.4 实测过程中的流量表现
我在本地回环地址上跑这个 Demo,传一个 100MB 的随机文件,整个过程大约 1 秒多。看抓包会发现,客户端写入文件内容时,TCP 层并不是一个一个 8KB 包发送的,内核会合并、批量发送,服务端read返回的字节数也经常不是 8192。
这正好印证了前面说的“TCP 是流,不是消息”的观点。你看起来调用了 12800 次write,对端实际可能只收到了 3000 个 TCP 报文段;反过来,一个应用层消息也可能被拆分到多个报文段里。协议里只要严格按照长度字段解析,就不会乱。
4. 常见问题与排查技巧实录
4.1 传完的文件比原始文件大或小
这是我在网上回答问题时最常遇到的现象。文件比原始文件大,通常是文件名或尾部多写了一些字节,比如用BufferedWriter.writeString写了文本内容,或者在文件字节之后又写了"\n"。
文件比原始文件小,多半是服务端读取循环条件写错了,比如用while ((len = in.read(buffer)) != -1)来控制文件正文结束。这个写法有个致命问题:如果 TCP 连接在服务端读完整文件前没有关闭,read不会返回 -1,服务端会一直阻塞;如果客户端发送完立即关闭连接,服务端可能只读到部分内容就因read返回 -1 而退出,导致文件不完整。
正确的判断标准只有一个:以文件大小字段为准循环读取,而不是依赖-1。
4.2 大文件传一半卡住
发生在大文件传输上的“卡住”,九成和服务端读完整文件后,客户端没有等待确认就关闭有关。
另一种情况是超时时间设置不合理。Socket.setSoTimeout是“读超时”,它只限制两次读操作之间的间隔。如果你传一个很大的文件,虽然整体耗时很长,但只要每 30 秒内有一次数据到达,就不会超时。所以不要把它理解成“全流程超时”。
还有一种情况是客户端write传完文件后没有flush()。DataOutputStream底层是缓冲输出流,缓冲区满才会真正发送,某些小文件可能一直卡在缓冲区里。写完文件后一定要flush()。
4.3 中文文件名乱码与目录穿越
中文文件名乱码基本就是编码不一致。老代码里常见的写法是new String(fileName.getBytes()),在不同系统上很容易产生乱码。统一用UTF_8后这个问题会消失。
目录穿越要严重得多。如果服务端直接把客户端传过来的文件名拼到路径里,比如:
File file = new File(saveDir, clientFileName);攻击者传一个"../../shell.jsp",就能把文件写到任意目录,这可是真实攻击手段。修复方法就是我在服务端代码里写的normalize()加startsWith()校验,或者干脆只取Path.getFileName()作为最终文件名。
4.4 服务端内存暴涨
新手版本的服务端经常写:
byte[] allBytes = in.readAllBytes();一旦文件有 2GB,服务端进程直接内存溢出。正确做法是用固定大小缓冲区边读边写,理想状态下服务端内存不会超过几个缓冲区的大小。
另外,文件名长度是客户端控制的。如果客户端发送一个nameLen = 2000000000,服务端直接new byte[nameLen]也会内存溢出。所以头部每个字段都要加合理上限校验。
4.5 一句shutdownOutput引发的血案
有的教程会在客户端传完文件后调用socket.shutdownOutput(),表示不再发送数据,服务端看到read返回 -1 就知道文件结束。
这个做法在“只传一次文件”的场景下能跑通,但如果你在同一个连接上继续传第二个文件,或者服务端想返回确认消息,就会出问题:半关闭相当于告诉服务端说“我不会再发数据了”,但服务端还可能继续向客户端发送响应。更麻烦的是,如果协议本身有严格长度字段,根本不需要半关闭,用了反而破坏后续传输。
我在生产环境踩过这个坑,当时某个客户端上传完第一个文件后调用shutdownOutput,第二个文件的数据全部丢失,排查了很久才发现是这个 API 的副作用。结论很简单:TCP 文件传输只要严格按长度字段解析,就不需要shutdownOutput。
5. 案例的延伸与我的个人建议
5.1 从单文件上传到断点续传要加什么
如果你把这个案例改成“断点续传”,需要在协议里增加几个字段:
- 会话 ID 或文件 ID,用来标识同一个文件。
- 偏移量,告诉服务端从文件的哪个位置开始接收。
- 分块校验值,比如每 1MB 一个 CRC32 或 MD5。
- 最后一块的结束标志。
断点续传的逻辑是:
- 客户端先查询服务端已接收长度。
- 客户端从偏移量读取剩余部分并发送。
- 服务端以追加模式打开文件,从偏移量继续写入。
- 传输完成后校验整个文件的哈希值,不一致则触发重传。
这个扩展对协议设计的要求更高,因为一旦涉及丢包重传,就不能再靠“一次 ACK”确认整个文件,而要考虑每个分块的确认窗口,相当于一个小型可靠传输协议。
5.2 给初学者的三条实战建议
第一,不要把案例停在“能跑就行”。试着传一个 100MB 的随机文件,用循环多传几次,再人为把缓冲区改小,看看会不会出问题。真正的理解来自于自己弄坏一个 Demo。
第二,把日志打清楚。客户端记录“已发送多少字节”,服务端记录“已接收多少字节”,两边一对比,问题边界立刻清楚。很多同学调半天不知道是客户端没发出去,还是服务端没收到,就是因为日志太少。
第三,尽量用DataInputStream和DataOutputStream,不要自己手动拼byte[]。这两个类天然支持读写基础类型,代码可读性高,也能避免手写字节序错误。字节序问题在 Java 里默认就是大端,协议文档里写清楚即可;如果用 C++ 或 Go 对接,就要对齐字节序。
最后分享一个我自己很喜欢的小技巧:把消息头的解析封装成一个独立方法,命名为readHeader(DataInputStream),把文件体解析封装成readBody(DataInputStream, long)。这样主流程非常干净,以后换成其他协议时,只需要替换头部解析部分。代码的可维护性,往往就是从这种小封装开始的。
TCP 文件上传这个案例,我到现在还会拿出来当面试题用:简单问“怎么做”,深入问“粘包怎么处理”,再往深问“如何做断点续传”。能把这一条链路想明白的人,写其他网络程序基本不会犯太低级的错误。希望这篇文章能帮你少踩几个我当年踩过的坑。