きっと経験があるはずです — スペースや特殊文字を含むURLでバグが発生したこと。検索クエリの&がおかしな動作を引き起こしたり、URLの中の%20が何なのか疑問に思ったり。すべてスッキリさせましょう。
URLにエンコーディングが必要な理由
URLに使える文字は限られています。RFC 3986仕様では、常に安全な「非予約」文字としてA-Z、a-z、0-9、-、_、.、~が定義されています。それ以外 — スペース、&、=、?、ñや日本語などの非ASCII文字 — はすべてパーセントエンコーディングが必要です。
パーセントエンコーディングの仕組み
シンプルです:文字のUTF-8バイト値を取り、各バイトを%に続く2桁の16進数で表現します。
例:
- スペース →
%20 &→%26=→%3Dé→%C3%A9(UTF-8で2バイト)日→%E6%97%A5(UTF-8で3バイト)
つまりHello WorldはURLパスではHello%20Worldになります。
最も多いバグ:二重エンコーディング
これは私が見てきた中で最もよくあるURLエンコーディングのミスで、しかも気づきにくいです。文字列をエンコードし、それをさらにエンコードする関数に渡してしまう。すると%20(すでにエンコードされたスペース)が%2520になります — %自体がエンコードされるためです。
問題の例:
解決策:値を一度だけ、適切なレベルでエンコードすること。すでにエンコードされたものを再エンコードしないでください。
プラス記号 vs. %20 — ええ、ややこしいです
URLのクエリ文字列では、スペースは+または%20で表現できます。+の慣例はHTMLフォームエンコーディング(application/x-www-form-urlencoded)に由来します。URLパスでは%20のみが有効です。
つまりhttps://example.com/hello world → パスはhttps://example.com/hello%20worldにエンコードされます
しかしhttps://example.com/search?q=hello world → クエリは?q=hello+worldまたは?q=hello%20worldになります
encodeURI vs. encodeURIComponent
JavaScriptには2つの関数があり、間違った方を使うのは典型的なミスです:
encodeURI()— URL全体をエンコードします。:、/、?、#、&、=はURLの構造的な部分なのでそのまま残します。encodeURIComponent()— URLの構成要素(クエリパラメータの値など)をエンコードします。:、/、?、#、&、=もデータの一部である可能性があるためエンコードします。
基本ルール:encodeURIComponent()は値に使い、URL全体には絶対使わないこと。MDNのドキュメントがその違いを見事に説明しています。
他の言語
- Python: パスには
urllib.parse.quote()、クエリ文字列にはurllib.parse.urlencode()— ドキュメントはこちら - Java:
URLEncoder.encode()(スペースに+を使用 — 要注意!) - PHP: クエリ文字列には
urlencode()、パスにはrawurlencode()
クイックヒント
- 常にパラメータの値だけをエンコードし、URL全体はエンコードしない
- 受信側では一度だけデコード — エンコード済みの値を複数のレイヤーに通さない
- エッジケースでテスト:スペース、
&、=、#、非ASCII文字、絵文字
JavaScriptで安全にURLを構築する
クエリパラメータ付きのURLを構築する正しい方法は、組み込みのURLとURLSearchParams APIを使うことです。エンコーディングをすべて処理してくれます:
URLSearchParamsがスペースに+を使い(HTMLフォームエンコーディングの慣例)、値の中の&を適切にエンコードしてパラメータを分離する&と混同しないようにしていることに注目してください。文字列連結よりはるかに安全です。
よくあるURLエンコーディングの落とし穴
1. 値だけでなくURL全体をエンコードしてしまう。 encodeURIComponent()をURL全体に実行すると、://、/、?、&がエンコードされ、URLが完全に使用不能になります。個々のパラメータ値だけをエンコードしてください。
2. ハッシュフラグメントのエンコードを忘れる。 URLの#文字はフラグメント識別子を開始します。データに#が含まれていてエンコードしないと、それ以降のすべてがサーバーの視点からは消えてしまいます。サーバーはフラグメント識別子を見ることはありません — クライアント側のみです。
3. 国際化ドメイン名(IDN)を処理しない。 example.日本のような非ASCII文字を含むドメイン名は、Punycodeエンコーディングと呼ばれる特別な処理が必要です。これはパーセントエンコーディングとは別で、URLのホスト名部分にのみ適用されます。
4. スペースのエンコードが一貫していない。 システムの一部がスペースを+でエンコードし、別の部分が%20でエンコードするかもしれません。デコードには通常問題ありませんが、URLの比較やキャッシングで問題を引き起こす可能性があります。一つの慣例を選んで一貫させましょう。
パーセントエンコーディング クイックリファレンス
| 文字 | エンコード | エンコードが必要な理由 |
| スペース | %20または+ | URLでは許可されていない |
& | %26 | クエリパラメータを分離する |
= | %3D | キーと値を分離する |
? | %3F | クエリ文字列を開始する |
# | %23 | フラグメント識別子を開始する |
% | %25 | エスケープ文字そのもの |
/ | %2F | パス区切り文字 |
@ | %40 | ユーザー情報セクションで使用 |
さまざまなコンテキストでのURLエンコーディング
URLエンコーディングはブラウザURLだけのものではありません。以下のような一般的な場面で遭遇します:
- APIリクエスト: REST APIコールのクエリパラメータは、特にユーザー入力を含む場合、適切なエンコーディングが必要です
- リダイレクトURL: リターンURLをパラメータとして渡す場合(
?redirect=https://...のように)、リダイレクトURL全体を値としてエンコードする必要があります - OAuthフロー: OAuthのコールバックURLとstateパラメータは、複数のURLエンコーディング層を含むため、特に厄介です
- ディープリンク: モバイルディープリンクは同じURLエンコーディングルールに従いますが、プラットフォームによっては追加の要件があります
URLエンコーディングの問題をデバッグする
URLエンコーディングで何かがうまくいかないときの、私のデバッグチェックリストです:
1. 二重エンコーディングをチェック — URLに%25がないか探します。これは%が二重にエンコードされたことを意味します
2. 生のリクエストをチェック — ブラウザのDevToolsのNetworkタブを使って、ブラウザがデコード版を表示する前に、実際にどのURLが送信されたかを確認します
3. エンコード済みとデコード済みを比較 — 問題のURLをデコーダーに貼り付けて、実際に何が含まれているか確認します
4. サーバー側のデコードをチェック — 一部のフレームワークはURLパラメータを自動デコードし、その上で手動デコードすると問題が発生します
URLの構造
さて、少し立ち戻りましょう。エンコーディングをさらに深く掘り下げる前に、URLが何で構成されているかを本当に理解する必要があります。わかっています、わかっています — キャリアを通じてずっとURLを使ってきましたよね。でも公式名称ですべての部分を知っているかというと... ほとんどの開発者は知りません。そして、そこからエンコーディングの混乱が始まるのです。
RFC 3986によると、URLは以下の構造を持っています:
それぞれの部分を説明しましょう:
- Scheme(
https、ftp、mailto)— プロトコルです。ここではエンコーディングは不要で、常にASCII文字です。 - Authority — オプションの
user:password@(2026年では基本的に使うべきではありません)、ホスト(ドメイン名またはIP)、オプションのポート番号を含みます。 - Path(
/search/results)— 階層的な部分です。スラッシュ/がセグメントを区切ります。各セグメント内では特殊文字をエンコードする必要がありますが、スラッシュ自体はエンコードしません。 - Query(
?q=hello&lang=en)—?の後のキーと値のペアです。&がペアを区切り、=がキーと値を区切ります。キーと値をエンコードしますが、構造的な&と=はエンコードしません。 - Fragment(
#section-2)—#の後の部分です。これは面白い — サーバーには送信されません。純粋にクライアント側です。しかし、その中の特殊文字はやはりエンコードする必要があります。
ここが人々を混乱させるポイントです:URLの異なる部分には異なるエンコーディングルールがあります。 /はパスでは全く問題ありません(区切り文字です!)が、クエリパラメータの値の中に現れる場合は%2Fとしてエンコードする必要があります。@記号はauthorityセクションでは問題ありませんが、パスではエンコードすべきです。だから万能なエンコーディング関数は多くのバグを引き起こすのです。
こう考えてみてください:URLは文法ルールのある文です。特殊文字は句読点です。実際に句読点として使われているカンマはエンコードしないでしょう — データの一部であり、句読点と混同される可能性のあるカンマだけをエンコードするのです。
encodeURI vs. encodeURIComponent:JavaScriptの地雷原
さて、これは私を本当にイライラさせます。開発者がこれを間違えるのをしょっちゅう見るからです。JavaScriptには2つのエンコーディング関数があり、ほとんど同じ名前に聞こえますが、間違った方を使うと一日が台無しになります。
はっきり説明しましょう:
encodeURI()はURL全体をエンコードするために設計されています。安全でない文字をエンコードしますが、構造的な文字 — :、/、?、#、&、=、@のようなもの — はそのまま残します。URL全体をエンコードするなら、URL構造を壊したくないのは当然ですからね。
encodeURIComponent()はURLの中に入る単一の値をエンコードするために設計されています。文字、数字、- _ . ~を除くすべてをエンコードします。これには:、/、?、#、&、=が含まれます — なぜなら、これらが値の中に現れる場合、それはデータであって構造ではないからです。
完全にクリアにする比較表がこちらです:
| 文字 | encodeURI() | encodeURIComponent() |
: | : (変更なし) | %3A |
/ | / (変更なし) | %2F |
? | ? (変更なし) | %3F |
# | # (変更なし) | %23 |
& | & (変更なし) | %26 |
= | = (変更なし) | %3D |
@ | @ (変更なし) | %40 |
| スペース | %20 | %20 |
é | %C3%A9 | %C3%A9 |
ここからが怖いところです。間違った方を使うとどうなるか見てみましょう:
そして逆のミスも同様にひどいです:
ゴールデンルール:値にはencodeURIComponent、URL全体にはencodeURI。 あるいはもっと良いのは、URL APIを使ってブラウザに処理を任せることです。本当に、URLとURLSearchParamsクラスには存在理由があります。詳細はMDN encodeURIComponentドキュメントとMDN encodeURIドキュメントをご覧ください。
さまざまな言語でのURLエンコーディング
JavaScriptだけがURLエンコーディング関数でわかりにくいわけではありません。どの言語にもそれぞれの癖があり、冗談抜きで、JavaScriptのペアよりさらにわかりにくいものもあります。
Python — 正しいモジュールを見つければ、実際にはかなり合理的です:
Java — ここが変なところです。URLEncoderはHTMLフォームエンコーディング用に設計されたもので、一般的なURLエンコーディング用ではありません。そのためスペースが%20ではなく+になります:
C# — .NETは実際に正しいツールを提供してくれますが、5つくらいの異なるメソッドがあって、すべてが微妙に異なることをします:
PHP — ああPHP。もちろん、ほぼ同じ名前の2つの関数があって、微妙に異なることをしますね:
Go — Goらしくクリーンで合理的です:
ポイントは?どの言語も+ vs. %20の扱いが異なり、どの言語にも少なくとも1つは驚かされる関数があります。作業している言語のドキュメントを必ず確認してください。JavaScriptと同じように動くとは思わないでください。
URLのUnicode:ワイルドウェスト
さて、ここからが本当に面白くなります。非ASCII文字をURLに入れるとどうなるでしょう?日本語、アラビア語、あるいは — 冗談じゃなく — 絵文字を含むURLが欲しい場合は?
答えは2つの全く異なるシステムに関わり、それらを混同するのは典型的なミスです。
パスとクエリ部分: パーセントエンコーディングを使います。文字のUTF-8バイトを取得し、それぞれをエンコードします。なのでcaféはcaf%C3%A9になります。ブラウザはこれを自動的に行い、通常はアドレスバーにきれいなバージョンを表示しますが、内部的にはパーセントエンコードされたバージョンを送信しています。
ドメイン名: パーセントエンコーディングは使いません。代わりにPunycodeというすごいシステムがあります。Unicodeのドメイン名をxn--で始まるASCII互換の文字列に変換します。
これを見てください:
café.com→xn--caf-dma.commünchen.de→xn--mnchen-3ya.de例え.jp→xn--r8jz45g.jp
なぜ2つの異なるシステムなのか?DNS(ドメイン名をIPアドレスに変換するシステム)が1980年代に構築され、ASCIIのみをサポートしているからです。そこでUnicodeをASCII文字列に詰め込む方法を発明する必要がありました — それがPunycodeです。一方、URLのパスとクエリ部分は、パーセントエンコードされたバイトを扱えるWebサーバーによって処理されます。
実は、URLのUnicode用にIRI(Internationalized Resource Identifiers)というRFC 3987で定義された仕様があります。IRIは基本的にUnicode文字を直接許可するURLです。ブラウザは裏側でIRIをURIに変換しています。
そうです、絵文字ドメインは存在します。💩.laは実在するドメインです(またはそうでした)。Punycodeでxn--ls8h.laにエンコードされます。真面目な用途に絵文字ドメインを使うことはお勧めしませんが、システムが機能する楽しい証拠です。
URLのUnicodeで注意すべき点:「同じ」文字の異なるUnicode表現です。例えば、éは単一のコードポイント(U+00E9)として表すことも、e + 結合アクセント記号(U+0065 + U+0301)として表すこともできます。これらは異なるパーセントエンコード文字列を生成します!WHATWG URL StandardはNFC正規化を推奨していますが、すべてのシステムが一貫してこれに従っているわけではありません。
二重エンコーディング:夢に出てくるバグ
先ほど二重エンコーディングについて触れましたが、このバグは他のどのURL関連の問題よりも多くの開発者の時間を無駄にしてきたので、独自の深掘りに値します。私自身も何度も経験しました。
基本的なシナリオです:
何が起きたのか?hello%20worldを2回目にエンコードすると、%文字が%25にエンコードされます。つまり%20が%2520になります。サーバーはスペースではなく、リテラル文字列%20を見ることになります。
こう書くと明白に聞こえますが、実際のコードベースでは信じられないほど巧妙です。二重エンコーディングに噛まれるシナリオは以下の通りです:
プロキシチェーン。 サーバーAにリクエストを送り、サーバーAがサーバーBに転送します。両方のサーバーがURLをエンコードすると、ドカン — 二重エンコード。Kong、AWS API Gateway、nginxリバースプロキシなどのAPIゲートウェイがよくある犯人です。
リダイレクトチェーン。 ユーザーがページAに行き、?returnUrl=...パラメータ付きでページBにリダイレクトされ、ページBがリターンURLをパラメータとして再度リダイレクトします。各リダイレクトでURLが再エンコードされる可能性があります。3回のリダイレクト後、URLは三重にエンコードされ完全にめちゃくちゃになります。
フレームワークの「ヘルパー」。 一部のWebフレームワークはURLパラメータを自動的にエンコードします。フレームワークに渡す前に手動でエンコードすると、二重エンコーディングになります。Spring Boot、Express.jsミドルウェア、DjangoのURL reversing でこれを見てきました。
二重エンコーディングをどう検出するか?URLに%25がないか探します。それはパーセント記号がエンコードされたもので、通常は何かが二重にエンコードされたことを意味します。%2520が見えたら、それは二重エンコードされたスペースです。%253Dが見えたら、二重エンコードされた=記号です。
修正方法:
最善の防御策は、コードに明確な境界を確立することです:エッジでエンコード(HTTPリクエストを送信する直前、または表示用のURLを構築する直前)し、内部ではどこでも生の、エンコードされていない文字列を渡すようにしましょう。
URLの長さ制限とその対策
驚くかもしれませんが、HTTP仕様自体はURLの最大長を定義していません。RFC 3986は、URLは「無制限の長さ」であるべきと言っています。しかし、現実世界は同意しません。
チェーンの各コンポーネントには独自の制限があり、最も短いものが勝ちます:
| コンポーネント | 最大URL長 |
| Internet Explorer(安らかに) | 2,083文字 |
| Chrome、Firefox、Safari | 約65,000文字以上 |
| Apache(デフォルト) | 8,190文字 |
| Nginx(デフォルト) | 8,192文字 |
| IIS(デフォルト) | 16,384文字 |
| AWS ALB | 8,192文字 |
| Cloudflare | 32,768文字 |
IEの古い2,083文字制限がすべてを支配していました。IEは今やほぼ死んでいますが、一部の開発者やツールはまだそれを金科玉条として扱っています。しかし実際には、ほとんどの最新スタックはもっと長いURLを処理できます。
とはいえ、65,000文字のURLを作れるからといって、作るべきだということにはなりません。URLを短く保つ実際の理由は以下の通りです:
- サーバーログ。 多くのロギングシステムは長いURLを切り詰め、デバッグが悪夢になります。
- コピー&ペースト。 ユーザーはURLをコピーして共有します。超長いURLはメール、チャットメッセージ、ドキュメントで壊れます。
- SEO。 検索エンジンは一般的にURLを2,000文字以下に保つことを推奨しています。
- キャッシング。 一部のCDNやプロキシのキャッシュキー制限はURLベースです。URLが長いほどキャッシュミスが増えます。
URLが長くなりすぎたらどうするか?いい質問です:
1. GETの代わりにPOSTを使う。 大量のデータを送信する場合、リクエストボディに入れましょう。リクエストボディには実質的なサイズ制限がありません。これは多数のフィルターを持つ複雑な検索フォームで最も一般的な解決策です。
2. URL短縮サービスまたは参照IDを使う。 サーバー側に保存されたパラメータの完全なセットにマッピングする短いトークンを生成します。/search?filter1=abc&filter2=def&filter3=...の代わりに/search/saved/abc123になります。
3. パラメータを圧縮する。 一部のアプリは圧縮されたJSONをbase64エンコードしてURLに入れます。きれいではありませんが、機能します。GrafanaのダッシュボードURLなどのツールでこれを見かけるでしょう。
フォームエンコーディング:application/x-www-form-urlencoded
URLエンコーディングをさらにわかりにくくしている元凶について話しましょう:HTMLフォームエンコーディングです。method="POST"のHTMLフォームを送信すると、ブラウザはapplication/x-www-form-urlencodedというフォーマットでフォームデータをエンコードします。このフォーマットは標準のパーセントエンコーディングとほぼ同じですが、みんなを狂わせる1つの重要な違いがあります。
スペースが%20ではなく+になります。
それだけです。それが主な違いです。でもまあ、混乱を引き起こすこと。
なぜこの違いが存在するのか?もちろん歴史的な理由です。HTMLフォームエンコーディング仕様はURLエンコーディング仕様より前に存在し、スペースに+を使っていました...まあ、90年代に誰かがそっちの方が見栄えがいいと思ったからです。そして今、私たちは永遠にそれと付き合わなければなりません。
HTMLフォームを送信すると、ブラウザはContent-Type: application/x-www-form-urlencodedヘッダーを送信し、サーバーは+をスペースとして解釈することを知っています。しかし、JavaScriptで手動でURLを構築し、パス部分でスペースに+を使うと、サーバーはリテラルのプラス記号を見ることになります。楽しいですね。
実際に重要な場面はこちらです:
そしてmultipart/form-dataがあり、これは全く別の獣です。フォームにファイルアップロード()が含まれると、ブラウザはmultipart/form-dataエンコーディングに切り替わり、&記号の代わりにバウンダリを使ってフィールドを区切ります。URLエンコーディングは全く使いません — 各パートには独自のヘッダーとボディがあります。マルチパートリクエストを手動でパースしようとしたことがある人なら、その辛さを知っているでしょう。
実用的なアドバイス:クエリ文字列とフォームデータにはURLSearchParamsを使いましょう。ファイルアップロードにはFormDataを使いましょう。本当に、本当に必要でない限り、手動でこれらをエンコードしようとしないでください。
自分で試してみましょう
おかしく見えるURLで作業中ですか?URLデコーダーに貼り付けて実際の文字を確認できます。URLに入れる前に値をエンコードする必要がありますか?URLエンコーダーが即座に処理します。複雑なURLを構成要素に分解するには、URLパーサーを使って各部分を明確に確認できます。