利用适配器设计模式解决对FTP的兼容问题

封面图

近日我接到了一个关于FTP监听处理的工单任务,在查阅代码的时候发现原有FTP监听逻辑是需要对FTP和SFTP进行支持的,但是同样的处理流程对FTP和SFTP写了两遍,追究其原因是因为缺少对对FTP和SFTP操作接口的抽象层来屏蔽两者差异。自然而然的会想到使用适配器模式来完成这件工作。

适配器模式

适配器模式(Adapter Pattern) :将一个接口转换成客户希望的另一个接口,适配器模式使接口不兼容的那些类可以一起工作,其别名为包装器(Wrapper)。 适配器模式既可以作为类结构型模式,也可以作为对象结构型模式。

模式结构

适配器模式包含如下角色: Target:目标抽象类 Adapter:适配器类 Adaptee:适配者类 Client:客户类 其UML结构如下图所示:Adapter.jpg

适配器模式有类适配器和对象适配器两种,上面展示的是对象适配器,类适配器不在此展开。

适配器如何解决FTP兼容性问题

我接到的工单任务中对FTP的接口使用有限的,不需要进行过多的FTP操作,主要用到了连接、关闭连接、扫描目录、下载、移动、重命名这几个主要的接口。 因此我的主要工作是完成这些接口的适配。根据上述的描述我抽象了FTP操作接口. 注意: 本文中的代码只是暂时适配器的使用,实际上并不可用,缺失了部分依赖

import java.util.Collections;
import java.util.List;

/**
 * FileToolClientAdaper
 * 文件客户端的适配器,用来屏蔽sftp,ftp等对文件的差异
 *
 * @author alvinkwok
 */
public interface FileToolClientAdapter {

    /**
     * 获取原始客户端对象
     *
     * @return
     */
    Object getClient();

    /**
     * 打开文件客户端
     *
     * @param url      连接地址
     * @param port     连接端口
     * @param username 连接用户名
     * @param password 连接密码
     * @throws FileToolClientAdapterException 连接失败时抛出该异常
     */
    boolean connect(String url, int port, String username, String password) throws FileToolClientAdapterException;

    /**
     * 关闭文件客户端
     *
     * @throws FileToolClientAdapterException 关闭失败时抛出该异常
     */
    void close() throws FileToolClientAdapterException;

    /**
     * 切换远程模块
     *
     * @param path 待切换路径
     * @throws FileToolClientAdapterException 切换失败时抛出异常
     */
    void cd(String path) throws FileToolClientAdapterException;

    /**
     * 上传文件
     *
     * @param path       待上传文件路径
     * @param remotePath 远程上传文件路径
     * @throws FileToolClientAdapterException 切换失败时抛出异常
     */
    boolean upload(String path, String remotePath) throws FileToolClientAdapterException;


    /**
     * 下载文件
     *
     * @param remotePath 本地保存文件路径
     * @param fileName   远程下载文件路径
     * @param localPath  本地保存路口
     * @return 下载成功返回true,失败返回false,内部自己处理异常
     */
    boolean download(String remotePath, String fileName, String localPath) throws FileToolClientAdapterException;

    /**
     * 重命名文件
     *重命名文件,仅限制于同层目录
     * @param remotePath  远程路径
     * @param oldFileName 待更新文件名
     * @param newFileName 更新后文件名
     * @return 更新成功返回true,失败返回false
     */
    boolean rename(String remotePath, String oldFileName, String newFileName) throws FileToolClientAdapterException;

    /**
     * 移动文件
     *
     * @param remotePath 远程路径
     * @param backupPath 备份路径
     * @param fileName   文件名
     * @return 移动成功返回true,移动失败返回false
     */
    boolean move(String remotePath, String backupPath, String fileName) throws FileToolClientAdapterException;

    /**
     * 检查文件是否存在
     *
     * @param workPath 工作路径
     * @param fileName 待检查文件
     * @return 文件存在返回true,否则返回false
     */
    boolean exist(String workPath, String fileName) throws FileToolClientAdapterException;

    /**
     * 获取数据文件列表
     *
     * @param remotePath 监听目录
     * @return 返回一个非null的数据列表
     * 内部消化掉异常
     */
    List<FileAdapter> listFiles(String remotePath,IFileFilter fileFilter) throws FileToolClientAdapterException;


    /**
     * 获取指定文件
     *
     * @param remotePath 监听目录
     * @return
     */
    FileAdapter getFile(String remotePath,String fileName) throws FileToolClientAdapterException;
}

FTP和SFTP操作的文件对象也是不一致的,所以也需要进行适配处理,也可以选择是将原有FTP/SFTP信息转换后作为一个新的结构来供调用, 在此我选择的是直接适配。需要先建立文件对象的适配。


import java.util.Date;

/**
 * FileAdapter 文件适配器
 * 用于屏蔽FTP和SFTP或者是其他的文件对象表达
 * @author alvinkwok
 */
public interface FileAdapter {
    /**
     * 文件是否是文件
     * @return 是文件返回true,不是文件返回false
     */
    boolean isFile();

    /**
     * 返回文件名称
     * @return 如果能够返回文件名称返回指定文件名称,否则返回null
     * 内部将会屏蔽获取文件名的异常
     */
    String getFileName();
    /**
     * 获取没有文件后缀的文件名
     * @return 无后缀文件名
     */
    String getFileNameWithoutSuffix();

    /**
     * 获取文件的最新修改时间
     * @return 文件最新修改时间
     */
    Date getUpdateTime();

    /**
     * 获取文件名后缀
     * @return 如果文件名为空 或者是无后缀将会返回null
     */
    String getFileSuffix();
}

接下来是对FTP的适配,由于篇幅关系SFTP的适配方式也大致相同,所以此处只展示FTP。


import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.net.ftp.FTPClient;
import org.apache.commons.net.ftp.FTPFile;
import org.apache.commons.net.ftp.FTPFileFilter;
import org.omg.CORBA.PRIVATE_MEMBER;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

/**
 * FTPToolClientAdapter
 *
 * @author alvinkwok
 */
@Slf4j
public class FTPToolClientAdapter implements FileToolClientAdapter {

    private FTPClient ftpClient;

    /**
     * 获取原始客户端对象
     *
     * @return
     */
    @Override
    public Object getClient() {
        return ftpClient;
    }

    /**
     * 打开文件客户端
     *
     * @param url      连接地址
     * @param port     连接端口
     * @param username 连接用户名
     * @param password 连接密码
     * @throws FileToolClientAdapterException 连接失败时抛出该异常
     */
    @Override
    public boolean connect(String url, int port, String username, String password) {
        ftpClient = new FTPClient();
        return FtpUtil.connect(ftpClient, url, port, username, password);
    }

    /**
     * 关闭文件客户端
     *
     * @throws FileToolClientAdapterException 关闭失败时抛出该异常
     */
    @Override
    public void close() throws FileToolClientAdapterException {
        try {
            if (ftpClient != null) {
                ftpClient.disconnect();
            }
        } catch (IOException e) {
            throw new FileToolClientAdapterException("关闭FTP连接异常", e);
        }
    }

    /**
     * 切换远程模块
     *
     * @param path 待切换路径
     * @throws FileToolClientAdapterException 切换失败时抛出异常
     */
    @Override
    public void cd(String path) throws FileToolClientAdapterException {

    }

    /**
     * 上传文件
     *
     * @param path       待上传文件路径
     * @param remotePath 远程上传文件路径
     * @throws FileToolClientAdapterException 切换失败时抛出异常
     */
    @Override
    public boolean upload(String path, String remotePath) throws FileToolClientAdapterException {
        throw new FileToolClientAdapterException("方法未实现");
    }

    /**
     * 下载文件
     *
     * @param remotePath 本地保存文件路径
     * @param fileName   远程下载文件路径
     * @param localPath  本地保存路口
     * @throws FileToolClientAdapterException 切换失败时抛出异常
     */
    @Override
    public boolean download(String remotePath, String fileName, String localPath) throws FileToolClientAdapterException {
        try {
            return FtpUtil.download(ftpClient, remotePath, fileName, localPath);
        } catch (Exception e) {
            throw new FileToolClientAdapterException(String.format("路径[%s]下载文件[%s]出现异常", remotePath, fileName), e);
        }
    }


    /**
     * 重命名文件
     *重命名文件,仅限制于同层目录
     * @param remotePath  远程路径
     * @param oldFileName 待更新文件名
     * @param newFileName 更新后文件名
     * @return 更新成功返回true,失败返回false
     */
    @Override
    public boolean rename(String remotePath, String oldFileName, String newFileName) throws FileToolClientAdapterException {
        try {
            return FtpUtil.rename(ftpClient, remotePath, oldFileName, newFileName);
        } catch (Exception e) {
            throw new FileToolClientAdapterException(String.format("路径[%s]重命名文件[%s]出现异常", remotePath, oldFileName), e);
        }
    }

    /**
     * 移动文件
     *
     * @param remotePath 远程路径
     * @param backupPath 备份路径
     * @param fileName   文件名
     * @return 移动成功返回true,移动失败返回false
     */
    @Override
    public boolean move(String remotePath, String backupPath, String fileName) throws FileToolClientAdapterException {

        try {
            return FtpUtil.moveFile(ftpClient, remotePath, backupPath, fileName);
        } catch (Exception e) {
            throw new FileToolClientAdapterException(
                String.format("路径[%s]文件[%s]移动到[%s]出现异常", remotePath, fileName, backupPath), e);
        }
    }

    /**
     * 检查文件是否存在
     *
     * @param workPath 工作路径
     * @param fileName 待检查文件
     * @return 文件存在返回true,否则返回false
     * @throws FileToolClientAdapterException 出现异常的时候直接抛出
     */
    @Override
    public boolean exist(String workPath, String fileName) throws FileToolClientAdapterException {
        try {
            return FtpUtil.isExist(ftpClient, workPath, fileName);
        } catch (Exception e) {
            throw new FileToolClientAdapterException(e);
        }
    }


    /**
     * 获取数据文件列表
     *
     * @param remotePath 监听目录
     * @return 返回一个非null的数据列表
     * 内部消化掉异常
     */
    @Override
    public List<FileAdapter> listFiles(String remotePath, IFileFilter filter) {
        // 进行类型强制转换
        try {
            FTPFile[] files = null;
            if (filter == null) {
                files = ftpClient.listFiles(remotePath);
            } else {
                files = ftpClient.listFiles(remotePath, file -> filter.accept(new FTPFileAdapter(file)));
            }
            return convertFiles(files);
        } catch (IOException e) {
            log.info("获取文件列表失败", e);
            return Collections.emptyList();
        }
    }

    /**
     * 获取指定文件
     *
     * @param remotePath 监听目录
     * @param fileName
     * @return 返回一个非null的数据列表
     * 内部消化掉异常
     */
    @Override
    public FileAdapter getFile(String remotePath, String fileName) throws FileToolClientAdapterException {
        try {
            if (FtpUtil.changeWorkingDirectory(ftpClient, remotePath)) {
                //utf-8文件中中文,如此编码new String(fileName.getBytes("UTF-8"),"iso-8859-1")
                FTPFile ftpFile = ftpClient.mlistFile(fileName);
                if (ftpFile == null) {
                    return null;
                }
                // 使用mlist拿到的信息是详细信息
                // 这玩意应该是这个ftp的缺陷,没有进行文件名的解析,getName方法直接返回了没有解析的内容。
                if (StrUtil.isBlank(ftpFile.getName())) {
                    return null;
                }
                String[] splitStrs = ftpFile.getName().split(";");
                // 4的情况是上述的例子,1的情况是返回正确的文件名
                if (splitStrs.length >= 4) {
                    // 是文件信息中的第4部分
                    ftpFile.setName(splitStrs[3].trim());
                } else if (splitStrs.length == 1) {
                    // 是文件信息中的第4部分
                    ftpFile.setName(splitStrs[0].trim());
                }
                return new FTPFileAdapter(ftpFile);
            }
        } catch (Exception e) {
            throw new FileToolClientAdapterException("获取文件" + fileName + "出现异常", e);
        }
        return null;
    }

    private List<FileAdapter> convertFiles(FTPFile[] files) {
        if (files == null || files.length == 0) {
            return Collections.emptyList();
        }
        List<FileAdapter> adapters = new ArrayList<>();
        for (FTPFile file : files) {
            adapters.add(convertFile(file));
        }
        return adapters;
    }

    private FileAdapter convertFile(FTPFile ftpFile) {
        return new FTPFileAdapter(ftpFile);
    }
}

有一个特殊的点是扫描过程中是有对文件过滤的需求的,所以在对接口抽象的过程中添加了一个IFileFilter接口。 实际在对FTP和SFTP都是有过滤接口的,在FTP和SFTP的扫描适配的过程中是将ftp文件对象转换为FileAdapter然后委托到原始的FTP过滤器中完成文件过滤。

public interface IFileFilter{
    /**
     * @param file 待过滤文件
     * @return 接受文件返回true,否则返回false
     */
    boolean accept(IFileAdapter file);
}

将FTP和SFTP进行封装适配后将会统一操作接口,避免了重复代码,后续对文件监听操作的处理流程也将会轻松很多。