encodeURIComponentが世界基準だと誤解してた話

URLをいじくるプログラムをいじっていて、仕様がよくわからなくて悩んだのでまとめます。

**2/23: 追試部分を追記**
**2018/7/14: JavaScriptのURLSearchParamsと、GoのPathEscapeについて追記**

ことの経緯
============

HTTPとはなんぞやとか、GETとPOSTがどうの、それぞれでパラメータがどういう経緯でウェブアプリケーション(とかCGI)に渡って来るのかぐらいは知っていました。で、ウェブでXHRでGETリクエストを送る場合にはJavaScriptの``encodeURIComponent()``で各パラメータをエンコードして、&でくっつけて、URLの末尾に?で付与すればいいんだよね？と思っていました。こんな感じに。

```js
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年)](https://www.ietf.org/rfc/rfc1738.txt)→[RFC2396 (1998年)](https://www.ietf.org/rfc/rfc2396.txt)→[RFC3986 (2005年)](http://www.ietf.org/rfc/rfc3986.txt)とバージョンがあがってきているようです。最新版だとAdobeの人が関わっているんですね。へぇーへぇー。時間があればあとで読む。

[Stack Overflowの投稿](http://stackoverflow.com/questions/13820280/encode-decode-urls)によると、GolangはRFCに厳格らしく、JavaScriptはちょっとルーズらしいです。

JavaScriptのエスケープ
--------------------------

JavaScriptだと、`encodeURI()`と、`encodeURIComponent()`があります。`encodeURI()`は&と=、/はそのままにするバージョンで、完全なURIを受け取って、そのままURIとして使える文字列を返す想定で、`encodeURIComponent()`はクエリパラメータで使える安全な文字列(URIの意味を壊さない)を返します。このエントリーでは後者だけを相手にします。

Content-Typeが`application/x-www-form-urlencoded`(POST)で送信するときは、さらに`%20`を+に変換せよ、と[MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent)の説明にはあります。また、RFC-3986に厳格に対応するためのコードも書かれてます。

```js
function fixedEncodeURIComponent (str) {
  return encodeURIComponent(str).replace(/[!'()*]/g, function(c) {
    return '%' + c.charCodeAt(0).toString(16);
  });
}
```

つまり、JavaScriptのエスケープの仕様はRFCの最新には対応していないということですね。ブラウザで検索するとスペースは常に``%20``になるかと思っていましたが、ウェブサイトで検索すると今は``+``になる模様。前からそうでしたっけ？ただ、場所によっては色々あるっぽいです。

<blockquote class="twitter-tweet" lang="en"><p><a href="https://twitter.com/shibu_jp">@shibu_jp</a> 我々が超お世話になってるgithubも+ですね。ただGoogleもChromeのアドレスバーから検索すると%になるみたいです。</p>&mdash; MURAOKA Taro (@kaoriya) <a href="https://twitter.com/kaoriya/status/569356497756364801">February 22, 2015</a></blockquote>
<script async src="//platform.twitter.com/widgets.js" charset="utf-8"></script>

ECMAScriptの標準JavaScriptの範囲内だと、``encodeURIComponent()``しかありませんが、node.jsには、このRFCに厳格なバージョンにも対応できる、[querystring](http://nodejs.org/api/querystring.html)モジュールがあります。expressは、[qs](https://github.com/hapijs/qs)というサードパーティ製のモジュールを利用しているようです。

**2018/7/14**

ウェブブラウザではURLSearchParamsというクラスが実装されました。Node.jsも8.0から実装されています。これだときちんと+になります。配列の処理とかパースとかも全部このクラスがまとめて面倒を見てくれますので、今後はみなさんこれを積極的につかいましょう。

```js
>>> const params = new URLSearchParams()
>>> params.append(" ", " ")
>>> params.toString();
"+=+"
```

* [URLSearchParams (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams)
* [URLSearchParamsによる簡単URL操作 (Qiita @yoichiro6642
2016年02月09日に更新
さん)](https://qiita.com/yoichiro6642/items/ea0508651ba20f4bfe14)

golangのエスケープ
---------------------

golangでは `net/url` モジュールが提供されています。 `url.Values.Encode()` を呼ぶと検索クエリをエスケープした文字列を作ってくれます。ソース読むと中で各コンポーネントの変換に使っているのは、 `url.QueryEscape` です。この関数は中でプライベート関数のescapeを使っていて、この関数はモードによって変換の仕方が少し変わる模様。パス部分のエンコードでは、スペースは``%20``になりますが、クエリ部分のモード(``url.QueryEscape()``と同様)では、``+``になります。mattnさんの書かれた方法を使えば``%20``にできないこともないです。

<blockquote class="twitter-tweet" lang="en"><p>(&amp;url.URL{Path:foo}).String() みたいにしてエンコード出来たりとか <a href="https://twitter.com/hashtag/golang?src=hash">#golang</a></p>&mdash; mattn (@mattn_jp) <a href="https://twitter.com/mattn_jp/status/569350680990736385">February 22, 2015</a></blockquote>
<script async src="//platform.twitter.com/widgets.js" charset="utf-8"></script>

**2018/7/14**

Go 1.8からは [url.PathEscape](https://golang.org/pkg/net/url/#PathEscape)が提供されるようになったため、プライベート関数をがんばって駆使しなくてもパス部分にマッチしたエスケープができるようになりました。mattnさん、コメントありがとうございました。

https://twitter.com/mattn_jp/status/788275361351999488

Pythonのエスケープ
---------------------

Python3の`urllib.parse`には、`quote()`と、`quote_plus()`があります。`quote`はJS相当、`quote_plus`はGolang相当っぽいです。さすが俺たちのPython。卒なくこなしやがる。

結局どうすべきか？
=====================

現状認識としては、Golangの挙動は正しいものの、JavaScriptのように古い規格のクエリを投げてくるクライアントもありえる、というのが実際のところかな、と思いました。もちろん、JavaScriptでも厳密に守ったコードを投げることもできますが、必ずしもそうはなっていない(Chromeのオムニバーでも)。

そのため、サーバ側で、スペースがどっちできてもきちんと解釈できるようにしておかないとダメってことですね。まぁ、このあたりはウェブアプリケーションサーバのフレームワーク側がやってくれる話なので、自分でソケット、もしくは 低レベルなHTTP APIを利用してウェブアプリを作る人以外は意識する必要はないのかもしれませんが・・・

<blockquote class="twitter-tweet" lang="en"><p>みんな！WebGLてのは低レベルAPIなんだ！ WebGLでゲームを作るというのは、ソケットでHTTPサーバ書くのと大体似たような感じ！ みんなはソケットでHTTPサーバ書きたいかい？ 当然書きたいよな！！！</p>&mdash; ハガ (@hagat) <a href="https://twitter.com/hagat/status/564659677468688384">February 9, 2015</a></blockquote>
<script async src="//platform.twitter.com/widgets.js" charset="utf-8"></script>

クライアントは、たとえそのリクエストが``application/x-www-form-urlencoded``だろうがそうじゃなかろうが、RFC3986の厳しいエンコードのしておけば良いということですかね。とりあえず、今書いているコードは直さなくても良さそうなことが分かりました。

Twitter上で色々コメントくださった方々、どうもありがとうざいました。

追試: ウェブのフォームはRFC関係ない？
=======================================

識者からコメントつきました。外に情報出すと、知識が跳ね返ってくる感じがいいですね。

<blockquote class="twitter-tweet" lang="en"><p><a href="http://t.co/lExZOEpwPn">http://t.co/lExZOEpwPn</a>&#10;クエリ文字列等でスペースを+にするのはHTMLの仕様で、RFC 3986は関係ありません。クエリ以外では勝手にスペースを+にしてはならず%20にする必要があります。Goが厳格とされてるのは!&#39;()*についてでスペースは関係ありません。</p>&mdash; ると (@cocoa_ruto) <a href="https://twitter.com/cocoa_ruto/status/569722274250514432">February 23, 2015</a></blockquote>
<script async src="//platform.twitter.com/widgets.js" charset="utf-8"></script>

なるほど。RFCは関係ないと。確かに、[HTML5のw3cの仕様](http://www.w3.org/TR/html5/forms.html#url-encoded-form-data)の中にフォームの送信ということで、具体的に書かれています。このリンク先の4.5.以下が各パートの変換方法を示しています。

1. 半角スペース(0x20)なら``+``(0x2B)に直す。
2. ``A-Z``, ``a-z``, ``0-9``, ``*``, ``-``, ``.``, ``_``はそのまま通す。
3. 以外の文字は ``%コード`` に直す。

と。なるほどなるほど。Goはnet/httpのサンプルで```url.Values```を使っていたので、それを使ってみます。

```go
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はどうですかね？

```py
>>> 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のライブラリでテスト。

```rb
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ということで、[この中にフォームの変換](http://tools.ietf.org/html/rfc1866#section-8.2)について記述されています。この時に、すでにスペースは+にすると書かれていますね。ただし、変換方法の詳細については書かれてません。その後は[RFC2854](http://tools.ietf.org/html/rfc2854)で更新されますが、この中には仕様は書かれてなくて、W3C側が今後は面倒を見ていくよ、となっています。なるほど。

追記後のまとめ
-----------------

* URIのQueryはRFC3986。GolangとPythonがそれを満たしている。JavaScript, Rubyはちょびっと違う結果を返す？
* POST時のエンコード(``application/x-www-form-urlencoded``)に関してはRFCではなく、HTML5の仕様に書かれている。RFC3986の変換とは微妙に異なる。で、それを満たしたエンコーダは現時点では見当たらず。JSに関してはMDNに参考実装あり。