JSON文字列のエスケープ完全ガイド:文字・stringify・落とし穴
JSON文字列をエスケープするとは、任意のテキストを、JSONドキュメントの中に文字列リテラルとして安全に置ける形へ変換することだ。ほんの一握りの文字 ——ダブルクオート、バックスラッシュ、そして改行やタブのような制御文字—— が構造上の意味を持っていたり、JSON文字列の中にそのまま置くことが単純に許されていなかったりするため、それぞれを \"、\\、\n といった安全なエスケープシーケンスに置き換える。これを間違えると、ペイロードはパースできなくなる。
この場面には頻繁に出くわす。あるJSONオブジェクトを別のJSONの文字列フィールドとして入れ子にするとき、複数行のコードスニペットをconfigの値に貼り付けるとき、あるいは curl 用のRESTリクエストボディを手作業で組み立てるときなどだ。この記事では、どの文字をエスケープすべきか、エスケープと JSON.stringify の違い、JSON-in-JSONの入れ子とUnicodeエスケープの扱い、そしてペイロードをひそかに壊す落とし穴を扱う。今すぐ何かをエスケープしたいだけなら、JSONエスケープツールがブラウザ上で処理してくれる。仕組みの背景まで知りたいなら、このまま読み進めるとよい。
JSON文字列エスケープとは何か
JSON文字列エスケープとは、生の文字列を、JSONドキュメントの中に安全に埋め込める形へ変換する処理だ。JSONは構造上の意味を持つ少数の文字を予約している。ダブルクオート " は文字列を区切り、バックスラッシュ \ はエスケープシーケンスを開始する。さらに、U+0020 未満の制御文字 ——改行、タブ、復帰—— は、JSON文字列の中に文字どおりそのまま現れることが一切許されていない。エスケープはこれらをそれぞれ安全なシーケンスに置き換え、結果の文字列がどこでもきれいにパースできるようにする。
では、実際にいつ必要になるのか。繰り返し登場する状況がいくつかある。
- JSON-in-JSON:webhookのエンベロープ、Kafkaメッセージ、監査ログがリクエストボディを文字列フィールドとして保存するため、内側のJSONを代入できるようにする前にエスケープしておかなければならない。
- 手書きのconfig:複数行のシェルスクリプト、SQLクエリ、コードスニペットを単一のJSON値に落とし込むには、すべての改行を
\nに変える必要がある。 - RESTリクエストボディ:
curlやHTTPクライアント向けにJSONボディを手作業で組み立てるとき、クオートや改行がシェルとネットワークを通り抜けて生き残らなければならない。 - ログ安全なエンコード:ユーザー由来のコンテンツを構造化ログ行へ書き込む際に、混入したクオートや改行がフォーマットを壊さないようにする。
操作の順序にも注意したい。乱雑だったり信頼できなかったりするJSONから始める場合は、エスケープの前に検証しておく。JSON整形ツールに貼り付けてpretty-printし、中身を確認してから、きれいになった結果をエスケープすればいい。ゴミをエスケープしても、エスケープされたゴミが返ってくるだけだ。
JSONでエスケープが必須の文字
JSON仕様は、正確で短いリストを定めている。7つの文字には専用の2文字エスケープがあり、それ以外の U+0020 未満の文字はすべて \uXXXX のUnicodeエスケープにフォールバックする。JSONのエスケープ対象文字の完全な一覧は次のとおりだ。
| 文字 | エスケープ結果 | 備考 |
|---|---|---|
" (U+0022) | \" | 文字列の区切り文字 |
\ (U+005C) | \\ | エスケープの先導文字(json escape backslash のケース) |
| 改行 (U+000A) | \n | |
| 復帰 (U+000D) | \r | |
| タブ (U+0009) | \t | |
| 後退 (U+0008) | \b | |
| 改ページ (U+000C) | \f | |
| その他 U+0020 未満の制御文字 | \uXXXX | 例:U+0000 → \u0000 |
エスケープが不要なものも、同じくらい重要だ。スラッシュ / はごく普通の文字で(エスケープは任意であり、後述する一つの限定的なケースでしか役に立たない)。シングルクオートは、JSONが区切り文字として使わないため、エスケープの必要が一切ない。そして U+0020 以上のすべての印字可能文字 ——é、日、😀 のようなマルチバイトUTF-8文字を含む—— はそのまま有効だ。
違いを具体的に示そう。左が生の入力、右がエスケープ済みのJSON文字列リテラルだ。
Input:
She said "hello" then left.
Escaped:
"She said \"hello\"\tthen left."
ダブルクオートは \" に、タブは \t になった。これでこの文字列は、どんなJSONパーサー、ログ行、リクエストボディにも安全に放り込める。
JSONエスケープ vs JSON Stringify:何が違うのか
ここは多くのチュートリアルが飛ばす点で、混乱の元になっている。エスケープと JSON.stringify は別々の2つの操作ではなく、同じ一つの操作を2つの視点から見たものだ。
JSON.stringify(value) は任意のJavaScript値をそのJSONテキスト表現へシリアライズする。その値がたまたま文字列だった場合、シリアライズとはそれをダブルクオートで包み、内部の特殊文字をエスケープすることを意味する。それがまさにJSONエスケープだ。だから JSON.stringify("a\tb") は、クオートを含めた7文字の文字列 "a\tb" を返す。
実用上の問いは、その外側のクオートが欲しいかどうかだ。これはJSONエスケープツールのダブルクオートで囲むオプションにそのまま対応する。
| モード | 入力 a"b に対する出力 | 使いどころ |
|---|---|---|
| 囲む オン | "a\"b" | 完全なJSON文字列リテラルで、JSON.stringify と同一。変数に代入したり、コロンの後に貼り付けたりする。 |
| 囲む オフ | a\"b | エスケープした本体のみで、周囲のクオートはなし。JSONドキュメントの中で自分でクオートを手打ちするときに使う。 |
つまり「json stringify」で検索してここに辿り着いたなら、考え方は単純でいい。文字列のstringify=囲むオンのエスケープ。囲まない形は、そこから外側のクオートを剥がしただけだ。
コードでJSON向けに文字列をエスケープする方法
鉄則:replace() の連鎖を手で組み上げてはいけない。主要な言語はどれもJSONシリアライザを備えていて、クオート、バックスラッシュ、制御文字、Unicodeを正しく処理してくれる。それを使え。
JavaScript
const text = 'She said "hi"\nthen left.';
const escaped = JSON.stringify(text);
console.log(escaped);
// "She said \"hi\"\nthen left."
文字列に対する JSON.stringify は、完全でクオート付きのリテラルを返す。本体だけが欲しい? 先頭と末尾の文字を切り落とそう:JSON.stringify(text).slice(1, -1)。
Python
import json
text = 'She said "hi"\nthen left.'
print(json.dumps(text))
# "She said \"hi\"\nthen left."
print(json.dumps(text, ensure_ascii=False))
# "She said \"hi\"\nthen left." (non-ASCII kept as UTF-8)
json.dumps のデフォルトは ensure_ascii=True で、すべての非ASCII文字を \uXXXX にエスケープする ——ツールのASCII安全モードと同じ挙動だ。生のUTF-8を保ちたいなら ensure_ascii=False を渡そう。
PHP
<?php
$text = "café \"quoted\"\nline";
echo json_encode($text);
// "caf\u00e9 \"quoted\"\nline" (default escapes non-ASCII to \uXXXX)
echo json_encode($text, JSON_UNESCAPED_UNICODE);
// "café \"quoted\"\nline"
json_encode はデフォルトで非ASCII文字とスラッシュの両方をエスケープする。アクセント記号を読みやすく保つには JSON_UNESCAPED_UNICODE を、/ をそのままにするには JSON_UNESCAPED_SLASHES を加える。
GoとJava
Goでは、json.Marshal(text) がエスケープ済みでクオート付きのバイト列を返す。
b, _ := json.Marshal(`a "quoted" line`)
// b == `"a \"quoted\" line"`
Javaでは、Jacksonの objectMapper.writeValueAsString(text) や org.json の JSONObject.quote(text) が同じクオート付きリテラルを生成する。どの言語でも、自前で組まずにライブラリに任せるのが安全だ。手作業では忘れがちなエッジケースまで、すでにカバーしてくれている。
JSONの中にJSONを埋め込む(JSON-in-JSON)
これは人々が手作業でJSONをエスケープする、最も多い理由だ。webhookのエンベロープ、メッセージキューのレコード、監査ログは、リクエストボディ全体を文字列フィールドとして保存することがよくある。そうするには、内側のJSONを先にエスケープしなければならない。
小さなオブジェクトが2層のエンコードを通り抜ける様子を見てみよう。
1. Inner object: {"a":1}
2. Escaped as a string: "{\"a\":1}"
3. Placed in envelope: {"payload": "{\"a\":1}"}
内側のオブジェクトのすべての " が \" になり、全体が外側のクオート1組で包まれた。結果は、payload に代入できる単一の有効な文字列値だ。
入れ子がより深くなるときの落とし穴は、バックスラッシュが増殖することだ。すでにエスケープされた文字列をエスケープすると、そのバックスラッシュもエスケープされるため、層が一つ増えるごとにおおよそ倍になっていく。\" だった内側のクオートは、1階層外側で \\\" に、もう1階層外側で \\\\\" になる。3層の深さのJSON-in-JSONは正直なところ読みづらく、だからこそツールが助けになる。逆方向に進んで内側のオブジェクトを文字列から取り出すには、JSONアンエスケープツールに通そう。
Unicode と \uXXXX エスケープ
デフォルトでは、JSONは生のUTF-8で満足している。é は é のまま、日 は 日 のまま残り、そのほうがドキュメントは読みやすい。印字可能なUnicode文字をエスケープする必要はない。
では、ASCII安全な \uXXXX 出力に手を伸ばすのはいつか。下流のシステムがUTF-8を任せられないときだけだ。古いSOAPやXMLのゲートウェイ、一部のロギングパイプライン、emailヘッダー、純粋なASCIIを保たねばならないソースファイルなど。ASCII安全モードでは、U+007F を超えるすべての文字が \uXXXX エスケープになる ——café は caf\u00e9 に変わる。ノイズは多いがバイト単位でASCIIであり、準拠したパーサーならどれでも元に戻せる。
一つだけ微妙な点がある。\uXXXX は単一の16ビットUTF-16コードユニットを表すが、基本多言語面の外にある文字 ——emojiや希少な文字体系—— には21ビットが必要だ。JSONはこれをサロゲートペアで扱う。2つの \uXXXX エスケープを背中合わせに並べるのだ。にっこり顔の 😀(U+1F600)は \ud83d\ude00 になる。たいていのシリアライザはこれを自動でやってくれる。危ないのは、対になっていない孤立したサロゲートを吐く手書きのエスケーパーだ。
サロゲートペアやコードポイントがなじみのない話なら、UTF-8 vs UTF-16 vs Unicode エンコーディングガイドが、1つの文字がどうバイトとコードユニットに対応づけられるのかを分解して説明している。1つのemojiがなぜ2つのエスケープを必要とするのか、その背景もそこで埋まるはずだ。
アンエスケープ:エスケープされたJSONを読み戻す
エスケープには逆操作がある。"a\tb" を本来の2行分のテキストやタブ入りテキストへ戻すには、それをパースする。JavaScriptなら JSON.parse(str)、Pythonなら json.loads(str) だ。パーサーは各エスケープシーケンスをたどり、サロゲートペアも含めて元の文字を組み立て直す。
アンエスケープが失敗するとき、エラーはほぼ必ず「invalid escape sequence」であり、よくある原因がいくつかある。
- JSONがエスケープと認識しない文字の前にある孤立したバックスラッシュ、例えば
\q。 \x41のようなでっち上げのエスケープ ——JSONに\xの16進エスケープはなく、使うのは\uだけだ。- 16進数字が4桁に満たない切り詰められた
\uエスケープ、例えば\u00。 - 文字列の境界を壊す、迷子の、あるいは対になっていないダブルクオート。
すべてのバックスラッシュが有効なエスケープ(\n \r \t \b \f \" \\ \/ \uXXXX)のいずれかを開始していること、そしてクオートが対になっていることを確認しよう。ログ行の途中からコピーしてきた ——外側のクオートが置き去りにされた—— エスケープ済み文字列については、JSONアンエスケープツールが周囲のクオートの有無を問わず本体を受け取り、どちらでもデコードする。
JSONエスケープでよくある落とし穴
壊れたペイロードのほとんどは、この6つのミスのいずれかに行き着く。
1. 二重エスケープ。 すでにエスケープされたテキストをエスケープすると、\n は \\n に、\" は \\\" になり、消費側は改行ではなく文字どおりのバックスラッシュ-n を読む。これはたいてい、上流のサービスがすでにその値をJSONエスケープしているのに、あなたがもう一度エスケープしたときに起きる。現在の状態を確認するために先にアンエスケープし、それからちょうど一度だけエスケープしよう。
2. 外側のクオートを忘れる。 囲むオフだと、完全な文字列ではなくエスケープした本体だけが得られる。hello \"world\" を、JSON値が期待される場所にそのまま貼り付けると、周囲のクオートが欠けているため無効になる。囲むオンを保つか、自分でクオートを打つかのどちらかにしよう。
3. 非ASCIIの過剰エスケープ。 消費側がUTF-8を問題なく扱えるのにASCII安全モードをオンにすると、出力が膨らむだけだ。café が意味もなく caf\u00e9 になる ——読みにくく、ネットワーク上で大きくなり、利点はゼロ。特定のレガシーシステムが純粋なASCIIを要求するのでない限り、オフのままにしよう。
4. 反射的にスラッシュをエスケープする。 / のエスケープが意味を持つのは、ちょうど一箇所だけだ。HTMLの <script> タグの中にインライン化されたJSONで、</script> という部分文字列がJSONの文脈に関係なくタグを早く閉じてしまう場面だ。/ を \/ にエスケープすればそれを無効化できる。その一例の外では、スラッシュのエスケープはただの雑音だ ——RESTボディ、configファイル、メッセージペイロードではオフにしておこう。
5. 手書きのreplace連鎖。 手作業の replace('"', '\\"') パイプラインは、ほぼ必ず何かを忘れる ——制御文字、後退、サロゲートペアなど。言語のシリアライザを使えば、仕様全体をカバーしてくれる。
6. エスケープしてアンエスケープしない(または2回アンエスケープする)。 往復は釣り合っていなければならない。入りで一度エスケープし、出で一度アンエスケープする。2回アンエスケープすると、データの一部だった本物のバックスラッシュを壊してしまう。
もう一つ、はっきりさせておく価値のある区別がある。JSONエスケープはURLエンコードやパーセントエンコードとは違う。それらは異なる転送のために異なる問題を解いており、両者を混ぜること ——値をパーセントエンコードしてからその結果をJSONエスケープする、あるいはその逆—— は、どちらのパーサーもきれいに読めない混沌を生む。URLエンコード・デコード実践ガイドは、パーセントエンコードが正しい道具となるのはいつか、そしてそれがJSONのすることとどう違うのかを扱っている。
よくある質問
JSONで文字列をエスケープするとはどういう意味か?
JSONにとって構造上の意味を持つ文字 ——ダブルクオート、バックスラッシュ、そして改行やタブのような制御文字—— を、\"、\\、\n といった安全なエスケープシーケンスに置き換えることを意味する。その結果は、パースを壊さずにJSONドキュメントの中へ文字列リテラルとして埋め込める。
JSONでエスケープが必要な文字は何か?
ダブルクオート、バックスラッシュ、改行、復帰、タブ、後退、改ページにはそれぞれ専用のエスケープがあり、それ以外の U+0020 未満の制御文字はすべて \uXXXX になる。印字可能文字とマルチバイトUTF-8はエスケープ不要で、スラッシュは任意であり、HTMLの <script> タグの中でのみ意味を持つ。
JSONエスケープは JSON.stringify と同じか?
ほぼ一つの操作の2つの視点だ。文字列に適用した JSON.stringify は、それをダブルクオートで包み、内部の特殊文字をエスケープする ——それがJSONエスケープだ。囲むオンはクオート付きの形(JSON.stringify と同一)に等しく、囲むオフは周囲のクオートなしのエスケープ本体だけを返す。
JavaScriptやPythonでJSON向けに文字列をエスケープするには?
JavaScriptでは JSON.stringify(str) を、Pythonでは json.dumps(str) を使う。手書きの replace 連鎖ではなく、常に組み込み関数に頼ること ——組み込みはUnicode、制御文字、そしてあなたが見落とすあらゆるエッジケースを正しく扱う。
なぜ余分なバックスラッシュでJSONが壊れるのか?
よくある原因は二重エスケープだ。すでにエスケープされたテキストをエスケープしてしまい、\n が \\n になり、消費側が改行ではなく文字どおりのバックスラッシュ-n を読む。まず値をアンエスケープして本当の状態を確認し、それからちょうど一度だけエスケープしよう。
JSONでスラッシュやUnicodeをエスケープする必要はあるか?
どちらも必須ではない。/ は普通の文字で、HTMLの <script> タグにJSONをインライン化して </script> シーケンスが早く閉じるのを止めるときだけエスケープが必要になる。Unicodeはデフォルトで生のUTF-8のまま残る。下流のシステムがUTF-8を扱えないときだけ \uXXXX を使おう。