本文首先闡述了人們關於統一資源定位符(URL)編碼的普遍的誤讀,其後通過闡明HTTP場景下的URL encoding 來引出我們經常遇到的問題及其解決方案。本文並不特定於某類程式語言,我們在Java環境下闡釋問題,最後從Web應用的多個層次描述如何解決URL編碼的問題來結尾。
簡介
當我們每天上網衝浪時,有一些技術我們無時無刻不在面對。有資料本身(網頁),資料的格式化,能夠讓我們獲取資料的傳輸機制,以及讓Web網路能夠真正成為Web的基礎及根本:從一頁到另一頁的連結。這些連結都是URL。
通用URL語法
我敢說每個人在其一生中至少見過一次URL。比如”http://www.google.com”,就是一個URL。一個URL是一個統一資源定位器 ,事實上它指向了一個網頁(大多數情況下)。實際上,自從1994年的第一版規範開始,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由”/”字元開始,每一個資料夾由”/”分隔,最後是檔案。例如”/photos/egypt/cairo/first.jpg”有四個路徑片段(segment):”photos”、”egypt”、”cairo” 和 “first.jpg”,可以由此推出:”first.jpg” 檔案在資料夾”cairo”中,而”egypt” 資料夾位於web站點的根資料夾”photos”裡面。
每一個path片段 可以有可選的 path引數 (也叫 matrix引數),這是在path片段的最後由”;”開始的一些字元。每個引數名和值由”=”字元分隔,像這樣:”/file;p=1″,這定義了path片段 “file”有一個 path引數 “p”,其值為”1″。這些引數並不常用 — 這得清楚 — 但是它們確實是存在,而且從 Yahoo RESTful API 文件我們能找到很好的理由去使用它們:
Matrix引數可以讓程式在GET請求中可以獲取部分的資料集。參考資料集的分頁。因為matrix引數可以跟任何資料集的URI格式的path片段,它們可以在內部的path片段中被使用。
在 路徑(path)部分之後是 查詢 (query)部分,它和 路徑 之間由一個“?”隔開, 查詢部分包含了一個由“&”分隔開的引數列表,每一個引數由引數名稱、“=”號以及引數值組成。比如”/file?q=2″定義了一個 查詢引數 “q” ,它的值是”2″。這在提交 HTML表單時,或者當你使用諸如Google搜尋等應用時, 用的非常多。
一個HTTP URL的最後部分是一個段落(fragment)部分,用來指向HTML檔案中具體的某個部分,而不是整個HTML頁面。比如說,當你點選連結時瀏覽器自動滾屏到某個部分而不是從頁面最頂部開始展示,就說明你點選了一個擁有段落部分的URL。
URL 語法
http URL 方案最初由 RFC 1738 定義(實際上,在之前的 RFC 1630也有涉及),而在 http URL 方案被重新定義之前,整個 URL 語法就已經由擴充套件了幾次 以適應發展的規範進化為一套 統一資源識別符號(Uniform Resource Identifiers 即 URIs)。
對於 URLs 如何拼裝,各部分如何分隔有一套語法。例如:”://”分隔方案和主機部分。主機同路徑片段部分由”/”分隔,而查詢部分緊跟在”?”之後。這意味著有些字元為語法保留。有些為整個URIs保留,而有些則被特定方案保留。所有出現在不應出現位置的 保留符(例如路徑片段——以檔名為例——可能包含”?”)必須被URL 編碼。
URL 編碼將字元轉變成對 URL 解析無意義的無害形式。它將字元轉化成為一種特定字元編碼的位元組序列,然後將位元組轉換為16進位制形式,並將其前面加上”%”。問號的 URL 編碼形式為”%3F”。
我們可以將指向 “to_be_or_not_to_be?.jpg”圖片的 URL 寫成:”http://example.com/to_be_or_not_to_be%3F.jpg”,這樣就沒有人會認為這兒可能由一個查詢部分了。
現今多數瀏覽器顯示 URLs 前都會對其解碼(將百分號編碼位元組轉回其原本字元),並在獲取其網路資源的時候重新編碼。這樣一來,很多使用者從未意識到編碼的存在。
另一方面,網頁作者,開發者必須明確認識到這一點,因為這裡存在著很多陷阱。
URL常見陷阱
如果你正和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程式碼片段
1 2 |
String str = "blue+light blue"; String url = "http://example.com/" + str + "?" + str; |
編碼URL並不是為了轉義保留字而進行的簡單字元迭代,我們需要確切的知道哪個URL部份有哪些保留字,而有針對性的進行編碼。
這也意味著URL重寫過濾器如果不考慮合適的編碼細節而對URL直接進行分段轉換通常是有問題的。對URL進行編碼而不考慮具體的分段規則是不切實際的。
保留字不是你想象的那樣
大多數人不知道”+”在路徑部分是被允許的並且特指正號而不是空格。其他類似的有:
- “?”在查詢部分允許不被轉義,
- “/”在查詢部分允許不被轉義,
- “=”在作為路徑引數或者查詢引數值以及在路徑部分允許不被轉義,
- “:@-._~!$&'()*+,;=”等字元在路徑部分允許不被轉義,
- “/?:@-._~!$&'()*+,;=”等字元在任何段中允許不被轉義。
這樣下面的地址雖然看起來有點混亂:”http://example.com/:@-._~!$&'()*+,=;:@-._~!$&'()*+,=:@-._~!$&'()*+,==?/?:@-._~!$'()*+,;=/?:@-._~!$'()*+,;==#/?:@-._~!$&'()*+,;=“
按照上面的規則,其實上是一個合法的地址。
不用奇怪,上面路徑可以被解析為:
部分 | 值 |
---|---|
協議 | http |
主機 | example.com |
路徑 | /:@-._~!$&'()*+,= |
路徑引數名 | :@-._~!$&'()*+, |
路徑引數值 | :@-._~!$&'()*+,== |
查詢引數名 | /?:@-._~!$'()* ,; |
查詢引數值 | /?:@-._~!$'()* ,;== |
段 | /?:@-._~!$&'()*+,;= |
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不能被再編碼為同樣的形式
如果你解碼”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。
不要用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編碼(是這樣,只不過是各部分分開處理的),但是要麼是壓根沒有,要麼是我們還沒有發現。不過,這種臆測導致許多人錯用了URLEncoder。
在對每一部分編碼之前不要拼裝URL
正如我們已經講過的:完整構建後的URL不能再被編碼。
以下面的程式碼為例:
1 2 |
String pathSegment = "a/b?c"; String url = "http://example.com/" + pathSegment; |
如果”a/b?c” 是一個路徑片段,那麼不可能把”http://example.com/a/b?c” 轉換回之前它的原樣,因為它碰巧是一個有效的URL。之前我們已經解釋過這一點。
下面是正確的程式碼:
1 2 3 |
String pathSegment = "a/b?c"; String url = "http://example.com/" + URLUtils.encodePathSegment(pathSegment); |
這裡我們使用了一個工具類URLUtils,它是我們自己開發的,因為網路上找不到一個詳盡的足夠快的工具類。上面的程式碼會帶給你正確編碼的URL “http://example.com/a%2Fb%3Fc”。
注意,同樣的方式也適用於查詢子串:
1 2 |
String value = "a&b==c"; String url = "http://example.com/?query=" + value; |
不要期望 URI.getPath()給你結構化的資料
因為一旦一個URL被解碼,句法資訊就會丟失,下面這樣的程式碼就是錯誤的:
1 2 3 |
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”,然後在不應該分割的地方將地址分割為地址片段。
正確的程式碼使用的是 未解碼的路徑 :
1 2 3 4 |
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的URI類能夠正確的做對
Apache Commons HTTPClient 3的 URI 類使用了Apache Commons Codec的URLCodec來做 URL編碼, 正如 API文件提到的 它是有問題的,因為它犯了和使用java.net.URLEncoder同樣的錯誤。它不但使用了錯誤的編碼器,還錯誤的 按照每一部分都具有同樣的預定設定進行解碼。
在web應用的每一層修復URL編碼問題
近來我們已經被動修復了許多應用中的URL編碼問題。從在Java中支援它,到低層次的URL重寫。這裡我們會列出一些必要的修改。
總是在建立的時候進行URL編碼
在我們的 HTML檔案中,我們將所有出現:
1 |
var url = "#{vl:encodeURL(contextPath + '/view/' + resource.name)}"; |
的地方替換為:
1 |
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。
很明顯,這個過程包含了一些字串從一個地址到另一個地址,這意味著我們要從路徑部分解碼並且把它重新編碼為另一個查詢值部分。
我們起初的規則,如下所示:
1 2 3 4 5 6 |
<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(不夠強大,只能用於這個查詢字串,所有的”&”或者”=”不被轉碼)。
We therefore made a big patch fixing a few issues like URL decoding, and adding the inline functionsescapePathSegment(String)andunescapePathSegment(String).
我們因此做了一個大修正補丁,用於修正諸如網址解碼問題以及增加內建函建escapePathSegment(String) 和 unescapePathSegment(String)
我們現在可以這樣寫,幾乎不會有錯誤
1 2 3 4 5 6 7 8 9 |
<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
1 2 3 4 5 6 7 8 9 10 11 12 |
... # 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] |
結論
我希望闡明一些URL技巧和常見的錯誤。簡而言之,能把它說明白就夠了,但這不是一些人想象的那樣簡單的。我們展示了java常見的錯誤和一個web 應用部署的整個過程。現在每個讀者都應該是一個URL專家了,並且我們希望不要在看見相關bugs再出現。請求SUN公司,請為URL encoding/decoding逐項的增加標準支援。