Java统一封装FTP与SFTP:上传下载删除遍历实战 1. 项目背景与核心需求拆解文件传输这件事说简单也简单说坑多那也是真的多。我在实际项目里接触过的文件对接场景少说也有几十种从早期的纯FTP脚本到后来SFTP逐步成为主流再到现在很多团队直接上对象存储但FTP和SFTP这两种协议在传统企业系统、银行对接、制造业MES系统、医疗HIS系统里依然是绝对主力。这个项目标题指向的就是一个非常典型的刚需用Java代码统一封装FTP和SFTP两种协议的文件上传、下载、删除、遍历操作。为什么这件事值得单独拿出来做一个项目因为大部分开发者第一次接触文件传输需求时往往是这样的路径先搜一个FTP工具类复制粘贴跑通了过两天对接方说我们只支持SFTP于是又去搜一个SFTP的Demo再复制粘贴。结果项目里躺着两套完全不同的API风格一套用commons-net一套用jsch异常处理逻辑各写各的连接池管理完全没有密码和密钥散落在配置文件各处。等到要加一个“遍历远程目录下所有文件”的需求时两边代码又得分别改一遍。这个项目要解决的核心问题就是统一抽象。它需要做到对外暴露一套统一的接口底层自动适配FTP和SFTP两种协议上传、下载、删除、遍历这四个基础操作要覆盖完整连接管理、异常处理、路径拼接、编码处理这些脏活累活要封装干净。适合谁来参考我认为三类人最需要一是正在做系统对接的后端开发二是需要维护老旧文件传输脚本的运维人员三是想理解协议差异和Java网络编程实践的学习者。从技术选型上看FTP协议在Java生态里最成熟的库是Apache Commons NetSFTP则基本是JSch的天下虽然现在也有sshj、Apache MINA SSHD等替代品但JSch的存量项目最多。这个项目的核心价值不在于用了什么高深技术而在于把两种协议的差异屏蔽掉让业务代码不需要关心底层走的是21端口还是22端口。注意FTP和SFTP虽然名字里都有“FTP”但它们是两个完全不同的协议。FTP是明文传输基于TCP的21端口控制和20端口数据SFTP是SSH协议的一部分走22端口全程加密。很多新手以为SFTP就是“安全的FTP”这个理解不算错但实现层面它们没有任何继承关系。2. 协议差异与统一抽象设计思路2.1 FTP与SFTP的核心差异对比在动手写代码之前必须先把两种协议的差异理清楚否则抽象层设计出来一定是漏的。我整理了一张实际开发中最容易踩坑的对比表对比维度FTPSFTP底层协议TCP明文SSH加密通道默认端口21控制 20数据22认证方式用户名密码用户名密码 或 密钥对被动/主动模式需要显式设置防火墙环境下通常用被动模式不存在这个概念连接保持控制连接长期保持数据连接每次新建单一连接复用目录遍历依赖LIST/NLST命令返回格式因服务器而异标准化目录列表中文路径编码问题极其常见相对好一些但仍有编码坑断点续传需要REST命令支持支持较好超时控制需要分别设置连接超时和数据超时统一会话超时这张表里被动模式和中文编码是FTP最容易出问题的两个点。我见过太多案例本地测试一切正常部署到生产环境后上传一直卡住最后发现是防火墙拦截了主动模式的数据连接。SFTP这边密钥认证的格式问题比如OpenSSH私钥和PuTTY私钥不兼容是最高频的故障。2.2 统一接口的抽象层次基于上面的差异分析我设计的抽象层次是这样的最上层定义一个FileTransferService接口包含upload、download、delete、listFiles四个核心方法。然后分别用FtpFileTransferService和SftpFileTransferService实现。再往上用一个工厂类根据配置决定实例化哪个实现。public interface FileTransferService { boolean upload(String localPath, String remotePath); boolean download(String remotePath, String localPath); boolean delete(String remotePath); ListString listFiles(String remoteDir); void close(); }这个接口看起来简单但每个方法的参数设计都有讲究。比如upload方法我选择用本地路径和远程路径两个字符串参数而不是用File对象或InputStream。原因是在实际对接场景中文件往往来自临时目录或共享存储用路径字符串最灵活而且断点续传、大文件分片这些扩展需求用路径比用流更容易实现。为什么不把listFiles设计成返回FileInfo对象列表因为大部分对接场景只需要文件名返回ListString足够用。如果后续需要文件大小、修改时间可以再加一个listFileDetails方法。接口设计要克制不要一开始就追求大而全这是我踩过多次坑之后的体会。2.3 配置模型的设计配置这块我建议用一个TransferConfig类来承载字段包括协议类型枚举FTP/SFTP、主机、端口、用户名、密码、私钥路径SFTP用、连接超时、数据超时、被动模式开关、字符编码。这些字段里字符编码是最容易被忽略但最容易出问题的。FTP服务器在Windows环境下常用GBKLinux环境下常用UTF-8如果客户端编码设置不对中文文件名会变成乱码甚至导致文件找不到。public class TransferConfig { private ProtocolType protocol; private String host; private int port; private String username; private String password; private String privateKeyPath; private int connectTimeout 10000; private int dataTimeout 30000; private boolean passiveMode true; private String charset UTF-8; // getter/setter省略 }提示被动模式默认设为true这是生产环境的经验值。主动模式下服务器会反向连接客户端的数据端口在NAT和防火墙环境下几乎必然失败。3. FTP协议实操实现与关键细节3.1 连接建立与被动模式配置FTP的连接建立看起来简单但有几个参数必须设置到位。我用的是Apache Commons Net的FTPClient核心代码如下FTPClient ftpClient new FTPClient(); ftpClient.setConnectTimeout(config.getConnectTimeout()); ftpClient.setDataTimeout(Duration.ofMillis(config.getDataTimeout())); ftpClient.setControlEncoding(config.getCharset()); ftpClient.connect(config.getHost(), config.getPort()); ftpClient.login(config.getUsername(), config.getPassword()); ftpClient.enterLocalPassiveMode(); ftpClient.setFileType(FTP.BINARY_FILE_TYPE);这里有几个关键点。setControlEncoding必须在connect之前调用否则不生效。enterLocalPassiveMode必须在login之后调用因为有些服务器在登录前不接受PASV命令。setFileType设为BINARY否则上传二进制文件比如图片、压缩包会被损坏。我见过一个案例上传Excel文件后打开提示损坏排查了半天发现就是没设BINARY模式默认的ASCII模式把换行符转换了。连接超时和数据超时要分开设置。连接超时管的是TCP握手阶段数据超时管的是文件传输阶段。如果只设一个大文件传输时可能因为数据超时太短而中断。我的经验值是连接超时10秒数据超时30秒大文件场景可以调到60秒。3.2 文件上传的完整流程上传操作的完整流程是检查本地文件是否存在、切换远程工作目录、执行上传、检查返回码、关闭数据连接。代码示例如下public boolean upload(String localPath, String remotePath) { File localFile new File(localPath); if (!localFile.exists()) { throw new TransferException(本地文件不存在: localPath); } try (InputStream is new FileInputStream(localFile)) { String remoteDir remotePath.substring(0, remotePath.lastIndexOf(/)); if (!ftpClient.changeWorkingDirectory(remoteDir)) { ftpClient.makeDirectory(remoteDir); ftpClient.changeWorkingDirectory(remoteDir); } boolean success ftpClient.storeFile( new String(remotePath.getBytes(config.getCharset()), StandardCharsets.ISO_8859_1), is); if (!success) { int replyCode ftpClient.getReplyCode(); throw new TransferException(上传失败返回码: replyCode); } return true; } catch (IOException e) { throw new TransferException(上传异常, e); } }这里有一个非常隐蔽的坑远程路径的编码转换。Commons Net在发送路径时内部会做一次编码转换如果直接传中文路径在某些服务器上会乱码。常见的做法是先把路径按配置的字符集编码成字节数组再用ISO-8859-1解码成字符串。这个操作看起来很奇怪但它是Commons Net的历史遗留问题实测下来能解决大部分中文路径问题。另一个坑是远程目录不存在时的处理。storeFile不会自动创建目录如果远程目录不存在会直接返回失败。所以上传前要先changeWorkingDirectory失败则makeDirectory再切换。但makeDirectory只能创建一级目录如果路径是多级的比如/a/b/c需要逐级创建。我在工具类里封装了一个ensureRemoteDir方法专门处理这个。3.3 文件下载与断点续传下载的基本流程和上传对称但有一个额外考虑大文件下载时如果网络中断重新下载整个文件代价太大。FTP协议支持REST命令指定偏移量可以实现断点续传。核心逻辑是先检查本地已下载文件的大小然后调用setRestartOffset设置偏移量再执行下载。public boolean download(String remotePath, String localPath) { File localFile new File(localPath); long localSize localFile.exists() ? localFile.length() : 0; try (RandomAccessFile raf new RandomAccessFile(localFile, rw)) { if (localSize 0) { ftpClient.setRestartOffset(localSize); raf.seek(localSize); } try (OutputStream os new FileOutputStream(localFile, localSize 0)) { boolean success ftpClient.retrieveFile(remotePath, os); return success; } } catch (IOException e) { throw new TransferException(下载异常, e); } }注意断点续传不是所有FTP服务器都支持有些服务器返回REST命令不支持的错误。所以代码里要做降级处理如果设置偏移量后下载失败就删掉本地文件重新完整下载。3.4 目录遍历与文件删除目录遍历用listFiles方法返回FTPFile数组。这里要注意的是不同服务器返回的FTPFile信息完整度不同有些服务器不返回文件大小和修改时间。如果业务依赖这些信息需要做空值判断。public ListString listFiles(String remoteDir) { try { FTPFile[] files ftpClient.listFiles(remoteDir); ListString result new ArrayList(); for (FTPFile file : files) { if (file.isFile()) { result.add(file.getName()); } } return result; } catch (IOException e) { throw new TransferException(遍历目录失败, e); } }删除操作用deleteFile返回布尔值。这里有一个经验删除前最好先确认文件存在因为有些服务器对删除不存在的文件会返回失败有些则返回成功行为不一致。统一处理方式是先listFiles检查或者直接忽略删除不存在的文件的异常。4. SFTP协议实操实现与密钥管理4.1 JSch会话建立与认证方式SFTP这边我用的是JSch虽然这个库已经很久没更新了但存量项目最多稳定性经过验证。会话建立的核心代码如下JSch jsch new JSch(); if (config.getPrivateKeyPath() ! null) { jsch.addIdentity(config.getPrivateKeyPath()); } Session session jsch.getSession(config.getUsername(), config.getHost(), config.getPort()); if (config.getPassword() ! null) { session.setPassword(config.getPassword()); } session.setConfig(StrictHostKeyChecking, no); session.setTimeout(config.getConnectTimeout()); session.connect(); ChannelSftp channel (ChannelSftp) session.openChannel(sftp); channel.connect();StrictHostKeyChecking设为no是为了跳过主机密钥确认在自动化脚本里必须这样设否则会卡在交互式确认。但生产环境更安全的做法是把服务器公钥加入known_hosts而不是简单跳过。这个取舍要看具体场景内部可信网络可以跳过公网环境建议严格校验。密钥认证有一个高频问题JSch默认只支持OpenSSH格式的私钥不支持PuTTY的PPK格式。如果拿到的是PPK文件需要先用工具转换成OpenSSH格式。另外带密码的私钥需要在addIdentity时传入密码参数否则会报invalid privatekey错误。4.2 SFTP上传下载与目录操作SFTP的操作API比FTP直观很多因为JSch的ChannelSftp提供了类似文件系统的方法public boolean upload(String localPath, String remotePath) { try { String remoteDir remotePath.substring(0, remotePath.lastIndexOf(/)); try { channel.cd(remoteDir); } catch (SftpException e) { createRemoteDir(remoteDir); channel.cd(remoteDir); } channel.put(localPath, remotePath, ChannelSftp.OVERWRITE); return true; } catch (SftpException e) { throw new TransferException(SFTP上传失败, e); } }put方法的第三个参数是模式OVERWRITE表示覆盖RESUME表示续传APPEND表示追加。续传场景直接用RESUME就行比FTP的偏移量方式简单。目录遍历用ls方法返回VectorChannelSftp.LsEntry。这里要注意过滤掉.和..两个特殊条目否则遍历结果会多出两个无意义的项。删除文件用rm删除目录用rmdir但rmdir只能删空目录非空目录需要递归删除。4.3 连接复用与超时处理SFTP的Session和Channel是长连接建立一次可以复用多次操作。但长时间空闲后服务器可能会主动断开导致下次操作报session is down。我的处理方式是每次操作前检查session.isConnected()如果断开则重连。同时设置一个心跳保活定期发送channel.sendKeepAliveMsg()。private void ensureConnected() { if (session null || !session.isConnected()) { reconnect(); } if (channel null || !channel.isConnected()) { channel (ChannelSftp) session.openChannel(sftp); channel.connect(); } }超时方面session.setTimeout设置的是socket超时影响所有操作。如果某个大文件传输需要更长时间可以在传输前临时调大超时传输完再调回来。但更推荐的做法是把大文件传输放到独立线程避免阻塞主流程。5. 统一封装与工厂模式落地5.1 工厂类的设计与协议自动识别工厂类的职责是根据配置创建对应的FileTransferService实现。我加了一个小优化如果配置里没指定协议类型根据端口自动推断——21端口走FTP22端口走SFTP。这个推断在大部分场景下是准确的。public class FileTransferFactory { public static FileTransferService create(TransferConfig config) { ProtocolType type config.getProtocol(); if (type null) { type config.getPort() 22 ? ProtocolType.SFTP : ProtocolType.FTP; } switch (type) { case FTP: return new FtpFileTransferService(config); case SFTP: return new SftpFileTransferService(config); default: throw new IllegalArgumentException(不支持的协议类型: type); } } }工厂模式的好处是业务代码完全不需要关心底层实现。比如一个批量上传任务代码只需要写service.upload(local, remote)至于走的是FTP还是SFTP由配置决定。这样在测试环境用FTP、生产环境用SFTP的切换只需要改一行配置。5.2 异常体系的统一两种协议的异常类型完全不同FTP抛IOExceptionSFTP抛SftpException。如果直接往外抛业务代码就得分别catch。我定义了一个统一的TransferException在实现类里把底层异常包装成这个统一异常并保留原始异常作为cause。public class TransferException extends RuntimeException { private final String protocol; private final int errorCode; public TransferException(String message, Throwable cause) { super(message, cause); this.protocol unknown; this.errorCode -1; } // 其他构造方法和getter省略 }异常信息里要包含足够的排查线索协议类型、远程路径、返回码、原始异常消息。我见过太多案例日志里只有一句“上传失败”排查起来全靠猜。好的异常信息应该让运维人员不看代码就能定位问题。5.3 资源关闭与连接池考量FTP的FTPClient和SFTP的Session都需要显式关闭否则会泄漏连接。我让FileTransferService继承AutoCloseable业务代码用try-with-resources管理。但这里有一个权衡如果每次操作都新建连接再关闭频繁操作时开销很大。所以我在实现类里做了连接复用close方法只在真正不需要时调用。try (FileTransferService service FileTransferFactory.create(config)) { service.upload(/local/a.txt, /remote/a.txt); service.download(/remote/b.txt, /local/b.txt); }对于高频操作场景可以考虑引入连接池比如Apache Commons Pool。但我的经验是大部分文件传输场景的频率并不高连接复用的收益有限反而增加了复杂度。除非是每秒几十次以上的操作否则简单的连接复用就够了。6. 常见问题排查与避坑经验实录6.1 FTP连接与传输问题速查问题现象可能原因排查方法解决方案连接超时防火墙拦截、端口错误telnet测试端口检查防火墙规则确认端口登录失败用户名密码错误、权限不足用命令行客户端验证核对凭据检查用户权限上传卡住不动主动模式被防火墙拦截查看是否卡在数据连接切换被动模式中文文件名乱码编码不一致查看服务器编码配置统一设置UTF-8或GBK大文件传输中断数据超时太短查看超时日志调大dataTimeout上传后文件损坏未设BINARY模式对比文件大小和MD5设置FTP.BINARY_FILE_TYPE6.2 SFTP密钥与连接问题速查问题现象可能原因排查方法解决方案invalid privatekey私钥格式不兼容查看私钥文件头转换为OpenSSH格式Auth fail密钥未授权、密码错误查看服务器auth日志检查authorized_keyssession is down连接空闲超时查看操作间隔时间加心跳保活或重连目录不存在路径错误、权限不足用sftp命令行验证创建目录或修正路径传输速度慢加密算法开销对比scp速度调整加密算法优先级6.3 几个血泪教训第一个教训永远不要在生产环境用root账号做文件传输。我见过一个案例脚本用root账号上传文件结果因为路径拼接错误把远程服务器的一个系统目录覆盖了。正确的做法是为文件传输创建专用账号并限制其只能访问特定目录。第二个教训上传前一定要检查磁盘空间。FTP和SFTP的API都不会在空间不足时给出明确错误往往表现为传输到一半中断。我的做法是在上传前用df命令通过SSH执行或FTP的STAT命令检查可用空间低于阈值就告警。第三个教训文件名里的特殊字符要处理。空格、中文、#、这些字符在不同服务器上的处理方式不同。最稳妥的做法是对文件名做URL编码或者在上传前重命名为纯ASCII字符。我现在的习惯是本地生成文件时就用时间戳UUID命名避免任何特殊字符。第四个教训不要忽略返回值检查。storeFile返回false不代表一定失败有些服务器返回false但文件实际传成功了。所以我的做法是返回false时再listFiles确认文件是否存在且大小匹配双重校验。7. 性能优化与扩展方向7.1 大文件传输的优化策略大文件传输的核心瓶颈是网络带宽和磁盘IO。在代码层面能做的优化有限但有几个点值得注意。一是缓冲区大小Commons Net默认的缓冲区是1024字节对于大文件来说太小了可以调到8192或16384。JSch的put方法也支持设置缓冲区大小。二是并发传输如果有多文件需要传输可以用线程池并发执行但要注意服务器端的连接数限制。ftpClient.setBufferSize(8192);这个设置看起来不起眼但在千兆网络下缓冲区从1K调到8K传输速度能有明显提升。原因是减少了系统调用的次数。7.2 断点续传与秒传的扩展断点续传前面已经讲了基本实现。秒传是另一个有意思的扩展上传前先计算文件的MD5然后查询远程是否已存在相同MD5的文件如果存在则直接跳过上传。这个功能在批量同步场景下能节省大量时间。实现方式是远程维护一个MD5索引文件或者用文件名包含MD5的方式。7.3 监控与日志的补充文件传输的监控指标包括传输成功率、平均耗时、失败原因分布。我的做法是在TransferException里带上足够的上下文然后在全局异常处理器里打点上报。日志方面每次操作记录协议、远程路径、文件大小、耗时、结果。这些日志在排查问题时非常有用。提示日志里不要打印密码和密钥内容这是基本的安全意识。我见过有人的日志把FTP密码明文打出来了这是严重的安全隐患。8. 项目结构参考与依赖说明8.1 Maven依赖配置dependencies dependency groupIdcommons-net/groupId artifactIdcommons-net/artifactId version3.9.0/version /dependency dependency groupIdcom.jcraft/groupId artifactIdjsch/artifactId version0.1.55/version /dependency dependency groupIdorg.slf4j/groupId artifactIdslf4j-api/artifactId version1.7.36/version /dependency /dependenciesCommons Net 3.9.0是当前稳定版本修复了之前的一些编码问题。JSch 0.1.55是最后一个官方版本虽然老但稳定。如果项目允许可以考虑用com.github.mwiede:jsch这个fork版本它修复了一些已知问题并支持更新的加密算法。8.2 包结构建议com.example.transfer ├── config │ ├── TransferConfig.java │ └── ProtocolType.java ├── core │ ├── FileTransferService.java │ ├── TransferException.java │ └── FileTransferFactory.java ├── ftp │ └── FtpFileTransferService.java ├── sftp │ └── SftpFileTransferService.java └── util ├── PathUtils.java └── CharsetUtils.java这个结构清晰地把配置、核心接口、两种协议实现、工具类分开。PathUtils负责路径拼接和规范化CharsetUtils负责编码转换。这两个工具类看起来简单但把散落在各处的路径和编码逻辑集中起来维护成本会低很多。8.3 测试建议文件传输的测试不能只测正常流程必须覆盖异常场景文件不存在、目录不存在、权限不足、网络中断、磁盘满、文件名含特殊字符、大文件、空文件。我的做法是搭一个本地的FTP服务器比如用Apache FtpServer和一个本地SFTP服务器用Apache MINA SSHD写集成测试覆盖这些场景。单元测试用Mockito模拟底层客户端集成测试用真实服务器。注意测试环境的FTP服务器配置要和生成环境一致特别是被动模式的端口范围。我遇到过测试环境正常、生产环境失败的情况最后发现是生产环境的防火墙只开放了21端口没有开放被动模式的数据端口范围。9. 实际对接中的场景化经验9.1 银行和政务系统的对接特点银行和政务系统的文件传输有几个鲜明特点一是必须用SFTPFTP基本不被接受二是密钥认证为主密码认证很少三是目录结构固定且层级深四是对文件命名有严格规范通常包含日期、批次号、机构代码。对接这类系统时建议把路径拼接和文件命名规则做成可配置的模板而不是硬编码在代码里。9.2 制造业和医疗系统的对接特点制造业MES系统和医疗HIS系统里FTP的使用比例反而更高因为这些系统往往年代较久改造动力不足。这类场景的特点是文件小但频率高目录结构扁平中文文件名常见。对接时重点要解决编码问题和连接复用因为高频操作下频繁建连的开销不可忽视。9.3 跨平台对接的编码处理Windows服务器和Linux服务器混用的场景下编码问题最头疼。我的经验是统一用UTF-8如果服务器不支持在配置里单独指定。具体做法是TransferConfig里加一个charset字段默认UTF-8对接方明确说用GBK时才改。同时在上传下载时对文件名做一次编码探测如果发现乱码尝试用备用编码重新解码。这个编码探测的逻辑不复杂把文件名按UTF-8解码如果得到的字符串包含替换字符UFFFD说明不是UTF-8再试GBK。虽然不能覆盖所有情况但能解决大部分问题。10. 个人实操体会与后续扩展思路这套封装我在几个项目里实际用过最大的体会是文件传输的复杂度不在于协议本身而在于环境差异。同样的代码在A环境跑得好好的到B环境就各种问题。所以工具类里要留足够的配置项和日志让运维人员能自己排查而不是每次都来找开发。后续如果要扩展我觉得有几个方向值得做。一是支持对象存储比如S3协议把FileTransferService再增加一个实现这样传统文件服务器和云存储就能用同一套接口。二是增加文件校验功能上传后自动比对MD5确保传输完整性。三是做一个简单的管理界面能查看传输历史、重试失败任务。这些扩展都不难核心抽象已经打好了基础。最后分享一个小技巧在TransferConfig里加一个dryRun开关开启时只打印将要执行的操作而不实际执行。这个功能在调试路径拼接和权限问题时特别有用能避免误删误传。我现在的习惯是任何涉及删除操作的工具类都加一个dryRun模式先跑一遍看看输出对不对确认无误再真正执行。