/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */
#content_views .toc,
/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */
#content_views.markdown_views > p:empty:has(+ .toc),
#content_views.markdown_views > .toc + p:empty,
/* 富文本旧版目录标记 */
#content_views.htmledit_views #main-toc,
#content_views.htmledit_views #hr-toc,
#content_views.htmledit_views p[id*=\”-toc\”] {
display: none !important;
}
/* 目录去掉后,紧跟的首个标题不再多出一块上边距 */
#content_views.markdown_views > .toc + h1,
#content_views.markdown_views > .toc + h2,
#content_views.markdown_views > .toc + h3,
#content_views.markdown_views > .toc + h4,
#content_views.markdown_views > .toc + p:empty + h1,
#content_views.markdown_views > .toc + p:empty + h2,
#content_views.markdown_views > .toc + p:empty + h3,
#content_views.markdown_views > .toc + p:empty + h4 {
margin-top: 0 !important;
}
老炮踩坑录 · ARC-D02 · 技术深挖系列 基于「基础档案」真实项目源码,一个 SFTP 工具类的逐行复盘 关键词:JSch · SFTP · 连接管理 · 目录创建 · 标志文件
👋 欢迎阅读

🏠个人主页: 知守观 📘我的专栏: 老炮踩坑录 💻当前内容:SFTP 文件传输工具类的边界
文章目录
-
- 引言
- 连接管理:两个文件两条连接,用完就断
- 目录创建:cdOrMkdir 写好了,但被注释掉了
- 上传校验:标志文件做对了,回查没做
- 下载侧还有一个反向的坑:先建同名目录,再删掉
- JSch 0.1.55 本身:一个停在 2018 年的依赖
- 速查表
- 老炮点评
引言
上一篇讲 PDF 链路的模板方法时,我在结尾留了个预告:这个项目的文件传输靠 JSch库,版本停更在 0.1.55,这篇兑现。
业务背景是这样的:
基础档案项目要对外提供区域、快递公司这类基础数据,对接方不按接口走,按文件走——服务把数据写成 JSON 文件,用 SFTP 推到对方服务器,对方定时拉取。负责这件事的是 SftpUtils 类,有 236 行代码,加上调用方 ExpressCompanyServiceImpl 的 pushParam 方法,就是整条链路的全部代码。
JSch 这个库年轻后端开发者可能没见过。2018 年 11 月发了 0.1.55 之后,jcraft 官方就再没更新过,是事实上的停更状态。这个项目 2022 年用它,属于当时的主流选择,我不打算拿 2026 年的眼光去苛责技术选型。我要复盘的是用法:连接怎么管、目录怎么建、传完怎么校验。这三件事每一件都有明确的边界,代码踩在线上还是线外,我们来逐行看看。
连接管理:两个文件两条连接,用完就断
pushParam 一次要推两个 JSON 文件,调用侧长这样:
SftpUtils sftpUtils = new SftpUtils();
sftpUtils.uploadFile(sftpConfig, sftpUploadList);
sftpUtils.uploadFile(sftpConfig, sftpUploadMap);
而 uploadFile 方法内部,每次调用都是完整的建立连接、传输数据、断开连接:
public void uploadFile(SftpConfig sftpConfig, SftpUploadRequest request) {
Session session = null;
ChannelSftp channelSftp = null;
try {
session = getSession(sftpConfig);
channelSftp = getChannelSftp(session);
// … cd、put
} finally {
disconnect(channelSftp, session);
}
}
一次推送建要两条 TCP 连接、做两次 SSH 握手。两个文件已经是链路里最轻的场景了,区域数据那个接口一次推的文件会更多。
这个写法有它的道理:无状态,不留连接,不怕对方服务端踢人,代码简单。对于这种一天跑一两次的定时推送,握手开销可以忽略。问题而是在另一个地方——getSession 方法里的这行:
session.setConfig("StrictHostKeyChecking", "no");
session.setTimeout(10 * 1000);
StrictHostKeyChecking=no 等于跳过了主机密钥校验。SFTP 第一次连一台服务器,正常流程是校验对方主机指纹,确认连的是目标机器而不是中间人。设成 no,谁来都信。企业内网里这么写是常见妥协,但它意味着这条传输链对中间人攻击不设防,传的还是客户可见的基础数据文件。这个配置项出现在代码里,至少应该配一条注释说明是评估过的,现在这样看不出是权衡还是顺手抄来的。
超时也只设了一半。setTimeout(10 * 1000) 控制的是建连超时,传输过程中的读超时没人管。对方网络抖动时,channelSftp.put 可以挂在那里很久,直到 TCP 层自己超时。定时任务半夜卡住,第二天对账才发现文件没到,这种事故模式在老系统里经常反复出现。
disconnect 本身写得是对的,先断 channel 再断 session,两个都判空:
private void disconnect(ChannelSftp channelSftp, Session session) {
if (channelSftp != null) {
channelSftp.disconnect();
}
if (session != null) {
session.disconnect();
}
}
资源释放的顺序和判空都挑不出毛病。这一处符合军规一。
目录创建:cdOrMkdir 写好了,但被注释掉了
上传文件前要先切到远端目录。代码里其实写了一个逐级建目录的方法:
private void cdOrMkdir(ChannelSftp channelSftp, String fileDir) throws SftpException {
if (StringUtils.isBlank(fileDir)) {
return;
}
for (String dirName : fileDir.split("/")) {
if (StringUtils.isBlank(dirName)) {
dirName = "/";
}
boolean dirExist = isDirExist(channelSftp, dirName);
if (!dirExist) {
channelSftp.mkdir(dirName);
}
channelSftp.cd(dirName);
}
}
然后看 uploadFile 里实际执行的:
// 切换到SFTP文件目录
// cdOrMkdir(channelSftp, remoteFileDir);
// 切换到SFTP文件目录
channelSftp.cd(remoteFileDir);
cdOrMkdir 被整行注释,留下的是裸 cd。远端目录不存在,直接抛 SftpException,文件传不上去。
这个改动的影响很实在:远端服务器重装、目录迁移、对接方清理磁盘,任何一种情况把目录弄没了,推送任务就开始报错,直到有人手工去服务器上把目录建出来。原本代码里有自动建目录的能力,被一行注释关掉了。
被关掉的 cdOrMkdir 本身也不结实。"/a/b".split("/") 会得到 ["", "a", "b"],空串被替换成 "/",靠逐级 cd 的状态往下走,这个流程对标准绝对路径能跑通。但如果路径中间出现连续斜杠,"a//b" 切出 ["a", "", "b"],中间那个空段被替换成 "/",cd("/") 一下跳回根目录,后面建的目录全落在根上。没人保证传进来的 remoteFilePath 永远不带重复斜杠,配置是人手维护的。
cdOrMkdir 依赖的 isDirExist 还有一个更隐蔽的问题:
private boolean isDirExist(ChannelSftp channelSftp, String fileDir) {
try {
SftpATTRS sftpATTRS = channelSftp.lstat(fileDir);
return sftpATTRS.isDir();
} catch (SftpException e) {
Logs.error("isDirExist error info", e);
return false;
}
}
lstat 抛异常一律返回 false。目录不存在会抛异常,权限不足也抛,连接断了也抛。后两种情况下目录明明是存在的,方法却报告不存在,上游的 cdOrMkdir 就会对一个已存在的目录调 mkdir,再抛一个新的异常。异常在这里被压成了一个布尔值,故障现场的真实原因到日志里只剩一行 error,调用链上的判断全是错的。
上传校验:标志文件做对了,回查没做
这条链路里有一个设计值得肯定。我们来看 uploadFile 方法的结尾:
channelSftp.put(request.getLocalFilePath(), remoteFileName);
if (StringUtils.isNoneBlank(request.getEndFlag())) {
channelSftp.put(request.getLocalFilePath() + request.getEndFlag(),remoteFileName + request.getEndFlag());
}
主文件传完之后,再传一个同名加后缀的标志文件,比如 expressCompanyList.json 配一个 expressCompanyList.json.end。下游拉文件时只认标志文件,见到 .end 才认为主文件传完了。这是文件对接里防半文件的标准做法——SFTP 的 put 没有事务,下游如果只靠主文件判断,可能拉到一个传到一半的文件。
配套地,下载侧校验存在性时只看标志文件:
private boolean isFileExist(ChannelSftp channelSftp, String fileDir, String fileName, String endFlag) {
if (StringUtils.isNoneBlank(endFlag)) {
if (!isFileExist(channelSftp, fileDir, fileName + endFlag)) {
return false;
}
} else {
if (!isFileExist(channelSftp, fileDir, fileName)) {
return false;
}
}
return true;
}
endFlag 非空时只查标志文件,主文件的存在性不查。这个取舍我能理解:标志文件就是协议本身,约定好了只看它。但协议只防住了"传一半",没防住"传完了又被删了"——标志文件还在、主文件没了的窗口期,这里照样放行,等 get 的时候才发现问题。
上传侧更彻底,put 返回之后零回查。不调 lstat 确认远端文件在不在,不比对本地远端的大小。JSch 的 put 传输失败会抛异常,正常情况下能拦住大部分问题,但"远端磁盘满了写出一半""对端有清理脚本秒删"这类场景,调用方拿到的结果都是成功。加一次回查只要三行代码,lstat 拿远端大小和本地一比。这个项目没写。
下载侧还有一个反向的坑:先建同名目录,再删掉
downloadFile 里下载前的本地路径处理:
File localFile = new File(request.getLocalFilePath());
FileUtils.forceMkdir(localFile);
FileUtils.deleteQuietly(localFile);
channelSftp.get(remoteFileName, request.getLocalFilePath());
forceMkdir 的语义是把参数当目录创建。这里传进去的是文件路径,效果是先在磁盘上建出一个和文件同名的目录(连带父级目录),再用 deleteQuietly 把这个同名目录删掉,最后 get 把文件下载到该路径。
绕这个弯的意图应该是建父目录。正确的写法是 FileUtils.forceMkdir(localFile.getParentFile()),一行就够。现在这个写法在日常场景下碰巧能工作,因为父目录确实被顺带建出来了。
但有一个场景直接翻车:覆盖下载。目标路径已存在文件时,forceMkdir 发现该路径存在且是文件,抛 FileExistsException,下载在第一步就失败。第一次下载成功,第二次必定失败,这类任务又常常是定时跑的,表现出来就是"昨天还好好的"。
这个坑的定位成本不低。报错信息是文件已存在,排查的人第一反应是去看远端,很难想到是本地在建目录。
JSch 0.1.55 本身:一个停在 2018 年的依赖
用法之外,版本本身的风险也要说清楚。
0.1.55 是 jcraft 官方的最后一个版本,发布于 2018 年 11 月。它支持的密钥交换和签名算法停留在那个年代,只支持 ssh-rsa,也就是 SHA-1 签名。OpenSSH 从 8.8 开始默认禁用 ssh-rsa,现在的服务端如果按时升级,SftpUtils 连握手都过不去,报 Auth fail 或算法协商失败,错误信息里看不出是算法问题。
运维升级一次对端服务器,这条链路就可能无声无息地断掉。说无声无息不准确,会抛异常,但这种异常在定时任务里常常表现为一条没人看的 error 日志。
社区有续命的 fork,com.github.mwiede:jsch,持续维护,支持 rsa-sha2-256、rsa-sha2-512、ed25519,包名和 API 兼容,换依赖坐标就能平移。这件事的优先级取决于对端服务器的升级节奏,主动权不在自己手里,我的建议是不等出事再换。
另外工具类里还留着一个死方法:
public SftpConfig getSftpConfig() {
return null;
}
方法体直接返回 null,全项目没有调用点。SftpConfig 实际是调用方手工 new 出来再 set 的。这个方法大概率是某次重构的遗留,留在那里等下一个读代码的人愣一下。
速查表

有个细节我没完全确认:cdOrMkdir 被注释的原因,我翻不到当时的提交记录。可能是某次目录权限问题后被人临时关掉的,也可能是测试时注的忘了恢复。知道内情的前同事看到了,评论区补一句。
自查打卡:搜一下你项目里 StrictHostKeyChecking 的配置值,再数数 SFTP/FTP 工具类里 put 之后有没有回查。 两个结果打在评论区(比如:no,0次),我看看谁的链路最“裸奔”。
老炮点评
这个工具类我最喜欢的部分恰恰是那个被注释掉的 cdOrMkdir——有人想过目录不存在怎么办,想过标志文件防半文件,说明写代码的人对文件对接的坑是有经验的。但想过和生效之间隔着一行注释。
文件对接这条线,出问题从来不在正常路径上。正常路径谁写都能跑,全部的事故都藏在边界里:目录没了怎么办、对端升级了怎么办、文件传一半怎么办、覆盖下载怎么办。这篇里翻车的四处,cdOrMkdir 被注释、isDirExist 把异常压成 false、put 后无回查、forceMkdir 建错对象,全是边界。
JSch 版本的事再说一句。0.1.55 在 2022 年能用,写代码的人没有错。但一个停更的依赖躺在 pom 里,风险是随时间累积的,对端每一次升级都在逼近握手失败的那天。依赖版本不是定完就一劳永逸的,它有保质期。
最后问一句:你项目里的文件传输,对端升级一次服务器(比如 OpenSSH 禁了 rsa-sha1),你的链路会断吗? 是打算等报错了再排查,还是已经把底层的 JSch 换成了 mwiede 的 fork?评论区聊聊。
如果本文对你有帮助,欢迎:👍 点赞 | ⭐ 收藏 | 👤 关注 | 💬 留言
欢迎留言探讨。你的每一次互动,都是我继续更新的动力🚀
下期预告:《商业授权文件和 Nacos 密码,都进了 Git 仓库》
下一篇还在基础档案。license.xml 这份 Aspose 商业授权文件躺在 resources 目录里,application-local.yml 里的 Nacos 密码是明文,两个都跟着代码进了 Git。授权文件泄露的法律风险和生产配置泄露的链路,一条条算。
我是老炮,18 年 Java 老兵,仍在一线。关注我的 CSDN 专栏「老炮踩坑录」,真实项目复盘,让你少走弯路。
网硕互联帮助中心




评论前必须登录!
注册