※時間がないとか、だらだらとRFCの解説を読んでる暇が無い方は、末尾のまとめ部分だけ目を通していただければ十分かと。
URLのpath中やquery中、POSTリクエストボディ中で、0x20のスペースを、"+"に変換するのが「正しい」のか、"%20"にするのが「正しい」のか、わからなくなってきたのでちょっと調べてみました。
ただしRFCの全文を熟読してるわけではないので、言い回しや表現はもとより理解そのものが間違ってる可能性もあるので、話半分程度に参考にしてください。
"+"を使うべきか、"%20"を使うべきか、よく迷う箇所:
stackoverflowでも、特にPHPで「rawurlencode()とurlencode()あるけど、どう違うんだよ!?」というのでよく質問されるようです。
2017-11-19追記 : URIのエスケープについて、その歴史や微妙な差異など、こちらの調査報告が非常に精密にまとめられているのでオススメです!
2014-03-16追記 : PHPのurlencode()とrawurlencode()の詳しい経緯についてはこのスライドがスゴイ!
目次:
"URLエンコード"について扱う様々な記事から参照されてるRFCその1(多分)。
http://www.ietf.org/rfc/rfc1738.txt
RFC1738ではHTTPの他にFTP/GOPHER/TELNETなど様々なスキームでのURLを取り上げてます。
その中で、以下の記号はURLを解析するときの区切り文字とかに使う特殊文字として定義されてます。なので、ホスト名とかパス中とかクエリ中には、使えないっぽい。
reserved = ";" | "/" | "?" | ":" | "@" | "&" | "="
英数字に加え、以下の記号が、"unreserved"として普通に使ってオッケーな記号。
safe = "$" | "-" | "_" | "." | "+" extra = "!" | "*" | "'" | "(" | ")" | "," ... unreserved = alpha | digit | safe | extra
さらに、実はRFC1738時点ではHTTPのPATHや"?"以降の"sedarch"部分の定義が以下のようになってました。
httpurl = "http://" hostport [ "/" hpath [ "?" search ]] hpath = hsegment *[ "/" hsegment ] hsegment = *[ uchar | ";" | ":" | "@" | "&" | "=" ] search = *[ uchar | ";" | ":" | "@" | "&" | "=" ]
なので、実は以下の様なURLもRFC1738に適合してる筈です。今時見かけたらぎょっとするようなURLですが。
http://www.example.com/hello!@my&na+me=$jonny;(good):morning?good;(even+ing!:i@am&bob=thanx'
・・・多分、適合してる筈(震え声)。
ただし、ややこしいんだけど"URL"全体を表現する文字としては上記でオッケーなんだけど、"reserved"は区切り文字とかの特殊文字で、「データ」の内容は表さない。URLを受け取った処理系が以下のように分割する「可能性がある」、ということかも。データとして "+" や "$" は残る感じです。
http www.example.com hello! my na+me $jonny (good) morning good (even+ing! i am bob thanx'
http://www.ietf.org/rfc/rfc1808.txt
これはちょっとマイナーかも。今回のテーマとはあんまり絡んでないようです。
RFC1738では、「相対パス」について深く突っ込んでないです。
そこで、RFC1808では、HTML中など現在参照している「コンテキスト」=文脈から、相対パスを解釈して絶対パスに変換する仕組みを取り上げてます。この段階では特に"+"の扱いは変更されてないです。
http://www.ietf.org/rfc/rfc2396.txt
"URLエンコード"について扱う様々な記事から参照されてるRFCその2(多分)。
"URI"の一般的な文法について取り上げてます。
RFC2396で、"+"文字が"reserved"に移動してます。
さらに厄介なことに、どうもここから"reserved"の意味が微妙に変わってるっぽい・・・。
G.2. Modifications from both RFC 1738 and RFC 1808 ... The plus "+", dollar "$", and comma "," characters have been added to those in the "reserved" set, since they are treated as reserved within the query component.
ではこの時点で使える文字はどうなってるのかというと、まず"reserved"な文字は以下になり、"+"や"$"が加わってます。
reserved = ";" | "/" | "?" | ":" | "@" | "&" | "=" | "+" | "$" | ","
で、エスケープなしでそのまま使える文字は英数字に加え以下の"mark"文字:
mark = "-" | "_" | "." | "!" | "~" | "*" | "'" | "(" | ")" unreserved = alphanum | mark
上記を組み合わせた、以下の文字種が定義されてます。"pchar"は、"unreserved"に ";", "/", "?" 以外の"reserved"文字を加えたもの。
pchar = unreserved | escaped | ":" | "@" | "&" | "=" | "+" | "$" | "," uric = reserved | unreserved | escaped
URLのpathとquery中に使える文字は以下のようになってます。
segment = *pchar *( ";" param ) param = *pchar query = *uric
"reserved"に"+"が加わったことで、どう影響がでるかというと
http://www.example.com/abc/def+ghi/jkl?hello=bob+alice
が、(もしかしたら)受け付ける処理系が
http://www.example.com/ abc def ghi jkl hello bob alice
という風に見ちゃうかもしれない、という「可能性が」あるということみたいです。「データ」として表記するのには使えない・・・って何を言ってるんだか分かんなくなってきました・・・。
とはいえ、"G.2."にある通りもともと"+"が"reserved"に移されたのは"+"がquery中で予約文字として扱われ始めた経緯があった筈です。これは "application/x-www-form-urlencoded" が使われ始めたから、という理由と、あるいはHTTP以外のスキームで"+"が区切り文字として使われ始めたから、という2つの可能性が考えられます。
URLというのは、印刷物など様々な経路でやり取りされるものであるため、エンコードなどによる影響を受け無いように、URLとして使える文字についていろいろ工夫を重ねていて、もちろんこの段階でも"+"は使えるのだけれど、区切り文字などに使われる特殊文字扱いになった、という事かもしれません。
http://www.ietf.org/rfc/rfc3986.txt
"URLエンコード"について扱う様々な記事から参照されてるRFCその3(多分)。
RFC2396を"obsoleted"にして、こっちが最新のURIの文法定義だよ!ということでPHPのrawurlencode()はこっちに従ってます。
とりあえず、"reserved"がさらに増えてます。
2.2. Reserved Characters ... reserved = gen-delims / sub-delims gen-delims = ":" / "/" / "?" / "#" / "[" / "]" / "@" sub-delims = "!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "="
RFC1738とRFC3986では、"reserved"が大分変わってます。そのため、URLを組み立てる側と、解析する側とで"reserved"文字種が異なる場合にトラブルになる場合があるようです。
なお、PHP5では http_build_query() というURLエンコードされたqueryを組み立てる関数がありますが、RFC1738に則ってエンコードするのか、RFC3986に則ってエンコードするのか指定するパラメータが存在します。
RFC1738, RFC2396, RFC3986 はいずれもURLで使える文字については定義していますが、本記事のテーマであるURLクエリやPOSTデータでスペースを"+"にするというマッピングについてはスルーしてます。
スペースを"+"にするというマッピングは、HTMLの仕様で「Formでsubmitされた時、フォームの値をどうやってサーバに送るか」で定義されてます。
・・・よくよく考えると、なんでわざわざスペースを"+"にマッピングしたんでしょうか。 "application/x-www-form-urlencoded" 周りの処理でも、明らかにスペースと"+"だけ扱いが違うんですよね・・・。他はもうpercent-encodingしろ、で終わってるのに、これだけ"+"にしてる。そもそもこれさえ無ければ、こんな記事書くのに時間かける必要もなかったんですが・・・。
PHPでは"+"も"%20"も、0x20に戻してるようです:
ちょっと古いですがServlet Specification Version 2.3で、HttpUtilsクラスの"parsePostData", "parseQueryString()"で以下のように"+"をスペースに変換するとありました。・・・とはいえ、Version 2.3も大分昔で、しかも2.3の時点でHttpUtilsが"Deprecated"されてますので、今も通用するかは不明ですが・・・。
SRV.15.1.15.2 Methods ... parsePostData(int, ServletInputStream) The keys and values in the hashtable are stored in their decoded form, so any + characters are converted to spaces, and characters sent in hexadecimal nota-tion (like%xx) are converted to ASCII characters. ... parseQueryString(String) The keys and values in the hashtable are stored in their decoded form, so any + characters are converted to spaces, and characters sent in hexadecimal notation (like%xx) are converted to ASCII characters.
Servlet Specification 3.0のPDFも確認してみたんですが、queryやpostの"+"の扱いについては特に記載されてないようです。
Groovy使ってますけど、勘弁して下さい:
def java_url_papssthru_and = '.-*_, +"\'.-*_, +"\'' println URLEncoder.encode(java_url_papssthru_and, 'UTF-8') def rfc3986_reserved = ':/?#[]@!$&\'()*+,;=:/?#[]@!$&\'()*+,;=' println URLEncoder.encode(rfc3986_reserved, 'UTF-8')
→
.-*_%2C+%2B%22%27.-*_%2C+%2B%22%27 %3A%2F%3F%23%5B%5D%40%21%24%26%27%28%29*%2B%2C%3B%3D%3A%2F%3F%23%5B%5D%40%21%24%26%27%28%29*%2B%2C%3B%3D
やっぱり、スペースは"+"で、アスタリスクはそのままになってます。
というわけで、RFC3986に合うようにスペースは"%20"に、アスタリスクは"%2A"に変換してみました。
def String rfc3986(String s, String cs) { URLEncoder.encode(s, cs).replace('+', '%20').replace('*', '%2A') } println rfc3986(java_url_papssthru_and, 'UTF-8') println rfc3986(rfc3986_reserved, 'UTF-8')
→
.-%2A_%2C%20%2B%22%27.-%2A_%2C%20%2B%22%27 %3A%2F%3F%23%5B%5D%40%21%24%26%27%28%29%2A%2B%2C%3B%3D%3A%2F%3F%23%5B%5D%40%21%24%26%27%28%29%2A%2B%2C%3B%3D
これなら、RFC3986でアスタリスクとか特殊扱いする処理系でもちゃんとデータとして扱えるURLを生成できそう。
web_escape_t1.groovy:(UTF-8で保存)
@Grapes([ @Grab(group='org.apache.commons', module='commons-lang3', version='3.1'), ]) import org.apache.commons.lang3.* def s1 = "Hello, \">/&<' world. こんにちは" println StringEscapeUtils.escapeHtml4(s1) println StringEscapeUtils.escapeXml(s1) def japanese_hello = "<'こんにちは & everybody!! \"/>" println StringEscapeUtils.escapeJava(japanese_hello) println StringEscapeUtils.escapeEcmaScript(japanese_hello) println URLEncoder.encode(japanese_hello, 'UTF-8') println URLEncoder.encode(japanese_hello, 'Windows-31J') println URLEncoder.encode(japanese_hello, 'euc-jp') def java_url_papssthru_and = '.-*_, +"\'.-*_, +"\'' println URLEncoder.encode(java_url_papssthru_and, 'UTF-8') def rfc3986_reserved = ':/?#[]@!$&\'()*+,;=:/?#[]@!$&\'()*+,;=' println URLEncoder.encode(rfc3986_reserved, 'UTF-8') println "abc def ghi def GHI".replace("def", "!") def String rfc3986(String s, String cs) { URLEncoder.encode(s, cs).replace('+', '%20').replace('*', '%2A') } println rfc3986(java_url_papssthru_and, 'UTF-8') println rfc3986(rfc3986_reserved, 'UTF-8')
実行結果:
Hello, ">/&<' world. こんにちは Hello, ">/&<' world. こんにちは <'\u7E3A\u8599\uFF53\u7E3A\uFF6B\u7E3A\uFF61\u7E3A\uFF6F & everybody!! \"/> <\'\u7E3A\u8599\uFF53\u7E3A\uFF6B\u7E3A\uFF61\u7E3A\uFF6F & everybody!! \"\/> %3C%27%E7%B8%BA%E8%96%99%EF%BD%93%E7%B8%BA%EF%BD%AB%E7%B8%BA%EF%BD%A1%E7%B8%BA%EF%BD%AF+%26+everybody%21%21+%22%2F%3E %3C%27%E3%81%93%E3%82%93%E3%81%AB%E3%81%A1%E3%81%AF+%26+everybody%21%21+%22%2F%3E %3C%27%E5%E1%C6%E5%A3%F3%E5%E1%8E%AB%E5%E1%8E%A1%E5%E1%8E%AF+%26+everybody%21%21+%22%2F%3E .-*_%2C+%2B%22%27.-*_%2C+%2B%22%27 %3A%2F%3F%23%5B%5D%40%21%24%26%27%28%29*%2B%2C%3B%3D%3A%2F%3F%23%5B%5D%40%21%24%26%27%28%29*%2B%2C%3B%3D abc ! ghi ! GHI .-%2A_%2C%20%2B%22%27.-%2A_%2C%20%2B%22%27 %3A%2F%3F%23%5B%5D%40%21%24%26%27%28%29%2A%2B%2C%3B%3D%3A%2F%3F%23%5B%5D%40%21%24%26%27%28%29%2A%2B%2C%3B%3D
0x20のURL中での表記の問題は、2つの観点が混ざっています。
まず後者の、HTML側の仕様ですが、これはHTML5になっても相変わらず、フォームで入力された0x20は "application/x-www-form-urlencoded" のMIME型では "+" に変換されます。なんでこれだけこうなってしまったのか、それはそれで興味があるんですが、ニート時代ならまだしも定職についてる今現在はそんな余裕は無い・・・。
つまり、スペースが"+"に変換されてPOSTリクエストボディやURLのquery中に現れるのは、当分は変わることはありません。
これはすなわち、URLやPOSTメソッドを受け付ける側でも"+"について特別な考慮が必要であることを意味します。
特別な考慮が必要なのは、"+"文字だけではありません。URL・・・というかURIで表現できるスキームは種類がありますので、それぞれで共通して、URIを解釈するときの分割などに用いる特殊文字がいくつかあります。そしてその特殊文字は、時代の移り変わりとともに変化してきました。
この変化の影響として、URIを生成する処理系と、URIを解釈する処理系とで特殊文字のセットが食い違うと予想通りに動作しない現象も出て来ました。特に、URIを生成する処理系が特殊文字が少ない時点での仕様(RFC1738, 2396)に基いていて、解釈する側の処理系が特殊文字が増えたRFC3986に基いていた場合に、生成する処理系では特殊文字として扱われずそのまま埋め込まれていた一部の文字が、解釈する側の処理系では特殊文字として特殊な扱われ方をしたため、予想通りにURIを処理してくれない、というトラブルに発展する場合があるようです。
URIを生成するユーティリティライブラリ等では、初期のバージョンはRFC1738に沿っていました。しかし上記のように、特殊文字が増えたRFC3986が登場したため、PHPのrawurlencode()のように(2014-03-16 修正) RFC3986に対応した新しいライブラリ関数を用意した処理系もあります。Javaのjava.net.URLEncoder()についてはRFC3986には特に追従していません。アスタリスクについて注意が必要になる可能性がありますが、若干のコーディングにより、容易にRFC3986にも対応したラッパーメソッドを作成出来ます。
実際のところ、RFCで規定しているのはURI中で使える文字種についてです。実際にCGIやServletなどWebアプリケーションレイヤーでどうquery文字列やPOSTリクエストボディを扱うのかは、その処理系に依存します。PHPやJava Servletにおいては、最新のバージョンでも "+" と "%20" を等しく0x20に変換してくれるようです。
また発散してきたのでもう一度整理し直すと、結局のところ「URIを生成する側は、可能な限り特殊文字が増えた最新のURIの仕様に合わせる」「URIを解釈する側は、古いURIの仕様で作られたURIについても解釈できたほうがベター」という状況のようです・・・多分(震え声)。
土曜日一日潰して、一体何をやっているんだと言いたくなってくる・・・。しかし、お金が絡まないこうした調べ事は楽しいですね!!
コメント