URL
URL
http://www.google.com
这个URL中读出下列详细信息:
Part | Data |
Scheme | http |
Host address | www.google.com |
如果我们看一个更复杂的URL,比如:
https://bob:bobby@www.lunatech.com:8080/file;p=1?q=2#third
我们就能获取到下列信息:
Part | Data |
Scheme | https |
User | bob |
Password | bobby |
Host address | www.lunatech.com |
Port | 8080 |
Path | /file |
Path parameters | p=1 |
Query parameters | q=2 |
Fragment | third |
协议 (即scheme,如上面的http和https (安全HTTP)) 定义了URL中其余部分的结构。大多数互联网URL协议 拥有通用的开头,包括用户,密码,主机名和端口,后面才是每个协议具体的部分。这个通用的部分负责处理认证,同时它也有能力知道为了请求数据应该链接到哪儿。
HTTP URL语法
对于HTTP URL(使用http 或 https 协议),URL的scheme
描述部分定义了数据的路径(path
),后面是可选的query
和fragment
。
path
部分看上去是一个分层的结构,类似于文件系统中文件夹和文件的分层结构。path由/
字符开始,每一个文件夹由/
分隔,最后是文件。
每一个path
片段 可以有可选的path
参数 (也叫matrix
参数),这是在path片段的最后由;
开始的一些字符。每个参数名和值由=
字符分隔,像这样:/file;p=1
,这定义了path片段file
有一个path
参数p
,其值为1
。这些参数并不常用但确实是存在,而且从Yahoo RESTful API文档我们能找到很好的理由去使用它们:
「Matrix参数可以让程序在GET请求中可以获取部分的数据集。参考数据集的分页。因为matrix参数可以跟任何数据集的URI格式的path片段,它们可以在内部的path片段中被使用。」
在路径(path)部分之后是查询(query)部分,它和路径之间由一个?
隔开, 查询部分包含了一个由&
分隔开的参数列表,每一个参数由参数名称、=
号以及参数值组成。
一个HTTP URL的最后部分是一个段落(fragment)部分,用来指向HTML文件中具体的某个部分,而不是整个HTML页面。比如说,当你点击链接时浏览器自动滚屏到某个部分而不是从页面最顶部开始展示,就说明你点击了一个拥有段落部分的URL。
URL 语法
http URL方案最初由RFC 1738定义(实际上,在之前的RFC 1630也有涉及),而在http URL方案被重新定义之前,整个URL语法就已经由扩展了几次以适应发展的规范进化为一套统一资源标识符(Uniform Resource Identifiers 即 URIs)。
URL中的与保留字符冲突的字符要被转成特殊的16进制,如:to_be_or_not_to_be?.jpg
图片的URL写成http://example.com/to_be_or_not_to_be%3F.jpg
。
URL常见陷阱
URL编码规范并没有定义使用何种字符编码形式去编码字节。一般的ASCII字母数字字符并不需要转义,但是ASCII之外的保留字需要。
例如法语单词「nœud」中的"œ"
我们必须提出疑问,应该使用哪类字符编码来编码URL字节。
当然如果只有Unicode的话,这个世界就会清净很多。因为每个字符都包含其中,但是它只是一个集合,或者说是列表如果你愿意,它本身并不是一中编码。Unicode可以使用多种方式进行编码,譬如UTF-8或者UTF-16(也有其它格式),但是问题并没有解决:我们应该使用哪类字符来编码URL(通常也指URI)。
标准并没有定义一个URI应该以何种方式指定其编码,所以其必须从环境信息中进行推导。对于HTTP URL,它可以是HTML页面的编码格式,或HTTP头的。这通常会让人迷惑,也是许多错误的根源。事实上,最新版的URI标准 定义了新的URI scheme将采用UTF-8,host(甚至已有的scheme)也使用UTF-8,这让我更加怀疑:难道host和path真的可以使用不同的编码方式?
每部分的保留字都是不同
是的,每一部分的保留字都是不同。
对于一个httpd连接,路径片段部分中的空格被编码为%20
(不,完全没有+
),而+
字符在路径片段部分可以保持不编码。
现在,在查询部分,一个空格可能会被编码为+
(为了向后兼容:不要试图在URI标准去搜索他)或者%20
,当作为+
字符(作为个统配符的结果)会被编译为%2B
。
这意味着blue+light blue
字串,如果在路径部分或者查询部分,将会有不同的编码。比如得到
http://example.com/blue+light%20blue?blue%2Blight+blue
这样的编码形式,这样我们不需从语法上分析url结构,就可以推导这个url的整个结构是可能的。
考虑如下组装URL的Java代码片段
String str = "blue+light blue"; String url = "http://example.com/" + str + "?" + str;
编码URL并不是为了转义保留字而进行的简单字符迭代,我们需要确切的知道哪个URL部份有哪些保留字,而有针对性的进行编码。
这也意味着URL重写过滤器如果不考虑合适的编码细节而对URL直接进行分段转换通常是有问题的。对URL进行编码而不考虑具体的分段规则是不切实际的。
保留字不是你想象的那样
大多数人不知道+
在路径部分是被允许的并且特指正号而不是空格。其他类似的有:
-
?
在查询部分允许不被转义, -
/
在查询部分允许不被转义, -
=
在作为路径参数或者查询参数值以及在路径部分允许不被转义, -
:@-._~!$&'()*+,;=
等字符在路径部分允许不被转义, -
/?:@-._~!$&'()*+,;=
等字符在任何段中允许不被转义。
这样下面的地址虽然看起来有点混乱:
http://example.com/:@-._~!$&'()*+,=;:@-._~!$&'()*+,=:@-._~!$&'()*+,==?/?:@-._~!$'()*+,;=/?:@-._~!$'()*+,;==#/?:@-._~!$&'()*+,;=
按照上面的规则,其实上是一个合法的地址:
部分 | 值 |
协议 | http |
主机 | example.com |
路径 | /:@-._~!$&'()*+,= |
路径参数名 | :@-._~!$&'()*+, |
路径参数值 | :@-._~!$&'()*+,== |
查询参数名 | /?:@-._~!$'()* ,; |
查询参数值 | /?:@-._~!$'()* ,;== |
段 | /?:@-._~!$&'()*+,;= |
不能分析解码后的URL
URL的语法只在它被解码前是有意义的,一旦解码就可能出现保留字。
例如
http://example.com/blue%2Fred%3Fand+green
在解码前由如下部分组成:
Part | Value |
Scheme | http |
Host | example.com |
Path segment | blue%2Fred%3Fand+green |
Decoded Path segment | blue/red?and+green |
这样看来, 我们是在请求一个名为blue/red?and+green
的文件,而不是一个位于blue
文件夹下的名为red?and+green
的文件。
如果我们把它解码为http://example.com/blue/red?and+green
,我们将得到如下部分:
Part | Value |
Scheme | http |
Host | example.com |
Path segment | blue |
Path segment | red |
Query parameter | name and green |
这明显是错误的,所以,对保留字和URL各部分的分析必须在URL解码之前完成。这意味着URL重写过滤器不应当在尝试匹配之前解码URL,当且仅当保留字允许进行URL编码时才可以(有时符合这种情形,有时不符合,这取决于你的应用)。
解码后的URL不能被再恢复
解码后的URL不能被再编码为同样的形式
如果你解码http://example.com/blue%2Fred%3Fand+green
为http://example.com/blue/red?and+green
然后对它进行编码(哪怕使用一个对URL每一部分都很了解的编码器),你将会得到
http://example.com/blue/red?and+green
这是因为它已经是一个有效的URL。它跟我们解码之前的URL非常的不同。
用Java正确处理URL
当你觉得自己已经拿到了URL的黑腰带(柔道中的最高级别--译者注),你将会发现仍有一些Java里特有的、URL相关的陷阱。如果没有一个强大的心脏,你很难正确的处理URL。
不要处理整个URL
不要用java.net.URLEncoder
或者java.net.URLDecoder
来处理整个URL
不开玩笑。这些类不是用来编码或解码URL的,API文档中清楚的写着:
「Utility class for HTML form encoding. This class contains static methods for converting a String to theapplication/x-www-form-urlencodedMIME format. For more information about HTML form encoding, consult the HTML specification.」
这不是给URL用的。充其量它类似于查询部分的编码方式。使用它来编码或解码整个URL是错误的。你肯定以为标准的JDK一定会有一个标准的类来正确的处理URL编码(是这样,只不过是各部分分开处理的),但是要么是压根没有,要么是我们还没有发现。不
分段编码后再拼装
正如我们已经讲过的:完整构建后的URL不能再被编码。
以下面的代码为例:
String pathSegment = "a/b?c"; String url = "http://example.com/" + pathSegment;
如果a/b?c
是一个路径片段,那么不可能把http://example.com/a/b?c
转换回之前它的原样,因为它碰巧是一个有效的URL。之前我们已经解释过这一点。
下面是正确的代码:
String pathSegment = "a/b?c"; String url = "http://example.com/" + URLUtils.encodePathSegment(pathSegment);
这里我们使用了一个工具类URLUtils
,它是我们自己开发的,因为网络上找不到一个详尽的足够快的工具类。上面的代码会带给你正确编码的http://example.com/a%2Fb%3Fc
。
注意,同样的方式也适用于查询子串:
String value = "a&b==c"; String url = "http://example.com/?query=" + value;
这会给你http://example.com/?query=a&b==c
,这是个有效的URL,而不是我们想得到的http://example.com/?query=a%26b==c
。
URI.getPath()有问题
不要期望URI.getPath()给你结构化的数据
因为一旦一个URL被解码,句法信息就会丢失,下面这样的代码就是错误的:
URI uri = new URI("http://example.com/a%2Fb%3Fc"); for(String pathSegment : uri.getPath().split("/")) System.err.println(pathSegment);
它会先将路径a%2Fb%3Fc
解码为a/b?c
,然后在不应该分割的地方将地址分割为地址片段。
正确的代码使用的是 未解码的路径:
URI uri = new URI("http://example.com/a%2Fb%3Fc"); for(String pathSegment : uri.getRawPath().split("/")) System.err.println(URLUtils.decodePathSegment(pathSegment));
注意路径参数仍然存在:如果需要的话再处理它们。
第三方库问题
Apache Commons HTTPClient 3的URI
类使用了Apache Commons Codec的URLCodec
来做 URL编码, 正如API文档提到的 它是有问题的,因为它犯了和使用java.net.URLEncoder
同样的错误。它不但使用了错误的编码器,还错误的按照每一部分都具有同样的预定设置进行解码。
在web应用的每一层修复URL编码问题
近来我们已经被动修复了许多应用中的URL编码问题。从在Java中支持它,到低层次的URL重写。这里我们会列出一些必要的修改。
在创建时候就要进行URL编码
在我们的 HTML文件中,我们将所有出现:
var url = "#{vl:encodeURL(contextPath + '/view/' + resource.name)}";
的地方替换为:
var url = "#{contextPath}/view/#{vl:encodeURLPathSegment(resource.name)}";
查询参数也是类似的。
URL-rewrite过滤器问题
Url 重写过滤器是一个重写过滤器,我们在seam中用于转化漂亮的地址去应用依赖的网址。
例如,我们用它把
http://beta.visiblelogistics.com/view/resource/FOO/bar
转化为
http://beta.visiblelogistics.com/resources/details.seam?owner=FOO&name=bar
。
很明显,这个过程包含了一些字符串从一个地址到另一个地址,这意味着我们要从路径部分解码并且把它重新编码为另一个查询值部分。
我们起初的规则,如下所示:
<urlrewrite decode-using="utf-8"> <rule> <from>^/view/resource/(.*)/(.*)$</from> <to encode="false">/resources/details.seam?owner=$1&name=$2</to> </rule> </urlrewrite>
从这我们可以看到在重写过滤器中只有两种方法处理网址重写:每一个的网址先被解码去做规则匹配(<to>模式),或者它不可用,所有规则去处理解码。在我们看来后者是比较好的选择,特别是当你要移动网址部分周围,或者想去包含URL解码路径分隔符的匹配路径部分时候。
在替换模式中(<to>模式)你可以使用内建的函数escape(String)
和unescape(String)
处理网站转码和解码。
在撰写这个文章的时候,Url Rewrite Filter Beta 3.2有一些bugs,限制住我们提高URL-correctness:
-
网址解码使用
java.net.URLDecoder
(这是错误的 -
escape(String)
和unescape(String)
内建函数使用java.net.URLDecoder
和java.net.URLEncoder
(不够强大,只能用于这个查询字串,所有的&
或者=
不被转码)。
我们因此做了一个大修正补丁,用于修正诸如网址解码问题以及增加内建函建escapePathSegment(String)
和unescapePathSegment(String)
。
我们现在可以这样写,几乎不会有错误
<urlrewrite decode-using="null"> <rule> <from>^/view/resource/(.*)/(.*)$</from> <-- Line breaks inserted for readability --> <to encode="false">/resources/details.seam ?owner=${escape:${unescapePath:$1}} &name=${escape:${unescapePath:$2}}</to> </rule> </urlrewrite>
唯一可能出问题的地方是由于我们的补丁还不能解决以下的问题:
-
内建的
escaping/unescaping
函数应能只能编码,这已经做为下一个补丁(已经做完了),或者能从http请求来确定(还不支持), -
oldescape(String)
和unescape(String)
内建函数被保留了,并且仍然调用java.net.URLDecoder
,而这个包在由于没有解决"&"和"="的问题,所以仍然有问题, - 我需要增加更多的局部特定的编码和解码函数,
-
我们需要增加一个方法去鉴别
per-rule
解码行为,对照全局在<urlrewrite>
`。
我们一有时间,我们就会发布第二个补丁。
正确使用Apache mod-rewrite
Apache mod-rewrite是一个Apache Web服务器的网址重写模块。例如用它来把
http://beta.visiblelogistics.com/foo
的流量代理到http://our-internal-server:8080/vl/foo
。
这是最后的要修正的事情,就像是Url Rewrite Filter,他默认解码网址给我们,并且从新编码重写过得网址给我们,这其实上是错误的,因为"解码的网址不能被重新编码"。
有一种方法可以避免这种行为,至少在我们的案例中我们没有转化一个网址部分到另一个网址,例如,我们不需要解码一个路径部分并且重新编码它到一个查询部分:没有加码也没有重编码。
我们通过THE_REQUEST来网址匹配来完成工作。他是完全的HTTP请求(包括HTTP方法和版本)联合解码。我们只要取host后面的URL部分,改变host和预设的/v/
前缀和tada
... # This is required if we want to allow URL-encoded slashes a path segment AllowEncodedSlashes On # Enable mod-rewrite RewriteEngine on # Use THE_REQUEST to not decode the URL, since we are not moving # any URI part to another part so we do not need to decode/reencode RewriteCond %{THE_REQUEST} "^[a-zA-Z]+ /(.*) HTTP/\d\.\d$" RewriteRule ^(.*)$ http://our-internal-server:8080/vl/%1 [P,L,NE]