如何在Java中从两个绝对路径(或URL)构建相对路径?

318

给定两个绝对路径,例如:

/var/data/stuff/xyz.dat
/var/data

如何创建一个相对路径,以第二个路径为基础?在上面的例子中,结果应该是:./stuff/xyz.dat


3
对于Java 7及更高版本,请查看@VitaliiFedorenko的答案。 - Andy Thomas
3
简短回答:Paths.get(startPath).relativize(Paths.get(endPath)).toString()(顺便说一句,在Java 8中,使用例如"../"的方式似乎可以正常工作,所以...) - Andrew
23个回答

333

这个方法有点绕,但是为什么不使用URI呢?它有一个relativize方法可以为您执行所有必要的检查。

String path = "/var/data/stuff/xyz.dat";
String base = "/var/data";
String relative = new File(base).toURI().relativize(new File(path).toURI()).getPath();
// relative == "stuff/xyz.dat"

请注意,对于文件路径,Java 1.7 中有 java.nio.file.Path#relativize 方法,正如@Jirka Meluzin另一个答案中指出的那样。


19
看Peter Mueller的回答。relativize()对于除了最简单的情况以外似乎非常有问题。 - Dave Ray
11
是的,它只有在基础路径是第一个路径的父级路径时才有效。如果您需要一些类似"../../relativepath"这样的层次结构向后访问,它将不起作用。我找到了一个解决方案:http://mrpmorris.blogspot.com/2007/05/convert-absolute-path-to-relative-path.html。 - Aurelien Ribon
4
正如@VitaliiFedorenko所写:使用java.nio.file.Path#relativize(Path),它可以处理父级双点和所有内容。 - Campa
考虑使用 toPath() 而不是 toURI()。它完全可以创建像 "..\.." 这样的东西。但是请注意,当从 "C:\temp" 请求相对路径到 "D:\temp" 时,可能会出现 java.lang.IllegalArgumentException: 'other' has different root 异常。 - Igor
这个并没有按照预期工作,它在我的测试案例中返回了 data/stuff/xyz.dat 的数据。 - unbekant

280

从Java 7开始,您可以使用relativize方法:

import java.nio.file.Path;
import java.nio.file.Paths;

public class Test {

     public static void main(String[] args) {
        Path pathAbsolute = Paths.get("/var/data/stuff/xyz.dat");
        Path pathBase = Paths.get("/var/data");
        Path pathRelative = pathBase.relativize(pathAbsolute);
        System.out.println(pathRelative);
    }

}

输出:

stuff/xyz.dat

3
不错,简短明了,没有额外的库 +1。Adam Crume的解决方案(排名第一)未通过我的测试,下一个答案(排名第二)"唯一可行的解决方案" 添加了一个新的jar包,并且比我的实现更长,后来我在这里发现了它... 比从未发现要好。-) - hokr
1
但要注意这个问题 - ben3000
1
已检查过,它可以正确地添加必要的“..”。 - Owen
很遗憾,Android不包括java.nio.file :( - Nathan Osman
2
如果在“relativize”之前未对“pathBase”进行“normalize”,则可能会得到奇怪的结果。虽然在这个例子中没问题,但我通常会遵循一个一般规则:pathBase.normalize().relativize(pathAbsolute); - pstanton
显示剩余2条评论

78

这篇文章撰写于2010年6月,当时这是唯一通过我的测试用例的解决方案。我不能保证此解决方案没有错误,但它确实可以通过包含的测试用例。我编写的方法和测试依赖于FilenameUtils类,该类来自于Apache commons IO

该解决方案已在Java 1.4上进行了测试。如果您使用的是Java 1.5(或更高版本),则应考虑将StringBuffer替换为StringBuilder(如果您仍在使用Java 1.4,则应考虑更换雇主)。

import java.io.File;
import java.util.regex.Pattern;

import org.apache.commons.io.FilenameUtils;

public class ResourceUtils {

    /**
     * Get the relative path from one file to another, specifying the directory separator. 
     * If one of the provided resources does not exist, it is assumed to be a file unless it ends with '/' or
     * '\'.
     * 
     * @param targetPath targetPath is calculated to this file
     * @param basePath basePath is calculated from this file
     * @param pathSeparator directory separator. The platform default is not assumed so that we can test Unix behaviour when running on Windows (for example)
     * @return
     */
    public static String getRelativePath(String targetPath, String basePath, String pathSeparator) {

        // Normalize the paths
        String normalizedTargetPath = FilenameUtils.normalizeNoEndSeparator(targetPath);
        String normalizedBasePath = FilenameUtils.normalizeNoEndSeparator(basePath);

        // Undo the changes to the separators made by normalization
        if (pathSeparator.equals("/")) {
            normalizedTargetPath = FilenameUtils.separatorsToUnix(normalizedTargetPath);
            normalizedBasePath = FilenameUtils.separatorsToUnix(normalizedBasePath);

        } else if (pathSeparator.equals("\\")) {
            normalizedTargetPath = FilenameUtils.separatorsToWindows(normalizedTargetPath);
            normalizedBasePath = FilenameUtils.separatorsToWindows(normalizedBasePath);

        } else {
            throw new IllegalArgumentException("Unrecognised dir separator '" + pathSeparator + "'");
        }

        String[] base = normalizedBasePath.split(Pattern.quote(pathSeparator));
        String[] target = normalizedTargetPath.split(Pattern.quote(pathSeparator));

        // First get all the common elements. Store them as a string,
        // and also count how many of them there are.
        StringBuffer common = new StringBuffer();

        int commonIndex = 0;
        while (commonIndex < target.length && commonIndex < base.length
                && target[commonIndex].equals(base[commonIndex])) {
            common.append(target[commonIndex] + pathSeparator);
            commonIndex++;
        }

        if (commonIndex == 0) {
            // No single common path element. This most
            // likely indicates differing drive letters, like C: and D:.
            // These paths cannot be relativized.
            throw new PathResolutionException("No common path element found for '" + normalizedTargetPath + "' and '" + normalizedBasePath
                    + "'");
        }   

        // The number of directories we have to backtrack depends on whether the base is a file or a dir
        // For example, the relative path from
        //
        // /foo/bar/baz/gg/ff to /foo/bar/baz
        // 
        // ".." if ff is a file
        // "../.." if ff is a directory
        //
        // The following is a heuristic to figure out if the base refers to a file or dir. It's not perfect, because
        // the resource referred to by this path may not actually exist, but it's the best I can do
        boolean baseIsFile = true;

        File baseResource = new File(normalizedBasePath);

        if (baseResource.exists()) {
            baseIsFile = baseResource.isFile();

        } else if (basePath.endsWith(pathSeparator)) {
            baseIsFile = false;
        }

        StringBuffer relative = new StringBuffer();

        if (base.length != commonIndex) {
            int numDirsUp = baseIsFile ? base.length - commonIndex - 1 : base.length - commonIndex;

            for (int i = 0; i < numDirsUp; i++) {
                relative.append(".." + pathSeparator);
            }
        }
        relative.append(normalizedTargetPath.substring(common.length()));
        return relative.toString();
    }


    static class PathResolutionException extends RuntimeException {
        PathResolutionException(String msg) {
            super(msg);
        }
    }    
}

这通过的测试案例有:

public void testGetRelativePathsUnix() {
    assertEquals("stuff/xyz.dat", ResourceUtils.getRelativePath("/var/data/stuff/xyz.dat", "/var/data/", "/"));
    assertEquals("../../b/c", ResourceUtils.getRelativePath("/a/b/c", "/a/x/y/", "/"));
    assertEquals("../../b/c", ResourceUtils.getRelativePath("/m/n/o/a/b/c", "/m/n/o/a/x/y/", "/"));
}

public void testGetRelativePathFileToFile() {
    String target = "C:\\Windows\\Boot\\Fonts\\chs_boot.ttf";
    String base = "C:\\Windows\\Speech\\Common\\sapisvr.exe";

    String relPath = ResourceUtils.getRelativePath(target, base, "\\");
    assertEquals("..\\..\\Boot\\Fonts\\chs_boot.ttf", relPath);
}

public void testGetRelativePathDirectoryToFile() {
    String target = "C:\\Windows\\Boot\\Fonts\\chs_boot.ttf";
    String base = "C:\\Windows\\Speech\\Common\\";

    String relPath = ResourceUtils.getRelativePath(target, base, "\\");
    assertEquals("..\\..\\Boot\\Fonts\\chs_boot.ttf", relPath);
}

public void testGetRelativePathFileToDirectory() {
    String target = "C:\\Windows\\Boot\\Fonts";
    String base = "C:\\Windows\\Speech\\Common\\foo.txt";

    String relPath = ResourceUtils.getRelativePath(target, base, "\\");
    assertEquals("..\\..\\Boot\\Fonts", relPath);
}

public void testGetRelativePathDirectoryToDirectory() {
    String target = "C:\\Windows\\Boot\\";
    String base = "C:\\Windows\\Speech\\Common\\";
    String expected = "..\\..\\Boot";

    String relPath = ResourceUtils.getRelativePath(target, base, "\\");
    assertEquals(expected, relPath);
}

public void testGetRelativePathDifferentDriveLetters() {
    String target = "D:\\sources\\recovery\\RecEnv.exe";
    String base = "C:\\Java\\workspace\\AcceptanceTests\\Standard test data\\geo\\";

    try {
        ResourceUtils.getRelativePath(target, base, "\\");
        fail();

    } catch (PathResolutionException ex) {
        // expected exception
    }
}

5
好的!不过有一个问题,如果原路径和目标路径相同,代码会出错 - 字符串common会以一个分隔符结尾,而标准化的目标路径则没有,所以子字符串调用会导致多获取一个数字。我认为通过在函数的最后两行之前添加以下内容可以解决这个问题:如果(common.length() >= normalizedTargetPath.length()) { return "."; } - Erhannis
4
说这是唯一可行的解决方案是误导性的。其他答案效果更好(当基础和目标相同时,此答案会崩溃),更简单,并且不依赖于commons-io。 - NateS

27

6
有一个解决方法,似乎可以应对这个问题:https://dev59.com/t3VC5IYBdhLWcg3wtzkQ#1290311。 - skaffman
在Java 8中,Paths.get(startPath).relativize(Paths.get(endPath)).toString() 看起来可以很好地使用例如"../"。 - Andrew
@skaffman 你确定吗? 这个答案提到了错误JDK-6226081,但是URIUtils.resolve()提到了JDK-4708535。从源代码中看不到任何与回溯(即..段)相关的内容。你混淆了这两个错误吗? - Garret Wilson
JDK-6920138被标记为JDK-4708535的重复。 - Christian K.

25
在Java 7及更高版本中,您可以直接使用以下代码(与URI相比,它是无错误的):

Java 7及更高版本中,您可以直接使用以下代码(与URI相比,它没有bug):

Path#relativize(Path)

16

另一个答案中提到的错误已被Apache HttpComponents中的URIUtils解决。

public static URI resolve(URI baseURI,
                          String reference)

根据基本URI解析一个URI引用。这是为了解决java.net.URI()中的错误而做的一个变通方法。


resolve方法不是从基础路径和相对路径生成绝对URI吗?这个方法如何帮助呢? - Chase

10

递归生成较小的解决方案。如果结果不可能(例如不同的Windows磁盘)或不切实际(根目录是唯一公共目录),则会抛出异常。

/**
 * Computes the path for a file relative to a given base, or fails if the only shared 
 * directory is the root and the absolute form is better.
 * 
 * @param base File that is the base for the result
 * @param name File to be "relativized"
 * @return the relative name
 * @throws IOException if files have no common sub-directories, i.e. at best share the
 *                     root prefix "/" or "C:\"
 */

public static String getRelativePath(File base, File name) throws IOException  {
    File parent = base.getParentFile();

    if (parent == null) {
        throw new IOException("No common directory");
    }

    String bpath = base.getCanonicalPath();
    String fpath = name.getCanonicalPath();

    if (fpath.startsWith(bpath)) {
        return fpath.substring(bpath.length() + 1);
    } else {
        return (".." + File.separator + getRelativePath(parent, name));
    }
}

getCanonicalPath 可能会很耗费资源,因此当您需要处理数十万条记录时,不建议使用此解决方案。例如,我有一些包含多达一百万条记录的列表文件,现在我想将它们移动到使用相对路径以实现可移植性。 - user2305886

10

如果您知道第二个字符串是第一个字符串的一部分:

String s1 = "/var/data/stuff/xyz.dat";
String s2 = "/var/data";
String s3 = s1.substring(s2.length());

或者,如果你真的想像你的例子中那样以句点开头:
String s3 = ".".concat(s1.substring(s2.length()));

3
我认为这个写法稍微更易读一些:String s3 = "." + s1.substring(s2.length()); - Dónal

10

这里有一个不需要其他库的解决方案:

Path sourceFile = Paths.get("some/common/path/example/a/b/c/f1.txt");
Path targetFile = Paths.get("some/common/path/example/d/e/f2.txt"); 
Path relativePath = sourceFile.relativize(targetFile);
System.out.println(relativePath);

输出

..\..\..\..\d\e\f2.txt

[编辑] 实际上它会输出一个更多的 ..\,因为源是文件而不是目录。 对于我的情况,正确的解决方案是:

Path sourceFile = Paths.get(new File("some/common/path/example/a/b/c/f1.txt").parent());
Path targetFile = Paths.get("some/common/path/example/d/e/f2.txt"); 
Path relativePath = sourceFile.relativize(targetFile);
System.out.println(relativePath);

6

我的版本基于MattSteve的版本,但有所改动:

/**
 * Returns the path of one File relative to another.
 *
 * @param target the target directory
 * @param base the base directory
 * @return target's path relative to the base directory
 * @throws IOException if an error occurs while resolving the files' canonical names
 */
 public static File getRelativeFile(File target, File base) throws IOException
 {
   String[] baseComponents = base.getCanonicalPath().split(Pattern.quote(File.separator));
   String[] targetComponents = target.getCanonicalPath().split(Pattern.quote(File.separator));

   // skip common components
   int index = 0;
   for (; index < targetComponents.length && index < baseComponents.length; ++index)
   {
     if (!targetComponents[index].equals(baseComponents[index]))
       break;
   }

   StringBuilder result = new StringBuilder();
   if (index != baseComponents.length)
   {
     // backtrack to base directory
     for (int i = index; i < baseComponents.length; ++i)
       result.append(".." + File.separator);
   }
   for (; index < targetComponents.length; ++index)
     result.append(targetComponents[index] + File.separator);
   if (!target.getPath().endsWith("/") && !target.getPath().endsWith("\\"))
   {
     // remove final path separator
     result.delete(result.length() - File.separator.length(), result.length());
   }
   return new File(result.toString());
 }

3
+1 对我来说没问题。只有一个小修正:你应该使用 separator.length 而不是 "/".length(). - leonbloy

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接