FTP、SFTP上传下传、进度监控、断点续传、连接池封装JAVA一网打尽(一)
FTP、SFTP上传下传、进度监控、断点续传、连接池封装JAVA一网打尽(二)
FTP、SFTP上传下传、进度监控、断点续传、连接池封装JAVA一网打尽(三)
FTP、SFTP上传下传、进度监控、断点续传、连接池封装JAVA一网打尽(四)
FTP、SFTP上传下传、进度监控、断点续传、连接池封装JAVA一网打尽(五)【完结篇】
FTP、SFTP上传下传、进度监控、断点续传、连接池封装JAVA一网打尽(六)【汇总篇】
第一篇:基础篇,讲FTP常规上传下载实现、SFTP常规上传下载实现、单元测试类
第二篇:FTP高级篇,讲FTP上传进度监控、断点续传,FTP下载进度监控、断点续传
第三篇:SFTP高级篇,讲SFTP上传进度监控、断点续传,SFTP下载进度监控、断点续传
第四篇:FTP进阶篇,讲FTP池化处理(连接池封装)
第五篇【完结篇】:SFTP进阶篇,讲SFTP池化处理(连接池封装)
第六篇:汇总篇,包含前面1~5篇所有内容,且增加更高级的相关知识点
本文是SFTP进阶篇,讲SFTP池化处理(连接池封装)
- SpringBoot 2.7.18 官方下载地址:SpringBoot 2.7.18
- commons-net-3.10.0.jar 官方下载地址:commons-net-3.10.0.jar
- commons-pool2-2.12.0.jar 官方下载地址:commons-pool2-2.12.0.jar
- jsch-0.1.55.jar 官方下载地址:jsch-0.1.55.jar
- Oracle JDK8u202(Oracle JDK8最后一个非商业版本) 下载地址:Oracle JDK8u202
- FileZilla Client 官方下载地址:FileZilla Client
注意:
- (特别是MacOS用户)FileZilla有MacOS版本,下载客户端是下Client,不是Server(注意一下名字,不要下错了)。
该系列文章通用,几篇FTP文章的pom文件都一样
4.0.0
person.brickman
ftp
1.0-SNAPSHOT
org.springframework.boot
spring-boot-starter-parent
2.7.18
UTF-8
UTF-8
1.8
4.5.14
1.18.32
1.3.1
3.14.0
2.15.1
1.10
3.10.0
2.12.0
0.1.55
3.2.5
3.0.1
false
false
org.springframework.boot
spring-boot-starter
org.springframework.boot
spring-boot-starter-actuator
provided
io.micrometer
micrometer-registry-prometheus
org.slf4j
slf4j-api
${slf4j.version}
org.projectlombok
lombok
compile
org.apache.commons
commons-lang3
commons-net
commons-net
${commons-net.version}
org.apache.commons
commons-pool2
commons-logging
commons-logging
${commons-logging.version}
com.jcraft
jsch
${jsch.version}
org.springframework.boot
spring-boot-starter-test
test
org.apache.maven.plugins
maven-jar-plugin
2.6
org.apache.maven.plugins
maven-source-plugin
${maven-source-plugin.version}
true
compile
org.apache.maven.plugins
maven-deploy-plugin
2.8.2
deploy
deploy
deploy
org.apache.maven.plugins
maven-surefire-plugin
false
${skipTests}
${junit.test.params} -Xmx512m -XX:MaxPermSize=256m
接口类,读者觉得常量类更顺眼也可以改(阿里原装的)
package person.brickman.ftp.consts;
/**
* alibaba.datax.ftpreader
*
* @author datax
*/
public interface FtpKeyValue {
/**
* FTP 常用键定义
*/
String PROTOCOL = "protocol";
String HOST = "host";
String USERNAME = "username";
String PASSWORD = "password";
String PORT = "port";
String TIMEOUT = "timeout";
String CONNECTPATTERN = "connectPattern";
String PATH = "path";
String MAXTRAVERSALLEVEL = "maxTraversalLevel";
/**
* 默认值定义
*/
int DEFAULT_FTP_PORT = 21;
int DEFAULT_SFTP_PORT = 22;
int DEFAULT_TIMEOUT = 60000;
int DEFAULT_MAX_TRAVERSAL_LEVEL = 100;
String DEFAULT_FTP_CONNECT_PATTERN = "PASV";
String CONTROL_ENCODING = "utf8";
String NO_SUCH_FILE = "no such file";
char C_STAR = '*';
String STAR = "*";
char C_QUESTION = '?';
String QUESTION = "?";
String SLASH = "/";
String DOT = ".";
String DOUBLE_DOT = "..";
}
抽象类,不管ftp还是sftp都使用此抽象类,而不是直接操作实现类
package person.brickman.ftp;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* alibaba.datax.ftpreader
*
* @author datax
*/
public abstract class AbstractFtpHelper {
public abstract void setFtpClient(Object ftpClient);
public abstract Object getFtpClient() ;
/**
* 与ftp服务器建立连接
*
* @param @param host
* @param @param username
* @param @param password
* @param @param port
* @param @param timeout
* @param @param connectMode PASV PORT
* @return void
* @throws
*/
public abstract void loginFtpServer(String host, String username, String password, int port, int timeout, String connectMode) throws InterruptedException;
/**
* 断开与ftp服务器的连接
*
* @param
* @return void
* @throws
*/
public abstract void logoutFtpServer();
/**
* 判断指定路径是否是目录
*
* @param @param directoryPath
* @param @return
* @return boolean
* @throws
*/
public abstract boolean isDirExist(String directoryPath);
/**
* 判断指定路径是否是文件
*
* @param @param filePath
* @param @return
* @return boolean
* @throws
*/
public abstract boolean isFileExist(String filePath);
/**
* 判断指定路径是否是软链接
*
* @param @param filePath
* @param @return
* @return boolean
* @throws
*/
public abstract boolean isSymbolicLink(String filePath);
/**
* 递归获取指定路径下符合条件的所有文件绝对路径
*
* @param @param directoryPath
* @param @param parentLevel 父目录的递归层数(首次为0)
* @param @param maxTraversalLevel 允许的最大递归层数
* @param @return
* @return HashSet
* @throws
*/
public abstract HashSet getAllFilesInDir(String directoryPath, int parentLevel, int maxTraversalLevel);
/**
* 获取指定路径的输入流
*
* @param @param filePath
* @param @return
* @return InputStream
* @throws
*/
public abstract InputStream getInputStream(String filePath);
/**
* 写入指定路径的输出流
*
* @param @param filePath
* @param @return
* @return InputStream
* @throws
*/
public abstract OutputStream getOutputStream(String filePath);
/**
* 写入指定路径的输出流
*
* @param @param filePath
* @param @param mode OVERWRITE = 0; RESUME = 1; APPEND = 2;
* @param @return
* @return InputStream
* @throws
*/
public abstract OutputStream getOutputStream(String filePath, int mode);
/**
* 获取指定路径列表下符合条件的所有文件的绝对路径
*
* @param @param srcPaths 路径列表
* @param @param parentLevel 父目录的递归层数(首次为0)
* @param @param maxTraversalLevel 允许的最大递归层数
* @param @return
* @return HashSet
* @throws
*/
public HashSet getAllFilesInDir(List srcPaths, int parentLevel, int maxTraversalLevel) {
HashSet sourceAllFiles = new HashSet();
if (!srcPaths.isEmpty()) {
for (String eachPath : srcPaths) {
sourceAllFiles.addAll(getAllFilesInDir(eachPath, parentLevel, maxTraversalLevel));
}
}
return sourceAllFiles;
}
/**
* 创建远程目录
* 不支持递归创建, 比如 mkdir -p
*
* @param directoryPath
*/
public abstract void mkdir(String directoryPath);
/**
* 创建远程目录
* 支持目录递归创建
*
* @param directoryPath
*/
public abstract void mkDirRecursive(String directoryPath);
/**
* Q:After I perform a file transfer to the server,
* printWorkingDirectory() returns null. A:You need to call
* completePendingCommand() after transferring the file. wiki:
* http://wiki.apache.org/commons/Net/FrequentlyAskedQuestions
*/
public abstract void completePendingCommand();
/**
* 删除文件
* warn: 不支持文件夹删除, 比如 rm -rf
*
* @param filesToDelete
*/
public abstract void deleteFiles(Set filesToDelete);
/**
* 移动文件
* warn: 不支持文件夹删除, 比如 rm -rf
*
* @param filesToMove
* @param targetPath
*/
public abstract void moveFiles(Set filesToMove, String targetPath);
}
FTP上传下载实现类
package person.brickman.ftp;
import com.jcraft.jsch.*;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import person.brickman.ftp.consts.FtpKeyValue;
import java.io.File;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.HashSet;
import java.util.Properties;
import java.util.Set;
import java.util.Vector;
/**
* alibaba.datax.ftpreader
*
* @author datax
*/
@Slf4j
public class SftpHelper extends AbstractFtpHelper {
Session session = null;
ChannelSftp channelSftp = null;
HashSet sourceFiles = new HashSet();
@Override
public void setFtpClient(Object channelSftp) {
this. channelSftp=(ChannelSftp)channelSftp;
}
@Override
public Object getFtpClient() {
return channelSftp;
}
@Override
public void loginFtpServer(String host, String username, String password, int port, int timeout, String connectMode) throws InterruptedException {
JSch jsch = new JSch();
try {
session = jsch.getSession(username, host, port);
// Thread.sleep(3*1000);
// 根据用户名,主机ip,端口获取一个Session对象
// 如果服务器连接不上,则抛出异常
if (session == null) {
throw new RuntimeException("session is null,无法通过sftp与服务器建立链接,请检查主机名和用户名是否正确." );
}
// 设置密码
session.setPassword(password);
Properties config = new Properties();
config.put("StrictHostKeyChecking" , "no" );
// 为Session对象设置properties
session.setConfig(config);
// 设置timeout时间
session.setTimeout(timeout);
// 通过Session建立链接
session.connect( );
// 打开SFTP通道
channelSftp = (ChannelSftp) session.openChannel("sftp" );
// channelSftp.
// 建立SFTP通道的连接
channelSftp.connect( 5*1000);
//设置命令传输编码
/// String fileEncoding = System.getProperty("file.encoding");
// channelSftp.setFilenameEncoding(fileEncoding);
} catch (JSchException e) {
e.printStackTrace();
if (null != e.getCause()) {
String cause = e.getCause().toString();
String unknownHostException = "java.net.UnknownHostException: " + host;
String illegalArgumentException = "java.lang.IllegalArgumentException: port out of range:" + port;
String wrongPort = "java.net.ConnectException: Connection refused";
if (unknownHostException.equals(cause)) {
String message = String.format("请确认ftp服务器地址是否正确,无法连接到地址为: [%s] 的ftp服务器" , host);
log.error(message);
throw new RuntimeException(message);
} else if (illegalArgumentException.equals(cause) || wrongPort.equals(cause)) {
String message = String.format("请确认连接ftp服务器端口是否正确,错误的端口: [%s] " , port);
log.error(message);
throw new RuntimeException(message);
}
} else {
if ("Auth fail".equals(e.getMessage())) {
String message = String.format("与ftp服务器建立连接失败,请检查用户名和密码是否正确: [%s]" , "message:host =" + host + ",username = " + username + ",port =" + port);
log.error(message);
throw new RuntimeException(message);
} else {
String message = String.format("与ftp服务器建立连接失败 : [%s]" , "message:host =" + host + ",username = " + username + ",port =" + port);
log.error(message);
throw new RuntimeException(message);
}
}
}
}
@Override
public void logoutFtpServer() {
if (channelSftp != null) {
channelSftp.disconnect();
}
if (session != null) {
session.disconnect();
}
}
@Override
public boolean isDirExist(String directoryPath) {
try {
SftpATTRS sftpATTRS = channelSftp.lstat(directoryPath);
return sftpATTRS.isDir();
} catch (SftpException e) {
if (e.getMessage().toLowerCase().equals(FtpKeyValue.NO_SUCH_FILE)) {
String message = String.format("请确认您的配置项path:[%s]存在,且配置的用户有权限读取" , directoryPath);
log.error(message);
throw new RuntimeException(message);
}
String message = String.format("进入目录:[%s]时发生I/O异常,请确认与ftp服务器的连接正常" , directoryPath);
log.error(message);
throw new RuntimeException(message);
}
}
@Override
public boolean isFileExist(String filePath) {
boolean isExitFlag = false;
try {
SftpATTRS sftpATTRS = channelSftp.lstat(filePath);
if (sftpATTRS.getSize() >= 0) {
isExitFlag = true;
}
} catch (SftpException e) {
if (e.getMessage().toLowerCase().equals(FtpKeyValue.NO_SUCH_FILE)) {
String message = String.format("请确认您的配置项path:[%s]存在,且配置的用户有权限读取" , filePath);
log.error(message);
throw new RuntimeException(message);
} else {
String message = String.format("获取文件:[%s] 属性时发生I/O异常,请确认与ftp服务器的连接正常" , filePath);
log.error(message);
throw new RuntimeException(message);
}
}
return isExitFlag;
}
@Override
public boolean isSymbolicLink(String filePath) {
try {
SftpATTRS sftpATTRS = channelSftp.lstat(filePath);
return sftpATTRS.isLink();
} catch (SftpException e) {
if (e.getMessage().toLowerCase().equals(FtpKeyValue.NO_SUCH_FILE)) {
String message = String.format("请确认您的配置项path:[%s]存在,且配置的用户有权限读取" , filePath);
log.error(message);
throw new RuntimeException(message);
} else {
String message = String.format("获取文件:[%s] 属性时发生I/O异常,请确认与ftp服务器的连接正常" , filePath);
log.error(message);
throw new RuntimeException(message);
}
}
}
@Override
public HashSet getAllFilesInDir(String directoryPath, int parentLevel, int maxTraversalLevel) {
if (parentLevel < maxTraversalLevel) {
// 父级目录,以'/'结尾
String parentPath;
int pathLen = directoryPath.length();
// *和?的限制
if (directoryPath.contains(FtpKeyValue.STAR) || directoryPath.contains(FtpKeyValue.QUESTION)) {
// path是正则表达式
String subPath = UnstructuredStorageReaderUtil.getRegexPathParentPath(directoryPath);
if (isDirExist(subPath)) {
parentPath = subPath;
} else {
String message = String.format("不能进入目录:[%s]," + "请确认您的配置项path:[%s]存在,且配置的用户有权限进入" , subPath, directoryPath);
log.error(message);
throw new RuntimeException(message);
}
} else if (isDirExist(directoryPath)) {
// path是目录
if (directoryPath.charAt(pathLen - 1) == File.separatorChar) {
parentPath = directoryPath;
} else {
parentPath = directoryPath + File.separatorChar;
}
} else if (isSymbolicLink(directoryPath)) {
//path是链接文件
String message = String.format("文件:[%s]是链接文件,当前不支持链接文件的读取" , directoryPath);
log.error(message);
throw new RuntimeException(message);
} else if (isFileExist(directoryPath)) {
// path指向具体文件
sourceFiles.add(directoryPath);
return sourceFiles;
} else {
String message = String.format("请确认您的配置项path:[%s]存在,且配置的用户有权限读取" , directoryPath);
log.error(message);
throw new RuntimeException(message);
}
try {
Vector vector = channelSftp.ls(directoryPath);
for (int i = 0; i < vector.size(); i++) {
ChannelSftp.LsEntry le = (ChannelSftp.LsEntry) vector.get(i);
String strName = le.getFilename();
String filePath = parentPath + strName;
if (isDirExist(filePath)) {
// 是子目录
if (!(strName.equals(FtpKeyValue.DOT) || strName.equals(FtpKeyValue.DOUBLE_DOT))) {
//递归处理
getAllFilesInDir(filePath, parentLevel + 1, maxTraversalLevel);
}
} else if (isSymbolicLink(filePath)) {
//是链接文件
String message = String.format("文件:[%s]是链接文件,当前不支持链接文件的读取" , filePath);
log.error(message);
throw new RuntimeException(message);
} else if (isFileExist(filePath)) {
// 是文件
sourceFiles.add(filePath);
} else {
String message = String.format("请确认path:[%s]存在,且配置的用户有权限读取" , filePath);
log.error(message);
throw new RuntimeException(message);
}
}
} catch (SftpException e) {
String message = String.format("获取path:[%s] 下文件列表时发生I/O异常,请确认与ftp服务器的连接正常" , directoryPath);
log.error(message);
throw new RuntimeException(message);
}
return sourceFiles;
} else {
//超出最大递归层数
String message = String.format("获取path:[%s] 下文件列表时超出最大层数,请确认路径[%s]下不存在软连接文件" , directoryPath, directoryPath);
log.error(message);
throw new RuntimeException(message);
}
}
@Override
public InputStream getInputStream(String filePath) {
try {
return channelSftp.get(filePath);
} catch (SftpException e) {
String message = String.format("读取文件 : [%s] 时出错,请确认文件:[%s]存在且配置的用户有权限读取" , filePath, filePath);
log.error(message);
throw new RuntimeException(message);
}
}
@Override
public OutputStream getOutputStream(String filePath) {
return getOutputStream(filePath, ChannelSftp.APPEND);
}
@Override
public OutputStream getOutputStream(String filePath, int mode) {
try {
this.printWorkingDirectory();
String parentDir = filePath.substring(0, filePath.lastIndexOf(File.separatorChar));
this.channelSftp.cd(parentDir);
this.printWorkingDirectory();
OutputStream writeOutputStream = this.channelSftp.put(filePath, mode);
String message = String.format("打开FTP文件[%s]获取写出流时出错,请确认文件%s有权限创建,有权限写出等" , filePath, filePath);
if (null == writeOutputStream) {
throw new RuntimeException(message);
}
return writeOutputStream;
} catch (SftpException e) {
String message = String.format("写出文件[%s] 时出错,请确认文件%s有权限写出, errorMessage:%s" , filePath, filePath, e.getMessage());
log.error(message);
throw new RuntimeException(message);
}
}
@Override
public void mkdir(String directoryPath) {
boolean isDirExist = false;
try {
this.printWorkingDirectory();
SftpATTRS sftpATTRS = this.channelSftp.lstat(directoryPath);
isDirExist = sftpATTRS.isDir();
} catch (SftpException e) {
if (e.getMessage().toLowerCase().equals(FtpKeyValue.NO_SUCH_FILE)) {
log.warn(String.format("您的配置项path:[%s]不存在,将尝试进行目录创建, errorMessage:%s" , directoryPath, e.getMessage()), e);
isDirExist = false;
}
}
if (!isDirExist) {
try {
// warn 检查mkdir -p
this.channelSftp.mkdir(directoryPath);
} catch (SftpException e) {
String message = String.format("创建目录:%s时发生I/O异常,请确认与ftp服务器的连接正常,拥有目录创建权限, errorMessage:%s" , directoryPath, e.getMessage());
log.error(message, e);
throw new RuntimeException(message);
}
}
}
@Override
public void mkDirRecursive(String directoryPath) {
boolean isDirExist = false;
try {
this.printWorkingDirectory();
SftpATTRS sftpATTRS = this.channelSftp.lstat(directoryPath);
isDirExist = sftpATTRS.isDir();
} catch (SftpException e) {
if (e.getMessage().toLowerCase().equals(FtpKeyValue.NO_SUCH_FILE)) {
log.warn(String.format("您的配置项path:[%s]不存在,将尝试进行目录创建, errorMessage:%s" , directoryPath, e.getMessage()), e);
isDirExist = false;
}
}
if (!isDirExist) {
StringBuilder dirPath = new StringBuilder();
dirPath.append(FtpKeyValue.SLASH);
String[] dirSplit = StringUtils.split(directoryPath, FtpKeyValue.SLASH);
try {
// ftp server不支持递归创建目录,只能一级一级创建
for (String dirName : dirSplit) {
dirPath.append(dirName);
mkDirSingleHierarchy(dirPath.toString());
dirPath.append(FtpKeyValue.SLASH);
}
} catch (SftpException e) {
String message = String.format("创建目录:%s时发生I/O异常,请确认与ftp服务器的连接正常,拥有目录创建权限, errorMessage:%s" , directoryPath, e.getMessage());
log.error(message, e);
throw new RuntimeException(message);
}
}
}
@Override
public void completePendingCommand() {
}
@Override
public void deleteFiles(Set filesToDelete) {
String eachFile = null;
try {
this.printWorkingDirectory();
for (String each : filesToDelete) {
log.info(String.format("delete file [%s]." , each));
eachFile = each;
this.channelSftp.rm(each);
}
} catch (SftpException e) {
String message = String.format("删除文件:[%s] 时发生异常,请确认指定文件有删除权限,以及网络交互正常, errorMessage:%s" , eachFile, e.getMessage());
log.error(message);
throw new RuntimeException(message);
}
}
@Override
public void moveFiles(Set filesToMove, String targetPath) {
if (StringUtils.isBlank(targetPath)) {
throw new RuntimeException("目标目录路径为空!" );
}
String eachFile = null;
try {
this.printWorkingDirectory();
// 创建目录
mkdir(targetPath);
for (String each : filesToMove) {
log.info(String.format("rename file [%s]." , each));
eachFile = each;
String targetName = String.format("%s%s" , targetPath.endsWith(File.separator) ?
targetPath.substring(0, targetPath.length() - 1) : targetPath, each.substring(each.lastIndexOf(File.separator)));
this.channelSftp.rename(each, targetPath);
}
} catch (SftpException e) {
String message = String.format("移动文件:[%s] 时发生异常,请确认指定文件有删除权限,以及网络交互正常, errorMessage:%s" , eachFile, e.getMessage());
log.error(message);
throw new RuntimeException(message);
}
}
public boolean mkDirSingleHierarchy(String directoryPath) throws SftpException {
boolean isDirExist = false;
try {
SftpATTRS sftpATTRS = this.channelSftp.lstat(directoryPath);
isDirExist = sftpATTRS.isDir();
} catch (SftpException e) {
if (!isDirExist) {
log.info(String.format("正在逐级创建目录 [%s]" , directoryPath));
this.channelSftp.mkdir(directoryPath);
return true;
}
}
if (!isDirExist) {
log.info(String.format("正在逐级创建目录 [%s]" , directoryPath));
this.channelSftp.mkdir(directoryPath);
}
return true;
}
private void printWorkingDirectory() {
try {
log.info(String.format("current working directory:%s" , this.channelSftp.pwd()));
} catch (Exception e) {
log.warn(String.format("printWorkingDirectory error:%s" , e.getMessage()));
}
}
}
- FTP上传下载进度监控实现类
- 此类可以日志中打印上传/下载进度
- 日志打印的精度可通过调整代码中被除数的陪数控制
- 实际应用中如果是web应用可通过session变量实现前台页面进度展示
- 前台进度更新精度实现同程序日志进度打印精度控制逻辑
package person.brickman.ftp;
import com.jcraft.jsch.SftpProgressMonitor;
import lombok.extern.slf4j.Slf4j;
/**
* @Description: sftp 上传下载进度监控
* @Author brickman
* @CreateDate: 2025/1/2 20:30
* @Version: 1.0
*/
@Slf4j
public class FileProgressMonitor implements SftpProgressMonitor {
private long count = 0; //当前接收的总字节数
private long max = 0; /* 最终文件大小 */
private long percent = -1;//进度
/**
* 当每次传输了一个数据块后,调用count方法,count方法的参数为这一次传输的数据块大小
* 大
*
* 这里显示的百分比是整数
*/
@Override
public boolean count(long count) {
this.count += count;
if (percent >= this.count * 100 / max) {
return true;
}
percent = this.count * 100 / max;
log.info("Completed {}({}%) out of {}.", this.count, percent, max ); //打印当前进度
return true;
}
/**
* 大大
* 当传输结束时,调用end方法
* 大
*/
@Override
public void end() {
log.info("Transferring done.");
}
/**
* 当文件开始传输时,调用init方法
* 大
*/
@Override
public void init(int op, String src, String dest, long max) {
log.info("Transferring begin. ");
this.max = max;
this.count = 0;
this.percent = -1;
}
}
package person.brickman.ftp.pool;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.net.ftp.FTPClient;
import org.apache.commons.net.ftp.FTPReply;
import org.apache.commons.pool2.DestroyMode;
import org.apache.commons.pool2.PooledObject;
import org.apache.commons.pool2.PooledObjectFactory;
import org.apache.commons.pool2.impl.DefaultPooledObject;
import java.net.UnknownHostException;
/**
* @Description: ftp 池化封装,工厂类
* @Author: brickman
* @CreateDate: 2025/1/2 20:30
* @Version: 1.0
*/
@Slf4j
public class FtpClientFactory implements PooledObjectFactory {
private String host;
private int port;
private String user;
private String password;
/** FTP主动模式(port)和被动模式(PASV) default:PASV */
private String connectMode="PASV";
public FtpClientFactory(String host, int port, String user, String password) {
this.host = host;
this.port = port;
this.user = user;
this.password = password;
}
public FtpClientFactory(String host, int port, String user, String password, String connectMode) {
this.host = host;
this.port = port;
this.user = user;
this.password = password;
this.connectMode= connectMode;
}
@Override
public void activateObject(PooledObject pooledObject) throws Exception {
if(pooledObject.getObject().isAvailable()){
pooledObject.getObject().sendNoOp();
}else{
pooledObject.getObject().disconnect();
}
}
@Override
public void destroyObject(PooledObject pooledObject) throws Exception {
if (pooledObject.getObject() != null) {
pooledObject.getObject().disconnect();
}
}
@Override
public void destroyObject(PooledObject p, DestroyMode destroyMode) throws Exception {
PooledObjectFactory.super.destroyObject(p, destroyMode);
}
@Override
public PooledObject makeObject() throws Exception {
FTPClient ftpClient = new FTPClient();
try {
// 连接
ftpClient.connect(host, port);
// 登录
ftpClient.login(user, password);
// 不需要写死ftp server的OS TYPE,FTPClient getSystemType()方法会自动识别
/// ftpClient.configure(new FTPClientConfig(FTPClientConfig.SYST_UNIX));
ftpClient.setConnectTimeout(15*1000);
// 0 infinite 无穷大, 当前设置15秒
ftpClient.setDataTimeout(15*1000);
if ("PASV".equals(connectMode)) {
ftpClient.enterRemotePassiveMode();
ftpClient.enterLocalPassiveMode();
} else if ("PORT".equals(connectMode)) {
ftpClient.enterLocalActiveMode();
/// ftpClient.enterRemoteActiveMode(host, port);
}
int reply = ftpClient.getReplyCode();
if (!FTPReply.isPositiveCompletion(reply)) {
ftpClient.disconnect();
String message = String.format("与ftp服务器建立连接失败,请检查用户名和密码是否正确: [%s]",
"message:host =" + host + ",username = " + user + ",port =" + port);
log.error(message);
throw new RuntimeException(message);
}
//设置命令传输编码
String fileEncoding = System.getProperty("file.encoding");
ftpClient.setControlEncoding(fileEncoding);
} catch (UnknownHostException e) {
String message = String.format("请确认ftp服务器地址是否正确,无法连接到地址为: [%s] 的ftp服务器", host);
log.error(message);
throw new RuntimeException(message);
} catch (IllegalArgumentException e) {
String message = String.format("请确认连接ftp服务器端口是否正确,错误的端口: [%s] ", port);
log.error(message);
throw new RuntimeException(message);
} catch (Exception e) {
String message = String.format("与ftp服务器建立连接失败 : [%s]",
"message:host =" + host + ",username = " + user + ",port =" + port);
log.error(message);
throw new RuntimeException(message);
}
return new DefaultPooledObject(ftpClient);
}
@Override
public void passivateObject(PooledObject pooledObject) throws Exception {
// 可不使用钝化方法就不用实现
// pooledObject.getObject().exit();
}
@Override
public boolean validateObject(PooledObject pooledObject) {
return pooledObject.getObject().isConnected();
}
}
package person.brickman.ftp.pool;
import com.jcraft.jsch.ChannelSftp;
import org.apache.commons.pool2.impl.GenericObjectPool;
import org.apache.commons.pool2.impl.GenericObjectPoolConfig;
import person.brickman.ftp.utils.EncryptUtil;
/**
* @Description: sftp 池化封装,池访问类
* @Author: brickman
* @CreateDate: 2025/1/2 20:30
* @Version: 1.0
*/
public class SftpClientPool {
private GenericObjectPool pool;
public SftpClientPool(String host, int port, String user, String password) {
GenericObjectPoolConfig poolConfig = new GenericObjectPoolConfig();
poolConfig.setMaxTotal(50); // 最大连接数
poolConfig.setMaxIdle(20); // 最大空闲连接数
poolConfig.setMinIdle(10); // 最小空闲连接数
pool = new GenericObjectPool<>(new SftpClientFactory(host, port, user, password), poolConfig);
}
public SftpClientPool( Boolean configDecrypt, String configDecryptKey,
String host, int port, String user, String password) throws Exception {
GenericObjectPoolConfig poolConfig = new GenericObjectPoolConfig();
poolConfig.setMaxTotal(50); // 最大连接数
poolConfig.setMaxIdle(20); // 最大空闲连接数
poolConfig.setMinIdle(10); // 最小空闲连接数
if(configDecrypt){
user = EncryptUtil.aesDecrypt( user, configDecryptKey );
password = EncryptUtil.aesDecrypt( password, configDecryptKey );
}
pool = new GenericObjectPool<>(new SftpClientFactory(host, port, user, password), poolConfig);
}
public ChannelSftp getSftpClient() throws Exception {
return pool.borrowObject();
}
public void returnSftpClient(ChannelSftp sftpClient) {
pool.returnObject(sftpClient);
}
}
使用springboot自带junit5实现
testUpload 上传带池
testUploadWithProgressMonitorWithRESUME 带池上传支持断点续传和进度监控
testDownload 下载带池
testDownloadWithProgerssMonitorWithRESUME 带池下载支持断点续传和进度监控
说明:
读者本地运行前先修改单元测试类中的常量:ip、port、username、password、路径、文件名等
package persion.brickman.ftp;
import com.jcraft.jsch.ChannelSftp;
import com.jcraft.jsch.SftpProgressMonitor;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.*;
import org.springframework.boot.test.context.SpringBootTest;
import person.brickman.ftp.AbstractFtpHelper;
import person.brickman.ftp.FileProgressMonitor;
import person.brickman.ftp.SftpHelper;
import person.brickman.ftp.pool.SftpClientPool;
import java.io.File;
import java.nio.file.Files;
import java.util.HashSet;
import java.util.Set;
/**
* @Description: sftpclient 连接池测试
*
* 同:SftpClientPoolTest,只是换了种写法,一个是在每个方法执行前都读配置,一个是所有方法执行前读配置
* 实际两种都只创建了一次池,不过是创建的时机不一样
*
* 上传:含 进度监控、断点续传
* 下载:含 进度监控、断点续传
*
* @Author: brickman
* @CreateDate: 2025/1/2 20:30
* @Version: 1.0
*/
@Slf4j
@SpringBootTest
public class SftpClientPoolTest {
private static Boolean configDecrypt;
private static String configDecryptKey;
private static SftpClientPool pool;
private static String host = "172.16.0.232";
private static int port = 22;
private static String username = "root";
private static String password = "root02027";
private static String localDir = "/Users/brickman/tmp";
private static String uploadLocalFileName = "工作JUDE_v5测试.zip";
private static String uploadLocalFilePath = localDir+"/"+uploadLocalFileName;
private static String remoteDir = "/root/tmp/sftpdir";
private static String downloadRemoteFileName = "工作JUDE_v5测试.zip";
private static String downloadRemoteFilePath = remoteDir+"/"+downloadRemoteFileName;
@BeforeAll
public static void setUpBeforeClass() throws Exception {
System.out.println("BeforeClass: 在所有测试方法执行前只执行一次");
if(pool==null){
pool = new SftpClientPool(host, port, username, password );
}
}
@BeforeEach
public void setUp() throws Exception {
System.out.println("Before: 在每个测试方法执行前都会执行");
}
/**
* sftp上传功能单元测试,
* 本地路径:uploadLocalFilePath eg: "/Users/brickman/tmp/工作JUDE_v5测试.zip";
* 远程路径:remoteFilePath eg:"/root/tmp/sftpdir/"+ unitTestMethodName+"-测试.zip";
* @throws Exception
*/
@Test
@Disabled
public void testUpload( ) throws Exception {
String method = Thread.currentThread().getStackTrace()[1].getMethodName();
String remoteFileName = method+"-测试.zip";
String remoteFilePath = remoteDir+"/"+remoteFileName;
long start = System.currentTimeMillis();
ChannelSftp sftp = pool.getSftpClient();
// 用于调用统一封装的方法
AbstractFtpHelper ftpHelper = new SftpHelper();
ftpHelper.setFtpClient(sftp);
try {
// sftp.lcd("");
// sftp.cd("");
// 使用sftp客户端执行操作, 默认模式:OVERWRITE(覆盖)
sftp.put( uploadLocalFilePath, remoteFilePath );
log.info("File transfer(upload) completed successfully.");
long end = System.currentTimeMillis();
log.info(" time cost: {} ms, req url ====> ftp://{}{}, ", end-start, host, remoteFilePath);
log.info(" localFilePath:{}, ", uploadLocalFilePath);
// 校验并删除(单元测试不留痕)
Assertions.assertTrue( ftpHelper.isFileExist(remoteFilePath) );
Set set = new HashSet();
set.add(remoteFilePath);
ftpHelper.deleteFiles(set);
}catch (Exception e){
log.error("文件上传失败 : ", e);
throw e;
} finally {
pool.returnSftpClient(sftp); // 归还到对象池
}
}
/**
* sftp上传功能单元测试,
* 本地路径:uploadLocalFilePath eg: "/Users/brickman/tmp/工作JUDE_v5测试.zip";
* 远程路径:remoteFilePath eg:"/root/tmp/sftpdir/"+ unitTestMethodName+"-测试.zip";
* @throws Exception
*/
@Test
// @Disabled
public void testUploadWithProgressMonitorWithRESUME2( ) throws Exception {
String method = Thread.currentThread().getStackTrace()[1].getMethodName();
String remoteFileName = method+"-测试.zip";
String remoteFilePath = remoteDir+"/"+remoteFileName;
long start = System.currentTimeMillis();
ChannelSftp sftp = pool.getSftpClient();
// 使用实现了SftpProgressMonitor接口的monitor对象来监控文件传输的进度
SftpProgressMonitor monitor = new FileProgressMonitor();
// 用于调用统一封装的方法
AbstractFtpHelper ftpHelper = new SftpHelper();
ftpHelper.setFtpClient(sftp);
try {
log.info("sftp.lpwd():{}",sftp.lpwd());
log.info("sftp.pwd():{}",sftp.pwd());
// 切换到远程目录
// sftpChannel.cd(remoteDir);
sftp.cd(remoteDir);
log.info("sftp.lpwd():{}",sftp.lpwd());
log.info("sftp.pwd():{}",sftp.pwd());
// 上传文件
// sftp.put(localFilePath, remoteFileName); // 上传到远程目录并重命名为 remoteFileName
// /* 上传到远程目录并重命名为remoteFileName, 带进度 */
// sftp.put(localFilePath, remoteFileName, monitor);
// mode(mode可选值为:ChannelSftp.OVERWRITE,ChannelSftp.RESUME,ChannelSftp.APPEND)
/* 上传到远程目录并重命名为remoteFileName, 带进度、断点续传 */
sftp.put(uploadLocalFilePath, remoteFileName, monitor, ChannelSftp.RESUME );
log.info("File transfer(upload) completed successfully.");
long end = System.currentTimeMillis();
log.info(" time cost: {} ms, req url ====> sftp://{}{}, ", end-start, host, remoteFilePath);
log.info(" localFilePath:{}, ", uploadLocalFilePath);
// 校验并删除(单元测试不留痕)
Assertions.assertTrue( ftpHelper.isFileExist(remoteFilePath) );
Set set = new HashSet();
set.add(remoteFilePath);
ftpHelper.deleteFiles(set);
} catch (Exception e){
log.error("文件上传失败 : ", e);
throw e;
}finally {
pool.returnSftpClient(sftp); // 归还到对象池
}
}
/**
* 使用 jsch
* sftp文件下载,普通下载(覆盖)
* 远程路径:downloadRemoteFilePath eg:"/root/tmp/sftpdir/工作JUDE_v5测试.zip";
* 本地路径:localFilePath eg: "/Users/brickman/tmp/"+ unitTestMethodName+"-测试.zip";
*/
@Test
@Disabled
public void testDownload() throws Exception {
String method = Thread.currentThread().getStackTrace()[1].getMethodName();
String localFileName = method+"-测试.zip";
String localFilePath = localDir+"/"+localFileName;
long start = System.currentTimeMillis();
ChannelSftp sftp = pool.getSftpClient();
// 用于调用统一封装的方法
try{
// 切换本地目录
log.info("sftp.lpwd():{}",sftp.lpwd());
log.info("sftp.pwd():{}",sftp.pwd());
sftp.lcd(localDir);
log.info("sftp.lpwd():{}",sftp.lpwd());
log.info("sftp.pwd():{}",sftp.pwd());
// 文件传输模式为mode(mode可选值为:ChannelSftp.OVERWRITE,ChannelSftp.RESUME,ChannelSftp.APPEND)
// 下载文件
// sftp.get( remoteFilePath ); // 这个并不是下载到默认目录,而是返回一个流
// sftp.get(remoteFilePath, monitor, mode);
sftp.get(downloadRemoteFilePath, localFileName); // 下载到本地目录并重命名为 localFileName
// sftp.get(remoteFilePath, localFileName, monitor, mode);
log.info("File transfer(download) completed successfully.");
long end = System.currentTimeMillis();
log.info(" time cost: {} ms, req url ====> sftp://{}{}, ", end-start, host, downloadRemoteFilePath);
log.info(" localFilePath:{}, ", localFilePath);
// 校验并删除(单元测试不留痕)
Assertions.assertTrue( new File(localFilePath).exists() );
Files.delete( new File(localFilePath).toPath() );
}catch (Exception e){
log.error("文件下载失败 : ", e);
throw e;
}finally {
pool.returnSftpClient(sftp); // 归还到对象池
}
}
/**
* 文件下载
* 使用 jsch
* 带进度监控,带断点续传
* 远程路径:downloadRemoteFilePath eg:"/root/tmp/sftpdir/工作JUDE_v5测试.zip";
* 本地路径:localFilePath eg: "/Users/brickman/tmp/"+ unitTestMethodName+"-测试.zip";
*/
@Test
public void testDownloadWithProgressMonitorWithRESUME2() throws Exception {
String method = Thread.currentThread().getStackTrace()[1].getMethodName();
String localFileName = method+"-测试.zip";
String localFilePath = localDir+"/"+localFileName;
long start = System.currentTimeMillis();
ChannelSftp sftp = pool.getSftpClient();
// 使用实现了SftpProgressMonitor接口的monitor对象来监控文件传输的进度
SftpProgressMonitor monitor = new FileProgressMonitor();
try{
// 切换本地目录
log.info("sftp.lpwd():{}",sftp.lpwd());
log.info("sftp.pwd():{}",sftp.pwd());
sftp.lcd(localDir);
log.info("sftp.lpwd():{}",sftp.lpwd());
log.info("sftp.pwd():{}",sftp.pwd());
// 文件传输模式为mode(mode可选值为:ChannelSftp.OVERWRITE,ChannelSftp.RESUME,ChannelSftp.APPEND)
/* 下载到本地目录并重命名为 localFileName , 带进度监控、断点续传 */
sftp.get(downloadRemoteFilePath, localFileName, monitor, ChannelSftp.RESUME);
log.info("File transfer(download) completed successfully.");
long end = System.currentTimeMillis();
log.info(" time cost: {} ms, req url ====> sftp://{}{}, ", end-start, host, downloadRemoteFilePath);
log.info(" localFilePath:{}, ", localFilePath);
// 校验并删除(单元测试不留痕)
Assertions.assertTrue( new File(localFilePath).exists() );
Files.delete( new File(localFilePath).toPath() );
}catch (Exception e){
log.error("文件下载失败 : ", e);
throw e;
}finally {
pool.returnSftpClient(sftp); // 归还到对象池
}
}
}
1、第五篇为SFTP进阶篇,讲SFTP池化处理(SFTP客户端连接池封装),单元测试类全是干货
2、【重点】带池上传支持断点续传和进度监控的单元测试方法:testUploadWithProgressMonitorWithRESUME
3、【重点】带池下载支持断点续传和进度监控的单元测试方法:testDownloadWithProgerssMonitorWithRESUME
4、请务必阅读前面的“篇章内容说明”,以便精准命中读者需求