URL 参数里的加号和路径空格,别交给同一段编码逻辑
下载名叫 a+b c.txt,放进查询参数以后能正确取回,放进路径以后名字却变了。先不要追加一轮URLDecoder;更应该看编码动作处理的究竟是表单值、路径段,还是整条URI。
URLEncoder 的名字很容易让人以为它能编码任何URL。它实际提供的是表单编码,空格会变成加号,原始加号则要另作转义。路径中的加号通常就是普通加号,不能沿用表单解码把它当空格。
下面固定 JDK 17,用同一份原始名称分别构造表单查询与URI路径。不请求网络,因此也不依赖某个服务器怎样宽容地处理错误编码。
原始名称只编码一次
保存为 UrlParts.java,执行 java UrlParts.java:
import java.net.*;
import java.nio.charset.StandardCharsets;
public class UrlParts {
public static void main(String[] args) throws Exception {
String raw = "a+b c.txt";
String value = URLEncoder.encode(raw, StandardCharsets.UTF_8);
URI query = URI.create("https://example.invalid/files?name=" + value);
System.out.println("query=" + query.getRawQuery());
System.out.println("query_value=" + URLDecoder.decode(value, StandardCharsets.UTF_8));
URI path = new URI("https", null, "example.invalid", -1,
"/files/" + raw, null, null);
System.out.println("path_raw=" + path.getRawPath());
System.out.println("path_value=" + path.getPath());
URI twice = new URI("https", null, "example.invalid", -1,
"/files/" + value, null, null);
System.out.println("double=" + twice.getRawPath());
if (!URLDecoder.decode(value, StandardCharsets.UTF_8).equals(raw))
throw new AssertionError("query round trip failed");
if (!path.getPath().equals("/files/" + raw)) throw new AssertionError("path failed");
}
}
输出:
query=name=a%2Bb+c.txt
query_value=a+b c.txt
path_raw=/files/a+b%20c.txt
path_value=/files/a+b c.txt
double=/files/a%252Bb+c.txt
查询里的 name=a%2Bb+c.txt 是一项表单风格参数。URLDecoder按同样的规则解码后,才会恢复成原来的加号和空格。路径部分则由URI多参数构造器根据组件规则处理空格,保留合法的加号。这里的相同名称,在两处并不需要得到相同的原始转义字符串。
程序特意在组件构造器前不用URLEncoder。若先得到带百分号的编码文本,再把它当原始路径交给会转义百分号的构造器,就可能得到 %25,形成重复编码。样本最后一行展示了这个结果。重复编码也不应该靠服务端无限解码来“兼容”,那会让安全检查和真实路由看到不同含义。
多参数构造器也不等于任意路径段编码器
本例名称没有斜杠,所以可以拼成一个明确路径。若用户名称本来带 /,它在路径组件中有结构含义。URI路径构造器不会替业务决定这个斜杠是目录边界还是文件名里的普通字符。
对真正要求单段的内部标识,更简单的办法是限制为固定协议字符,或者使用服务端生成的URL安全编号,将原始名称放进元数据。必须允许任意字符时,应使用明确提供路径段构造能力的客户端库,并与接收端约定编码后的斜杠如何处理。代理、容器和路由器可能对这些形式采取不同策略,单看Java对象能构造成功不足以证明链路能往返。
同样,不能对整条地址运行URLEncoder。冒号、斜杠、问号和等号本来承担结构分隔职责,整体编码会把它们也变成数据。正确顺序是先区分scheme、host、path和参数,再按各自规则构造。
getPath 与 getRawPath 回答两个不同问题
读取URI时,原始组件保留百分号转义,解码组件则给出相应字符。诊断时应同时观察两者,确认错误发生在构造前、编码时,还是下游再次解释时。
已经拿到 getPath() 的结果,不应顺手用URLDecoder再解一遍。除了加号语义不同,原始名称若含字面 %2F,额外解码还可能把它变成斜杠,改变路径结构。每一层都应该知道自己的输入是原始文本还是已转义文本;仅靠一个String类型,编译器不会替你分辨。
查询也需要相同的边界意识。getQuery() 本身不是完整的参数字典解析器,值中的分隔符、重复参数、缺失值和空值如何解释,都要交给协议明确的解析步骤。先整串解码再按 & 切分,可能把参数值内部的转义分隔符错误地当成结构。
真实链路要核对哪些样本
至少准备空格、加号、中文、百分号、问号、井号与斜杠这些输入,逐项核对客户端原始组件、发出的目标地址和服务端最终字段值。转义文本看着陌生不是失败;原始值按约定恢复才是成功。
服务器允许和拒绝哪些编码路径,还应与反向代理一致。不要在客户端看见404就随机切换编码次数,也不要把任意可解析URI直接当作可信目标。主机、协议、重定向与授权是另外的校验层,正确转义只保证字符被放到预期组件中。
这套分工比记住“空格要替换成%20”更有用:先确定语法层次,再在这一层编码一次,最后在同一层按相同协议解码。遇到不兼容时保留原始与线上的目标样本,才能定位究竟哪一端把数据又解释了一遍。