qinghuan
← 回到知识探索

后端现场 / 动手实验

文件名里的 + 号,为什么解码后变成了空格?

URLDecoder 的名字容易让人误以为它能通吃 URL。把表单值和 URI 路径放进同一个实验,观察同一串字符怎样产生不同含义。

展开本文目录
  1. 下载完整练习包
  2. 先动手看看
  3. 同样的原始路径,两种读取方式
  4. 表单内部往返成功,搬进路径却改变含义
  5. 按组件构造,再交给匹配的读取规则
  6. 问号后的查询部分,也不能只凭位置猜格式
  7. 保存完整程序,再核对输出
  8. 我们验证了字符语义,没有访问服务器
  9. 记住这些要点
  10. 别把例子用过头
  11. 自己试一小步
  12. 查原始资料
开始前,会这些就够了
遇到不熟悉的词,先看这 2 个解释
表单编码
为 HTML 表单值约定的一套文本表示规则。
原始路径
保留 URI 路径中百分号转义序列的表示。
打开完整术语小抄 ↗

服务收到路径 /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 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

切换字符与构造方式,找出第一次改变含义的地方

正在准备实验控件…

收到的原始路径:/files/A+B.txt

先找出空格、字面 + 和百分号序列,再预测两种读取结果。它们的差异来自协议,不来自是否使用 UTF-8。

以下是 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。程序没有联网、连接数据库或依赖第三方库。

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

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

小实验 · java
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 构造器当成任意文件名的单段隔离与校验工具。

自己试一小步

一段文件名经过前端、代理和后端后发生变化,你会怎样定位第一次解释错误的位置?

  1. 将文件名换成同时含中文、空格、加号的字符串,显式保留 UTF-8;先手算新输入的预期表单编码,并更新 formEncoded.equals(...) 中的固定字符串,保留往返相等的断言。
  2. 逐步打印原始文本、编码结果、getRawPath、getPath 与表单解码结果。
  3. 另建一个含字面百分号的输入,先预测把已编码值重复送入编码器会怎样,再运行核对。

做到这些,就算完成

  • 每一列写清楚它是原始文本、URI 组件还是表单值。
  • 为真正需要互逆的一对操作检查往返相等,不要求不同协议的解码器相等。
  • 对 HTTP 框架的行为单独运行验证,不把本页纯 Java 字符串结果当成服务器实测。

查原始资料

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

  • Java 17 URLEncoder:表单编码规则 (新标签页)↗Oracle / OpenJDK · 英文

    工具用途是表单编码;字母数字及 .-*_ 保持不变,空格变为 +,其余需转义字符按指定字符集形成百分号序列。

    实际阅读类说明和字符转换规则,以及显式 Charset 的 encode 重载说明;未把名称解释成通用 URI 编码器。

  • Java 17 URLDecoder:表单解码规则 (新标签页)↗Oracle / OpenJDK · 英文

    字面 + 还原为空格,百分号字节序列按给定字符集解码。

    实际阅读类说明、+ 与百分号转换规则及 Charset 重载;没有使用平台默认编码重载。

  • Java 17 URI:路径组件、构造与读取 (新标签页)↗Oracle / OpenJDK · 英文

    组件构造器处理路径表示,getRawPath 与 getPath 分别提供原始与已解码路径。

    实际阅读五参数构造器、getRawPath、getPath、getQuery 和转义说明;未通读所有 URI 解析与规范化方法。