URL エンコードで空白が「+」になったのは10言語中6言語、~ を変えたのは4言語 ― よく使う書き方を10言語で⑦

C#
B!

検索語や日本語の名前を ?q=... に入れるために URL エンコードする。どの言語にも関数があって、1行で済むはずの処理です。よく見かける書き方を10言語で試したところ、**空白3つを渡しただけで6言語が +++ を返しました**。

何をしたか

CODE Polyglot の「よく使う書き方」 で、決まりを先に決めました(正解表15件)。RFC 3986(URL の決まり)のとおりです。

  • そのまま残すのは A〜Z a〜z 0〜9 と 「-」「_」「.」「~」の4つだけ
  • それ以外は、UTF-8 のバイトに分けて1バイトずつ %XX(大文字の16進数2桁)にする
  • 空白は %20(+ にしない)

10言語(PHP・Python・JavaScript・Go・Java・C#・Rust・Ruby・Kotlin・Swift)で書き、よく見かける書き方(各言語の「URL エンコード」の関数をそのまま呼ぶ)も10言語で用意して、同じ正解表で動かしました。

空白3つで、6言語が +++ を返した

空白だけを渡したケース(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 のフォームを送るときの形(application/x-www-form-urlencoded)です。「URL エンコード」という名前の関数には、フォームの形と URL の形の2通りがあり、名前からはどちらか分かりません。

クエリの値なら + でも読める相手が多いのですが、同じ関数で作った文字列をパスの一部やヘッダーの値に入れると、+ がそのまま残って別の文字列になります。日本語(c005)でも、空白のところだけ + になりました。

~ を変えたのは4言語、関数によっても違う

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

同じ言語の中でも、関数によって違います。

CGI.escape("a-b_c.d~e !*")                   # => "a-b_c.d~e+%21%2A"
URI.encode_www_form_component("a-b_c.d~e !*") # => "a-b_c.d%7Ee+%21*"

Ruby 4.0.7 で確かめました。~ も * も違います。「どちらを使ってもだいたい同じ」ではありません。

! * ' ( ) の答えが4通りに割れた

この5文字だけを渡したケース(c003)です。

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

5文字とも、URL としては変えなくても読めます。ただし ' が残ったまま HTML の属性に入れると引用符を閉じてしまうので、全部変えておくほうが安全です。JavaScript で encodeURIComponent のあとに5文字だけ変える書き方が貼られているのは、この差を埋めるためです。

// 決まりに合わせる(! * ' ( ) の5文字だけ足す)
const q = encodeURIComponent(s).replace(/[!*'()]/g, (c) =>
  "%" + c.charCodeAt(0).toString(16).toUpperCase(),
);

標準の関数が無い言語で、文字とバイトを取り違える

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 を返します(第6回の slug と同じ問題です)。is_ascii_alphanumeric() に替えると日本語は %XX になりますが、**文字の値をそのまま16進数にすると %1F363 のように桁が増えます**。

パーセントエンコードは「1バイトを %XX 1つ」にする決まりです。先に UTF-8 のバイトに分けます。

for &b in s.as_bytes() {  // chars() ではなく as_bytes()
    let c = b as char;
    if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '~') {
        out.push(c);
    } else {
        out.push_str(&format!("%{:02X}", 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)に使う集合なので、& = ? / : を残し、入力をほぼそのまま返しました

どちらも関数が悪いのではなく、値1つを作るのか、クエリ全体を作るのか、パスを作るのかで、許す文字が変わるということです。値1つなら、残す文字をいちばん少なくしておくと間違えません。

どう書いたか

決まりに合う書き方は、言語によって手間が違いました。

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

PHP は urlencode と rawurlencode の2つがあり、後ろに raw が付くほうが URL の形です。C# の Uri.EscapeDataString は .NET 6 から RFC 3986 のとおりになりました(それより前は !*'() を残していました)。Go は QueryEscape のあとに + を %20 に置き換えるだけで済みます(入力にあった + は %2B になっているので、残っている + は空白から来たものだけです)。

10言語のコードは、どれも15件すべてで決まりどおりの答えを出しています。

まとめ

  • 「URL エンコード」の関数は、フォームの形(空白が +)と URL の形(空白が %20)の2通りある。名前だけでは分からないので、空白1文字で試す
  • ~ や !*'() を変えるかどうかは、言語と関数で割れる。値1つを作るなら、残す文字を A-Za-z0-9-_.~ だけにそろえる
  • パーセントエンコードは文字ではなくバイトの処理。手で書くときは、先に UTF-8 のバイトに分ける

10言語のコードと全15件の結果は CODE Polyglot「URL エンコードする」 にあります。

B!
← 一覧へ戻る