在实际开发中,我们经常需要从远程服务器下载文件。SFTP作为SSH协议的一部分,提供了安全的文件传输能力。但原生SFTP客户端有个明显的痛点:它只能单文件操作,无法直接下载整个文件夹。想象一下,如果你要下载一个包含数百个子文件夹和数千个文件的目录结构,手动一个个下载简直是噩梦。
更糟糕的是,当网络不稳定时,下载大文件夹经常会在中途断开。我曾经在一个项目中,花了3小时下载一个20GB的文件夹,结果在95%的时候网络闪断,一切都要从头开始。这种体验实在太糟糕了,于是我决定用Hutool工具库来解决这个问题。
Hutool的Sftp工具类已经封装了基本的SFTP操作,但它的递归下载功能还不够完善。我们需要在它的基础上增加两个关键能力:一是完整的文件夹递归下载,二是可靠的断点续传机制。这样即使在网络不稳定的环境下,也能保证文件传输的可靠性。
首先,我们需要在项目中引入Hutool和JSch的依赖。JSch是Java实现的SSH2库,Hutool的SFTP功能就是基于它封装的。
如果你使用Maven,在pom.xml中添加:
xml复制<dependencies>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.16</version>
</dependency>
<dependency>
<groupId>com.jcraft</groupId>
<artifactId>jsch</artifactId>
<version>0.1.54</version>
</dependency>
</dependencies>
如果是Gradle项目,在build.gradle中添加:
groovy复制dependencies {
implementation 'com.jcraft:jsch:0.1.54'
implementation 'cn.hutool:hutool-all:5.8.16'
}
Hutool提供了非常简便的方式来创建SFTP连接。基本连接代码如下:
java复制import cn.hutool.extra.ssh.JschUtil;
import cn.hutool.extra.ssh.Sftp;
public class SftpDemo {
public static void main(String[] args) {
String host = "your.sftp.server";
int port = 22;
String username = "your_username";
String password = "your_password";
Sftp sftp = JschUtil.createSftp(host, port, username, password);
// 使用完毕后记得关闭连接
sftp.close();
}
}
在实际项目中,我建议把这些连接参数放在配置文件中,而不是硬编码在代码里。这样既安全又便于维护。
Hutool的Sftp类已经提供了基本的文件操作,但没有直接的文件夹递归下载功能。我们需要自己实现这个逻辑。核心思路是:
下面是实现代码:
java复制import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.extra.ssh.Sftp;
import com.jcraft.jsch.ChannelSftp;
import com.jcraft.jsch.SftpException;
import java.io.File;
import java.util.Vector;
public class SftpDownloader {
/**
* 递归下载SFTP文件夹
* @param sftp SFTP连接
* @param remotePath 远程路径
* @param localPath 本地路径
* @throws SftpException
*/
public static void downloadDir(Sftp sftp, String remotePath, String localPath) throws SftpException {
ChannelSftp channelSftp = sftp.getClient();
channelSftp.cd(remotePath);
// 列出远程文件夹下的所有内容
Vector<ChannelSftp.LsEntry> fileList = channelSftp.ls(".");
for (ChannelSftp.LsEntry entry : fileList) {
String filename = entry.getFilename();
// 跳过当前目录和上级目录
if (".".equals(filename) || "..".equals(filename)) {
continue;
}
String remoteFilePath = StrUtil.appendIfMissing(remotePath, "/") + filename;
String localFilePath = StrUtil.appendIfMissing(localPath, "/") + filename;
if (entry.getAttrs().isDir()) {
// 如果是目录,先创建本地目录,然后递归下载
FileUtil.mkdir(localFilePath);
downloadDir(sftp, remoteFilePath, localFilePath);
} else {
// 如果是文件,确保父目录存在后下载
FileUtil.mkdir(FileUtil.file(localFilePath).getParent());
channelSftp.get(remoteFilePath, localFilePath);
System.out.println("下载完成: " + remoteFilePath);
}
}
}
}
在实际使用中,我发现路径处理有几个常见问题需要注意:
路径分隔符问题:不同操作系统使用不同的路径分隔符(Windows用\,Linux用/)。Hutool的FileUtil已经处理了这个问题,但远程路径必须使用Linux风格的/。
特殊字符处理:如果文件名包含空格或特殊字符,需要特别注意。Hutool的StrUtil.appendIfMissing方法可以确保路径拼接正确。
权限问题:确保本地有创建目录和文件的权限,远程用户有读取文件的权限。
断点续传的核心思想是:在下载中断后,能够从中断点继续下载,而不是从头开始。实现这个功能需要:
对于SFTP协议,JSch库本身不支持真正的断点续传(即从文件中间继续下载),但我们可以实现"文件级别"的断点续传:只下载那些未完成或缺失的文件。
下面是增强版的下载工具类,增加了断点续传功能:
java复制import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.extra.ssh.Sftp;
import com.jcraft.jsch.ChannelSftp;
import com.jcraft.jsch.SftpException;
import java.io.File;
import java.util.HashSet;
import java.util.Set;
import java.util.Vector;
public class SftpResumableDownloader {
// 用于记录已下载的文件路径
private static Set<String> downloadedFiles = new HashSet<>();
/**
* 带断点续传功能的递归下载
* @param sftp SFTP连接
* @param remotePath 远程路径
* @param localPath 本地路径
* @param resume 是否启用断点续传
* @throws SftpException
*/
public static void downloadDir(Sftp sftp, String remotePath, String localPath, boolean resume) throws SftpException {
ChannelSftp channelSftp = sftp.getClient();
channelSftp.cd(remotePath);
Vector<ChannelSftp.LsEntry> fileList = channelSftp.ls(".");
for (ChannelSftp.LsEntry entry : fileList) {
String filename = entry.getFilename();
if (".".equals(filename) || "..".equals(filename)) {
continue;
}
String remoteFilePath = StrUtil.appendIfMissing(remotePath, "/") + filename;
String localFilePath = StrUtil.appendIfMissing(localPath, "/") + filename;
if (entry.getAttrs().isDir()) {
// 处理目录
FileUtil.mkdir(localFilePath);
downloadDir(sftp, remoteFilePath, localFilePath, resume);
} else {
// 处理文件
File localFile = new File(localFilePath);
if (resume && localFile.exists()) {
// 如果启用断点续传且文件已存在,跳过下载
if (!downloadedFiles.contains(remoteFilePath)) {
System.out.println("文件已存在,跳过: " + remoteFilePath);
downloadedFiles.add(remoteFilePath);
}
continue;
}
// 确保父目录存在
FileUtil.mkdir(localFile.getParent());
// 下载文件
channelSftp.get(remoteFilePath, localFilePath);
downloadedFiles.add(remoteFilePath);
System.out.println("下载完成: " + remoteFilePath);
}
}
}
}
上面的实现虽然解决了基本的断点续传需求,但还有改进空间:
文件校验:仅检查文件是否存在是不够的,还应该检查文件大小或校验和(如MD5)是否匹配。
下载进度记录:将已下载的文件信息持久化到文件或数据库,这样即使程序重启也能恢复。
并发控制:对于大量文件,可以考虑使用多线程下载提高速度。
下面是增强版的实现,增加了文件大小校验:
java复制// 在原有代码基础上增加文件大小检查
if (resume && localFile.exists()) {
long localSize = localFile.length();
long remoteSize = entry.getAttrs().getSize();
if (localSize == remoteSize) {
System.out.println("文件已完整存在,跳过: " + remoteFilePath);
downloadedFiles.add(remoteFilePath);
continue;
} else if (localSize < remoteSize) {
System.out.println("发现不完整文件,重新下载: " + remoteFilePath);
localFile.delete();
}
// 如果本地文件比远程还大,也重新下载
else {
System.out.println("本地文件异常,重新下载: " + remoteFilePath);
localFile.delete();
}
}
结合前面的所有优化,下面是完整的工具类实现:
java复制import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.extra.ssh.Sftp;
import com.jcraft.jsch.ChannelSftp;
import com.jcraft.jsch.SftpException;
import java.io.File;
import java.util.HashSet;
import java.util.Set;
import java.util.Vector;
public class EnhancedSftpDownloader {
private Set<String> downloadedFiles = new HashSet<>();
private boolean resumeEnabled;
private Sftp sftp;
public EnhancedSftpDownloader(Sftp sftp, boolean resumeEnabled) {
this.sftp = sftp;
this.resumeEnabled = resumeEnabled;
}
public void downloadDirectory(String remotePath, String localPath) throws SftpException {
ChannelSftp channelSftp = sftp.getClient();
channelSftp.cd(remotePath);
Vector<ChannelSftp.LsEntry> entries = channelSftp.ls(".");
for (ChannelSftp.LsEntry entry : entries) {
String filename = entry.getFilename();
if (".".equals(filename) || "..".equals(filename)) {
continue;
}
String remoteFilePath = StrUtil.appendIfMissing(remotePath, "/") + filename;
String localFilePath = StrUtil.appendIfMissing(localPath, "/") + filename;
if (entry.getAttrs().isDir()) {
handleDirectory(remoteFilePath, localFilePath);
} else {
handleFile(entry, remoteFilePath, localFilePath);
}
}
}
private void handleDirectory(String remotePath, String localPath) throws SftpException {
FileUtil.mkdir(localPath);
downloadDirectory(remotePath, localPath);
}
private void handleFile(ChannelSftp.LsEntry entry, String remotePath, String localPath) throws SftpException {
File localFile = new File(localPath);
if (resumeEnabled && localFile.exists()) {
if (isFileComplete(entry, localFile)) {
System.out.println("跳过已下载文件: " + remotePath);
downloadedFiles.add(remotePath);
return;
}
FileUtil.del(localPath);
}
FileUtil.mkdir(localFile.getParent());
sftp.getClient().get(remotePath, localPath);
downloadedFiles.add(remotePath);
System.out.println("成功下载: " + remotePath);
}
private boolean isFileComplete(ChannelSftp.LsEntry entry, File localFile) {
return localFile.length() == entry.getAttrs().getSize();
}
}
下面是如何使用这个增强版的下载工具类:
java复制import cn.hutool.extra.ssh.JschUtil;
import cn.hutool.extra.ssh.Sftp;
public class Demo {
public static void main(String[] args) {
String host = "sftp.example.com";
int port = 22;
String username = "user";
String password = "password";
String remotePath = "/path/to/remote/folder";
String localPath = "C:/downloads/local_folder";
// 创建SFTP连接
Sftp sftp = JschUtil.createSftp(host, port, username, password);
try {
// 创建下载器实例,启用断点续传
EnhancedSftpDownloader downloader = new EnhancedSftpDownloader(sftp, true);
// 开始下载
downloader.downloadDirectory(remotePath, localPath);
System.out.println("下载任务完成");
} catch (Exception e) {
System.err.println("下载过程中出错: " + e.getMessage());
e.printStackTrace();
} finally {
// 确保关闭连接
sftp.close();
}
}
}
在实际项目中使用这个工具时,有几个经验分享:
java复制import com.jcraft.jsch.JSch;
import com.jcraft.jsch.Session;
Session session = JSch.getSession(username, host, port);
session.setPassword(password);
// 设置连接超时为30秒
session.setTimeout(30000);
session.connect();
Sftp sftp = new Sftp(session);
java复制private void downloadWithRetry(String remotePath, String localPath, int maxRetries) throws SftpException {
int retries = 0;
while (retries < maxRetries) {
try {
sftp.getClient().get(remotePath, localPath);
return;
} catch (SftpException e) {
retries++;
if (retries >= maxRetries) {
throw e;
}
System.out.println("下载失败,第" + retries + "次重试...");
try {
Thread.sleep(1000 * retries); // 指数退避
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new SftpException(0, "下载被中断");
}
}
}
}