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 Net,SFTP则基本是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的核心差异对比
在动手写代码之前,必须先把两种协议的差异理清楚,否则抽象层设计出来一定是漏的。我整理了一张实际开发中最容易踩坑的对比表:
| 对比维度 | FTP | SFTP |
|---|---|---|
| 底层协议 | 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); List<String> listFiles(String remoteDir); void close(); }这个接口看起来简单,但每个方法的参数设计都有讲究。比如upload方法,我选择用本地路径和远程路径两个字符串参数,而不是用File对象或InputStream。原因是:在实际对接场景中,文件往往来自临时目录或共享存储,用路径字符串最灵活;而且断点续传、大文件分片这些扩展需求,用路径比用流更容易实现。
为什么不把listFiles设计成返回FileInfo对象列表?因为大部分对接场景只需要文件名,返回List<String>足够用。如果后续需要文件大小、修改时间,可以再加一个listFileDetails方法。接口设计要克制,不要一开始就追求大而全,这是我踩过多次坑之后的体会。
2.3 配置模型的设计
配置这块我建议用一个TransferConfig类来承载,字段包括:协议类型(枚举FTP/SFTP)、主机、端口、用户名、密码、私钥路径(SFTP用)、连接超时、数据超时、被动模式开关、字符编码。这些字段里,字符编码是最容易被忽略但最容易出问题的。FTP服务器在Windows环境下常用GBK,Linux环境下常用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 List<String> listFiles(String remoteDir) { try { FTPFile[] files = ftpClient.listFiles(remoteDir); List<String> 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方法,返回Vector<ChannelSftp.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端口走FTP,22端口走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抛IOException,SFTP抛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_TYPE |
6.2 SFTP密钥与连接问题速查
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| invalid privatekey | 私钥格式不兼容 | 查看私钥文件头 | 转换为OpenSSH格式 |
| Auth fail | 密钥未授权、密码错误 | 查看服务器auth日志 | 检查authorized_keys |
| session 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> <groupId>commons-net</groupId> <artifactId>commons-net</artifactId> <version>3.9.0</version> </dependency> <dependency> <groupId>com.jcraft</groupId> <artifactId>jsch</artifactId> <version>0.1.55</version> </dependency> <dependency> <groupId>org.slf4j</groupId> <artifactId>slf4j-api</artifactId> <version>1.7.36</version> </dependency> </dependencies>Commons 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 银行和政务系统的对接特点
银行和政务系统的文件传输有几个鲜明特点:一是必须用SFTP,FTP基本不被接受;二是密钥认证为主,密码认证很少;三是目录结构固定且层级深;四是对文件命名有严格规范,通常包含日期、批次号、机构代码。对接这类系统时,建议把路径拼接和文件命名规则做成可配置的模板,而不是硬编码在代码里。
9.2 制造业和医疗系统的对接特点
制造业MES系统和医疗HIS系统里,FTP的使用比例反而更高,因为这些系统往往年代较久,改造动力不足。这类场景的特点是:文件小但频率高,目录结构扁平,中文文件名常见。对接时重点要解决编码问题和连接复用,因为高频操作下频繁建连的开销不可忽视。
9.3 跨平台对接的编码处理
Windows服务器和Linux服务器混用的场景下,编码问题最头疼。我的经验是:统一用UTF-8,如果服务器不支持,在配置里单独指定。具体做法是:TransferConfig里加一个charset字段,默认UTF-8,对接方明确说用GBK时才改。同时在上传下载时,对文件名做一次编码探测,如果发现乱码,尝试用备用编码重新解码。
这个编码探测的逻辑不复杂:把文件名按UTF-8解码,如果得到的字符串包含替换字符(U+FFFD),说明不是UTF-8,再试GBK。虽然不能覆盖所有情况,但能解决大部分问题。
10. 个人实操体会与后续扩展思路
这套封装我在几个项目里实际用过,最大的体会是:文件传输的复杂度不在于协议本身,而在于环境差异。同样的代码,在A环境跑得好好的,到B环境就各种问题。所以工具类里要留足够的配置项和日志,让运维人员能自己排查,而不是每次都来找开发。
后续如果要扩展,我觉得有几个方向值得做。一是支持对象存储(比如S3协议),把FileTransferService再增加一个实现,这样传统文件服务器和云存储就能用同一套接口。二是增加文件校验功能,上传后自动比对MD5,确保传输完整性。三是做一个简单的管理界面,能查看传输历史、重试失败任务。这些扩展都不难,核心抽象已经打好了基础。
最后分享一个小技巧:在TransferConfig里加一个dryRun开关,开启时只打印将要执行的操作而不实际执行。这个功能在调试路径拼接和权限问题时特别有用,能避免误删误传。我现在的习惯是,任何涉及删除操作的工具类,都加一个dryRun模式,先跑一遍看看输出对不对,确认无误再真正执行。