开始前,会这些就够了
- 能区分 URL 中的路径与问号后的查询部分。
- 知道字符需要按字符集转换成字节,本例统一用 UTF-8。
- 先读 字符串与
StringBuilder:文本长度并不总是字符数:先理解字符串表示和字符集,再观察同一字符怎样进入不同编码协议。
服务收到路径 /files/A+B.txt,你想还原文件名,顺手调用 URLDecoder.decode。打印结果却变成 /files/A B.txt,真实的加号消失了。这个函数没有随机丢字符,而是在忠实执行另一种格式的规则。
URLEncoder 和 URLDecoder 处理的是 application/x-www-form-urlencoded 表单格式。URI 路径有自己的字符规则。选择工具前,需要先知道这段字符串是哪一个组件、按什么协议编码的。
把例子带到自己的电脑上
下载完整练习包
需要 完整 JDK 17 或更新版本;实测 Windows / JDK 17.0.20.1 / 21.0.12。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。
下载 ZIP · 5 个文件包里有什么
- Main.java:完整可运行源码
- README.md:运行步骤与练习
- observed.stdout.txt:本次实际输出
- verification.json:源码和运行验证记录
- SHA256SUMS.txt:文件摘要
在这个文件夹打开 PowerShell,逐行运行
java -version
javac -version
javac -encoding UTF-8 --release 17 Main.java
java -cp . Main解压后,在含 Main.java 的目录打开终端。命令分别回车执行。固定输入的独立 Java 17 程序,双 JDK 实测;互动区只回放记录。
找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。
切换字符与构造方式,找出第一次改变含义的地方
正在准备实验控件…
以下是 JDK 17/21 真实输出的固定回放;没有发起 HTTP 请求。URLDecoder 使用 UTF-8 表单规则,URI.getPath 使用 URI 路径规则。
同样的原始路径,两种读取方式
先看 /files/A+B.txt。URI.getPath() 保留字面加号,得到 /files/A+B.txt;把原始路径交给 URLDecoder,则得到 /files/A B.txt。在表单规则里,字面 + 代表空格。
再看 /files/A%20B.txt:两种方式都得到含空格的 /files/A B.txt。最后看 /files/A%2BB.txt:两种方式都得到含加号的 /files/A+B.txt。它们在部分输入上碰巧一致,并不能证明两个解码器可以互换。
读日志时也要区分 raw 与 decoded。getRawPath 保留本例中的百分号序列;getPath 把路径里的转义字节解开。程序把原始路径、URI 结果和表单解码结果并排输出,避免只比较看上去相似的一列。
表单内部往返成功,搬进路径却改变含义
现在从真正想表达的文件名 A B+C.txt 开始。URLEncoder 使用 UTF-8 得到 A+B%2BC.txt:原来的空格写成 +,原来的加号写成 %2B。再用 URLDecoder 解码,能够完整还原原文件名。这个往返本身是正确的。
问题出在把这段表单编码结果直接拼到 /files/ 后面,再按 URI 路径读取。getPath 得到 /files/A+B+C.txt:原来的空格已经变成了字面加号。编码与读取使用不同格式,数据的含义就在交界处改变了。
这种错误可能在普通字母文件名上隐藏很久。只测试 report.txt 看不出差异;至少要把空格和真实加号放在同一份样例中,才容易看见哪一个字符被错误解释。
按组件构造,再交给匹配的读取规则
本页的路径对照使用 new URI(null, null, originalPath, null, null),把尚未进行百分号编码的完整路径传给 path 参数。对这个固定样例,原始路径被表示为 /files/A%20B+C.txt,getPath 又还原为 /files/A B+C.txt。
这里的构造器参数是完整路径,不是一个“任意文件名单段编码器”。斜杠仍然是路径分隔符。若文件名来自用户、可能包含斜杠或其他特殊结构,还要按你的路由和存储规则验证;本文没有证明该构造器能把任意名字安全地塞进单个路径段。
也不要先手工百分号编码,再把结果交给期待原始组件的构造器。你必须记录每个变量当前保存的是原始文本还是已经编码的文本,让编码只发生在约定的位置。
问号后的查询部分,也不能只凭位置猜格式
很多网页表单和框架用表单规则组织查询参数,因而把 + 当作空格。但“属于 URI 的 query 组件”本身,并没有自动宣布它一定采用这套参数协议。要看生成端、服务端框架和接口约定如何解释。
排查问题时,先确定值来自原始请求、框架已经解析的参数,还是你自行读取的 URI 组件。框架已解码的值再解一次,可能再次改变内容。本文对比的是显式调用的 Java API,没有代替你的 HTTP 框架做验证。
不要用全局替换 + 或 %20 来掩盖问题。真实加号、代表空格的表单加号和百分号序列,各有来源;一刀切替换可能修好一个样例,又破坏另一个样例。
保存完整程序,再核对输出
程序先打印三组解码对照,再打印两组路径构造对照,共五行。输出字符串只来自固定 ASCII 样例,因此可直接打印成 JSON 行;这段打印代码不是处理任意用户文本的通用 JSON 序列化器。
2026-10-26,分别在 Microsoft OpenJDK 17.0.20.1 与 21.0.12 上使用 --release 17 编译并运行,两个版本输出一致。互动区回放这些固定记录;浏览器没有启动 JVM。程序没有联网、连接数据库或依赖第三方库。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
import java.net.URI;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
public class Main {
static void check(boolean condition, String message) {
if (!condition) throw new AssertionError(message);
}
static String formDecode(String value) {
return URLDecoder.decode(value, StandardCharsets.UTF_8);
}
static void decode(String mode, String raw, String expectedPath, String expectedForm) {
URI uri = URI.create(raw);
String path = uri.getPath();
String form = formDecode(uri.getRawPath());
check(path.equals(expectedPath), "unexpected URI path");
check(form.equals(expectedForm), "unexpected form decoding");
System.out.printf("{\"type\":\"decode\",\"mode\":\"%s\",\"rawPath\":\"%s\","
+ "\"uriPath\":\"%s\",\"formDecoded\":\"%s\"}%n",
mode, uri.getRawPath(), path, form);
}
static void construct(String mode, String original, URI uri) {
System.out.printf("{\"type\":\"construct\",\"mode\":\"%s\",\"original\":\"%s\","
+ "\"rawPath\":\"%s\",\"uriPath\":\"%s\",\"formDecoded\":\"%s\","
+ "\"uriMatchesOriginal\":%s}%n",
mode, original, uri.getRawPath(), uri.getPath(),
formDecode(uri.getRawPath()), uri.getPath().equals(original));
}
public static void main(String[] args) throws Exception {
decode("literal-plus", "/files/A+B.txt", "/files/A+B.txt", "/files/A B.txt");
decode("escaped-space", "/files/A%20B.txt", "/files/A B.txt", "/files/A B.txt");
decode("escaped-plus", "/files/A%2BB.txt", "/files/A+B.txt", "/files/A+B.txt");
String name = "A B+C.txt";
String original = "/files/" + name;
String formEncoded = URLEncoder.encode(name, StandardCharsets.UTF_8);
check(formEncoded.equals("A+B%2BC.txt"), "unexpected form encoding");
check(formDecode(formEncoded).equals(name), "form round trip must work");
URI wrong = URI.create("/files/" + formEncoded);
URI right = new URI(null, null, original, null, null);
check(!wrong.getPath().equals(original), "form output is not path encoding");
check(right.getPath().equals(original), "path constructor round trip must work");
construct("form-as-path", original, wrong);
construct("path-constructor", original, right);
}
}
怎样运行:在独立练习文件夹中,依次执行下载区的命令。2026-10-26 使用 完整 JDK 17 或更新版本;实测 Windows / JDK 17.0.20.1 / 21.0.12 复跑这份源码并核对完整输出。固定输入的独立 Java 17 程序,双 JDK 实测;互动区只回放记录。
运行后对照结果
{"type":"decode","mode":"literal-plus","rawPath":"/files/A+B.txt","uriPath":"/files/A+B.txt","formDecoded":"/files/A B.txt"}
{"type":"decode","mode":"escaped-space","rawPath":"/files/A%20B.txt","uriPath":"/files/A B.txt","formDecoded":"/files/A B.txt"}
{"type":"decode","mode":"escaped-plus","rawPath":"/files/A%2BB.txt","uriPath":"/files/A+B.txt","formDecoded":"/files/A+B.txt"}
{"type":"construct","mode":"form-as-path","original":"/files/A B+C.txt","rawPath":"/files/A+B%2BC.txt","uriPath":"/files/A+B+C.txt","formDecoded":"/files/A B+C.txt","uriMatchesOriginal":false}
{"type":"construct","mode":"path-constructor","original":"/files/A B+C.txt","rawPath":"/files/A%20B+C.txt","uriPath":"/files/A B+C.txt","formDecoded":"/files/A B C.txt","uriMatchesOriginal":true}我们验证了字符语义,没有访问服务器
本次实测覆盖三个固定路径和一个同时含空格、加号的文件名。它证明的是 Java 表单 API 与 URI path API 的规则差异,没有发起 HTTP 请求、读取真实文件,也没有验证服务器的路径规范化或权限检查。
遇到中文、已编码百分号、路由参数或代理转发时,可以沿用这里的记录方式继续做实验:每一层保留输入表示、使用的协议、实际 API 和输出。先找出第一次改变含义的边界,再修改那一层。
记住这些要点
- URLDecoder 处理表单格式,字面 + 在该格式下表示空格。
- URI 路径里的字面 + 不会被
getPath自动改为空格。 - 编码与解码各自正确,格式不匹配仍会改变数据。
- 区分组件、协议以及是否已被框架解码,再选择 API。
别把例子用过头
- 看到 URLDecoder 的名字就拿它解整个 URL 或任何路径。
- 只测英文字母,不测空格与真实加号同时出现的输入。
- 把整个 query 组件一概当作表单格式。
- 给已解码参数再解码,或对整段地址盲目替换字符。
- 把完整 path 构造器当成任意文件名的单段隔离与校验工具。
自己试一小步
一段文件名经过前端、代理和后端后发生变化,你会怎样定位第一次解释错误的位置?
- 将文件名换成同时含中文、空格、加号的字符串,显式保留 UTF-8;先手算新输入的预期表单编码,并更新
formEncoded.equals(...)中的固定字符串,保留往返相等的断言。 - 逐步打印原始文本、编码结果、
getRawPath、getPath与表单解码结果。 - 另建一个含字面百分号的输入,先预测把已编码值重复送入编码器会怎样,再运行核对。
做到这些,就算完成
- 每一列写清楚它是原始文本、URI 组件还是表单值。
- 为真正需要互逆的一对操作检查往返相等,不要求不同协议的解码器相等。
- 对 HTTP 框架的行为单独运行验证,不把本页纯 Java 字符串结果当成服务器实测。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- Java 17 URLEncoder:表单编码规则 (新标签页)↗
工具用途是表单编码;字母数字及 .-*
_保持不变,空格变为 +,其余需转义字符按指定字符集形成百分号序列。 - Java 17 URLDecoder:表单解码规则 (新标签页)↗
字面 + 还原为空格,百分号字节序列按给定字符集解码。
- Java 17 URI:路径组件、构造与读取 (新标签页)↗
组件构造器处理路径表示,
getRawPath与getPath分别提供原始与已解码路径。