Apache VFS 移动FTP文件太慢的原因

项目中使用VFS移动文件是通过使用FileSystemManagerresolveFile方法获得FileObject,然后调用其moveTo方法来达到FTP文件移动的目的。

我们使用的FileSystemManager是默认的DefaultFileSystemManager,在操作FTP文件的时候会调用AbstractOriginatingFileProviderfindFile方法。

protected FileObject findFile(final FileName name, final FileSystemOptions fileSystemOptions)
            throws FileSystemException {
        // Check in the cache for the file system
        final FileName rootName = getContext().getFileSystemManager().resolveName(name, FileName.ROOT_PATH);

        final FileSystem fs = getFileSystem(rootName, fileSystemOptions);

        // Locate the file
        // return fs.resolveFile(name.getPath());
        return fs.resolveFile(name);
    }

从这里可以看到会使用FileName获取到一个FileSystem,然后调用FlieSystem的resolveFile方法。这个FileName是从FTP的uri中解析出来的。FTP的uri(例如:ftp://username:password@host:port/)如果username,password,host,port相同,这里取到的FileSystem是同一个。这里涉及到两个重要的类。

  1. FileObject,这里是FtpFileObject
protected FtpFileObject(final AbstractFileName name, final FtpFileSystem fileSystem, final FileName rootName)
            throws FileSystemException {
        super(name, fileSystem);
        final String relPath = UriParser.decode(rootName.getRelativeName(name));
        if (".".equals(relPath)) {
            // do not use the "." as path against the ftp-server
            // e.g. the uu.net ftp-server do a recursive listing then
            // this.relPath = UriParser.decode(rootName.getPath());
            // this.relPath = ".";
            this.relPath = null;
        } else {
            this.relPath = relPath;
        }
    }

从构造函数可以看出,并没有做太多事情,而且最关键的属性

private FTPFile fileInfo;

没有初始化。

  1. FileSystem,这里是FtpFileSystem
public void putClient(final FtpClient client) {
        // Save client for reuse if none is idle.
        if (!idleClient.compareAndSet(null, client)) {
            // An idle client is already present so close the connection.
            closeConnection(client);
        }
    }
public FtpClient getClient() throws FileSystemException {
        FtpClient client = idleClient.getAndSet(null);

        if (client == null || !client.isConnected()) {
            client = createWrapper();
        }

        return client;
    }

这个类就是对FtpClient进行了封装,操作FTP文件时会先调用getClient(),操作完成后再调用putClient。这个类使用AtomicReference来保持他只持有一个FtpClient,每次get的时候会置null,如果有其他的线程get,那么会创建一个新的client返回。在put的时候,如果这个类已经持有一个client了,就把put进来的client关掉。

接下来看AbstractFileObjectmoveTo方法

@Override
    public void moveTo(final FileObject destFile) throws FileSystemException {
        if (canRenameTo(destFile)) {
            if (!getParent().isWriteable()) {
                throw new FileSystemException("vfs.provider/rename-parent-read-only.error", getName(),
                        getParent().getName());
            }
        } else {
            if (!isWriteable()) {
                throw new FileSystemException("vfs.provider/rename-read-only.error", getName());
            }
        }

        if (destFile.exists() && !isSameFile(destFile)) {
            destFile.deleteAll();
            // throw new FileSystemException("vfs.provider/rename-dest-exists.error", destFile.getName());
        }

        if (canRenameTo(destFile)) {
            // issue rename on same filesystem
            try {
                attach();
                // remember type to avoid attach
                final FileType srcType = getType();

                doRename(destFile);

                FileObjectUtils.getAbstractFileObject(destFile).handleCreate(srcType);
                destFile.close(); // now the destFile is no longer imaginary. force reattach.

                handleDelete(); // fire delete-events. This file-object (src) is like deleted.
            } catch (final RuntimeException re) {
                throw re;
            } catch (final Exception exc) {
                throw new FileSystemException("vfs.provider/rename.error", exc, getName(), destFile.getName());
            }
        } else {
            // different fs - do the copy/delete stuff

            destFile.copyFrom(this, Selectors.SELECT_SELF);

            if ((destFile.getType().hasContent()
                    && destFile.getFileSystem().hasCapability(Capability.SET_LAST_MODIFIED_FILE)
                    || destFile.getType().hasChildren()
                            && destFile.getFileSystem().hasCapability(Capability.SET_LAST_MODIFIED_FOLDER))
                    && fs.hasCapability(Capability.GET_LAST_MODIFIED)) {
                destFile.getContent().setLastModifiedTime(this.getContent().getLastModifiedTime());
            }

            deleteSelf();
        }

    }

这个方法也不复杂,移动文件有两种情况

  1. 源文件和目标文件在同一个filesystem,使用doRename
  2. 源文件和目标文件不在同一个filesystem,使用copyFrom

前面我们已经知道username,password,host,port相同的时候取到的就是同一个filesystem,所以这里判断源文件和目标文件是否在同一个filesystem也很简单,直接用==判断。

 @Override
    public boolean canRenameTo(final FileObject newfile) {
        return fs == newfile.getFileSystem();
    }

doRename的实现原理就是调用FTPClient的rename方法,而这个FTPClient是通过FTP的RNFR和RNTO指令实现的。
copyFrom则是通过FTP协议中的RETR和STOR命令来下载上传实现的。

目前来看,文件移动都没什么问题,然而项目中导致移动文件慢的竟然是这个方法

@Override
    public boolean exists() throws FileSystemException {
        return getType() != FileType.IMAGINARY;
    }

不管源文件与目标文件是否在同一个文件系统都会对源文件和目标文件执行这个getType()方法。这个方法最终会调用FtpFileObjectdoGetType()方法。

@Override
    protected FileType doGetType() throws Exception {
        // VFS-210
        synchronized (getFileSystem()) {
            if (this.fileInfo == null) {
                getInfo(false);
            }

            if (this.fileInfo == UNKNOWN) {
                return FileType.IMAGINARY;
            } else if (this.fileInfo.isDirectory()) {
                return FileType.FOLDER;
            } else if (this.fileInfo.isFile()) {
                return FileType.FILE;
            } else if (this.fileInfo.isSymbolicLink()) {
                final FileObject linkDest = getLinkDestination();
                // VFS-437: We need to check if the symbolic link links back to the symbolic link itself
                if (this.isCircular(linkDest)) {
                    // If the symbolic link links back to itself, treat it as an imaginary file to prevent following
                    // this link. If the user tries to access the link as a file or directory, the user will end up with
                    // a FileSystemException warning that the file cannot be accessed. This is to prevent the infinite
                    // call back to doGetType() to prevent the StackOverFlow
                    return FileType.IMAGINARY;
                }
                return linkDest.getType();

            }
        }
        throw new FileSystemException("vfs.provider.ftp/get-type.error", getName());
    }

上面已经说过,FtpFileObject的fileInfo没有初始化,所以这里会执行getInfo方法,而getInfo方法又会调用getChildFile

private FTPFile getChildFile(final String name, final boolean flush) throws IOException {
        /*
         * If we should flush cached children, clear our children map unless we're in the middle of a refresh in which
         * case we've just recently refreshed our children. No need to do it again when our children are refresh()ed,
         * calling getChildFile() for themselves from within getInfo(). See getChildren().
         */
        if (flush && !inRefresh) {
            children = null;
        }

        // List the children of this file
        doGetChildren();

        // VFS-210
        if (children == null) {
            return null;
        }

        // Look for the requested child
        final FTPFile ftpFile = children.get(name);
        return ftpFile;
    }

就是这里,我们可以看到,获取某个文件时,会先获取父路径的所有子文件,然后从子文件中获取你要的那个文件。
如果你要的那个文件在一个文件非常多的目录里,而且关闭了缓存,你每获取这个目录的一个文件就要把目录里的所有文件列一次。

FTPClient是可以通过listFiles列出单个文件的,所以解决办法就是

  1. 使用缓存
  2. 不要用VFS了,直接用FTPClient的rename方法(仅限于同一个FTPClient,如果时跨文件服务器的需要FTPClient的上传下载实现)。
    下面附上解决办法2的代码。
    代码很简单,大多数都是解析URI的,全塞一个类里了,如果真要用建议把一些代码拆出来。
import java.io.IOException;
import java.util.Arrays;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;

import org.apache.commons.net.ftp.FTPClient;
import org.apache.commons.net.ftp.FTPFile;
import org.apache.commons.vfs2.FileSystemException;
import org.apache.commons.vfs2.FileSystemOptions;
import org.apache.commons.vfs2.provider.UriParser;
import org.apache.commons.vfs2.provider.ftp.FtpClientFactory;
import org.apache.commons.vfs2.provider.ftp.FtpFileSystemConfigBuilder;
import org.apache.commons.vfs2.util.Cryptor;
import org.apache.commons.vfs2.util.CryptorFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.google.common.collect.Maps;

public class FtpUtil {
    
    private static Logger logger = LoggerFactory.getLogger(FtpUtil.class);
    
    private final static Map<Auth, AtomicReference<FTPClient>> clients = Maps.newConcurrentMap();
    
    public static boolean move(String src, String tar) throws IOException {
        FtpPath srcFtpPath = parse(src);
        FtpPath tarFtpPath = parse(tar);
        if (!srcFtpPath.auth.equals(tarFtpPath.auth)) {
            throw new UnsupportedOperationException("源目录和目标目录的ftp服务器连接信息不一致");
        }
        FTPClient ftpClient = getFTPClient(srcFtpPath.auth);
        try {
            return ftpClient.rename(srcFtpPath.path, tarFtpPath.path);
        } catch (IOException e) {
            closeConnection(ftpClient);
            throw e;
        } finally {
            putFTPClient(srcFtpPath.auth, ftpClient);
        }
    }
    
    public static FtpPath parse(String uri) throws FileSystemException {
        FtpPath ftpPath = new FtpPath();
        StringBuilder name = new StringBuilder();
        UriParser.extractScheme(uri, name);
        // Expecting "//"
        if (name.length() < 2 || name.charAt(0) != '/' || name.charAt(1) != '/') {
            throw new FileSystemException("vfs.provider/missing-double-slashes.error", uri);
        }
        name.delete(0, 2);
     // Extract userinfo, and split into username and password
        final String userInfo = extractUserInfo(name);
        final String userName;
        final String password;
        if (userInfo != null) {
            final int idx = userInfo.indexOf(':');
            if (idx == -1) {
                userName = userInfo;
                password = null;
            } else {
                userName = userInfo.substring(0, idx);
                password = userInfo.substring(idx + 1);
            }
        } else {
            userName = null;
            password = null;
        }
        
        String u = UriParser.decode(userName);
        String p = UriParser.decode(password);

        if (p != null && p.startsWith("{") && p.endsWith("}")) {
            try {
                final Cryptor cryptor = CryptorFactory.getCryptor();
                p = cryptor.decrypt(p.substring(1, p.length() - 1));
            } catch (final Exception ex) {
                throw new FileSystemException("Unable to decrypt password", ex);
            }
        }
        
        ftpPath.auth.username = u == null ? null : u.toCharArray();
        ftpPath.auth.password = p == null ? null : p.toCharArray();
        
        // Extract hostname, and normalise (lowercase)
        final String hostName = extractHostName(name);
        if (hostName == null) {
            throw new FileSystemException("vfs.provider/missing-hostname.error", uri);
        }
        ftpPath.auth.host = hostName.toLowerCase();

        // Extract port
        ftpPath.auth.port = extractPort(name, uri);

        // Expecting '/' or empty name
        if (name.length() > 0 && name.charAt(0) != '/') {
            throw new FileSystemException("vfs.provider/missing-hostname-path-sep.error", uri);
        }
        
        ftpPath.path = name.toString();
        return ftpPath;
    }
    
    /**
     * Extracts the user info from a URI.
     *
     * @param name string buffer with the "scheme://" part has been removed already. Will be modified.
     * @return the user information up to the '@' or null.
     */
    private static String extractUserInfo(final StringBuilder name) {
        final int maxlen = name.length();
        for (int pos = 0; pos < maxlen; pos++) {
            final char ch = name.charAt(pos);
            if (ch == '@') {
                // Found the end of the user info
                final String userInfo = name.substring(0, pos);
                name.delete(0, pos + 1);
                return userInfo;
            }
            if (ch == '/' || ch == '?') {
                // Not allowed in user info
                break;
            }
        }

        // Not found
        return null;
    }

    /**
     * Extracts the hostname from a URI.
     *
     * @param name string buffer with the "scheme://[userinfo@]" part has been removed already. Will be modified.
     * @return the host name or null.
     */
    private static String extractHostName(final StringBuilder name) {
        final int maxlen = name.length();
        int pos = 0;
        for (; pos < maxlen; pos++) {
            final char ch = name.charAt(pos);
            if (ch == '/' || ch == ';' || ch == '?' || ch == ':' || ch == '@' || ch == '&' || ch == '=' || ch == '+'
                    || ch == '$' || ch == ',') {
                break;
            }
        }
        if (pos == 0) {
            return null;
        }

        final String hostname = name.substring(0, pos);
        name.delete(0, pos);
        return hostname;
    }

    /**
     * Extracts the port from a URI.
     *
     * @param name string buffer with the "scheme://[userinfo@]hostname" part has been removed already. Will be
     *            modified.
     * @param uri full URI for error reporting.
     * @return The port, or -1 if the URI does not contain a port.
     * @throws FileSystemException if URI is malformed.
     * @throws NumberFormatException if port number cannot be parsed.
     */
    private static int extractPort(final StringBuilder name, final String uri) throws FileSystemException {
        if (name.length() < 1 || name.charAt(0) != ':') {
            return -1;
        }

        final int maxlen = name.length();
        int pos = 1;
        for (; pos < maxlen; pos++) {
            final char ch = name.charAt(pos);
            if (ch < '0' || ch > '9') {
                break;
            }
        }

        final String port = name.substring(1, pos);
        name.delete(0, pos);
        if (port.length() == 0) {
            throw new FileSystemException("vfs.provider/missing-port.error", uri);
        }

        return Integer.parseInt(port);
    }
    
    private static FTPClient getFTPClient(Auth key) throws IOException {
        AtomicReference<FTPClient> refClient = clients.getOrDefault(key, new AtomicReference<FTPClient>(null));
        
        FTPClient client = refClient.getAndSet(null);
        if (client == null || !client.isConnected()) {
            client = createClient(key);
        }
        return client;
    }
    
    private static FTPClient createClient(Auth key) throws IOException {
        FtpFileSystemConfigBuilder builder = FtpFileSystemConfigBuilder.getInstance();
        FileSystemOptions options = new FileSystemOptions();
        builder.setControlEncoding(options, "UTF-8");
        builder.setServerLanguageCode(options, "zh");
        builder.setPassiveMode(options, true);
        return FtpClientFactory.createConnection(key.host, key.port, key.username, key.password, null, options);
    }
    
    private static void putFTPClient(Auth key, FTPClient client) {
        AtomicReference<FTPClient> refClient = clients.getOrDefault(key, new AtomicReference<FTPClient>(null));
        
        if (!refClient.compareAndSet(null, client)) {
            closeConnection(client);
        }
    }

    private static void closeConnection(FTPClient client) {
        try {
            if (client.isConnected()) {
                client.disconnect();
            }
        } catch (final IOException e) {
            logger.error(e.getMessage(), e);
        }
    }

    private static class Auth {
        String host;
        int port;
        char[] username;
        char[] password;
        
        @Override
        public boolean equals(Object obj) {
            if (this == obj) {
                return true;
            }
            if (obj instanceof Auth) {
                Auth k = (Auth) obj;
                return this.host.equals(k.host) && this.port == k.port && Arrays.equals(this.username, k.username)
                        && Arrays.equals(this.password, k.password);
            }
            return false;
        }
        
        @Override
        public int hashCode() {
            int h = host.hashCode();
            h = 31 * h + port;
            h = 31 * h + username.hashCode();
            h = 31 * h + password.hashCode();
            return h;
        }
    }
    
    private static class FtpPath {
        Auth auth = new Auth();
        String path;
    }
}
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,293评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,604评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,958评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,729评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,719评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,630评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,000评论 3 397
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,665评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,909评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,646评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,726评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,400评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,986评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,959评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,197评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,996评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,481评论 2 342

推荐阅读更多精彩内容