CODE Polyglot / よく使う書き方 / 07

URL エンコードする

検索語や日本語の名前を、URL のクエリの値(`?q=...`)に入れられる形に変えます。よく見かける書き方は、空白を「+」にしたり、`!` や `~` を変えずに残したりして、受け取る側で別の文字列になります。

画面にこう出れば正解

どの言語で書いても、同じ入力なら画面に出る文字は1文字も違わない、というのが正解の決め方です(例は Python で動かしたときのコマンドです)。

基本(空白と記号)

$ python3 url_encode.py "Hello, World!"
Hello%2C%20World%21

そのまま残す4つの記号

$ python3 url_encode.py "a-b_c.d~e"
a-b_c.d~e

言語によって残りやすい記号

$ python3 url_encode.py "!*'()"
%21%2A%27%28%29

URL をまるごと渡す(: も / も変える)

$ python3 url_encode.py "https://example.com/?q=1&r=2"
https%3A%2F%2Fexample.com%2F%3Fq%3D1%26r%3D2

日本語

$ python3 url_encode.py "天気 東京"
%E5%A4%A9%E6%B0%97%20%E6%9D%B1%E4%BA%AC

絵文字(4バイトの文字)

$ python3 url_encode.py 🍣
%F0%9F%8D%A3

全角の英数字

$ python3 url_encode.py AB1
%EF%BC%A1%EF%BC%A2%EF%BC%91

「+」と「=」

$ python3 url_encode.py 1+1=2
1%2B1%3D2

すでにパーセントで書いてある

$ python3 url_encode.py a%20b
a%2520b

改行(\r\n)が入っている

$ python3 url_encode.py "1行目
2行目"
1%E8%A1%8C%E7%9B%AE%0D%0A2%E8%A1%8C%E7%9B%AE

タブ

$ python3 url_encode.py "a	b"
a%09b

濁点を分けて書いた「が」

$ python3 url_encode.py が
%E3%81%8B%E3%82%99

空白だけ

$ python3 url_encode.py "   "
%20%20%20

空の文字列

$ python3 url_encode.py ""

長い値(日本語30文字)

$ python3 url_encode.py ああああああああああああああああああああああああああああああ
%E3%81%82%E3%81%82%E3%81%82%E3%81%82%E3%81%82%E3%81%82%E3%81%82%E3%81%82%E3%81%82%E3%81%82%E3%81%82%E3%81%82%E3%81%82%E3%81%82%E3%81%82%E3%81%82%E3%81%82%E3%81%82%E3%81%82%E3%81%82%E3%81%82%E3%81%82%E3%81%82%E3%81%82%E3%81%82%E3%81%82%E3%81%82%E3%81%82%E3%81%82%E3%81%82

10言語のプログラム

どれもそのまま保存して動かせます。毎朝、上の正解の例をすべて実際に動かして確かめています。

url_encode.php8.4.26 で 15 / 15 件の出力が正解と一致

<?php
// 文字列を URL のクエリの値に入れられる形(パーセントエンコード)に変える
// rawurlencode は RFC 3986 のとおり(残すのは英数字と - _ . ~ だけ・空白は %20・16進数は大文字)
// urlencode は空白を「+」にして「~」も %7E にする(名前が似ているので取り違えやすい)
echo rawurlencode($argv[1]) . "\n";

動かし方(ファイルを保存したフォルダで)

$ php url_encode.php "Hello, World!"

よく見かける書き方で試すと

仕事でよく見かける書き方(標準の関数をそのまま使う形や、よく紹介される正規表現など)を、同じ正解表で動かした結果です。言語を出した答えごとにまとめ、赤い字はその言語が実際に出した間違った行です。

入力よく見かける書き方の答え(10言語)
基本(空白と記号)"Hello, World!"
  • PHP・Go・Java・Ruby・KotlinHello%2C+World%21
  • JavaScriptHello%2C%20World!
  • C#Hello%2C+World!
  • SwiftHello,%20World!
  • Python・Rust✓ 正解
そのまま残す4つの記号"a-b_c.d~e"
  • PHP・Java・C#・Kotlina-b_c.d%7Ee
  • Rusta%2Db%5Fc%2Ed%7Ee
  • Python・Go・JavaScript・Ruby・Swift✓ 正解
言語によって残りやすい記号"!*'()"
  • JavaScript・Swift!*'()
  • Java・Kotlin%21*%27%28%29
  • C#!*%27()
  • PHP・Python・Go・Rust・Ruby✓ 正解
URL をまるごと渡す(: も / も変える)"https://example.com/?q=1&r=2"
  • Pythonhttps%3A//example.com/%3Fq%3D1%26r%3D2
  • Rusthttps%3A%2F%2Fexample%2Ecom%2F%3Fq%3D1%26r%3D2
  • Swifthttps://example.com/?q=1&r=2
  • PHP・Go・JavaScript・Java・C#・Ruby・Kotlin✓ 正解
日本語"天気 東京"
  • PHP・Go・Java・C#・Ruby・Kotlin%E5%A4%A9%E6%B0%97+%E6%9D%B1%E4%BA%AC
  • Rust天気%20東京
  • Python・JavaScript・Swift✓ 正解
絵文字(4バイトの文字)🍣
  • Rust%1F363
  • PHP・Python・Go・JavaScript・Java・C#・Ruby・Kotlin・Swift✓ 正解
全角の英数字AB1
  • RustAB1
  • PHP・Python・Go・JavaScript・Java・C#・Ruby・Kotlin・Swift✓ 正解
「+」と「=」1+1=2
  • Swift1+1=2
  • PHP・Python・Go・JavaScript・Java・C#・Rust・Ruby・Kotlin✓ 正解
すでにパーセントで書いてあるa%20b

✓10言語すべて正解

改行(\r\n)が入っている"1行目 2行目"
  • Rust1行目%0D%0A2行目
  • PHP・Python・Go・JavaScript・Java・C#・Ruby・Kotlin・Swift✓ 正解
タブ"a b"

✓10言語すべて正解

濁点を分けて書いた「が」が
  • Rustか%3099
  • PHP・Python・Go・JavaScript・Java・C#・Ruby・Kotlin・Swift✓ 正解
空白だけ" "
  • PHP・Go・Java・C#・Ruby・Kotlin+++
  • Python・JavaScript・Rust・Swift✓ 正解
空の文字列""

✓10言語すべて正解

長い値(日本語30文字)ああああああああああああああああああああああああああああああ
  • Rustああああああああああああああああああああああああああああああ
  • PHP・Python・Go・JavaScript・Java・C#・Ruby・Kotlin・Swift✓ 正解

この書き方の決まり

  • 引数は文字列1つ。パーセントエンコードして1行で出す
  • そのまま残すのは A〜Z a〜z 0〜9 と 「-」「_」「.」「~」の4つだけ(RFC 3986 の「予約されていない文字」)
  • それ以外は、その文字を UTF-8 のバイトに分けて、1バイトずつ %XX にする(XX は大文字の16進数2桁)
  • 空白は %20 にする(+ にしない)。改行・タブも同じようにバイトにする
  • 文字の単位ではなくバイトの単位で変える(絵文字は4バイトなので %XX が4つ並ぶ)
  • 空の文字列は空のまま

解説

標準の関数がそのまま使えるのは10言語中3言語

「URL エンコード」という名前の関数は、たいていどの言語にもあります。ただしフォームの形(application/x-www-form-urlencoded)と URL の形(RFC 3986)の2通りがあり、名前からはどちらか分かりません。この回の決まり(RFC 3986)に、標準の関数がそのまま合ったのは10言語中3言語でした。

言語書き方
そのまま使えるPHP・Python・C#rawurlencode($s)・quote(s, safe="")・Uri.EscapeDataString(s)
1行足せば済むJavaScript・GoencodeURIComponent のあと ! * ' ( ) を変える・QueryEscape のあと + を %20 にする
自分でバイトを回すJava・Kotlin・Ruby・Rust・SwiftUTF-8 のバイトを1つずつ見て %XX にする

PHP は urlencode と rawurlencode の2つがあり、後ろに raw が付くほうが URL の形です。Python の quote は safe の既定が "/" なので、クエリの値に使うときは safe="" を書き足します。C# の Uri.EscapeDataString は .NET 6 から RFC 3986 のとおりになりました(それより前は ! * ' ( ) を残していました)。

空白が「+」になる(10言語中6言語)

空白3つだけを渡したケース(c013)で、よく見かける書き方の 10言語中6言語が +++ を返しました。

よく見かける書き方Hello, World! の答え
決まりHello%2C%20World%21
PHP urlencodeHello%2C+World%21
Go url.QueryEscapeHello%2C+World%21
Java・Kotlin URLEncoder.encodeHello%2C+World%21
Ruby CGI.escapeHello%2C+World%21
C# WebUtility.UrlEncodeHello%2C+World!
JavaScript encodeURIComponentHello%2C%20World!
Swift .urlQueryAllowedHello,%20World!

+ を空白と読むのは、HTML のフォームを送るときの形です。クエリの値としては + でも読めることが多いのですが、同じ文字列をパスの一部やヘッダーの値に入れると、+ のまま残って別の文字列になります。日本語(c005)も、空白のところだけ + になります。

逆に、入力に入っていた + は %2B になります(c008)。Go の書き方で QueryEscape のあとに + を %20 に置き換えてよいのは、このためです。残っている + は空白から来たものだけです。

「~」を変えてしまう(10言語中4言語)

a-b_c.d~e(c002)は、決まりではまったく変わりません。よく見かける書き方では 10言語中4言語(PHP・Java・Kotlin・C#)が a-b_c.d%7Ee を返しました。~ が「予約されていない文字」に入ったのは RFC 3986(2005年)からで、それより前の決まりで作られた関数は ~ を変えます。

Ruby の CGI.escape は ~ を残しますが、URI.encode_www_form_component は %7E にします(Ruby 4.0.7 で確かめました)。同じ言語の中でも関数によって違います。

! * ' ( ) が残る

!*'()(c003)の5文字で、よく見かける書き方の答えが4通りに割れました。

よく見かける書き方!*'() の答え
決まり%21%2A%27%28%29
JavaScript encodeURIComponent!*'()
Swift .urlQueryAllowed!*'()
C# WebUtility.UrlEncode!*%27()
Java・Kotlin URLEncoder.encode%21*%27%28%29

この5文字は RFC 3986 では「副デリミタ」で、変えなくても URL としては正しく読めます。ただし、' が残ったまま HTML の属性に入れると引用符を閉じてしまうので、02 HTML の特殊文字をエスケープする と組み合わせるか、この回のように全部変えてしまうほうが安全です。JavaScript で encodeURIComponent のあとに5文字だけ変える書き方は、この差を埋めるためによく貼られています。

文字ではなくバイトに分ける

Rust には標準に URL の関数が無いので、自分で書くことになります。よく見かける「1文字ずつ見て、英数字でなければ %XX にする」書き方は、15件中7件しか通りませんでした(10言語で唯一、半分を割りました)。

入力よく見かける書き方(Rust)の答え決まり
天気 東京(c005)天気%20東京%E5%A4%A9%E6%B0%97%20%E6%9D%B1%E4%BA%AC
🍣(c006)%1F363%F0%9F%8D%A3
AB1(c007)AB1%EF%BC%A1%EF%BC%A2%EF%BC%91
a-b_c.d~e(c002)a%2Db%5Fc%2Ed%7Eea-b_c.d~e

is_alphanumeric() は Unicode 全体を見るので、ひらがな・漢字・全角の英数字にも true を返します(06 URL 用の文字列(slug)を作る と同じ問題です)。is_ascii_alphanumeric() に替えると、今度は日本語が %XX になりますが、文字の値をそのまま16進数にしてしまうと %1F363 のように桁が増えます。パーセントエンコードは「1バイトを %XX 1つ」にする決まりなので、先に UTF-8 のバイトに分けます。

Rust の as_bytes()、Swift の utf8、Java の getBytes(StandardCharsets.UTF_8)、Ruby の .b が、その「バイトの並び」です。Java と Kotlin の byte は符号付きなので、& 0xFF を付けないと %FFFFFFE3 のようになります。

バイトに分けてあれば、濁点を分けて書いた「が」(c012。か+濁点の2文字)も %E3%81%8B%E3%82%99 と素直に出ます。文字数を数える必要がないので、絵文字の数え方の違いも関係しません。

URL をまるごと渡すと

https://example.com/?q=1&r=2(c004)を渡したとき、決まりでは : も / も ? も & も変えます(クエリの値に入れる想定なので、区切り記号も値の一部として変える)。よく見かける書き方では2言語が違いました。

  • Python の quote(s) は safe の既定が "/" なので、https%3A//example.com/%3Fq%3D1%26r%3D2 と / だけ残します。パスを作るときの既定です
  • Swift の addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) は、クエリ全体(q=1&r=2 のような文字列)に使う集合なので & = ? / : を残し、入力をほぼそのまま返しました

どちらも関数が悪いのではなく、「URL のどの部分を作るのか」で許す文字が変わるということです。値1つを作るときは、残す文字をいちばん少なくしておくと間違えません。

すでに %20 と書いてある文字列

a%20b(c009)は、決まりでは a%2520b になります。よく見かける書き方も 10言語すべてが同じ答えでした。% 自体を %25 にするのは正しい動きです。

困るのは、エンコード済みの文字列をもう一度エンコードしてしまう場合です。どの言語も「もうエンコードしてあるか」は判断できないので、変える前の文字列を持っておいて、組み立てるときに1回だけ変えるようにします(02 HTML の特殊文字をエスケープする と同じ考え方です)。

改行とタブ

改行(c010)は %0D%0A、タブ(c011)は %09 になります。よく見かける書き方でも、タブは10言語すべてが、改行は Rust 以外の9言語が同じ答えでした。ヘッダーやログに値を入れるときは、改行が %0D%0A になっていることが大事です(生の改行が混ざると、行が増えて別の意味になります)。