home ホーム search 検索 -  login ログイン  | reload edit datainfo version cmd icon diff delete  | help ヘルプ

技術/HTTP/URLエンコードで 0x20(スペース) を "+" にすべきか "%20" にすべきか (v1)

技術/HTTP/URLエンコードで 0x20(スペース) を "+" にすべきか "%20" にすべきか (v1)

技術 / HTTP / URLエンコードで 0x20(スペース) を "+" にすべきか "%20" にすべきか (v1)
id: 1170 所有者: msakamoto-sf    作成日: 2013-03-23 22:18:13
カテゴリ: HTML HTTP ネットワーク 

※時間がないとか、だらだらとRFCの解説を読んでる暇が無い方は、末尾のまとめ部分だけ目を通していただければ十分かと。

URLのpath中やquery中、POSTリクエストボディ中で、0x20のスペースを、"+"に変換するのが「正しい」のか、"%20"にするのが「正しい」のか、わからなくなってきたのでちょっと調べてみました。
ただしRFCの全文を熟読してるわけではないので、言い回しや表現はもとより理解そのものが間違ってる可能性もあるので、話半分程度に参考にしてください。

"+"を使うべきか、"%20"を使うべきか、よく迷う箇所:

  • URLのパス中
  • URLのクエリ中
  • "application/x-www-form-urlencoded" 形式中
    • URLのクエリ中
    • POSTメソッドのリクエストボディ中

stackoverflowでも、特にPHPで「rawurlencode()とurlencode()あるけど、どう違うんだよ!?」というのでよく質問されるようです。

目次:


RFC1738, Uniform Resource Locators (URL), 1994-12

"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'

RFC1808, Relative Uniform Resource Locators, 1995-06

http://www.ietf.org/rfc/rfc1808.txt

これはちょっとマイナーかも。今回のテーマとはあんまり絡んでないようです。
RFC1738では、「相対パス」について深く突っ込んでないです。
そこで、RFC1808では、HTML中など現在参照している「コンテキスト」=文脈から、相対パスを解釈して絶対パスに変換する仕組みを取り上げてます。この段階では特に"+"の扱いは変更されてないです。

RFC2396, Uniform Resource Identifiers (URI): Generic Syntax, 1998-08

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として使える文字についていろいろ工夫を重ねていて、もちろんこの段階でも"+"は使えるのだけれど、区切り文字などに使われる特殊文字扱いになった、という事かもしれません。

RFC3986, Uniform Resource Identifier (URI): Generic Syntax, 2005-01

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  = "!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "="

URLを組み立てる側と、解析する側の食い違い

RFC1738とRFC3986では、"reserved"が大分変わってます。そのため、URLを組み立てる側と、解析する側とで"reserved"文字種が異なる場合にトラブルになる場合があるようです。

  • 実際に、JavaScript側がRFC2396で、URLを受け付けるRails側がRFC3986だったため、"'"の扱いで食い違いが発生し、Rails側で正常に処理できなかった例:

なお、PHP5では http_build_query() というURLエンコードされたqueryを組み立てる関数がありますが、RFC1738に則ってエンコードするのか、RFC3986に則ってエンコードするのか指定するパラメータが存在します。

スペースを"+"にするのは "application/x-www-form-urlencoded" 由来?

RFC1738, RFC2396, RFC3986 はいずれもURLで使える文字については定義していますが、本記事のテーマであるURLクエリやPOSTデータでスペースを"+"にするというマッピングについてはスルーしてます。

スペースを"+"にするというマッピングは、HTMLの仕様で「Formでsubmitされた時、フォームの値をどうやってサーバに送るか」で定義されてます。

・・・よくよく考えると、なんでわざわざスペースを"+"にマッピングしたんでしょうか。 "application/x-www-form-urlencoded" 周りの処理でも、明らかにスペースと"+"だけ扱いが違うんですよね・・・。他はもうpercent-encodingしろ、で終わってるのに、これだけ"+"にしてる。そもそもこれさえ無ければ、こんな記事書くのに時間かける必要もなかったんですが・・・。

PHPでは"+"をどう受け付けるか

PHPでは"+"も"%20"も、0x20に戻してるようです:

Java Servletでは"+"をどう受け付けるか

ちょっと古いですが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の"+"の扱いについては特に記載されてないようです。

java.net.URLENcoderはどうなっているか

  • URLEncoder (Java Platform SE 6)
    • http://docs.oracle.com/javase/jp/6/api/java/net/URLEncoder.html
      • application/x-www-form-urlencoded形式に変換するためのものですので、スペースは"+"に変換します。
      • 英数字以外でそのまま残すのは、「.」、「-」、「*」、および「_」だけのようです。 * についてはRFC3986で"reserved"になったので、受け付ける側で特殊扱いしてしまう可能性があるかも・・・。

java.net.URLEncoderを実際に使ってみる

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を生成できそう。

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, &quot;&gt;/&amp;&lt;' world. こんにちは
Hello, &quot;&gt;/&amp;&lt;&apos; 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つの観点が混ざっています。

  1. URLを表現するときに使える文字の扱い
    1. これはさらに、「URLを作る側の動作」と「URLを解釈する側の動作」に分かれます。
  2. HTMLの仕様で定まっている、 "application/x-www-form-urlencoded" での扱い

まず後者の、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()のようにRFC3986に対応した新しいライブラリ関数を用意した処理系もあります。Javaのjava.net.URLEncoder()についてはRFC3986には特に追従していません。アスタリスクについて注意が必要になる可能性がありますが、若干のコーディングにより、容易にRFC3986にも対応したラッパーメソッドを作成出来ます。

実際のところ、RFCで規定しているのはURI中で使える文字種についてです。実際にCGIやServletなどWebアプリケーションレイヤーでどうquery文字列やPOSTリクエストボディを扱うのかは、その処理系に依存します。PHPやJava Servletにおいては、最新のバージョンでも "+" と "%20" を等しく0x20に変換してくれるようです。

また発散してきたのでもう一度整理し直すと、結局のところ「URIを生成する側は、可能な限り特殊文字が増えた最新のURIの仕様に合わせる」「URIを解釈する側は、古いURIの仕様で作られたURIについても解釈できたほうがベター」という状況のようです・・・多分(震え声)。

  1. URIを生成する側
    1. HTMLの仕様で定まっている、 "application/x-www-form-urlencoded"
      1. URLのquery、およびPOSTメソッドのリクエストボディに使われる。
      2. 0x20が "+" に変換されるが、特殊扱いはそれだけ。RFC3986で"reserved"指定の文字は軒並みpercent-encodeされるっぽいので、基本的には問題無さそう。
    2. PHPのurlencode(), rawurlencode()
      1. urlencode()はRFC1738, rawurlencode()はRFC3986 に従っている。
      2. URIを生成する側は最新の仕様に合わせたほうが良い、という観点から、rawurlencode()を使ったほうが安全かも、という程度かも?(震え声)
    3. Javaのjava.net.URLEncoder()
      1. 基本的にRFC1738ベースだが、0x20とアスタリスク(0x2A)以外はRFC3986でも問題ない形に変換してくれる。
      2. どうしてもRFC3986に厳密に従いたかったら、encode()したあとにさらに"+"とアスタリスクを"%20"と"%2A"に変換するラッパーを自作すれば良い。
    4. JavaScriptのencodeURIComponent()
      1. RFC2396ベースで、RFC3986で"reserved"に移動している一部の文字をそのまま残してしまうため、URIを解釈する受け手によっては問題となる事例あり。これもやっぱり変換ラッパーを自作すれば良さそう。
  2. URIを解釈する側
    1. 基本的にプログラマーがどうこうできる余地は少ない。
    2. PHP, Java Servletでは "+" も "%20" もどちらも 0x20 に戻してくれるので安心。
    3. それ以外の、RFC1738/2396で "unreserved" だったのに RFC3986 で "reserved" に移動された文字を受け付けた場合にどうなるかはちょっと不明。実験してみて!

土曜日一日潰して、一体何をやっているんだと言いたくなってくる・・・。しかし、お金が絡まないこうした調べ事は楽しいですね!!



プレーンテキスト形式でダウンロード
表示中のバージョン : 1
現在のバージョン : 3
更新者: msakamoto-sf
更新日: 2017-11-19 16:15:33
md5:ef280483bf9592555f29898e4c141af3
sha1:3aa9be6c7293ad410a02042ff5ab37da37fde41f
コメント
コメントを投稿するにはログインして下さい。