java文件拷贝的小坑

101 阅读4分钟

背景

笔者要实现一个文件夹拷贝的功能。类似脚本的cp -r。但是在实现的过程中,发现文件拷贝失败了,抛出了异常。经过排查发现,我们在做文件拷贝功能的时候,其实忽略了一种情况,那就是符号链接。

背景重现

出错的原因本质是因为他是一个失效的链接。我们来模拟一个场景。

# 创建测试文件
touch testfile

# 创建测试链接
ln -s testfile testlink

# 更改测试文件
mv testfile testfile1

经过如上的步骤,现在testlink就变成了一个失效的符号链接。

我们使用java nio2的目录遍历模式来进行文件拷贝,案例代码如下

public class CopyFile extends SimpleFileVisitor<Path> {


    private Path source;
    private Path target;

    public CopyFile(Path source, Path target) {
        this.source = source;
        this.target = target;
    }

    @Override
    public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException {
        CopyOption[] options = new CopyOption[]{COPY_ATTRIBUTES};
        Path newDir = target.resolve(source.relativize(dir));
        try {
            Files.copy(dir, newDir, options);
        } catch (FileAlreadyExistsException x) {
            // ignore
            // directory is exists
        }
        return FileVisitResult.CONTINUE;
    }

    @Override
    public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
        copyFile(file, target.resolve(source.relativize(file)), true);
        return FileVisitResult.CONTINUE;
    }

    public static void copyFile(Path source, Path target, boolean preserve) throws IOException {
        CopyOption[] options = new CopyOption[]{COPY_ATTRIBUTES};

        if (Files.notExists(target)) {
            Files.copy(source, target, options);
        }

    }
}

这里的逻辑很简单,遍历目录用了SimpleFileVisitor,preVisitDirectory就是开始遍历目录之前的操作,主要是把目标目录建立出来,visitFile的动作就是复制文件,复制文件的实现就是下面的copyFile方法。这里是精简的代码preserve的逻辑都删除了,这个不影响案例重现。执行这段代码,就会抛出异常。内容是软连接不存在。

曲折的过程

看到报错内容的时候其实是有点茫然的,因为我们选择了COPY_ATTRIBUTES的选项,应该是可以把属性都移动到新文件的。但是这里没有,我们去检查符号链接的时候,发现符号链接是存在的。我们怀疑是不是文件的属性的选项不对就看了一下其他的枚举。


public enum StandardCopyOption implements CopyOption {
    /**
     * Replace an existing file if it exists.
     */
    REPLACE_EXISTING,
    /**
     * Copy attributes to the new file.
     */
    COPY_ATTRIBUTES,
    /**
     * Move the file as an atomic file system operation.
     */
    ATOMIC_MOVE;
}

发现其他的枚举类型都和这个没有关系,这里就选择问了大模型,大模型的回复是针对符号链接,应该使用的是创建一个新的符号链接,对应的文件名一样,然后创建链接。

这种确实可以解决这个问题。但是代码上就要单独区分一下目前是不是符号链接,如果是的话,还得调用创建符号链接的方法。

下面的思路放在了CopyOption上,是不是还有其他的实现方式。

public enum LinkOption implements OpenOption, CopyOption {
    /**
     * Do not follow symbolic links.
     *
     * @see Files#getFileAttributeView(Path,Class,LinkOption[])
     * @see Files#copy
     * @see SecureDirectoryStream#newByteChannel
     */
    NOFOLLOW_LINKS;
}

果然发现了一个新的相关实现类LinkOption。

这里的NOFOLLOW_LINKS是我们期待的要求。看到这里肯定是有疑问,为啥最开始没有走这个思路,而是在StandardCopyOption里找,这里因为我们的项目同时在使用另外一个开源的库,里面的文件拷贝传递的就是StandardCopyOption,而不是接口的CopyOption。导致从寻找问题的方向上出现了偏差。

解析LinkOption

看到LinkOption,突然意识到一个场景,是有争议的。
场景:如果有个符号链接需要拷贝(testlink)。你是准备拷贝这个符号链接,并且还链接着文件(testlink),还是准备直接拷贝链接的文件(testlink)。

这里就有2种场景,第一种是下载文件的,下载符号链接的本意其实是下载符号链接背后的文件。只是说这个文件名应该是符号链接的名字。第二种是我上面的场景,本身是一种文件整体的复制,希望文件保持原样。

看到这2个场景的时候,我们再看copy的方法。就了解到后面其实是需要准确的描述出场景的。这里的NOFOLLOW_LINKS就是拷贝符号链接本身,并且链接着后面的文件。如果不加这个选项就是拷贝的是背后的文件,然后名字是符号链接的名字。

这里我们就理解了为什么,他最终的报错是符号链接不存在,其实是符号链接背后的文件不存在了。

扩展

当意识到这个地方的场景后,我们其实发现文件的很多操作都需要传递类似的配置。 例如

exists(Path path, LinkOption... options) 

文件的操作都有类似的含义,我们检测的是符号链接本身还是符号链接背后的文件。

总结

  1. java默认执行的文件操作都是链接背后的文件。

  2. 日常的java研发过程中,遇到符号链接的场景比较有限,所以很多开源的库也没有涉及这部分,在具体的场景下,文件的任何操作都需要考虑我们的操作是对应符号链接本身还是符号链接背后的文件。具体的区分如上文介绍,我们明确自己的需求点,在文件操作时,先把符号链接的含义考虑进来,让我们的程序更健壮。