qinghuan
← 回到知识探索

后端现场 / 动手实验

基础地址只差一个斜杠,为什么新路径换了位置?

用 JDK 17 的 URI.resolve 实跑九组语法对照,观察尾斜杠、根路径、两个点、换主机和仅片段引用分别改变什么。

展开本文目录
  1. 下载完整练习包
  2. 先把地址拆成几部分,再谈怎么合并
  3. 尾斜杠决定最后一段是否参与保留
  4. 一个斜杠、一个点和两个点分别做什么
  5. 没有写协议,也可能把主机换掉
  6. 只有片段时,路径和查询有专门的处理
  7. 运行九组对照,把完整目标和组件一起保存
  8. 把“想去哪里”作为可以验证的输入约定
  9. 保留 Java 的规则边界,再考虑迁移
  10. 记住这三点
  11. 别把例子用过头
  12. 自己试一小步
  13. 查原始资料
开始前,会这些就够了
  • 能读 Java 字符串和简单方法调用;认识地址中的协议、主机和路径。
  • 完整 JDK 17 或更新版本;仅标准库,不需要启动 HTTP 服务。
  • 知道本篇处理的是地址字符串,运行不会连接示例主机。

你配置了 https://api.example.test/v1/orders,想在它后面加上 42。调用 resolve("42") 后,得到的路径却是 /v1/42。把基础地址末尾补一个斜杠,结果又变成 /v1/orders/42。方法没有猜错业务,它执行的是地址引用的合并规则。

本篇把九组输入交给真实 JDK 17,保存解析后的协议、主机、路径、查询和片段。我们既看字符串去了哪里,也看输入在 Java 中算不算绝对 URI,从而发现“没有写协议”为什么仍可能更换主机。

把例子带到自己的电脑上

下载完整练习包

需要 JDK 17 或更新版本,无第三方依赖;制作时实测 openjdk 17.0.20.1 2026-08-18 LTS。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 5 个文件
包里有什么
  • UriResolveBoundary.java:完整示例
  • README.md:环境、范围与独立练习
  • observed.stdout.txt:制作时的实际输出
  • verification.json:源码与产物验证记录
  • SHA256SUMS.txt:文件内容摘要

在这个文件夹打开 PowerShell,逐行运行

java --version
java --source 17 UriResolveBoundary.java

解压到独立练习文件夹,在含源文件的目录打开终端运行。程序会在当前目录写入教学报告;先预测结果,再查看当次输出。真实 JDK 17 的九组 URI 语法解析;不发网络请求,不验证目标存在、授权、重定向或浏览器解析。

找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

先把地址拆成几部分,再谈怎么合并

在本文的 HTTPS 样本里,https 是方案,api.example.test 是主机,/v1/orders 是路径。问号后的内容是查询,井号后的内容是片段。URI.resolve 接收的是一个 URI 引用,会根据它明确提供了哪些部分,决定哪些部分从基础地址继承。

因此它不是简单的字符串连接。接收 "42"、"/42" 和 "//cdn.example.test/42",走的规则可以不同。先识别输入的结构,再预测输出,比先拼一个看起来顺眼的字符串更容易排查错误。

Java 的 isAbsolute 检查是否指定了方案。路径以斜杠开头,与整个 URI 指定了方案,是两个不同条件。报告分别记录 reference_is_absolute 和 reference_has_authority,防止一个“绝对”标签混淆了两种概念。

尾斜杠决定最后一段是否参与保留

前两组只有基础地址的尾斜杠不同。对于 /v1/orders 和相对引用 42,结果是 /v1/42;对于 /v1/orders/ 和同一引用,结果是 /v1/orders/42。这是程序实际断言并写入矩阵的两行结果。

JDK 文档给出的规则是:保留基础路径中最后一段之前的部分,再接上相对路径并规范化。没有尾斜杠时,orders 就是被排除的最后一段;有尾斜杠时,最后一段为空,orders 前面的内容仍留在合并前缀里。

这里常用“像文件的地址”和“像目录的地址”辅助理解,但 URI 没有去服务器检查 orders 真的是文件还是目录。即使服务端把这两个地址映射到同一业务资源,Java 在本地处理这两个字符串时仍可得到不同结果。

配置项最好明确表示“用于合并子路径的基础地址”还是“已经完整的资源地址”。若调用方以为它是目录,提供方却交出没有尾斜杠的地址,二者都可能觉得自己的字符串没错。把最终目标也列入配置验收,才能直接暴露这项约定差异。

一个斜杠、一个点和两个点分别做什么

第三组把引用改为 /42。它没有方案,也没有主机,但路径从根开始,因此保留基础地址的协议和主机,直接使用 /42;前面的 /v1/orders/ 没有继续作为路径前缀。补好基础地址尾斜杠,也不会改变这个分支。

第四组使用 ./42,合并并规范化后得到 /v1/orders/42;第五组使用 ../42,得到 /v1/42。这些点段在本例的相对路径合并中被处理,而不是作为业务编号原样保留。

当前实验没有访问真实文件系统,也没有处理符号链接。不要把 URI 的语法规范化直接当作 Linux 或 Windows 查找文件的实际过程。本站文件路径课程讨论的存在性、链接和权限,需要另外的系统调用证据。

没有写协议,也可能把主机换掉

第六组直接传入 https://other.example.test/42。它本身已经是绝对 URI,resolve 返回该目标。基础地址不是一个强制锁住主机的容器,传入完整地址也不会自动被理解为基础路径下面的文字。

第七组更容易漏看://cdn.example.test/42 的 isAbsolute 为 false,因为它没有方案;但它带有 authority,也就是主机及可选用户信息、端口所在的部分。结果继承 https,却使用 cdn.example.test 作为主机。

因此仅拒绝 isAbsolute 为 true 的输入,不能证明解析后仍是原主机。第九组还显示另一个方向:主机保持 api.example.test,方案却可变为 http。报告把“同主机”和“同方案”分列,只表示这批固定小写样本的字段比较。

只有片段时,路径和查询有专门的处理

第八组的基础地址包含 /v1/orders?view=full,引用只有 #detail。结果保留原来的路径和 view=full,再使用新的片段 detail。它没有按照普通子路径的方式把 orders 替换掉。

JDK 的 resolve 文档单独描述了这一条件:片段存在、路径为空,并且方案、authority、查询都未定义。只因为另一段字符串也以标点开头,不能推断它必然走同一分支。仅查询引用不在本次九组之中。

报告中 null 表示组件没有定义,不能与空字符串随意合并。CSV 用 <null> 保留这项区别。调试时同时记录组件与完整结果,就能看清变化发生在路径、查询还是片段,避免只凭末尾文字猜测。

运行九组对照,把完整目标和组件一起保存

把完整代码保存为 UriResolveBoundary.java,在源码目录运行命令。程序只创建 URI 对象并写出教学报告,没有调用 URL、HttpClient、套接字或 DNS 查询;示例主机的服务状态不会影响结果。

uri-output/report.json 保存九行结构化记录,matrix.csv 保存对应表格。重复运行会更新同名结果,也可传入另一个输出目录。每组都核对完整结果、输入分类,并确认基础 URI 与引用对象的字符串没有被修改。

2026 年 10 月 16 日使用 Microsoft OpenJDK 17.0.20.1 实际运行,九组断言通过。JSON 记录 java.version。先比对前两行的路径,再找第七行的输入分类与主机变化,最后检查第九行的协议变化,便能抓住本实验的三个重点。

展开收起代码 · 小实验java · 112 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · java
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

public class UriResolveBoundary {
    static final String HOST = "https://api.example.test";
    static final String ITEM_BASE = HOST + "/v1/orders";
    static final String CHILD_BASE = ITEM_BASE + "/";

    record Fixture(String name, String base, String reference, String expected,
                   boolean expectedAbsolute, boolean expectedAuthority) {}
    record Row(String name, String base, String reference, String resolved,
               boolean referenceAbsolute, boolean referenceAuthority,
               String scheme, String host, String rawPath, String query, String fragment,
               boolean sameBaseScheme, boolean sameBaseHost) {
        String json() {
            return "{\"case\":" + quote(name) + ",\"base\":" + quote(base)
                + ",\"reference\":" + quote(reference) + ",\"resolved\":" + quote(resolved)
                + ",\"reference_is_absolute\":" + referenceAbsolute
                + ",\"reference_has_authority\":" + referenceAuthority
                + ",\"result_scheme\":" + quote(scheme) + ",\"result_host\":" + quote(host)
                + ",\"result_raw_path\":" + quote(rawPath) + ",\"result_query\":" + quote(query)
                + ",\"result_fragment\":" + quote(fragment)
                + ",\"same_base_scheme\":" + sameBaseScheme
                + ",\"same_base_host\":" + sameBaseHost + "}";
        }
        String csv() {
            return String.join(",", List.of(name, base, reference, resolved,
                Boolean.toString(referenceAbsolute), Boolean.toString(referenceAuthority),
                scheme, host, rawPath, query == null ? "<null>" : query,
                fragment == null ? "<null>" : fragment,
                Boolean.toString(sameBaseScheme), Boolean.toString(sameBaseHost))
                .stream().map(UriResolveBoundary::csvCell).toList());
        }
    }

    static String quote(String value) {
        if (value == null) return "null";
        return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"")
            .replace("\r", "\\r").replace("\n", "\\n").replace("\t", "\\t") + "\"";
    }
    static String csvCell(String value) { return "\"" + value.replace("\"", "\"\"") + "\""; }
    static void require(boolean condition, String message) {
        if (!condition) throw new AssertionError(message);
    }

    public static void main(String[] args) throws Exception {
        Path output = args.length == 0 ? Path.of("uri-output") : Path.of(args[0]);
        List<Fixture> cases = List.of(
            new Fixture("base_without_slash", ITEM_BASE, "42", HOST + "/v1/42", false, false),
            new Fixture("base_with_slash", CHILD_BASE, "42", CHILD_BASE + "42", false, false),
            new Fixture("root_path", CHILD_BASE, "/42", HOST + "/42", false, false),
            new Fixture("dot_path", CHILD_BASE, "./42", CHILD_BASE + "42", false, false),
            new Fixture("parent_path", CHILD_BASE, "../42", HOST + "/v1/42", false, false),
            new Fixture("absolute_other_host", CHILD_BASE, "https://other.example.test/42",
                "https://other.example.test/42", true, true),
            new Fixture("network_path", CHILD_BASE, "//cdn.example.test/42",
                "https://cdn.example.test/42", false, true),
            new Fixture("fragment_only", ITEM_BASE + "?view=full", "#detail",
                ITEM_BASE + "?view=full#detail", false, false),
            new Fixture("absolute_other_scheme", CHILD_BASE, "http://api.example.test/42",
                "http://api.example.test/42", true, true)
        );
        List<Row> rows = new ArrayList<>();
        for (Fixture fixture : cases) {
            // URI parses syntax only. No URL, socket, HttpClient or DNS calls are made.
            URI base = URI.create(fixture.base);
            URI reference = URI.create(fixture.reference);
            URI result = base.resolve(reference);
            require(result.toString().equals(fixture.expected), fixture.name + ": " + result);
            require(reference.isAbsolute() == fixture.expectedAbsolute, fixture.name + ": absolute");
            require((reference.getRawAuthority() != null) == fixture.expectedAuthority,
                fixture.name + ": authority");
            require(base.toString().equals(fixture.base), "base URI unexpectedly changed");
            require(reference.toString().equals(fixture.reference), "reference unexpectedly changed");
            require(result.isAbsolute() && !result.isOpaque() && result.getHost() != null,
                fixture.name + ": unexpected fixture type");
            rows.add(new Row(fixture.name, fixture.base, fixture.reference, result.toString(),
                reference.isAbsolute(), reference.getRawAuthority() != null,
                result.getScheme(), result.getHost(), result.getRawPath(),
                result.getRawQuery(), result.getRawFragment(),
                Objects.equals(base.getScheme(), result.getScheme()),
                Objects.equals(base.getHost(), result.getHost())));
        }
        require(!rows.get(6).referenceAbsolute && !rows.get(6).sameBaseHost,
            "a relative network-path reference can replace authority");
        require(rows.get(8).sameBaseHost && !rows.get(8).sameBaseScheme,
            "same host alone does not preserve the scheme");
        require(rows.get(7).query.equals("view=full") && rows.get(7).fragment.equals("detail"),
            "fragment-only reference keeps fixture path/query");
        Files.createDirectories(output);
        String version = System.getProperty("java.version");
        String json = "{\n  \"java_version\":" + quote(version)
            + ",\n  \"scope\":\"JDK_URI_syntax_only_no_network_or_authorization\","
            + "\n  \"cases\":9,\n  \"rows\":[\n    "
            + String.join(",\n    ", rows.stream().map(Row::json).toList()) + "\n  ]\n}\n";
        String csv = "case,base,reference,resolved,reference_is_absolute,reference_has_authority,"
            + "result_scheme,result_host,result_raw_path,result_query,result_fragment,"
            + "same_base_scheme,same_base_host\n"
            + String.join("\n", rows.stream().map(Row::csv).toList()) + "\n";
        Files.writeString(output.resolve("report.json"), json, StandardCharsets.UTF_8);
        Files.writeString(output.resolve("matrix.csv"), csv, StandardCharsets.UTF_8);
        System.out.println("Java " + version + "; 9 cases; URI syntax only; no network requests");
        System.out.println("base tail: orders + 42 -> /v1/42; orders/ + 42 -> /v1/orders/42");
        System.out.println("//cdn reference: isAbsolute=false, host changes; same host can change scheme");
        System.out.println("PASS; " + output);
    }
}

怎样运行:java --source 17 UriResolveBoundary.java(在源码目录执行;完整 JDK 17 或更新版本,仅标准库,无网络请求。) 2026-10-16 已用 openjdk 17.0.20.1 2026-08-18 LTS 执行这份代码并通过自带检查;下方是当次输出。真实 JDK 17 的九组 URI 语法解析;不发网络请求,不验证目标存在、授权、重定向或浏览器解析。

运行后对照结果
Java 17.0.20.1; 9 cases; URI syntax only; no network requests
base tail: orders + 42 -> /v1/42; orders/ + 42 -> /v1/orders/42
//cdn reference: isAbsolute=false, host changes; same host can change scheme
PASS; uri-output

把“想去哪里”作为可以验证的输入约定

如果一个工具只允许调用方传入某个资源的子编号,就应先定义这种输入允许包含哪些字符和结构,再构造目标。接受任意 URI 引用与接受单个路径段是不同的接口约定,不能用一个 resolve 调用悄悄代替这项决定。

验收时可以把基础地址、原始引用和最终解析结果放在同一行,与预期目标比较。若要求固定主机和方案,就检查相关解析字段;如果还限制端口、用户信息或路径范围,需要把这些条件也明确列出。

这只是输入与目标约定的设计建议。本脚本没有实现通用地址白名单、访问授权或网络出站控制,同主机布尔值也不是安全判定。解析成功只证明当前字符串能按这套 API 处理,不能证明用户有权访问它。

保留 Java 的规则边界,再考虑迁移

Java 17 的 URI 文档说明其语法依据 RFC 2396,并列出偏差;浏览器 URL 解析则有 WHATWG URL 标准。两者不是同一套 API,本次没有做浏览器对照,不能把九行 Java 输出升级成所有语言和浏览器的共同保证。

固定标签的 OpenJDK 源码能解释本次分支:先处理片段和已有方案,再检查 authority,之后才决定直接使用根路径还是合并相对路径。阅读实现用于对应观察结果;业务代码仍调用公开 URI API,不复制内部解析器。

实验也没有验证 DNS、目标存在性、重定向、服务器路由和授权结果。如果程序后来真正发起请求,应另行记录该阶段的结果。一个正确解析出的地址,仍只是后续操作的输入,不是请求已经成功的证据。

记住这三点

  • URI.resolve 按组件规则合并;基础路径的尾斜杠会改变相对路径结果。
  • isAbsolute 为 false 不代表只能留在原主机,带 authority 的引用仍可更换主机。
  • 解析结果、业务允许的目标和真实请求结果,应分别验证。

别把例子用过头

  • 把 resolve 当作在原字符串尾部无条件追加文本。
  • 只检查输入有没有协议,便断定最终结果一定属于原主机。
  • 把 JDK URI 的本地语法结果当成浏览器通用规则、目标可达性或授权证明。

自己试一小步

当地址包含显式端口、查询或编码字符时,你还能说清哪些部分会被继承吗?

  1. 自拟三个基础地址,其中一个带显式端口、一个带查询,保持全部为无需访问的虚构地址。
  2. 设计至少六个引用,包含一个只含查询的引用和一个含百分号编码的路径段;先记录完整结果与各组件的预测。
  3. 新增实跑对照,同时记录 getRawPath 与 getPath、端口、查询和片段;不要发送网络请求来替代语法验证。
  4. 为只接收单个子编号的接口写一份输入约定,另列应该拒绝的结构,并用自己的样本验证。

做到这些,就算完成

  • 未定义组件与空字符串分开保存,预测和实际结果不相同时说明依据。
  • 检查最终目标字段,没有只依据 isAbsolute 作判断。
  • 提交源码、版本、输入矩阵和结果;不把这项练习称为完整访问控制实现。
  • 保留原来九组回归对照,新增样本有运行前的预测和运行后的逐字段记录。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。

  • Java 17:URI 的组件与 resolve 规则 (新标签页)↗Oracle Java 官方文档 · 英文

    核读语法组件、isAbsolute、URIs/URLs/URNs、resolve(URI) 与 resolve(String),对应尾斜杠、authority、片段及纯语法处理。

    2026-10-16 实际核读所列范围;优先按 JDK17 API 的语义断言;不声称它与所有浏览器规则一致。

  • RFC 2396:相对 URI 引用的合并步骤 (新标签页)↗RFC Editor · 英文

    核读第 5.2 节的方案、authority、根路径与相对路径合并步骤,特别是保留最后一段之前的基础路径。

    2026-10-16 实际核读所列范围;作为 Java17 文档指向的历史语法依据;不是把旧 RFC 说成全部现代 URL 实现的唯一规范。

  • OpenJDK 17.0.20:URI.resolve 与 resolvePath (新标签页)↗OpenJDK 官方仓库 · 英文

    读取固定标签源码中 resolvePath、resolve 的分支,核对最后斜杠位置、片段特例、方案和 authority 的处理顺序。

    2026-10-16 实际核读所列范围;实际读取官方 raw 源码;本机运行17.0.20.1;本篇代码是调用公开 API 的原创实验。

  • WHATWG URL Standard:URL parsing (新标签页)↗WHATWG · 英文

    核读 URL parsing 的入口与 basic URL parser,说明浏览器体系有自己的解析规则和输入/基础 URL 处理。

    2026-10-16 实际核读所列范围;这里只用于限定适用范围;没有声称九组样本已在浏览器或 Node.js 重新运行。