URLをいじくるプログラムをいじっていて、仕様がよくわからなくて悩んだのでまとめます。
2/23: 追試部分を追記
2018/7/14: JavaScriptのURLSearchParamsと、GoのPathEscapeについて追記
ことの経緯
HTTPとはなんぞやとか、GETとPOSTがどうの、それぞれでパラメータがどういう経緯でウェブアプリケーション(とかCGI)に渡って来るのかぐらいは知っていました。で、ウェブでXHRでGETリクエストを送る場合にはJavaScriptのencodeURIComponent()
で各パラメータをエンコードして、&でくっつけて、URLの末尾に?で付与すればいいんだよね?と思っていました。こんな感じに。
var finalUrl = [url, "?", encodeURIComponent("key"), "=", encodeURIComponent("value")].join("");
これを受け取る側は、JavaScript(node.js)ならdecodeURIComponent()
を使っているはずだ。ウェブの世界ではJavaScriptが中心だし、きっと他の言語のWeb系のAPIはJavaScript基準に合わせているのでは?と。
そう思っていたところ、Golangのnet/url
のurl.QueryEscape()
関数がencodeURIComponent()
とはちょっと違う結果を返すことに気づきました。僕の感覚ではスペースは%20
になると思ってたんですが、Golangでは+
になります。Golangが生成したエンコード文字列を、JavaScriptのdecodeURIComponent()
で戻すと、+
が残ってしまいます。さて、困った。どこが原因なのか?
現状把握
RFC
URIの形式については、RFC1738 (1994年)→RFC2396 (1998年)→RFC3986 (2005年)とバージョンがあがってきているようです。最新版だとAdobeの人が関わっているんですね。へぇーへぇー。時間があればあとで読む。
Stack Overflowの投稿によると、GolangはRFCに厳格らしく、JavaScriptはちょっとルーズらしいです。
JavaScriptのエスケープ
JavaScriptだと、encodeURI()
と、encodeURIComponent()
があります。encodeURI()
は&と=、/はそのままにするバージョンで、完全なURIを受け取って、そのままURIとして使える文字列を返す想定で、encodeURIComponent()
はクエリパラメータで使える安全な文字列(URIの意味を壊さない)を返します。このエントリーでは後者だけを相手にします。
Content-Typeがapplication/x-www-form-urlencoded
(POST)で送信するときは、さらに%20
を+に変換せよ、とMDNの説明にはあります。また、RFC-3986に厳格に対応するためのコードも書かれてます。
function fixedEncodeURIComponent (str) {
return encodeURIComponent(str).replace(/[!'()*]/g, function(c) {
return '%' + c.charCodeAt(0).toString(16);
});
}
つまり、JavaScriptのエスケープの仕様はRFCの最新には対応していないということですね。ブラウザで検索するとスペースは常に%20
になるかと思っていましたが、ウェブサイトで検索すると今は+
になる模様。前からそうでしたっけ?ただ、場所によっては色々あるっぽいです。
@shibu_jp 我々が超お世話になってるgithubも+ですね。ただGoogleもChromeのアドレスバーから検索すると%になるみたいです。
— MURAOKA Taro (@kaoriya) February 22, 2015
ECMAScriptの標準JavaScriptの範囲内だと、encodeURIComponent()
しかありませんが、node.jsには、このRFCに厳格なバージョンにも対応できる、querystringモジュールがあります。expressは、qsというサードパーティ製のモジュールを利用しているようです。
2018/7/14
ウェブブラウザではURLSearchParamsというクラスが実装されました。Node.jsも8.0から実装されています。これだときちんと+になります。配列の処理とかパースとかも全部このクラスがまとめて面倒を見てくれますので、今後はみなさんこれを積極的につかいましょう。
>>> const params = new URLSearchParams()
>>> params.append(" ", " ")
>>> params.toString();
"+=+"
golangのエスケープ
golangでは net/url
モジュールが提供されています。 url.Values.Encode()
を呼ぶと検索クエリをエスケープした文字列を作ってくれます。ソース読むと中で各コンポーネントの変換に使っているのは、 url.QueryEscape
です。この関数は中でプライベート関数のescapeを使っていて、この関数はモードによって変換の仕方が少し変わる模様。パス部分のエンコードでは、スペースは%20
になりますが、クエリ部分のモード(url.QueryEscape()
と同様)では、+
になります。mattnさんの書かれた方法を使えば%20
にできないこともないです。
(&url.URL{Path:foo}).String() みたいにしてエンコード出来たりとか #golang
— mattn (@mattn_jp) February 22, 2015
2018/7/14
Go 1.8からは url.PathEscapeが提供されるようになったため、プライベート関数をがんばって駆使しなくてもパス部分にマッチしたエスケープができるようになりました。mattnさん、コメントありがとうございました。
Pythonのエスケープ
Python3のurllib.parse
には、quote()
と、quote_plus()
があります。quote
はJS相当、quote_plus
はGolang相当っぽいです。さすが俺たちのPython。卒なくこなしやがる。
結局どうすべきか?
現状認識としては、Golangの挙動は正しいものの、JavaScriptのように古い規格のクエリを投げてくるクライアントもありえる、というのが実際のところかな、と思いました。もちろん、JavaScriptでも厳密に守ったコードを投げることもできますが、必ずしもそうはなっていない(Chromeのオムニバーでも)。
そのため、サーバ側で、スペースがどっちできてもきちんと解釈できるようにしておかないとダメってことですね。まぁ、このあたりはウェブアプリケーションサーバのフレームワーク側がやってくれる話なので、自分でソケット、もしくは 低レベルなHTTP APIを利用してウェブアプリを作る人以外は意識する必要はないのかもしれませんが・・・
みんな!WebGLてのは低レベルAPIなんだ! WebGLでゲームを作るというのは、ソケットでHTTPサーバ書くのと大体似たような感じ! みんなはソケットでHTTPサーバ書きたいかい? 当然書きたいよな!!!
— ハガ (@hagat) February 9, 2015
クライアントは、たとえそのリクエストがapplication/x-www-form-urlencoded
だろうがそうじゃなかろうが、RFC3986の厳しいエンコードのしておけば良いということですかね。とりあえず、今書いているコードは直さなくても良さそうなことが分かりました。
Twitter上で色々コメントくださった方々、どうもありがとうざいました。
追試: ウェブのフォームはRFC関係ない?
識者からコメントつきました。外に情報出すと、知識が跳ね返ってくる感じがいいですね。
http://t.co/lExZOEpwPn クエリ文字列等でスペースを+にするのはHTMLの仕様で、RFC 3986は関係ありません。クエリ以外では勝手にスペースを+にしてはならず%20にする必要があります。Goが厳格とされてるのは!'()*についてでスペースは関係ありません。
— ると (@cocoa_ruto) February 23, 2015
なるほど。RFCは関係ないと。確かに、HTML5のw3cの仕様の中にフォームの送信ということで、具体的に書かれています。このリンク先の4.5.以下が各パートの変換方法を示しています。
- 半角スペース(0x20)なら
+
(0x2B)に直す。 -
A-Z
,a-z
,0-9
,*
,-
,.
,_
はそのまま通す。 - 以外の文字は
%コード
に直す。
と。なるほどなるほど。Goはnet/httpのサンプルでurl.Values
を使っていたので、それを使ってみます。
gore> :import net/url
gore> url.Values{"key": {"!\"#$%&'()*+,-./:;<=>?@\\[]^_"}}.Encode()
"key=%21%22%23%24%25%26%27%28%29%2A%2B%2C-.%2F%3A%3B%3C%3D%3E%3F%40%5C%5B%5D%5E_"
ぬ・・・残さなければならないはずの*
が変換されてしまっている・・・Golangは内部で、url.QueryEscape()
を呼んでいるので、RFC3986相当ですね。
じゃあPythonはどうですかね?
>>> import urllib.parse
>>> urllib.parse.quote_plus("!\"#$%&'()*+,-./:;<=>?@\\[]^_")
'%21%22%23%24%25%26%27%28%29%2A%2B%2C-.%2F%3A%3B%3C%3D%3E%3F%40%5C%5B%5D%5E_'
Golangと同じ結果になりました。
コメントでriocamposさんに教えてもらったRubyのWEBrickのライブラリでテスト。
irb> require 'webrick/httputils'
irb> puts WEBrick::HTTPUtils.escape_form("!\"#$%&'()*+,-./:;<=>?@\\[]^_")
!%22%23%24%25%26'()*%2B%2C-.%2F%3A%3B%3C%3D%3E%3F%40%5C%5B%5D%5E_
あれ・・・・本来は変換しなきゃいけない!'()
、が残ってる・・・
仕様通りに実装された処理系がないという衝撃の結果に(MDNでこうしなさい、という参考実装が提供されているJavaScript以外)。%xxとなっているのを戻すというのは汎化されたロジックで実装されているだろうから、GolangとかPython方面の方法が安全ですかね。まぁ、制御文字ではないので、Ruby方式で送って問題になるということもなさそうですが。
あと、確かにコメントでいただいたように、今はRFCは関係ないですが、昔はHTMLはRFC1866ということで、この中にフォームの変換について記述されています。この時に、すでにスペースは+にすると書かれていますね。ただし、変換方法の詳細については書かれてません。その後はRFC2854で更新されますが、この中には仕様は書かれてなくて、W3C側が今後は面倒を見ていくよ、となっています。なるほど。
追記後のまとめ
- URIのQueryはRFC3986。GolangとPythonがそれを満たしている。JavaScript, Rubyはちょびっと違う結果を返す?
- POST時のエンコード(
application/x-www-form-urlencoded
)に関してはRFCではなく、HTML5の仕様に書かれている。RFC3986の変換とは微妙に異なる。で、それを満たしたエンコーダは現時点では見当たらず。JSに関してはMDNに参考実装あり。