开始前,会这些就够了
- 能读 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 查找文件的实际过程。本站文件路径课程讨论的存在性、链接和权限,需要另外的系统调用证据。
只有片段时,路径和查询有专门的处理
第八组的基础地址包含 /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。先比对前两行的路径,再找第七行的输入分类与主机变化,最后检查第九行的协议变化,便能抓住本实验的三个重点。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
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 的本地语法结果当成浏览器通用规则、目标可达性或授权证明。
自己试一小步
当地址包含显式端口、查询或编码字符时,你还能说清哪些部分会被继承吗?
- 自拟三个基础地址,其中一个带显式端口、一个带查询,保持全部为无需访问的虚构地址。
- 设计至少六个引用,包含一个只含查询的引用和一个含百分号编码的路径段;先记录完整结果与各组件的预测。
- 新增实跑对照,同时记录
getRawPath与getPath、端口、查询和片段;不要发送网络请求来替代语法验证。 - 为只接收单个子编号的接口写一份输入约定,另列应该拒绝的结构,并用自己的样本验证。
做到这些,就算完成
- 未定义组件与空字符串分开保存,预测和实际结果不相同时说明依据。
- 检查最终目标字段,没有只依据
isAbsolute作判断。 - 提交源码、版本、输入矩阵和结果;不把这项练习称为完整访问控制实现。
- 保留原来九组回归对照,新增样本有运行前的预测和运行后的逐字段记录。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- Java 17:URI 的组件与 resolve 规则 (新标签页)↗
核读语法组件、
isAbsolute、URIs/URLs/URNs、resolve(URI)与resolve(String),对应尾斜杠、authority、片段及纯语法处理。 - RFC 2396:相对 URI 引用的合并步骤 (新标签页)↗
核读第 5.2 节的方案、authority、根路径与相对路径合并步骤,特别是保留最后一段之前的基础路径。
- OpenJDK 17.0.20:URI.resolve 与 resolvePath (新标签页)↗
读取固定标签源码中
resolvePath、resolve 的分支,核对最后斜杠位置、片段特例、方案和 authority 的处理顺序。 - WHATWG URL Standard:URL parsing (新标签页)↗
核读 URL parsing 的入口与 basic URL parser,说明浏览器体系有自己的解析规则和输入/基础 URL 处理。