URLエンコードとは:パーセントエンコーディングの仕組み
URLエンコードは、Webアドレスにそのまま書けないバイトを、%記号とそのバイト値の16進数表記に置き換える仕組みです。
URLに使えるのはごく限られたASCII文字だけで、しかもそのいくつかには決まった役割があります。/はパスのセグメントを区切り、?はクエリの始まりを示し、&はパラメーターを区切り、#はフラグメントを示します。データにスペース、アクセント付きの文字、日本語のような非ASCII文字、あるいはこれらの記号が含まれていれば、エンコードが必要です。URLエンコードはパーセントエンコーディングとも呼ばれ、RFC 3986で定義されています。これがサーバーに伝えているのは、「この文字は区切り記号ではなく、値の一部です」ということです。
基本原理:1バイトが3文字になる
エンコードされた文字は、%のあとに1バイトの値を表す2桁の16進数を続けて書きます(RFC 3986、セクション2.1)。スペースの文字コードは32で、16進数では20なので、%20になります。テキストのエンコードは次の3ステップです。
- テキストをUTF-8のバイト列に変換します。
- 非予約文字(ASCIIの英字、数字、
- . _ ~)を表すバイトはそのまま残します。 - それ以外のすべてのバイトを、
%とその16進数の値に置き換えます。16進数は大文字が推奨されます。
ASCII以外の文字はUTF-8で複数バイトを占めるため、1文字が複数のパーセントグループに変わることがあります。日本語の文字も同じです。
| 文字 | UTF-8のバイト | エンコード後 |
|---|---|---|
| スペース | 20 | %20(フォームでは+) |
& | 26 | %26 |
= | 3D | %3D |
+ | 2B | %2B |
é | C3 A9 | %C3%A9 |
€ | E2 82 AC | %E2%82%AC |
| 😀 | F0 9F 98 80 | %F0%9F%98%80 |
したがって「café & thé」はcaf%C3%A9%20%26%20th%C3%A9になります。UTF-8は恣意的な選択ではありません。RFC 3986が推奨しており(セクション2.5)、ブラウザーや現代の言語が使っているのもUTF-8です。古いシステムの中にはテキストをLatin-1でエンコードしていたものもあり、その場合éは1バイトで%E9になります。UTF-8のデコーダーはこのシーケンスを拒否します。JavaScriptではdecodeURIComponent('%E9')がURIError: URI malformed(URIの形式が不正)を投げます。
予約文字と非予約文字
RFC 3986は、文字を2つのグループに分けています。
- 非予約文字(セクション2.3):
A-Z a-z 0-9 - . _ ~。エンコードは一切不要で、規格はエンコードしないよう求めています。%41とAは同じ意味です。 - 予約文字(セクション2.2):一般区切り文字
: / ? # [ ] @と、サブ区切り文字! $ & ' ( ) * + , ; =。これらがURLの構造を形作ります。
ルールは一文で言えます。予約文字は、区切り記号として働くときはそのまま書き、データの一部であるときはエンコードします。次の2つを比べてください。
https://example.com/search?q=bread&milk -> q = "bread", plus an empty parameter "milk"
https://example.com/search?q=bread%26milk -> q = "bread&milk"
1行目では、qの値は「bread」になり、さらに空のパラメーター「milk」が加わります。2行目では、qの値が「bread&milk」になります。それ以外の文字、つまりスペース、引用符、%そのもの、アクセント付きの文字、絵文字などは、どこに現れてもエンコードしなければなりません。そのため%記号は%25と書きます。スペースを含む「100 %」は100%20%25になります。
スペースは%20か+か
RFC 3986では、スペースは%20にエンコードされます。ところがHTMLフォームはapplication/x-www-form-urlencodedという別の形式を使い、そこではスペースが+になり、本物のプラス記号は%2Bと書く必要があります。ブラウザーはGETで送信するフォームにこの形式を使い、ほとんどのサーバーもクエリ文字列をこの方式でデコードします。
ここでよく混乱が起きます。パスの中の+はただのプラス記号ですが、クエリ文字列の中では通常スペースとして読まれます。「C++」をエンコードせずに?q=C++として検索すると、サーバーには「C」とスペース2つとして届きます。正しくエンコードすれば?q=C%2B%2Bです。
| 言語 | スペースを%20にする(RFC 3986) | スペースを+にする(フォーム) |
|---|---|---|
| JavaScript | encodeURIComponent() | URLSearchParams |
| PHP | rawurlencode() | urlencode()、http_build_query() |
| Python | urllib.parse.quote() | quote_plus()、urlencode() |
デコードする側がエンコードする側と同じ規則に従っていれば、クエリ文字列ではどちらの形式も正しく扱われます。
JavaScriptでのエンコード:どの関数を使うか
JavaScriptには組み込み関数が2つあり、そのまま残す文字が異なります。クエリパラメーターでもパスのセグメントでも、1つの値をエンコードするならencodeURIComponentを使います。この関数はA-Z a-z 0-9 - _ . ! ~ * ' ( )以外のすべてをエンコードします。encodeURIは、構造がすでに正しい完全なURLを受け取る前提なので、&、/、?などの区切り記号は、たとえデータの一部であってもそのまま残します。各関数がエンコードしない文字の正確な一覧と、encodeURIが今でも適しているケースは、encodeURIとencodeURIComponentの違いで解説しています。
部品からURLを組み立てるときに最も安全なのは、URL APIに任せて、各部分をそれぞれ適切な位置でエンコードさせる方法です。
const url = new URL('https://example.com/search');
url.searchParams.set('q', 'café & thé');
url.searchParams.set('sort', 'price');
url.href; // 'https://example.com/search?q=caf%C3%A9+%26+th%C3%A9&sort=price'
searchParamsはフォームの規則に従うため、スペースが+になっている点に注意してください。
JavaScript・PHP・Pythonでの例
同じ値を3つの言語でエンコード・デコードし、スペースの両方の規則を示したのが次の例です。
// JavaScript
encodeURIComponent('café & thé'); // 'caf%C3%A9%20%26%20th%C3%A9'
new URLSearchParams({ q: 'café & thé' }).toString(); // 'q=caf%C3%A9+%26+th%C3%A9'
decodeURIComponent('caf%C3%A9%20%26%20th%C3%A9'); // 'café & thé'
// PHP (file saved as UTF-8)
rawurlencode('café & thé'); // caf%C3%A9%20%26%20th%C3%A9
urlencode('café & thé'); // caf%C3%A9+%26+th%C3%A9
http_build_query(['q' => 'café & thé']); // q=caf%C3%A9+%26+th%C3%A9
urldecode('caf%C3%A9+%26+th%C3%A9'); // café & thé
# Python
from urllib.parse import quote, quote_plus, urlencode, unquote_plus
quote('café & thé') # 'caf%C3%A9%20%26%20th%C3%A9'
quote_plus('café & thé') # 'caf%C3%A9+%26+th%C3%A9'
urlencode({'q': 'café & thé'}) # 'q=caf%C3%A9+%26+th%C3%A9'
unquote_plus('caf%C3%A9+%26+th%C3%A9') # 'café & thé'
PHPの例は、ファイルをUTF-8で保存している前提です。知っておきたい細かい点が2つあります。Pythonのquote()は、既定ではスラッシュをそのまま残します。quote('a/b')はa/bを返します。単独の値をエンコードするにはsafe=''を渡すと、a%2Fbになります。PHPのurlencode()はチルダも%7Eにエンコードしますが、rawurlencode()はRFC 3986の求めるとおりチルダをそのまま残します。
二重エンコード
すでにエンコード済みの文字列をもう一度エンコードすると、すべての%が%25に変わります。%20は%2520に、caf%C3%A9はcaf%25C3%25A9になります。フォームの規則では、+が問題の手がかりになります。PHPのurlencode(urlencode('a b'))はa%2Bbを返し、サーバーはこれを「a b」ではなく「a+b」とデコードします。
原因はほぼ決まっていて、2つの層のコードが同じ値をエンコードしていることです。たとえば、自分でエンコード済みのパラメーターを、HTTPライブラリーがさらにエンコードするケースです。デコード側にも同じ罠があります。PHPの$_GETにはすでにデコードされた値が入っており、それにurldecode()を呼ぶと2回目のデコードになり、正当な+記号までスペースに変わってしまいます。ルールは、生の値をURLに入れる時点で1回だけエンコードし、読み出すときに1回だけデコードすることです。
二重エンコードに見えるものが正しいケースは1つだけです。redirect=のように、URLを別のURLのパラメーターとして渡す場合です。内側のURLがすでに%20を含んでいれば、そのエンコード後の形には%2520が含まれますが、それで正しいのです。受け取った側が1回デコードすれば、元のURLに戻ります。
オンラインでエンコード・デコードする
コードを書かずに値を確認したいときは、URLエンコード・デコードツールがencodeURIComponentとencodeURIの両方のモードを備えているので、自分のテキストで結果を比べられます。URLデコードは入力と同時にデコードし、フォームと同じく+をスペースとして読むため、本物のプラス記号は%2Bと入力する必要があります。処理はすべてブラウザー内で行われます。