FTP上传下传、SFTP上传下传、进度监控、断点续传、连接池封装JAVA一网打尽(二)FTP高级篇【2/5】

一、摘要(本系列汇总说明)

 - 总纲 

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篇所有内容,且增加更高级的相关知识点

- 本篇

本文是FTP高级篇,讲FTP上传进度监控、断点续传,FTP下载进度监控、断点续传

二、环境

- 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(注意一下名字,不要下错了)。

三、POM依赖

该系列文章通用,几篇FTP文章的pom文件都一样

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>person.brickman</groupId>
    <artifactId>ftp</artifactId>
    <version>1.0-SNAPSHOT</version>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.7.18</version>
    </parent>
    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <java.version>1.8</java.version>
        <httpclient.version>4.5.14</httpclient.version>

        <!-- 工具  -->
        <lombok.version>1.18.32</lombok.version>
        <commons-logging.version>1.3.1</commons-logging.version>
        <commons-lang3.version>3.14.0</commons-lang3.version>
        <commons-io.version>2.15.1</commons-io.version>
        <commons-configuration.version>1.10</commons-configuration.version>
        <commons-net.version>3.10.0</commons-net.version>
        <commons-pool2.version>2.12.0</commons-pool2.version>
        <jsch.version>0.1.55</jsch.version>
<!--        <sshd.version>2.12.1</sshd.version>-->

        <!-- 2.20.1  2.22.2  3.0.0-M2 3.2.5  -->
        <maven-surefire-plugin.version>3.2.5</maven-surefire-plugin.version>
        <!-- 3.0.1  2.4  -->
        <maven-source-plugin.version>3.0.1</maven-source-plugin.version>
        <!--忽略本包测试-->
        <maven.test.skip>false</maven.test.skip>
        <skipTests>false</skipTests>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
            <scope>provided</scope>
        </dependency>

        <dependency>
            <groupId>io.micrometer</groupId>
            <artifactId>micrometer-registry-prometheus</artifactId>
        </dependency>

        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-api</artifactId>
            <version>${slf4j.version}</version>
        </dependency>

        <!--  工具  -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <scope>compile</scope>
        </dependency>

        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
        </dependency>

        <dependency>
            <groupId>commons-net</groupId>
            <artifactId>commons-net</artifactId>
            <version>${commons-net.version}</version>
        </dependency>

        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-pool2</artifactId>
        </dependency>

        <dependency>
            <groupId>commons-logging</groupId>
            <artifactId>commons-logging</artifactId>
            <version>${commons-logging.version}</version>
        </dependency>

        <dependency>
            <groupId>com.jcraft</groupId>
            <artifactId>jsch</artifactId>
            <version>${jsch.version}</version>
        </dependency>

        <!-- 测试相关 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

    </dependencies>

    <build>
        <plugins>

            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-jar-plugin</artifactId>
                <version>2.6</version>
            </plugin>

            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-source-plugin</artifactId>
                <version>${maven-source-plugin.version}</version>
                <configuration>
                    <attach>true</attach>
                </configuration>
                <executions>
                    <execution>
                        <phase>compile</phase>
                    </execution>
                </executions>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-deploy-plugin</artifactId>
                <version>2.8.2</version>
                <executions>
                    <execution>
                        <id>deploy</id>
                        <phase>deploy</phase>
                        <goals>
                            <goal>deploy</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <configuration><!-- 跳过失败的单元测试 -->
                    <testFailureIgnore>false</testFailureIgnore>
                    <skipTests>${skipTests}</skipTests>
                    <argLine>${junit.test.params} -Xmx512m -XX:MaxPermSize=256m</argLine>
                </configuration>
            </plugin>

        </plugins>

    </build>

</project>

四、实现类

1、(常量)接口类:FtpKeyValue

接口类,读者觉得常量类更顺眼也可以改(阿里原装的)

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 = "..";
}

2、统一抽象类:AbstractFtpHelper 

抽象类,不管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<String>
     * @throws
     */
    public abstract HashSet<String> 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<String>
     * @throws
     */
    public HashSet<String> getAllFilesInDir(List<String> srcPaths, int parentLevel, int maxTraversalLevel) {
        HashSet<String> sourceAllFiles = new HashSet<String>();
        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<String> filesToDelete);

    /**
     * 移动文件
     * warn: 不支持文件夹删除, 比如 rm -rf
     *
     * @param filesToMove
     * @param targetPath
     */
    public abstract void moveFiles(Set<String> filesToMove, String targetPath);
}

3、FTP实现类:StandardFtpHelper

FTP上传下载实现类

package person.brickman.ftp;

import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.net.ftp.FTP;
import org.apache.commons.net.ftp.FTPClient;
import org.apache.commons.net.ftp.FTPFile;
import org.apache.commons.net.ftp.FTPReply;
import person.brickman.ftp.consts.FtpKeyValue;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.UnknownHostException;
import java.util.HashSet;
import java.util.Set;

/**
 * alibaba.datax.ftpreader
 *
 * @author datax
 */
@Slf4j
public class StandardFtpHelper extends AbstractFtpHelper {
    FTPClient ftpClient = null;
    HashSet<String> sourceFiles = new HashSet<String>();

    @Override
    public void setFtpClient(Object ftpClient) {
        this. ftpClient=(FTPClient)ftpClient;
    }
    @Override
    public Object getFtpClient() {
        return ftpClient;
    }
    @Override
    public void loginFtpServer(String host, String username, String password, int port, int timeout,
                               String connectMode) {
        ftpClient = new FTPClient();
        try {
            // 连接
            ftpClient.connect(host, port);
            // 登录
            ftpClient.login(username, password);
            // 不需要写死ftp server的OS TYPE,FTPClient getSystemType()方法会自动识别
            /// ftpClient.configure(new FTPClientConfig(FTPClientConfig.SYST_UNIX));
            ftpClient.setConnectTimeout(timeout);
            ftpClient.setDataTimeout(timeout);
            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 = " + username + ",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 = " + username + ",port =" + port);
            log.error(message);
            throw new RuntimeException(message);
        }

    }

    @Override
    public void logoutFtpServer() {
        if (ftpClient.isConnected()) {
            try {
                //todo ftpClient.completePendingCommand();//打开流操作之后必须,原因还需要深究
                ftpClient.logout();
            } catch (IOException e) {
                String message = "与ftp服务器断开连接失败";
                log.error(message);
                throw new RuntimeException(message);
            } finally {
                if (ftpClient.isConnected()) {
                    try {
                        ftpClient.disconnect();
                    } catch (IOException e) {
                        String message = "与ftp服务器断开连接失败";
                        log.error(message);
                        throw new RuntimeException(message);
                    }
                }

            }
        }
    }

    @Override
    public boolean isDirExist(String directoryPath) {
        try {
            return ftpClient.changeWorkingDirectory(new String(directoryPath.getBytes(), FTP.DEFAULT_CONTROL_ENCODING));
        } catch (IOException e) {
            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 {
            FTPFile[] ftpFiles = ftpClient.listFiles(new String(filePath.getBytes(), FTP.DEFAULT_CONTROL_ENCODING));
            if (ftpFiles.length == 1 && ftpFiles[0].isFile()) {
                isExitFlag = true;
            }
        } catch (IOException e) {
            String message = String.format("获取文件:[%s] 属性时发生I/O异常,请确认与ftp服务器的连接正常", filePath);
            log.error(message);
            throw new RuntimeException(message);
        }
        return isExitFlag;
    }

    @Override
    public boolean isSymbolicLink(String filePath) {
        boolean isExitFlag = false;
        try {
            FTPFile[] ftpFiles = ftpClient.listFiles(new String(filePath.getBytes(), FTP.DEFAULT_CONTROL_ENCODING));
            if (ftpFiles.length == 1 && ftpFiles[0].isSymbolicLink()) {
                isExitFlag = true;
            }
        } catch (IOException e) {
            String message = String.format("获取文件:[%s] 属性时发生I/O异常,请确认与ftp服务器的连接正常", filePath);
            log.error(message);
            throw new RuntimeException(message);
        }
        return isExitFlag;
    }

    @Override
    public HashSet<String> getAllFilesInDir(String directoryPath, int parentLevel, int maxTraversalLevel) {
        if (parentLevel < maxTraversalLevel) {
            // 父级目录,以'/'结尾
            String parentPath = null;
            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 (isFileExist(directoryPath)) {
                // path指向具体文件
                sourceFiles.add(directoryPath);
                return sourceFiles;
            } else if (isSymbolicLink(directoryPath)) {
                //path是链接文件
                String message = String.format("文件:[%s]是链接文件,当前不支持链接文件的读取", directoryPath);
                log.error(message);
                throw new RuntimeException(message);
            } else {
                String message = String.format("请确认您的配置项path:[%s]存在,且配置的用户有权限读取", directoryPath);
                log.error(message);
                throw new RuntimeException(message);
            }

            try {
                FTPFile[] fs = ftpClient.listFiles(new String(directoryPath.getBytes(), FTP.DEFAULT_CONTROL_ENCODING));
                for (FTPFile ff : fs) {
                    String strName = ff.getName();
                    String filePath = parentPath + strName;
                    if (ff.isDirectory()) {
                        if (!(strName.equals(FtpKeyValue.DOT) || strName.equals(FtpKeyValue.DOUBLE_DOT))) {
                            //递归处理
                            getAllFilesInDir(filePath, parentLevel + 1, maxTraversalLevel);
                        }
                    } else if (ff.isFile()) {
                        // 是文件
                        sourceFiles.add(filePath);
                    } else if (ff.isSymbolicLink()) {
                        //是链接文件
                        String message = String.format("文件:[%s]是链接文件,当前不支持链接文件的读取", filePath);
                        log.error(message);
                        throw new RuntimeException(message);
                    } else {
                        String message = String.format("请确认path:[%s]存在,且配置的用户有权限读取", filePath);
                        log.error(message);
                        throw new RuntimeException(message);
                    }
                }
            } catch (IOException 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 ftpClient.retrieveFileStream(new String(filePath.getBytes(), FTP.DEFAULT_CONTROL_ENCODING));
        } catch (IOException 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, 2);
    }

    @Override
    public OutputStream getOutputStream(String filePath, int mode) {
        try {
            this.printWorkingDirectory();
            String parentDir = filePath.substring(0, filePath.lastIndexOf(File.separatorChar));
            this.ftpClient.changeWorkingDirectory(parentDir);
            this.printWorkingDirectory();
            OutputStream writeOutputStream = this.ftpClient.appendFileStream(filePath);
            String message = String.format("打开FTP文件[%s]获取写出流时出错,请确认文件%s有权限创建,有权限写出等", filePath, filePath);
            if (null == writeOutputStream) {
                throw new RuntimeException(message);
            }

            return writeOutputStream;
        } catch (IOException 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) {
        String message = String.format("创建目录:%s时发生异常,请确认与ftp服务器的连接正常,拥有目录创建权限", directoryPath);
        try {
            this.printWorkingDirectory();
            boolean isDirExist = this.ftpClient.changeWorkingDirectory(directoryPath);
            if (!isDirExist) {
                int replayCode = this.ftpClient.mkd(directoryPath);
                message = String.format("%s,replayCode:%s", message, replayCode);
                if (replayCode != FTPReply.COMMAND_OK && replayCode != FTPReply.PATHNAME_CREATED) {
                    throw new RuntimeException(message);
                }
            }
        } catch (IOException e) {
            message = String.format("%s, errorMessage:%s", message, e.getMessage());
            log.error(message);
            throw new RuntimeException(message);
        }
    }

    @Override
    public void mkDirRecursive(String directoryPath) {
        StringBuilder dirPath = new StringBuilder();
        dirPath.append(FtpKeyValue.SLASH);
        String[] dirSplit = StringUtils.split(directoryPath, FtpKeyValue.SLASH);
        String message = String.format("创建目录:%s时发生异常,请确认与ftp服务器的连接正常,拥有目录创建权限", directoryPath);
        try {
            // ftp server不支持递归创建目录,只能一级一级创建
            for (String dirName : dirSplit) {
                dirPath.append(dirName);
                boolean mkdirSuccess = mkDirSingleHierarchy(dirPath.toString());
                dirPath.append(FtpKeyValue.SLASH);
                if (!mkdirSuccess) {
                    throw new RuntimeException(message);
                }
            }
        } catch (IOException e) {
            message = String.format("%s, errorMessage:%s", message, e.getMessage());
            log.error(message);
            throw new RuntimeException(message);
        }
    }

    @Override
    public void completePendingCommand() {
        /*
         * 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
         */
        try {
            boolean isOk = this.ftpClient.completePendingCommand();
            if (!isOk) {
                throw new RuntimeException("完成ftp completePendingCommand操作发生异常");
            }
        } catch (IOException e) {
            String message = String.format("完成ftp completePendingCommand操作发生异常, errorMessage:%s", e.getMessage());
            log.error(message);
            throw new RuntimeException(message);
        }
    }

    @Override
    public void deleteFiles(Set<String> filesToDelete) {
        String eachFile = null;
        boolean deleteOk = false;
        try {
            this.printWorkingDirectory();
            for (String each : filesToDelete) {
                log.info(String.format("delete file [%s].", each));
                eachFile = each;
                deleteOk = this.ftpClient.deleteFile(each);
                if (!deleteOk) {
                    String message = String.format("删除文件:[%s] 时失败,请确认指定文件有删除权限", eachFile);
                    throw new RuntimeException(message);
                }
            }
        } catch (IOException e) {
            String message = String.format("删除文件:[%s] 时发生异常,请确认指定文件有删除权限,以及网络交互正常, errorMessage:%s", eachFile, e.getMessage());
            log.error(message);
            throw new RuntimeException(message);
        }
    }

    @Override
    public void moveFiles(Set<String> filesToMove, String targetPath) {
        if (StringUtils.isBlank(targetPath)) {
            throw new RuntimeException("目标目录路径为空!" );
        }
        String eachFile = null;
        try {
            this.printWorkingDirectory();
            // 创建目录
            mkdir(targetPath);
            for (String each : filesToMove) {
                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)));
                log.info(String.format("rename file [%s] to [%s] ." , each, targetName));
                this.ftpClient.rename(each, targetPath);
            }
        } catch (IOException e) {
            String message = String.format("移动文件:[%s] 时发生异常,请确认指定文件有删除权限,以及网络交互正常, errorMessage:%s" , eachFile, e.getMessage());
            log.error(message);
            throw new RuntimeException(message);
        }
    }

    public boolean mkDirSingleHierarchy(String directoryPath) throws IOException {
        boolean isDirExist = this.ftpClient.changeWorkingDirectory(directoryPath);
        // 如果directoryPath目录不存在,则创建
        if (!isDirExist) {
            int replayCode = this.ftpClient.mkd(directoryPath);
            if (replayCode != FTPReply.COMMAND_OK && replayCode != FTPReply.PATHNAME_CREATED) {
                return false;
            }
        }
        return true;
    }

    private void printWorkingDirectory() {
        try {
            log.info(String.format("current working directory:%s", this.ftpClient.printWorkingDirectory()));
        } catch (Exception e) {
            log.warn(String.format("printWorkingDirectory error:%s", e.getMessage()));
        }
    }
}

4、FTP进度监控实现类:FileProgressMonitor

- 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;
    }
}

5、文件过滤器 CustomFTPFileFilter

目前判断服务器文件是否存在时用,因不能直接判断某个文件是否存在,所以先列目录过滤出文件,再做判断
package person.brickman.ftp;

import lombok.extern.slf4j.Slf4j;
import org.apache.commons.net.ftp.FTPFile;
import org.apache.commons.net.ftp.FTPFileFilter;

/**
 * @Description: ftp 文件过滤用,
 *  目前判断服务器文件是否存在时用,因不能直接判断某个文件是否存在,所以先列目录过滤出文件,再做判断
 * @Author brickman
 * @CreateDate:     2025/1/2 20:30
 * @Version: 1.0
 */
@Slf4j
public class CustomFTPFileFilter implements FTPFileFilter {

    private String fileName;

    public CustomFTPFileFilter(String fileName){
        this.fileName=fileName;
    }
    @Override
    public boolean accept(FTPFile ftpFile) {
        // 文件名
        String name = ftpFile.getName();
//        log.info("Objects.equals(name,fileName ) :{}",Objects.equals(name,fileName ) );
//        log.info("name:{},  fileName:{}",name, fileName  );
        // 获取指定文件
        return fileName.equalsIgnoreCase( name ) ;
    }
}

五、【重点】单元测试类

1、FTP单元测试类:StandardFtpHelperTest

使用springboot自带junit5实现

testUploadWithRESUME  上传支持断点续传的单元测试方法

testUploadWithProgressMonitor  上传支持进度监控的单元测试方法

testUploadWithProgressMonitorWithRESUME  上传支持断点续传和进度监控的单元测试方法

testDownloadWithProgressMonitor 下载支持断点续传的单元测试方法

testDownloadWithRESUME  下载支持进度监控的单元测试方法

testDownloadWithProgerssMonitorWithRESUME  下载支持断点续传和进度监控的单元测试方法

说明:

    读者本地运行前先修改单元测试类中的常量:ip、port、username、password、路径、文件名等

package persion.brickman.ftp;

import com.jcraft.jsch.JSchException;
import com.jcraft.jsch.SftpException;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.net.ftp.FTP;
import org.apache.commons.net.ftp.FTPClient;
import org.apache.commons.net.ftp.FTPFile;
import org.apache.commons.net.ftp.FTPReply;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import person.brickman.ftp.AbstractFtpHelper;
import person.brickman.ftp.CustomFTPFileFilter;
import person.brickman.ftp.StandardFtpHelper;
import person.brickman.ftp.consts.FtpKeyValue;

import java.io.*;
import java.nio.file.Files;
import java.util.HashSet;
import java.util.Set;

/**
 * @Description:    标准ftp单元测试类(不用池)
 *
 *     上传:含 进度监控、断点续传
 *     下载:含 进度监控、断点续传
 * @Author:         brickman
 * @CreateDate:     2025/1/2 20:30
 * @Version:        1.0
 */
@Slf4j
@SpringBootTest
public class StandardFtpHelperTest {
    private static final int BUFFER_SIZE = 1024;

    String host = "172.16.0.232";
    int port = 21;
    String username = "ftpuser";
    String password = "ftpuser";
    String localDir = "/Users/brickman/tmp";
    String uploadLocalFileName = "工作JUDE_v5测试.zip";
    String uploadLocalFilePath = localDir+"/"+uploadLocalFileName;
    String remoteDir = "/usr/local/ftpdir";
    String downloadRemoteFileName = "工作JUDE_v5测试.zip";
    String downloadRemoteFilePath = remoteDir+"/"+downloadRemoteFileName;

    AbstractFtpHelper ftpHelper = new StandardFtpHelper();

    @Test
    @Disabled
    public void testGetFtpClient() {
    }

    @Test
    @Disabled
    public void testLoginFtpServer() {
    }
    /**
     *  使用 jsch
     *   文件上传,普通上传(覆盖)
     * @throws SftpException
     */
    @Test
    @Disabled
    public void testUpload() throws SftpException, InterruptedException, IOException {
        String method = Thread.currentThread().getStackTrace()[1].getMethodName();
        String remoteFileName = method+"-测试.zip";
        String remoteFilePath = remoteDir+"/"+remoteFileName;

        //  单位:毫秒
        int timeout = 60*1000;
        String connectMode= FtpKeyValue.DEFAULT_FTP_CONNECT_PATTERN;

        ftpHelper.loginFtpServer(  host,  username,  password,  port,  timeout,  connectMode );

        FTPClient ftp = (FTPClient)ftpHelper.getFtpClient();
        log.info("ftp.lpwd():{}",ftp.printWorkingDirectory());
        log.info("ftp.pwd():{}",ftp.pwd());
        ftp.cwd(remoteDir);
//        ftp.changeWorkingDirectory(remoteDir);
//        ftp.changeWorkingDirectory(remoteDir);

        log.info("ftp.lpwd():{}",ftp.printWorkingDirectory());
        log.info("ftp.pwd():{}",ftp.pwd());//

        long start = System.currentTimeMillis();
        // 上传文件
        InputStream is = null;
        try{
            // 读源文件上传
            is = Files.newInputStream(new File(uploadLocalFilePath).toPath());

            // 如果使用断点续传,这个必须设置
            ftp.setFileType( FTP.BINARY_FILE_TYPE);
            // 解决中文名乱码问题
            remoteFilePath = new String(remoteFilePath.getBytes("UTF-8"),"ISO-8859-1");

            boolean ret = ftp.storeFile(remoteFilePath,  is );

            Assertions.assertTrue(ret);
            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<String> set = new HashSet<String>();
            set.add(remoteFilePath);
            ftpHelper.deleteFiles(set);
        }catch(Exception e){
            log.error("upload failed dir:{} fileName:{} isCover: {}", localDir, uploadLocalFileName, true, e);
        }finally {
            try {
                is.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
            if (!FTPReply.isPositiveIntermediate(ftp.getReplyCode())) {
                ftpHelper.logoutFtpServer();// 包含logout和disconnect
                log.error("File transfer failed.");
            }
            // 一定要放在关流之后
//            if (null!=ftp && !ftp.completePendingCommand()) {
                try {
                    ftpHelper.logoutFtpServer();// 包含logout和disconnect
                } catch (Exception e) {
                    e.printStackTrace();
                }
//            }
        }
    }
    /**
     *  使用 jsch
     *   文件上传  , 带断点续传
     * @throws SftpException
     */
    @Test
    @Disabled
    public void testUploadWithRESUME() throws SftpException, InterruptedException, IOException {
        String method = Thread.currentThread().getStackTrace()[1].getMethodName();
        String remoteFileName = method+"-测试.zip";
        String remoteFilePath = remoteDir+"/"+remoteFileName;

        //  单位:毫秒
        int timeout = 60*1000;
        String connectMode= FtpKeyValue.DEFAULT_FTP_CONNECT_PATTERN;

        ftpHelper.loginFtpServer(  host,  username,  password,  port,  timeout,  connectMode );

        FTPClient ftp = (FTPClient)ftpHelper.getFtpClient();
        log.info("ftp.lpwd():{}",ftp.printWorkingDirectory());
        log.info("ftp.pwd():{}",ftp.pwd());
        ftp.cwd(remoteDir);
//        ftp.changeWorkingDirectory(remoteDir);
//        ftp.changeWorkingDirectory(remoteDir);
        log.info("ftp.lpwd():{}",ftp.printWorkingDirectory());
        log.info("ftp.pwd():{}",ftp.pwd());//

        File f = new File(uploadLocalFilePath);

        FTPFile[] files = ftp.listFiles(remoteDir, new CustomFTPFileFilter( remoteFileName ) );
        log.info("ftp.listFiles(remoteDir, new CustomFTPFileFilter( remoteFileName ) ).length:{}",
                ftp.listFiles(remoteDir, new CustomFTPFileFilter( remoteFileName ) ).length);

        long start = System.currentTimeMillis();
        // 上传文件
        InputStream is = null;
        try{
            // 读源文件上传
            is = new FileInputStream( f );

            // 远程文件存在
            if(files.length == 1){
                long lRemoteSize = files[0].getSize();
                log.info("远程文件大小为:{}, 启动断点续传...", lRemoteSize );
                if( lRemoteSize >= f.length()){
                    log.error("远程文件大小大于本地文件大小,上传中止");
                    return;
                }
                if(!(is.skip(lRemoteSize)==lRemoteSize)){
                    log.error("尝试断点续传失败,上传中止。可能原因:stream does not support seek, or some other I/O error occurs.");
                    return;
                }
                ftp.setRestartOffset(lRemoteSize);
            }else{
                log.info("远程没有该文件,普通模式上传开始..." );
            }

            ftp.enterLocalPassiveMode();// 被动模式  被动模式是“服务器”开放端口给客户端连接;
//            ftp.enterLocalActiveMode(); // 主动模式  主动模式传输数据时是“服务器”连接到“客户端”的端口;

            // 如果使用断点续传,这个必须设置
            ftp.setFileType( FTP.BINARY_FILE_TYPE);

            // 解决中文名乱码问题
            remoteFilePath = new String(remoteFilePath.getBytes("UTF-8"),"ISO-8859-1");
            boolean ret = ftp.storeFile(remoteFilePath,  is );

            Assertions.assertTrue(ret);
            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<String> set = new HashSet<String>();
            set.add(remoteFilePath);
            ftpHelper.deleteFiles(set);
        }catch(Exception e){
            log.error("upload failed dir:{} fileName:{} isCover: {}", localDir, uploadLocalFileName, true, e);
        }finally {
            try {
                is.close();
            } catch (IOException e) {
                e.printStackTrace();
            }

            // 一定要放在关流之后
            if (null!=ftp && !ftp.completePendingCommand()) {
                try {
                    ftpHelper.logoutFtpServer();// 包含logout和disconnect
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }
    }
    /**
     *  使用 jsch
     *   文件上传, 普通上传(覆盖),带进度监控
     * @throws SftpException
     */
    @Test
    @Disabled
    public void testUploadWithProgressMonitor() throws SftpException, InterruptedException, IOException {
        String method = Thread.currentThread().getStackTrace()[1].getMethodName();
        String remoteFileName = method+"-测试.zip";
        String remoteFilePath = remoteDir+"/"+remoteFileName;

        //  单位:毫秒
        int timeout = 60*1000;
        String connectMode= FtpKeyValue.DEFAULT_FTP_CONNECT_PATTERN;

        ftpHelper.loginFtpServer(  host,  username,  password,  port,  timeout,  connectMode );

        FTPClient ftp = (FTPClient)ftpHelper.getFtpClient();
        log.info("ftp.lpwd():{}",ftp.printWorkingDirectory());
        log.info("ftp.pwd():{}",ftp.pwd());
        ftp.cwd(remoteDir);
//        ftp.changeWorkingDirectory(remoteDir);

        log.info("ftp.lpwd():{}",ftp.printWorkingDirectory());
        log.info("ftp.pwd():{}",ftp.pwd());//

        long start = System.currentTimeMillis();
        // 上传文件
        InputStream is = null;
        OutputStream os = null;
        try{
            File sourceFile = new File(uploadLocalFilePath);
            // 读源文件上传
            is = new FileInputStream( sourceFile );

            // 如果使用断点续传,这个必须设置
//            ftp.setFileType( FTP.BINARY_FILE_TYPE);
            // 解决中文名乱码问题
//            remoteFilePath = new String(remoteFilePath.getBytes("UTF-8"),"ISO-8859-1");
            os = ftp.storeFileStream(new String(remoteFilePath.getBytes("UTF-8"),"ISO-8859-1")  );

            byte[] buffer = new byte[BUFFER_SIZE];
            int bytesRead;
            long totalBytesRead = 0;
            long fileSize = sourceFile.length();

            double percentCompleted = (double) totalBytesRead / fileSize * 100;
            while ((bytesRead = is.read(buffer)) != -1) {
                os.write(buffer, 0, bytesRead);
                totalBytesRead += bytesRead;
//                double percentCompleted = (double) totalBytesRead / fileSize * 100;
//                log.info("Transfer progress: {}", String.format("%.2f%%",percentCompleted) );// 保留两位时日志打太多了

                /*
                %s: 字符串
                %d: 十进制整数
                %f: 浮点数
                %c: 字符
                %b: 布尔值
                %n: 换行符
                 */
                if ((int)percentCompleted >= (int)totalBytesRead * 100 / fileSize) {

                }else{
                    percentCompleted = (double) totalBytesRead / fileSize * 100;
                    log.info("Completed {}({}) out of {}.", totalBytesRead, String.format("%.0f%%",percentCompleted), fileSize ); //打印当前进度
                }
            }

            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);

            // 校验并删除(单元测试不留痕) java.net.SocketException: Connection reset
            Assertions.assertTrue( ftpHelper.isFileExist(remoteFilePath) );
            Set<String> set = new HashSet<String>();
            set.add(remoteFilePath);
            ftpHelper.deleteFiles(set);

        }catch(Exception e){
            log.error("upload failed dir:{} fileName:{} isCover: {}", localDir, uploadLocalFileName, true, e);
        }finally {
            try {
                is.close();
            } catch (IOException e) {
                e.printStackTrace();
            }

            try {
                os.close();
            } catch (IOException e) {
                e.printStackTrace();
            }

            // 一定要放在关流之后
            if (!ftp.completePendingCommand()) {
                try {
                    ftpHelper.logoutFtpServer();// 包含logout和disconnect
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }
    }
    /**
     *  使用 jsch
     *   文件上传, 带进度监控、带断点续传
     *
     * @throws SftpException
     */
    @Test
    @Disabled
    public void testUploadWithProgressMonitorWithRESUME() throws SftpException, InterruptedException, IOException {
        String method = Thread.currentThread().getStackTrace()[1].getMethodName();
        String remoteFileName = method+"-测试.zip";
        String remoteFilePath = remoteDir+"/"+remoteFileName;

        //  单位:毫秒
        int timeout = 60*1000;
        String connectMode= FtpKeyValue.DEFAULT_FTP_CONNECT_PATTERN;

        ftpHelper.loginFtpServer(  host,  username,  password,  port,  timeout,  connectMode );

        FTPClient ftp = (FTPClient)ftpHelper.getFtpClient();
        log.info("ftp.lpwd():{}",ftp.printWorkingDirectory());
        log.info("ftp.pwd():{}",ftp.pwd());
//        ftp.cwd(remoteDir);
        ftp.changeWorkingDirectory(remoteDir);
        log.info("ftp.lpwd():{}",ftp.printWorkingDirectory());
        log.info("ftp.pwd():{}",ftp.pwd());//

//        ftp.setControlEncoding("UTF-8");
        // 如果使用断点续传,这个必须设置
        ftp.setFileType( FTP.BINARY_FILE_TYPE);

        File f = new File(uploadLocalFilePath);

        FTPFile[] files = ftp.listFiles(remoteDir, new CustomFTPFileFilter( remoteFileName ) );
        log.info("ftp.listFiles(remoteDir, new CustomFTPFileFilter( remoteFileName ) ).length:{}",
                ftp.listFiles(remoteDir, new CustomFTPFileFilter( remoteFileName ) ).length);

        long start = System.currentTimeMillis();
        // 上传文件
        InputStream is = null;
        OutputStream os = null;
        try{
            is = Files.newInputStream(f.toPath());

            byte[] buffer = new byte[BUFFER_SIZE];
            int bytesRead;
            long totalBytesRead = 0;
            long fileSize = f.length();
            // 远程文件存在
            if(files.length == 1){
                long lRemoteSize = files[0].getSize();
                log.info("远程文件大小为:{}, 启动断点续传...", lRemoteSize );
                if( lRemoteSize >= f.length()){
                    log.error("远程文件大小大于本地文件大小,上传中止");
                    return;
                }
                if(!(is.skip(lRemoteSize)==lRemoteSize)){
                    log.error("尝试断点续传失败,上传中止。可能原因:stream does not support seek, or some other I/O error occurs.");
                    return;
                }
                ftp.setRestartOffset(lRemoteSize);
                totalBytesRead = lRemoteSize;
            }else{
                log.info("远程没有该文件,普通模式上传开始..." );
            }

            // 解决中文名乱码问题
            remoteFilePath = new String(remoteFilePath.getBytes("UTF-8"),"ISO-8859-1");
            os = ftp.storeFileStream( remoteFilePath  );

            double percentCompleted = (double) totalBytesRead / fileSize * 100;
            while ((bytesRead = is.read(buffer)) != -1) {
                os.write(buffer, 0, bytesRead);
                totalBytesRead += bytesRead;

//                double percentCompleted = (double) totalBytesRead / fileSize * 100;
//                log.info("Transfer progress: {}", String.format("%.2f%%",percentCompleted) );// 保留两位时日志打太多了

                /*
                %s: 字符串
                %d: 十进制整数
                %f: 浮点数
                %c: 字符
                %b: 布尔值
                %n: 换行符
                 */
                if ((int)percentCompleted >= (int)totalBytesRead * 100 / fileSize) {

                }else{
                    percentCompleted = (double) totalBytesRead / fileSize * 100;
                    log.info("Completed {}({}) out of {}.", totalBytesRead, String.format("%.0f%%",percentCompleted), fileSize ); //打印当前进度
                }
            }

            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<String> set = new HashSet<String>();
            set.add(remoteFilePath);
            ftpHelper.deleteFiles(set);
        }catch(Exception e){
            log.error("upload failed dir:{} fileName:{} isCover: {}", localDir, uploadLocalFileName, true, e);
        }finally {
            try {
                is.close();
            } catch (IOException e) {
                e.printStackTrace();
            }

            try {
                os.close();
            } catch (IOException e) {
                e.printStackTrace();
            }

            // 一定要放在关流之后
            if (!ftp.completePendingCommand()) {
                try {
                    ftpHelper.logoutFtpServer();// 包含logout和disconnect
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }
    }
    /**
     * 文件下载,普通下载(覆盖)
     *  使用 jsch
     */
    @Test
    @Disabled
    public void testDownload() throws SftpException, InterruptedException, JSchException, IOException {
        String method = Thread.currentThread().getStackTrace()[1].getMethodName();
        String localFileName = method+"-测试.zip";
        String localFilePath = localDir+"/"+localFileName;

        //  单位:毫秒
        int timeout = 60*1000;
        // FTP主动模式(port)和被动模式(PASV)
        String connectMode=FtpKeyValue.DEFAULT_FTP_CONNECT_PATTERN;

        ftpHelper.loginFtpServer(  host,  username,  password,  port,  timeout,  connectMode );

        FTPClient ftp =  (FTPClient)ftpHelper.getFtpClient();
        // 切换本地目录
        log.info("ftp.lpwd():{}",ftp.printWorkingDirectory());
        log.info("ftp.pwd():{}",ftp.pwd());
        ftp.changeWorkingDirectory(remoteDir);
//        ftp.cwd(remoteDir);
        log.info("ftp.lpwd():{}",ftp.printWorkingDirectory());
        log.info("ftp.pwd():{}",ftp.pwd());

//        FTPFile[] files = ftp.listFiles(remoteDir, new CustomFTPFileFilter( downloadRemoteFileName ) );
//        log.info("ftp.listFiles(remoteDir, new CustomFTPFileFilter( remoteFileName ) ).length:{}",
//                ftp.listFiles(remoteDir, new CustomFTPFileFilter( downloadRemoteFileName ) ).length);

        long start = System.currentTimeMillis();
        // 下载文件
        OutputStream os = null;
        try{
            os = new BufferedOutputStream(new FileOutputStream(localFilePath));
            ftp.enterLocalPassiveMode();// 被动模式  被动模式是“服务器”开放端口给客户端连接;
//            ftp.enterLocalActiveMode(); // 主动模式  主动模式传输数据时是“服务器”连接到“客户端”的端口;

            // 如果使用断点续传,这个必须设置
            ftp.setFileType( FTP.BINARY_FILE_TYPE);

            // 解决中文名乱码问题 否则带中文名的文件下载不下来(下载需要)
            downloadRemoteFilePath = new String(downloadRemoteFilePath.getBytes("UTF-8"),"ISO-8859-1");
            boolean ret = ftp.retrieveFile(downloadRemoteFilePath, os);

            Assertions.assertTrue(ret);

            log.info("File transfer(download) completed successfully.");
            long end = System.currentTimeMillis();
            log.info(" time cost: {} ms, req url ====> ftp://{}{},  ", 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("download failed dir:{} fileName:{} isCover: {}", remoteDir, localFileName, true, e);
        }finally {
            try {
                os.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
            if (!FTPReply.isPositiveIntermediate(ftp.getReplyCode())) {
                ftpHelper.logoutFtpServer();// 包含logout和disconnect
                log.error("File transfer failed.");
            }
            // 一定要放在关流之后, 下载
            if (!ftp.completePendingCommand()) {
                try {
                    ftpHelper.logoutFtpServer();// 包含logout和disconnect
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }
    }
    /**
     * 文件下载
     *    普通下载(覆盖),带进度监控
     *  使用 jsch
     */
    @Test
    @Disabled
    public void testDownloadWithProgressMonitor() throws SftpException, InterruptedException, JSchException, IOException {
        String method = Thread.currentThread().getStackTrace()[1].getMethodName();
        String localFileName = method+"-测试.zip";
        String localFilePath = localDir+"/"+localFileName;

        //  单位:毫秒
        int timeout = 60*1000;
        // FTP主动模式(port)和被动模式(PASV)
        String connectMode=FtpKeyValue.DEFAULT_FTP_CONNECT_PATTERN;

        ftpHelper.loginFtpServer(  host,  username,  password,  port,  timeout,  connectMode );

        FTPClient ftp =  (FTPClient)ftpHelper.getFtpClient();
        // 切换本地目录
        log.info("ftp.lpwd():{}",ftp.printWorkingDirectory());
        log.info("ftp.pwd():{}",ftp.pwd());
        ftp.changeWorkingDirectory(remoteDir);
//        ftp.cwd(remoteDir);
        log.info("ftp.lpwd():{}",ftp.printWorkingDirectory());
        log.info("ftp.pwd():{}",ftp.pwd());

        long start = System.currentTimeMillis();
        // 下载文件
        OutputStream os = null;
        InputStream is = null;
        try{
            ftp.enterLocalPassiveMode();// 被动模式  被动模式是“服务器”开放端口给客户端连接;
//            ftp.enterLocalActiveMode(); // 主动模式  主动模式传输数据时是“服务器”连接到“客户端”的端口;

            // 如果使用断点续传,这个必须设置
//            ftp.setFileType( FTP.BINARY_FILE_TYPE);

//            int fileSize = ftp.size(remoteFilePath); // 不能用,会一直阻塞

            FTPFile[] files = ftp.listFiles(remoteDir, new CustomFTPFileFilter( downloadRemoteFileName ) );
            log.info("ftp.listFiles(remoteDir, new CustomFTPFileFilter( remoteFileName ) ).length:{}",
                    ftp.listFiles(remoteDir, new CustomFTPFileFilter( downloadRemoteFileName ) ).length);
            long lRemoteSize = files[0].getSize();

            // 解决中文名乱码问题 否则带中文名的文件下载不下来
            downloadRemoteFilePath = new String(downloadRemoteFilePath.getBytes("UTF-8"),"ISO-8859-1");
            // 读远程文件下载
            is  = ftp.retrieveFileStream(downloadRemoteFilePath);

            os = new BufferedOutputStream(new FileOutputStream(localFilePath));

            byte[] buffer = new byte[BUFFER_SIZE];
            int bytesRead;
            long totalBytesRead = 0;
            log.info("remote fileSize:{}", lRemoteSize);

            double percentCompleted = (double) totalBytesRead / lRemoteSize * 100;
            // 这里日志中显示的百分比精确到2位小数
            while ((bytesRead = is.read(buffer)) != -1) {
                os.write(buffer, 0, bytesRead);
                totalBytesRead += bytesRead;
//                double percentCompleted = (double) totalBytesRead / fileSize * 100;
//                log.info("Transfer progress: {}", String.format("%.2f%%",percentCompleted) ); // 保留两位时日志打太多了
                /*
                %s: 字符串
                %d: 十进制整数
                %f: 浮点数
                %c: 字符
                %b: 布尔值
                %n: 换行符
                 */
                if ((int)percentCompleted >= (int)totalBytesRead * 100 / lRemoteSize) {

                }else{
                    percentCompleted = (double) totalBytesRead / lRemoteSize * 100;
                    log.info("Completed {}({}) out of {}.", totalBytesRead, String.format("%.0f%%",percentCompleted), lRemoteSize ); //打印当前进度
                }
            }

            log.info("File transfer(download) completed successfully.");
            long end = System.currentTimeMillis();
            log.info(" time cost: {} ms, req url ====> ftp://{}{},  ", 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("download failed dir:{} fileName:{} isCover: {}", remoteDir, localFileName, true, e);
        }finally {
            try {
                os.close();
            } catch (IOException e) {
                e.printStackTrace();
            }

            // 一定要放在关流之后, 下载
            if (!ftp.completePendingCommand()) {
                try {
                    ftpHelper.logoutFtpServer();// 包含logout和disconnect
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }
    } /**
     * 文件下载
     *    带断点续传
     *  使用 jsch
     */
    @Test
    @Disabled
    public void testDownloadWithRESUME() throws SftpException, InterruptedException, JSchException, IOException {
        String method = Thread.currentThread().getStackTrace()[1].getMethodName();
        String localFileName = method+"-测试.zip";
        String localFilePath = localDir+"/"+localFileName;

        //  单位:毫秒
        int timeout = 60*1000;
        // FTP主动模式(port)和被动模式(PASV)
        String connectMode=FtpKeyValue.DEFAULT_FTP_CONNECT_PATTERN;

        ftpHelper.loginFtpServer(  host,  username,  password,  port,  timeout,  connectMode );

        FTPClient ftp =  (FTPClient)ftpHelper.getFtpClient();
        // 切换本地目录
        log.info("ftp.lpwd():{}",ftp.printWorkingDirectory());
        log.info("ftp.pwd():{}",ftp.pwd());
        ftp.changeWorkingDirectory(remoteDir);
//        ftp.cwd(remoteDir);
        log.info("ftp.lpwd():{}",ftp.printWorkingDirectory());
        log.info("ftp.pwd():{}",ftp.pwd());

        File f = new File(localFilePath);

//        FTPFile[] files = ftp.listFiles(remoteFilePath  );
        FTPFile[] files = ftp.listFiles(remoteDir, new CustomFTPFileFilter( downloadRemoteFileName ) );
//        log.info("ftp.listFiles().length:{}", ftp.listFiles().length);
//        log.info("ftp.listFiles(remoteDir).length:{}", ftp.listFiles(remoteDir).length);
        log.info("ftp.listFiles(remoteDir, new CustomFTPFileFilter( remoteFileName ) ).length:{}",
                ftp.listFiles(remoteDir, new CustomFTPFileFilter( downloadRemoteFileName ) ).length);
//        log.info("ftp.listFiles(remoteFilePath).length:{}", ftp.listFiles(remoteFilePath).length);

        if(files.length != 1){
            System.out.println("远程文件不唯一");// 一般是不存在
            return;
        }
        long lRemoteSize = files[0].getSize();

        long start = System.currentTimeMillis();
        // 本地文件不存在
        // 下载文件
        OutputStream os = null;
        try{
            // 本地文件存在
            if(f.exists()){
                log.info("本地文件大小为:{}, 启动断点续传...", f.length());
                if(f.length() >= lRemoteSize){
                    log.warn("本地文件大小大于远程文件大小,下载中止");
                    return;
                }
                ftp.setRestartOffset(f.length());
                os = new BufferedOutputStream(new FileOutputStream(localFilePath, true ));
            }else{
                log.info("本地没有该文件,普通模式下载开始..." );
                os = new BufferedOutputStream(new FileOutputStream(localFilePath));
            }

            ftp.enterLocalPassiveMode();// 被动模式  被动模式是“服务器”开放端口给客户端连接;
//            ftp.enterLocalActiveMode(); // 主动模式  主动模式传输数据时是“服务器”连接到“客户端”的端口;

            // 如果使用断点续传,这个必须设置
            ftp.setFileType( FTP.BINARY_FILE_TYPE);
            // 解决中文名乱码问题 否则带中文名的文件下载不下来(下载需要)
            downloadRemoteFilePath = new String(downloadRemoteFilePath.getBytes("UTF-8"),"ISO-8859-1");
            boolean ret = ftp.retrieveFile(downloadRemoteFilePath, os);

            Assertions.assertTrue(ret);
            log.info("File transfer(download) completed successfully.");
            long end = System.currentTimeMillis();
            log.info(" time cost: {} ms, req url ====> ftp://{}{},  ", 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("download failed dir:{} fileName:{} isCover: {}", remoteDir, localFileName, true, e);
        }finally {
            try {
                os.close();
            } catch (IOException e) {
                e.printStackTrace();
            }

            if (!FTPReply.isPositiveIntermediate(ftp.getReplyCode())) {
                ftpHelper.logoutFtpServer();// 包含logout和disconnect
                log.error("File transfer failed.");
            }
            // 一定要放在关流之后, 下载
            if (!ftp.completePendingCommand()) {
                try {
                    ftpHelper.logoutFtpServer();// 包含logout和disconnect
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }
    } /**
     * 文件下载
     *    带进度监控、 带断点续传
     *  使用 jsch
     */
    @Test
    @Disabled
    public void testDownloadWithProgerssMonitorWithRESUME() throws SftpException, InterruptedException, JSchException, IOException {
        String method = Thread.currentThread().getStackTrace()[1].getMethodName();
        String localFileName = method+"-测试.zip";
        String localFilePath = localDir+"/"+localFileName;

        //  单位:毫秒
        int timeout = 60*1000;
        // FTP主动模式(port)和被动模式(PASV)
        String connectMode=FtpKeyValue.DEFAULT_FTP_CONNECT_PATTERN;

        ftpHelper.loginFtpServer(  host,  username,  password,  port,  timeout,  connectMode );

        FTPClient ftp =  (FTPClient)ftpHelper.getFtpClient();
        // 切换本地目录
        log.info("ftp.lpwd():{}",ftp.printWorkingDirectory());
        log.info("ftp.pwd():{}",ftp.pwd());
        ftp.changeWorkingDirectory(remoteDir);
//        ftp.cwd(remoteDir);
        log.info("ftp.lpwd():{}",ftp.printWorkingDirectory());
        log.info("ftp.pwd():{}",ftp.pwd());

        File f = new File(localFilePath);

        FTPFile[] files = ftp.listFiles(remoteDir, new CustomFTPFileFilter( downloadRemoteFileName ) );
        log.info("ftp.listFiles(remoteDir, new CustomFTPFileFilter( remoteFileName ) ).length:{}",
                ftp.listFiles(remoteDir, new CustomFTPFileFilter( downloadRemoteFileName ) ).length);

        if(files.length != 1){
            System.out.println("远程文件不唯一");// 一般是不存在
            return;
        }
        long lRemoteSize = files[0].getSize();

        long start = System.currentTimeMillis();
        // 下载文件
        OutputStream os = null;
        InputStream is = null;
        try{
            ftp.enterLocalPassiveMode();// 被动模式  被动模式是“服务器”开放端口给客户端连接;
//            ftp.enterLocalActiveMode(); // 主动模式  主动模式传输数据时是“服务器”连接到“客户端”的端口;

            // 如果使用断点续传,这个必须设置
            ftp.setFileType( FTP.BINARY_FILE_TYPE);

            byte[] buffer = new byte[BUFFER_SIZE];
            int bytesRead;
            long totalBytesRead = 0;
            log.info("remote lRemoteSize:{}", lRemoteSize);
            // 本地文件存在
            if(f.exists()){
                log.info("本地文件大小为:{}, 启动断点续传...", f.length());
                if(f.length() >= lRemoteSize){
                    log.warn("本地文件大小大于远程文件大小,下载中止");
                    return;
                }
                ftp.setRestartOffset(f.length());
                os = new BufferedOutputStream(new FileOutputStream(localFilePath, true ));
                totalBytesRead = f.length();
            }else{
                log.info("本地没有该文件,普通模式下载开始..." );
                os = new BufferedOutputStream(new FileOutputStream(localFilePath));
            }
            // 解决中文名乱码问题 否则带中文名的文件下载不下来
            downloadRemoteFilePath = new String(downloadRemoteFilePath.getBytes("UTF-8"),"ISO-8859-1");
            // 读远程文件下载
            is  = ftp.retrieveFileStream(downloadRemoteFilePath);

            double percentCompleted = totalBytesRead * 100 / lRemoteSize;
            // 这里日志中显示的百分比精确到2位小数
            while ((bytesRead = is.read(buffer)) != -1) {
                os.write(buffer, 0, bytesRead);
                totalBytesRead += bytesRead;

                /*
                %s: 字符串
                %d: 十进制整数
                %f: 浮点数
                %c: 字符
                %b: 布尔值
                %n: 换行符
                 */
                if ((int)percentCompleted >= (int)totalBytesRead * 100 / lRemoteSize) {

                }else{
                    percentCompleted = (double) totalBytesRead / lRemoteSize * 100;
                    log.info("Completed {}({}) out of {}.", totalBytesRead, String.format("%.0f%%",percentCompleted), lRemoteSize ); //打印当前进度
                }
//                log.info("Transfer progress: {}", String.format("%.2f%%",percentCompleted) ); // 保留两位时日志打太多了
            }

            log.info("File transfer(download) completed successfully.");
            long end = System.currentTimeMillis();
            log.info(" time cost: {} ms, req url ====> ftp://{}{},  ", 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("download failed dir:{} fileName:{} isCover: {}", remoteDir, localFileName, true, e);
        }finally {
            try {
                os.close();
            } catch (IOException e) {
                e.printStackTrace();
            }

            // 一定要放在关流之后, 下载
            if (!ftp.completePendingCommand()) {
                try {
                    ftpHelper.logoutFtpServer();// 包含logout和disconnect
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

六、总结

1、第二篇为FTP高级篇,讲FTP上传进度监控、断点续传,FTP下载进度监控、断点续传,单元测试类全是干货,啃完你就会变得很棒~

2、【重点】上传支持断点续传和进度监控的单元测试方法:testUploadWithProgressMonitorWithRESUME  

3、重点下载支持断点续传和进度监控的单元测试方法:testDownloadWithProgerssMonitorWithRESUME  

4、请务必阅读前面的“篇章内容说明”,以便精准命中目标

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

飞火流星02027

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值