URLエンコードとは:パーセントエンコーディングの仕組み

URLエンコードは、Webアドレスにそのまま書けないバイトを、%記号とそのバイト値の16進数表記に置き換える仕組みです。

URLに使えるのはごく限られたASCII文字だけで、しかもそのいくつかには決まった役割があります。/はパスのセグメントを区切り、?はクエリの始まりを示し、&はパラメーターを区切り、#はフラグメントを示します。データにスペース、アクセント付きの文字、日本語のような非ASCII文字、あるいはこれらの記号が含まれていれば、エンコードが必要です。URLエンコードはパーセントエンコーディングとも呼ばれ、RFC 3986で定義されています。これがサーバーに伝えているのは、「この文字は区切り記号ではなく、値の一部です」ということです。

基本原理:1バイトが3文字になる

エンコードされた文字は、%のあとに1バイトの値を表す2桁の16進数を続けて書きます(RFC 3986、セクション2.1)。スペースの文字コードは32で、16進数では20なので、%20になります。テキストのエンコードは次の3ステップです。

  1. テキストをUTF-8のバイト列に変換します。
  2. 非予約文字(ASCIIの英字、数字、- . _ ~)を表すバイトはそのまま残します。
  3. それ以外のすべてのバイトを、%とその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)スペースを+にする(フォーム)
JavaScriptencodeURIComponent()URLSearchParams
PHPrawurlencode()urlencode()、http_build_query()
Pythonurllib.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と入力する必要があります。処理はすべてブラウザー内で行われます。

よくある質問

URLでスペースが%20になるのはなぜですか?

スペースの文字コードは10進数で32、16進数で20です。URLエンコードは1バイトを%とその2桁の16進数で書くため、%20になります。

アクセント付きの文字はURLでエンコードが必要ですか?

はい。éのような文字はUTF-8のバイト列に変換され、各バイトがエンコードされるので、éは%C3%A9になります。ブラウザーはアドレスバーにデコードした形を表示することが多いですが、リクエストにはエンコードされた形が含まれています。

%20と+の違いは何ですか?

%20は、RFC 3986におけるスペースの標準的なエンコードです。+がスペースを意味するのはフォームデータ、実際にはクエリ文字列の中だけで、パスの中ではプラス記号です。

エンコードが一切不要な文字はどれですか?

RFC 3986の非予約文字、つまりアクセントのないASCIIの英字、数字、ハイフン、ピリオド、アンダースコア、チルダです。それ以外の文字は、データの一部であればすべてエンコードしなければなりません。