资讯详情

资讯详情

Java基于TCP Socket实现文件传输:从原理到实战

1. 为什么还要聊Socket传文件这件事干了十来年后端我发现一个挺有意思的现象身边不少做Java的朋友平时用Spring Boot写接口、调中间件、玩消息队列都很溜但一提到“用Socket传个文件”立马就卡壳了。不是不会是太久没碰底层了脑子里只剩下InputStream和OutputStream这两个模糊的影子。这个项目标题——Java中基于TCP通过socket嵌套字连接方式传送文件——说白了就是让你抛开所有高级框架用最原始的TCP连接把一台机器上的文件完整地送到另一台机器上。它解决的核心问题很朴素两台机器之间怎么可靠地搬运字节流。听起来简单但真动手写你会发现坑一个接一个文件传一半断了怎么办大文件内存扛不住怎么办服务端怎么知道客户端传完了多个客户端同时传会不会串数据我写这篇文章就是想把这套东西从头到尾捋一遍。适合谁看刚学完Java IO、想找个练手项目的学生工作中需要做设备间文件同步、但不想引入重型中间件的开发者还有那些准备面试、被问到“手写一个文件传输”就心里发虚的朋友。我会把设计思路、代码实现、参数选择、踩坑经验全部摊开讲你照着抄就能跑起来改一改就能用到实际项目里。先说清楚一个前提这里讲的TCP Socket传文件走的是标准的网络字节流不涉及任何特殊网络环境或工具就是最普通的客户端-服务端模型。理解了这套东西你再去看FTP、HTTP文件上传、甚至一些RPC框架的底层会发现思路都是相通的。2. 整体设计思路与方案选型2.1 为什么选TCP而不是UDP文件传输这个场景第一要务是数据不能丢、不能乱序。TCP本身就是面向连接的、可靠的、基于字节流的协议它自带重传、排序、流量控制。UDP虽然快但丢包了得自己补顺序乱了得自己排对于文件这种“少一个字节就废了”的场景纯UDP就是给自己找麻烦。有人可能会说那用UDP加自己写的确认重传机制行不行行但那是另一个复杂度级别的事了。除非你有极致的低延迟需求比如实时音视频否则文件传输老老实实选TCP。这个项目里TCP的“可靠”正好对上文件传输的“完整”需求属于天作之合。2.2 客户端-服务端角色划分标准模型是服务端监听某个端口客户端主动连接然后客户端把文件推给服务端。当然反过来也行服务端主动去客户端拉文件但那样客户端就得先监听角色就互换了。为了符合大多数人的直觉我们采用“客户端发送、服务端接收”的模式。这里有个设计决策点是客户端连上就发还是服务端先发个指令说“我要了”再发简单场景下客户端连上直接发就行。但如果要做成通用文件服务器通常需要先传一个“文件元信息”文件名、大小服务端确认后再传内容。我下面会采用“先发元信息再发内容”的方式这样服务端能提前知道要接收多大的文件、存成什么名字逻辑更完整。2.3 协议格式的约定TCP是字节流没有消息边界。你连续发两次数据接收端可能一次全收到也可能分三次收到。所以必须自己定一个应用层协议来划分消息。我采用的约定是先发一个固定长度的头部比如用DataOutputStream先写文件名长度int、文件名UTF字符串、文件大小long。接收端按同样的顺序读读完头部就知道接下来要读多少字节是文件内容。文件内容按固定大小的缓冲区循环读写直到读完指定字节数。这个设计的好处是边界清晰、可扩展。以后想加MD5校验、文件类型、时间戳往头部里加字段就行不影响主体逻辑。2.4 缓冲区大小的选择缓冲区大小直接影响到传输效率。太小了系统调用次数多CPU都花在切换上了太大了内存占用高而且最后一次可能读不满。我实测下来的经验值8KB到64KB之间比较合适。8KB是很多系统的默认页大小兼容性好64KB在千兆局域网里能跑满带宽。我一般用8192或者16384。如果你传的是超大文件几个G可以用到65536甚至131072但再大收益就不明显了反而增加内存压力。注意缓冲区大小不是越大越好。我试过用1MB的缓冲区传小文件结果每次都要分配1MB数组GC压力明显上升得不偿失。3. 核心细节解析与实操要点3.1 文件元信息的封装与解析元信息是接收端正确落盘的前提。我见过有人只传文件内容接收端随便起个名字结果传了十个文件全是temp.dat覆盖得亲妈都不认识。所以文件名必须传。用DataOutputStream写元信息是最省事的因为它提供了writeInt、writeUTF、writeLong这些方法跟DataInputStream的readInt、readUTF、readLong天然配对。注意writeUTF写的是 modified UTF-8最大支持65535字节的字符串普通文件名绝对够用。代码骨架大概长这样// 发送端 DataOutputStream dos new DataOutputStream(socket.getOutputStream()); dos.writeUTF(file.getName()); dos.writeLong(file.length()); dos.flush(); // 然后开始写文件内容接收端对应DataInputStream dis new DataInputStream(socket.getInputStream()); String fileName dis.readUTF(); long fileSize dis.readLong();这里有个关键细节DataOutputStream和DataInputStream的读写顺序必须严格一致写的时候先文件名后大小读的时候也必须先文件名后大小错一个顺序就全乱了。3.2 文件内容的循环读写文件内容不能一次性读进内存再发那样大文件直接OOM。正确做法是边读边发边收边写。发送端逻辑byte[] buffer new byte[8192]; int len; while ((len fileInputStream.read(buffer)) ! -1) { dos.write(buffer, 0, len); } dos.flush();接收端逻辑byte[] buffer new byte[8192]; long remaining fileSize; while (remaining 0) { int len dis.read(buffer, 0, (int) Math.min(buffer.length, remaining)); if (len -1) break; fileOutputStream.write(buffer, 0, len); remaining - len; }注意接收端这里用了remaining来控制读取量而不是简单读到-1。为什么因为TCP连接可能被复用或者后面还有别的数据你不能一直读到流结束。按文件大小精确读取读完就停这是最稳妥的。3.3 流关闭的顺序与资源释放Java 7之后用try-with-resources是最省心的但Socket场景下有个坑先关输出流再关Socket还是直接关Socket我的经验是先flush再关输出流最后关Socket。因为Socket.close()会直接断开连接如果输出流里还有缓冲数据没发出去就丢了。flush()强制把缓冲区数据推出去然后再关。接收端则是先关输入流再关文件输出流最后关Socket。顺序反了可能导致文件没写完就断了。实操心得我习惯在finally块里统一关闭并且每个close()都单独try-catch避免一个关失败影响后面的关闭。虽然啰嗦但线上环境稳。3.4 大文件传输的内存控制前面说了用固定缓冲区这是内存控制的核心。但还有一个隐藏问题DataOutputStream和BufferedOutputStream的叠加使用。如果你在DataOutputStream外面再包一层BufferedOutputStream写元信息的时候可能被缓冲住导致接收端读不到。所以要么不用缓冲流要么在写完元信息后立刻flush()。我一般直接用DataOutputStream包Socket的输出流文件内容自己用缓冲区控制不额外加缓冲层逻辑更清晰。4. 完整实操过程与核心环节实现4.1 服务端实现监听、接收、落盘服务端要做的事绑定端口、等待连接、读取元信息、循环接收内容、写入本地文件。public class FileServer { public static void main(String[] args) throws IOException { ServerSocket serverSocket new ServerSocket(9999); System.out.println(服务端启动监听9999端口); while (true) { Socket socket serverSocket.accept(); new Thread(() - handleClient(socket)).start(); } } private static void handleClient(Socket socket) { try (DataInputStream dis new DataInputStream(socket.getInputStream())) { String fileName dis.readUTF(); long fileSize dis.readLong(); File target new File(received/ fileName); target.getParentFile().mkdirs(); try (FileOutputStream fos new FileOutputStream(target)) { byte[] buffer new byte[8192]; long remaining fileSize; while (remaining 0) { int len dis.read(buffer, 0, (int) Math.min(buffer.length, remaining)); if (len -1) break; fos.write(buffer, 0, len); remaining - len; } } System.out.println(文件接收完成 fileName 大小 fileSize); } catch (IOException e) { e.printStackTrace(); } finally { try { socket.close(); } catch (IOException ignored) {} } } }这里用了一个线程处理一个连接简单直接。如果并发量高可以换成线程池但要注意线程池大小和文件大小的平衡。4.2 客户端实现连接、发送、确认客户端要做的事连接服务端、发送元信息、循环发送内容、等待服务端确认可选。public class FileClient { public static void main(String[] args) throws IOException { File file new File(test.zip); try (Socket socket new Socket(127.0.0.1, 9999); DataOutputStream dos new DataOutputStream(socket.getOutputStream()); FileInputStream fis new FileInputStream(file)) { dos.writeUTF(file.getName()); dos.writeLong(file.length()); dos.flush(); byte[] buffer new byte[8192]; int len; long total 0; while ((len fis.read(buffer)) ! -1) { dos.write(buffer, 0, len); total len; } dos.flush(); System.out.println(文件发送完成共发送字节 total); } } }注意客户端这里没有等确认发完就关。如果要加确认可以在发送完后调用socket.shutdownOutput()然后读一个字节作为服务端的ACK。4.3 参数计算缓冲区与超时设置缓冲区大小我选8192理由前面说了。超时设置也很关键socket.setSoTimeout(30000)表示读操作最多阻塞30秒超时就抛SocketTimeoutException。这个在接收端特别有用防止客户端挂了服务端一直傻等。文件大小用long而不是int因为int最大才2GB现在随便一个视频就超了。writeLong和readLong配对使用别写成writeInt。4.4 实操现场记录一次完整的传输我在本地测试传一个约500MB的压缩包。服务端先启动客户端运行后服务端控制台输出“服务端启动监听9999端口”客户端连接成功开始发送大约8秒后客户端输出“文件发送完成共发送字节524288000”服务端输出“文件接收完成test.zip大小524288000”对比源文件和目标文件的MD5完全一致。整个过程内存占用稳定在几十MB没有出现OOM。提示测试大文件时建议先用小文件跑通逻辑再逐步加大。我见过有人直接传4GB文件结果因为用了int读大小直接溢出成负数排查了半天。5. 常见问题与排查技巧实录5.1 连接被拒绝或超时最常见的原因是服务端没启动或者防火墙拦了端口。排查步骤先用telnet 127.0.0.1 9999测试端口通不通不通就是服务端问题通了但连不上检查客户端IP和端口是否写错。还有一个隐蔽原因服务端绑定了127.0.0.1而不是0.0.0.0。new ServerSocket(9999)默认绑所有网卡但如果你写成new ServerSocket(9999, 50, InetAddress.getByName(127.0.0.1))那就只有本机能连。跨机传输时一定要注意。5.2 文件传了一半就断了原因可能有三类网络抖动、缓冲区没flush、接收端提前关闭。排查方法在发送端每次write后加日志看传到多少字节断的在接收端看remaining还剩多少。如果发送端显示发完了但接收端没收到大概率是flush()没调用数据还在缓冲区里。我踩过的一个坑发送端用了BufferedOutputStream包DataOutputStream结果元信息被缓冲了接收端读readUTF一直阻塞。后来在写元信息后强制flush()才解决。5.3 文件名乱码writeUTF用的是modified UTF-8一般不会乱码。但如果文件名包含特殊字符或者你用了writeBytes就可能出问题。统一用writeUTF和readUTF别混用。如果确实遇到乱码检查两端JVM的默认字符集是否一致。可以在启动参数里加-Dfile.encodingUTF-8强制统一。5.4 多客户端并发时文件串了如果服务端用单线程处理第二个客户端连上后第一个客户端的数据可能被第二个读到。解决办法就是每个连接开一个独立线程或者用NIO的Selector。我上面代码里用了new Thread简单有效。但要注意线程数量如果同时传100个文件100个线程对内存和CPU都是压力这时候就该上线程池了。5.5 常见问题速查表问题现象可能原因排查方法解决方案连接超时服务端未启动/防火墙拦截telnet测试端口启动服务端/开放端口读阻塞不返回元信息未flush检查发送端flush调用写元信息后立即flush文件大小对不上int溢出/读取量错误打印文件大小和实际接收量改用long按remaining读取文件名乱码字符集不一致检查两端编码统一用writeUTF/readUTF并发串数据单线程处理多连接查看线程模型每连接独立线程或线程池大文件OOM一次性读入内存查看内存曲线改用固定缓冲区循环读写5.6 独家避坑技巧第一个技巧在元信息里加一个MD5字段。发送端先算好文件MD5接收端收完后也算一遍对比一致才算成功。这样能发现传输过程中的静默错误。虽然多花点CPU但对于重要文件绝对值得。第二个技巧用shutdownOutput()代替直接close()。发送端发完数据后调用socket.shutdownOutput()这会发送一个FIN包告诉服务端“我没数据了”但连接还没完全关闭服务端还能回一个确认。这样比直接关更优雅。第三个技巧接收端先写临时文件收完再改名。如果传到一半断了临时文件可以删掉不会污染正式目录。改名用File.renameTo()原子操作安全。6. 性能优化与扩展方向6.1 用NIO提升并发能力上面的代码是BIO模型一个连接一个线程。如果并发连接数上千线程切换开销就大了。这时候可以换成NIO用ServerSocketChannel和Selector一个线程管理多个连接。但NIO的代码复杂度高很多缓冲区管理、半包粘包处理都要自己写。我的建议是并发低于500用BIO足够超过再考虑NIO。6.2 加断点续传断点续传的核心是记录已传输的偏移量。发送端在元信息里带上“从第几字节开始传”接收端用RandomAccessFile定位到对应位置继续写。这样网络断了重连后不用从头再来。实现起来不难但要注意偏移量的持久化不然重启就丢了。6.3 压缩后再传如果传的是文本、日志这类可压缩文件可以先GZIPOutputStream压缩再发接收端解压。能省不少带宽。但压缩本身耗CPU要权衡。我一般对超过10MB的文本文件才开压缩。6.4 多文件批量传输如果要传一个目录可以先把文件列表发过去然后逐个传。或者打个ZIP包再传接收端解压。前者灵活后者简单。我倾向于打ZIP因为少了很多协议交互出错概率低。7. 一些个人体会这套Socket传文件的代码我前后写过不下十遍。每次写都有新收获。最开始只求能跑通后来开始关注大文件、并发、异常处理再后来会去抠缓冲区大小、flush时机这些细节。说实话把简单的东西写稳比写复杂的东西更难。我印象最深的一次是帮一个做硬件的朋友传设备日志。他们的设备只能跑最基础的Java环境没有任何HTTP库。我用这套Socket方案加了MD5校验和断点续传跑了半年没出过问题。那一刻我觉得底层的东西虽然土但真的可靠。如果你也在做类似的事情我的建议是先把单文件传明白再考虑并发和优化。别一上来就搞NIO、搞断点续传基础没打牢后面全是坑。另外多写日志发送端和接收端都写出问题时日志就是你的救命稻草。最后分享一个小技巧测试的时候可以用dd命令生成一个指定大小的随机文件比如dd if/dev/urandom oftest.bin bs1M count100这样能快速造出100MB的测试文件比找真实文件方便多了。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →