2026/10/21 3:36:43

基于ftp4j的FTPS文件传输:断点续传与文件夹递归实现

基于ftp4j的FTPS文件传输:断点续传与文件夹递归实现 1. 项目背景与核心需求拆解文件传输这件事看起来简单真到生产环境里落地坑是一层接一层。尤其是涉及安全合规的场景明文FTP早就被淘汰了FTP over TLS也就是FTPS成了很多企业内网文件交换的标配方案。但Java生态里原生JDK对FTPS的支持一直很弱Apache Commons Net虽然能用API设计偏底层断点续传要自己处理一堆细节。这时候ftp4j这个库就进入了视野——它封装得比较到位支持显式/隐式TLSAPI也相对友好。这个项目的核心目标很明确用ftp4j实现一套完整的FTPS文件上传下载工具要求支持单文件和整个文件夹的递归传输并且上传和下载都要支持断点续传。听起来像是常规需求但真正动手写的时候你会发现断点续传在FTPS场景下比普通FTP复杂得多涉及TLS会话恢复、数据连接复用、服务端兼容性等一系列问题。先说说这个工具适合谁用。如果你在做企业级应用的文件同步模块或者需要定期把本地生成的报表、日志、备份文件推到远程FTPS服务器又或者要从合作伙伴的FTPS站点拉取数据文件那这套方案基本可以直接拿来改改用。对Java中级开发者来说理解里面的TLS握手细节和断点续传逻辑也能帮你把网络编程的基础打得更扎实。我先把整体设计思路理一遍。ftp4j的核心类是FTPClient它内部维护了控制连接和数据连接。FTPS模式下控制连接在登录时通过AUTH TLS命令升级为加密通道数据连接则通过PROT P命令设置为私有模式。断点续传的关键在于两个操作上传时用APPE命令追加写入下载时用REST命令指定偏移量。但ftp4j对这两个命令的封装并不是直接暴露的需要结合FTPClient的upload和download方法传入特定的参数来实现。这里有个容易踩的坑很多FTPS服务器比如某些版本的Serv-U、FileZilla Server在TLS模式下对REST命令的支持有问题要么直接返回错误要么偏移量不生效。所以实际开发中断点续传的逻辑必须加上服务端能力探测和降级处理。我后面会详细讲怎么处理这些兼容性问题。另一个设计决策是关于文件夹递归传输。ftp4j本身没有提供递归上传文件夹的方法需要自己遍历本地目录树逐级创建远程目录再逐个文件上传。下载文件夹同理要先递归创建本地目录结构再逐个文件下载。这里面的难点在于路径分隔符的处理——Windows用反斜杠Linux用正斜杠FTP协议统一用正斜杠转换的时候要特别小心。注意FTPS有两种模式显式TLSExplicit和隐式TLSImplicit。显式模式默认端口21先建立明文连接再升级加密隐式模式默认端口990连接建立时就是加密的。ftp4j对两种模式都支持但显式模式兼容性更好建议优先使用。2. ftp4j核心API与TLS配置详解2.1 连接与TLS握手流程ftp4j的连接过程分几步走。首先创建FTPClient实例然后调用connect方法连接服务器。如果是显式TLS连接成功后需要调用auth方法发送AUTH TLS命令再调用pbsz和prot方法设置数据通道保护级别。隐式TLS则是在connect时传入FTPClient.SECURITY_FTPIS参数直接建立加密连接。FTPClient client new FTPClient(); // 显式TLS先连普通端口再升级 client.connect(ftp.example.com, 21); client.auth(FTPClient.AUTH_TLS); client.pbsz(0); // 保护缓冲区大小通常设为0 client.prot(FTPClient.PROT_PRIVATE); // 数据通道加密 client.login(username, password);这段代码看起来简单但每一步都可能出问题。auth方法失败通常是因为服务器不支持TLS或者证书不被信任。ftp4j默认会验证服务器证书如果用的是自签名证书需要自定义SSLSocketFactory来跳过验证。生产环境当然不建议跳过但测试环境里这是个常见的卡点。pbsz和prot这两个命令很多人会忽略。PBSZ设置的是TLS握手后用于数据保护的缓冲区大小RFC 4217规定必须设为0表示由TLS层自己协商。PROT P表示数据连接也要加密如果设成PROT C就是明文数据连接那就失去FTPS的意义了。我见过有人只加密了控制连接数据连接还是明文文件内容在网络里裸奔这跟普通FTP没区别。2.2 断点续传的API支持ftp4j的download方法有一个重载版本可以传入rest参数指定断点位置client.download(remote/file.zip, new File(local/file.zip), 1024L);这里的1024L表示从远程文件的第1024字节开始下载本地文件的前1024字节会被保留。但这里有个细节ftp4j在下载时会先删除本地文件再重新创建如果你传了rest参数它会在创建文件后调用REST命令。实际测试中发现某些服务器在REST之后返回的响应码不是350而是250或者其他ftp4j会抛出异常。这时候需要捕获异常并判断是否真的不支持断点续传。上传的断点续传更麻烦。ftp4j的upload方法没有直接提供rest参数需要用append模式client.upload(remote/file.zip, new File(local/file.zip), FTPClient.APPEND);APPEND模式会让ftp4j发送APPE命令在远程文件末尾追加内容。但这里有个前提远程文件必须已经存在且本地文件的大小要大于远程文件的大小。如果远程文件不存在APPE会创建新文件那就变成全量上传了。所以上传断点续传的逻辑是先获取远程文件大小再获取本地文件大小如果本地更大就从远程大小处开始读取本地文件用APPEND模式上传剩余部分。提示APPE命令在TLS模式下同样可能遇到兼容性问题。有些服务器要求先执行REST再APPE有些则直接拒绝APPE。建议在代码里做能力探测先尝试小文件追加失败则降级为全量上传。2.3 文件夹递归传输的实现思路ftp4j没有现成的递归上传方法需要自己实现。核心逻辑是深度优先遍历本地目录对每个目录调用createDirectory创建远程目录对每个文件调用upload上传。这里要注意路径拼接本地路径用File.separator远程路径统一用/。另外创建远程目录时要逐级创建比如/a/b/c要先创建/a再创建/a/b最后创建/a/b/c。ftp4j的createDirectory方法支持传入完整路径但有些服务器不支持自动创建父目录所以稳妥的做法是逐级判断和创建。下载文件夹的逻辑类似先递归创建本地目录结构再逐个文件下载。这里有个优化点如果本地文件已经存在且大小与远程一致可以跳过下载。这个判断在断点续传场景下特别有用能避免重复传输。3. 完整实操流程与核心代码实现3.1 环境准备与依赖引入项目用Maven管理依赖ftp4j的坐标如下dependency groupIdit.sauronsoftware.ftp4j/groupId artifactIdftp4j/artifactId version1.7.2/version /dependency这个版本是ftp4j比较稳定的一个版本虽然有些年头了但功能完整社区里用的人也多。JDK版本建议用8以上因为TLS 1.2在JDK 8里才默认启用。如果服务端要求TLS 1.3那就得用JDK 11。除了ftp4j本身还需要一个日志框架来记录传输过程方便排查问题。我用的是SLF4J Logback这个组合比较通用。另外如果要做证书信任管理可能还需要引入Bouncy Castle不过大多数场景下JDK自带的JSSE就够了。3.2 FTPS连接工具类封装先封装一个连接管理类负责建立FTPS连接、登录、断开。这个类要处理TLS配置、超时设置、被动模式切换等细节。public class FtpsClientWrapper { private FTPClient client; private String host; private int port; private String username; private String password; private boolean implicitTls; public void connect() throws FTPException, IOException { client new FTPClient(); client.setCharset(UTF-8); client.setType(FTPClient.TYPE_BINARY); client.setPassive(true); // 被动模式穿透防火墙更友好 if (implicitTls) { client.connect(host, port, FTPClient.SECURITY_FTPIS); } else { client.connect(host, port); client.auth(FTPClient.AUTH_TLS); client.pbsz(0); client.prot(FTPClient.PROT_PRIVATE); } client.login(username, password); // 设置连接超时和读取超时 client.setAutoNoopTimeout(30000); // 30秒无操作自动发NOOP保活 } }这里有几个关键点。setType(FTPClient.TYPE_BINARY)设置二进制传输模式避免文本文件换行符被转换。setPassive(true)启用被动模式在NAT和防火墙环境下比主动模式可靠得多。setAutoNoopTimeout是ftp4j的一个特色功能它会定期发送NOOP命令保持控制连接活跃防止长时间传输时连接被服务端断开。被动模式下数据连接的建立是客户端连服务端的某个随机端口。FTPS的被动模式还需要考虑数据连接的TLS会话复用问题。有些服务器要求数据连接的TLS会话必须与控制连接相同否则拒绝传输。ftp4j内部会尝试复用会话但并不是所有服务器都兼容。如果遇到数据连接建立失败可以尝试关闭会话复用或者换用主动模式测试。3.3 单文件断点续传下载实现下载的断点续传逻辑相对直接。先检查本地文件是否存在如果存在则获取其大小然后调用download方法并传入偏移量。public void downloadWithResume(String remotePath, File localFile) throws FTPException, IOException { long localSize 0; if (localFile.exists()) { localSize localFile.length(); } // 获取远程文件大小 long remoteSize client.fileSize(remotePath); if (localSize remoteSize) { System.out.println(本地文件已完整跳过下载); return; } if (localSize 0) { // 断点续传 client.download(remotePath, localFile, localSize); } else { // 全量下载 client.download(remotePath, localFile); } }这段代码看起来简单但实际运行时client.download的第三个参数是long类型的rest值。ftp4j内部会先打开本地文件的RandomAccessFile定位到rest位置然后发送REST命令给服务器。如果服务器返回350就开始传输如果返回其他码ftp4j会抛出FTPException。我实测下来FileZilla Server在显式TLS模式下对REST的支持是正常的但某些国产FTPS服务器这里不点名会返回500错误。遇到这种情况我的处理方式是捕获异常判断异常信息里是否包含REST关键字如果是就降级为全量下载并在日志里记录警告。注意断点续传下载时如果本地文件在传输过程中被其他程序修改会导致文件损坏。建议在下载前对本地文件加锁或者下载到临时文件再重命名。3.4 单文件断点续传上传实现上传的断点续传要复杂一些。核心思路是对比本地和远程文件大小如果本地更大就从远程大小处读取本地文件用APPEND模式上传剩余部分。public void uploadWithResume(String remotePath, File localFile) throws FTPException, IOException { long localSize localFile.length(); long remoteSize 0; try { remoteSize client.fileSize(remotePath); } catch (FTPException e) { // 远程文件不存在全量上传 client.upload(remotePath, localFile); return; } if (remoteSize localSize) { System.out.println(远程文件已完整跳过上传); return; } // 从远程大小处读取本地文件 try (RandomAccessFile raf new RandomAccessFile(localFile, r)) { raf.seek(remoteSize); byte[] buffer new byte[8192]; int bytesRead; // 这里需要用ftp4j的流式上传接口 // 实际实现中ftp4j的upload方法不支持直接传流 // 需要先截取本地文件从remoteSize开始的部分到临时文件 File tempFile File.createTempFile(upload_resume, .tmp); try (FileOutputStream fos new FileOutputStream(tempFile)) { while ((bytesRead raf.read(buffer)) ! -1) { fos.write(buffer, 0, bytesRead); } } client.upload(remotePath, tempFile, FTPClient.APPEND); tempFile.delete(); } }这里有个性能问题为了用APPEND模式我先把本地文件的剩余部分写到了临时文件再上传临时文件。对于大文件来说这多了一次磁盘IO。更好的做法是用ftp4j的upload方法的重载版本它支持传入InputStream。但ftp4j的upload(String, InputStream)方法内部会先把流读完再上传对于超大文件来说内存压力很大。折中方案是用FTPDataTransferClient但那个API更底层用起来更复杂。实际项目中我建议对超过1GB的文件做分块上传每块比如100MB这样断点续传的粒度更细失败重试的成本也更低。分块上传的实现思路是把本地文件按固定大小切分每块单独上传为临时文件全部上传完后再在服务端合并。但FTPS协议本身不支持服务端合并文件所以这个方案需要服务端有额外的合并脚本配合。3.5 文件夹递归上传与下载文件夹递归上传的实现如下public void uploadDirectory(String remoteDir, File localDir) throws FTPException, IOException { // 确保远程目录存在 createRemoteDirectory(remoteDir); File[] files localDir.listFiles(); if (files null) return; for (File file : files) { String remotePath remoteDir / file.getName(); if (file.isDirectory()) { uploadDirectory(remotePath, file); } else { uploadWithResume(remotePath, file); } } } private void createRemoteDirectory(String remoteDir) throws FTPException, IOException { String[] parts remoteDir.split(/); String current ; for (String part : parts) { if (part.isEmpty()) continue; current / part; try { client.changeDirectory(current); } catch (FTPException e) { client.createDirectory(current); } } }createRemoteDirectory方法逐级检查目录是否存在不存在则创建。这里用changeDirectory来探测目录是否存在比list方法更轻量。但要注意changeDirectory会改变当前工作目录所以在递归过程中要小心路径状态。我的做法是在每次操作前都切回根目录或者用绝对路径。下载文件夹的逻辑类似但要多一步先创建本地目录再下载文件。这里有个优化点如果本地文件已存在且大小与远程一致就跳过下载。这个判断在断点续传场景下特别有用能避免重复传输。public void downloadDirectory(String remoteDir, File localDir) throws FTPException, IOException { if (!localDir.exists()) { localDir.mkdirs(); } FTPFile[] files client.list(remoteDir); for (FTPFile file : files) { if (file.getName().equals(.) || file.getName().equals(..)) { continue; } String remotePath remoteDir / file.getName(); File localFile new File(localDir, file.getName()); if (file.getType() FTPFile.TYPE_DIRECTORY) { downloadDirectory(remotePath, localFile); } else { downloadWithResume(remotePath, localFile); } } }client.list方法返回FTPFile数组每个FTPFile包含文件名、大小、类型、修改时间等信息。这里要注意过滤.和..否则会无限递归。另外list方法在TLS模式下可能会因为数据连接问题失败如果遇到可以尝试用listNames方法它只返回文件名列表不包含详细信息但更稳定。4. 常见问题与排查技巧实录4.1 TLS握手失败与证书信任问题这是FTPS开发中最常见的问题。表现是connect或auth方法抛出SSLHandshakeException原因通常是服务器证书不被JDK信任。如果是自签名证书需要把证书导入JDK的信任库或者自定义SSLSocketFactory。自定义SSLSocketFactory的方式如下SSLContext sslContext SSLContext.getInstance(TLS); sslContext.init(null, new TrustManager[]{new X509TrustManager() { public void checkClientTrusted(X509Certificate[] chain, String authType) {} public void checkServerTrusted(X509Certificate[] chain, String authType) {} public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; } }}, new SecureRandom()); client.setSSLSocketFactory(sslContext.getSocketFactory());这段代码创建了一个信任所有证书的TrustManager仅限测试环境使用。生产环境应该把服务器证书导入信任库或者用TrustManagerFactory加载指定的信任库文件。注意JDK 8默认禁用TLS 1.0和TLS 1.1如果服务器只支持这两个版本需要在java.security文件里修改jdk.tls.disabledAlgorithms配置或者通过系统属性-Dhttps.protocolsTLSv1.2指定协议版本。4.2 数据连接建立失败FTPS的数据连接建立失败通常有几个原因。一是被动模式端口范围没开放服务端返回的被动端口被防火墙拦截。解决方法是配置服务端的被动端口范围并在防火墙上放行。二是TLS会话复用问题某些服务器要求数据连接的TLS会话必须与控制连接相同但JDK的SSLSession缓存策略可能导致会话不匹配。可以尝试在SSLSocketFactory上禁用会话缓存或者设置jdk.tls.useExtendedMasterSecretfalse。三是主动模式下的连接问题。主动模式要求服务端反向连接客户端如果客户端在NAT后面服务端无法连接。所以生产环境一律用被动模式这是铁律。排查数据连接问题的时候可以开启ftp4j的调试日志client.setDebug(true);开启后ftp4j会把所有发送和接收的命令打印到控制台能看到PASV、REST、RETR等命令的交互过程对定位问题非常有帮助。4.3 断点续传偏移量不生效偏移量不生效的表现是明明传了rest参数但下载的文件还是从头开始或者上传时覆盖了远程文件。原因可能是服务器不支持REST命令或者REST命令在TLS模式下被忽略。排查方法是先手动用命令行工具测试。比如用lftp或curl连接FTPS服务器手动发送REST命令看响应。如果服务器返回350说明支持如果返回500或502说明不支持。对于不支持的服务器只能降级为全量传输或者换用支持断点续传的协议比如SFTP。另一个可能的原因是ftp4j的download方法在rest参数大于0时会先删除本地文件再重新创建。如果本地文件被其他进程占用删除会失败导致断点续传失败。解决方法是下载前检查文件锁或者下载到临时文件再重命名。4.4 中文文件名乱码FTPS协议本身对文件名编码没有强制规定不同服务器的处理方式不同。ftp4j默认用ISO-8859-1编码遇到中文文件名会乱码。解决方法是在连接后设置字符集client.setCharset(UTF-8);但有些服务器比如老版本的Serv-U用的是GBK编码设置UTF-8反而乱码。这时候需要根据服务器实际情况调整。我的一般做法是先用UTF-8测试如果乱码再试GBK。另外list方法返回的文件名编码也受这个设置影响如果列表里的中文文件名乱码上传下载时也会有问题。4.5 常见问题速查表问题现象可能原因排查方法解决方案TLS握手失败证书不被信任查看异常堆栈导入证书或自定义TrustManager数据连接超时被动端口被拦截检查防火墙规则开放被动端口范围REST命令返回500服务器不支持断点续传命令行手动测试降级为全量传输中文文件名乱码字符集不匹配查看list返回的文件名设置UTF-8或GBK上传大文件失败连接超时查看日志中的超时时间设置AutoNoopTimeout下载文件损坏本地文件被修改对比文件MD5下载到临时文件再重命名5. 性能优化与生产环境建议5.1 连接池与复用频繁建立FTPS连接的开销很大每次都要做TLS握手耗时可能达到几百毫秒。生产环境建议用连接池复用FTPClient实例。但ftp4j的FTPClient不是线程安全的一个实例同时只能处理一个传输任务。所以连接池的实现方式是维护一个FTPClient队列每个线程从队列里取一个实例用完还回去。队列的大小根据并发传输任务数来定一般设为5到10个就够了。连接池还要处理连接失效的问题。FTPS连接可能因为网络抖动或服务端超时被断开从池里取出的连接可能已经不可用。我的做法是在取出连接后先发送一个NOOP命令探测如果失败则重建连接。5.2 大文件传输的分块策略对于超过1GB的大文件建议分块传输。每块大小设为100MB左右这样单块传输失败时只需要重传这一块而不是整个文件。分块传输的实现方式是把本地文件按100MB切分每块上传为filename.part1、filename.part2等全部上传完后再在服务端合并。但FTPS不支持服务端合并所以这个方案需要服务端有额外的合并脚本。另一种方案是用APPE模式做逻辑分块。比如先上传前100MB然后用APPE追加下一个100MB以此类推。这样服务端看到的是一个完整的文件不需要合并。但APPE模式的性能不如直接上传因为每次追加都要重新建立数据连接。5.3 传输日志与监控生产环境一定要记录传输日志包括传输开始时间、结束时间、文件大小、传输速率、是否断点续传、失败原因等。这些日志对排查问题和容量规划都很有用。我一般用SLF4J的MDC把文件名、传输方向等信息放到日志上下文里这样日志看起来更清晰。监控方面可以统计传输成功率和平均传输速率。如果成功率突然下降可能是网络问题或服务端故障。如果传输速率下降可能是服务端限速或磁盘IO瓶颈。5.4 安全加固建议FTPS虽然加密了传输通道但还有一些安全细节要注意。一是禁用不安全的协议版本只允许TLS 1.2及以上。二是定期轮换证书避免证书过期导致传输失败。三是限制被动端口范围减少攻击面。四是启用服务端的登录失败锁定策略防止暴力破解。另外密码不要硬编码在代码里用配置中心或环境变量管理。如果条件允许用客户端证书认证代替密码认证安全性更高。6. 个人实操心得与避坑总结这套FTPS工具我在几个项目里都用过踩过的坑确实不少。最大的体会是FTPS的兼容性问题比想象中多得多不同服务器、不同版本、不同配置下的行为差异很大。所以代码里一定要做能力探测和降级处理不能假设服务器支持所有命令。另一个心得是关于断点续传的。很多人以为断点续传就是传个偏移量那么简单实际上偏移量的语义在不同服务器上不一样。有的服务器REST偏移量是从文件开头算的有的服务器是从当前传输位置算的。所以做断点续传之前一定要先用小文件测试服务器的行为。还有一点是关于文件夹递归的。递归上传时如果本地目录里有符号链接要特别小心可能会造成无限递归。我的做法是检测符号链接并跳过或者限制递归深度。最后分享一个小技巧如果传输过程中频繁出现连接断开可以在FTPClient上设置setAutoNoopTimeout让ftp4j定期发送NOOP命令保活。这个参数的单位是毫秒一般设为3000030秒比较合适。设得太短会增加网络开销设得太长起不到保活作用。这个工具后续还可以扩展的方向包括支持SFTP协议作为备选方案、增加传输限速功能、支持多线程并行传输多个文件、集成到Spring Batch做定时文件同步任务。这些扩展我在其他项目里都做过有机会再单独写一篇分享。