一、一段陈年老代码
在读一份陈年老代码时,发现了下面这段有意思的东西,这段代码的目的,是将 web 请求的入参 url encode 之后,计算 md5 当做签名。
import java.net.URLEncoder;
encodedFix(URLEncoder.encode(value, "UTF-8"))
private static String encodedFix(String encoded) {
encoded = encoded.replace("+", "%20");
encoded = encoded.replace("*", "%2A");
encoded = encoded.replace("%7E", "~");
return encoded;
}
那么问题就来了,url encode 就 encode 呗,为什么还要再 encodedFix 修复一下呢?
为了探究这样写的原因,我们得看一看 url encode 的具体定义或者规范。事实上,url encode 并不是一个标准的正式名称,但它对应的编码规则来自 RFC 3986(统一资源标识符 URI 通用语法)和更早的 HTML form encoding(application/x-www-form-urlencoded) 规范。
二、URI
URI(Uniform Resource Identifier,统一资源标识符)是用来标识互联网上某一资源的字符串。它的主要目的是唯一标识某个资源,不一定能访问,但必须能区分。
URI 有两种主要形式:
| 类型 | 全称 | 举例 | 用途 |
|---|---|---|---|
| URL | Uniform Resource Locator | https://example.com/page.html | 既标识资源,也提供获取方式(协议) |
| URN | Uniform Resource Name | urn:isbn:978-3-16-148410-0 | 标识资源的“名字”,但不指明位置 |
URI 的通用结构为:
scheme:[//authority]path[?query][#fragment]
比如:
https://www.example.com:8080/path/to/page?lang=en#section2
RFC 3986 规范中明确的指出了,对于 URI 来说:
- 保留字符 - 通用分隔符(gen-delims):
: / ? # [ ] @ - 保留字符 - 子分隔符(sub-delims):
! $ & ' ( ) * + , ; = - 非保留字符:
字母 数字 - . _ ~
保留字符 - 通用分隔符(gen-delims),主要用于分隔 URI 各个主要组成部分。
| 字符 | 用途举例 |
|---|---|
: |
分隔 scheme 和其他部分,如 http: |
/ |
分隔路径层级 |
? |
启动查询参数 |
# |
启动 fragment |
[ ] |
IPv6 地址 |
@ |
用户信息与主机的分隔符,如 user:pass@example.com |
保留字符 - 子分隔符(sub-delims),则用于细化每个部分内部的结构,比如 query 参数的键值对、列表项之间的分隔等。这些字符的具体用途并不是 RFC 3986 固定规定的,而是留给具体 URI 的使用场景去定义,像 HTTP、OAuth、HTML 表单 等场景,均可以有自己的设定。
这些保留字符在是否要编码取决于它们的作用,如果用作语法结构就不能被编码,否则 URI 就被破坏了。如果这些字符是普通数据的一部分,就必须编码,避免歧义。
三. URL encode
urlencode 是一种对 URL 中的字符串进行编码的方式,目的是确保 URL 在传输时不会因特殊字符而出错。常用于:
- 将数据作为查询字符串(query string)附加到 URL 后面
- 表单提交(application/x-www-form-urlencoded)
针对第一种场景(url 参数),因为 URL 是 URI 的子集,因此,也适用 RFC3986 规范,即非保留字符出现在 key/value 中,保持不变,而保留字符如果出现在 key/value 中,则都需要编码
针对第二种场景(表单提交),则与 RFC 3986 不完全一致,它的额外规则:空格 被转换成 + (而不是 %20)
四、不同编程语言的表现
在不同的编程语言中,对 url encode 有着不同的实现(字母和数字都是完全一样的,这里就不列出来了):
| 原字符 | java | urllib.parse.quote(xxx, safe='/') safe参数的默认取值是 '/' | urllib.parse.quote(xxx, safe='') | urllib.parse.urlencode | encodeURI | encodeURIComponent |
|---|---|---|---|---|---|---|
| : | %3A | %3A | %3A | %3A | : | %3A |
| / | %2F | / | %2F | %2F | / | %2F |
| ? | %3F | %3F | %3F | %3F | ? | %3F |
| # | %23 | %23 | %23 | %23 | # | %23 |
| [ | %5B | %5B | %5B | %5B | %5B | %5B |
| ] | %5D | %5D | %5D | %5D | %5D | %5D |
| @ | %40 | %40 | %40 | %40 | @ | %40 |
| ! | %21 | %21 | %21 | %21 | ! | ! |
| $ | %24 | %24 | %24 | %24 | $ | %24 |
| & | %26 | %26 | %26 | %26 | & | %26 |
| ' | %27 | %27 | %27 | %27 | ' | ' |
| ( | %28 | %28 | %28 | %28 | ( | ( |
| ) | %29 | %29 | %29 | %29 | ) | ) |
| * | * | %2A | %2A | %2A | * | * |
| + | %2B | %2B | %2B | %2B | + | %2B |
| , | %2C | %2C | %2C | %2C | , | %2C |
| ; | %3B | %3B | %3B | %3B | ; | %3B |
| = | %3D | %3D | %3D | %3D | = | %3D |
| - | - | - | - | - | - | - |
| . | . | . | . | . | . | . |
| _ | _ | _ | _ | _ | _ | _ |
| ~ | %7E | ~ | ~ | ~ | ~ | ~ |
| 空格 | + | %20 | %20 | + | %20 | %20 |
| 制表符 \t | %09 | %09 | %09 | %09 | %09 | %09 |
| 换行符 \n | %0A | %0A | %0A | %0A | %0A | %0A |
我们可以看到,urllib.parse.quote(xxx, safe='') 的结果和 RFC3986 的要求是一致的。
而 java.net.URLEncoder 的处理结果,和 RFC3986 中要求的并不完全一致:
空格被处理成了+,而不是%20*没有按要求进行处理。~应该不变,但是被处理成了%7E
这下就清晰了,陈年代码中的 encodedFix 其实是为了让 java.net.URLEncoder 的处理结果,和 RFC3986 保持一致:
import java.net.URLEncoder;
encodedFix(URLEncoder.encode(value, "UTF-8"))
private static String encodedFix(String encoded) {
// 表单中的空格,被转换成 + 了,需要按照 RFC3986 转为 %20
encoded = encoded.replace("+", "%20");
// URLEncoder 没有处理 *,需要额外处理下
encoded = encoded.replace("*", "%2A");
// URLEncoder 编码了 ~,但它属于非保留字符,不需要编码,所以要还原回去
encoded = encoded.replace("%7E", "~");
return encoded;
}