今さらですが、JSONにおける文字列のエスケープ処理について調べたので、そのメモ。
JSONの規格は現在では幾度かの改定を経て、RFC8259にまとまっている。
https://tools.ietf.org/html/rfc8259
文字列については「7. Strings」にまとまっている。あまり長くないので、順番に全部見てみよう。
The representation of strings is similar to conventions used in the C family of programming languages. A string begins and ends with quotation marks. All Unicode characters may be placed within the quotation marks, except for the characters that MUST be escaped: quotation mark, reverse solidus, and the control characters (U+0000 through U+001F).
必ずエスケープされなければいけない文字として、quotation mark(")と、reverse solidus(\)、the control characters (U+0000 through U+001F)が指定されている。
JSONにおける文字列はquotation markで囲まれ、エスケープreverse solidusを使うからですね。あとは、画面に表示できないthe control characters(改行とか、タブとか)が指定されている。
エスケープされなければならないthe control charactersの範囲としてU+0000からU+001Fまでしか定義されていなくて、DELを示すU+007Fが指定されていないのは何故だろう?
ちなみに、DELがU+007Fに用意されている理由、今回調べて初めて知った。面白い。
https://ja.wikipedia.org/wiki/ASCII
ASCII 127(全てのビットがオン、つまり、2進数で1111111)は、delete(削除文字) として知られる制御文字である。この記号が現れた場合、その部分のデータが消去されていることを示す。この制御文字だけ先頭部分になく最後にある理由は、パンチテープへの記録は上書きが出来ないため、削除する際には全てに穴を空けることで対応できるというところからきている
続いて、エスケープの表記方法へ続く。
Any character may be escaped. If the character is in the Basic Multilingual Plane (U+0000 through U+FFFF), then it may be represented as a six-character sequence: a reverse solidus, followed by the lowercase letter u, followed by four hexadecimal digits that encode the character's code point. The hexadecimal letters A through F can be uppercase or lowercase. So, for example, a string containing only a single reverse solidus character may be represented as "\u005C".
Unicodeの基本多言語面(the Basic Multilingual Plane (U+0000 through U+FFFF))は、どれもエスケープ表現OKで、「バックスラッシュ」+「u」+「4文字の16進数」で表現される。
16進数を表すのに使うAからFは大文字でも小文字でも可。
Alternatively, there are two-character sequence escape representations of some popular characters. So, for example, a string containing only a single reverse solidus character may be represented more compactly as "\".
それとは別に、いくつかのメジャーな文字については、2文字でのエスケープ表現が用意されている。おなじみのバックスラッシュで始まる表記。
コード | 文字 | 2文字でのエスケープ表記 | 6文字でのエスケープ表記 |
---|---|---|---|
U+0022 | quotation mark | \" | \u0022 |
U+005C | reverse solidus | \\ | \u005C |
U+002F | solidus | \/ | \u002F |
U+0008 | backspace | \b | \u0008 |
U+000C | form feed | \f | \u000C |
U+000A | line feed | \n | \u000A |
U+000D | carriage return | \r | \u000D |
U+0009 | tab | \t | \u0009 |
通常文字列の中に入る制御文字といえば、タブと改行くらいなので、UTF-8が取り扱える環境であれば、ほぼ6文字でのエスケープ表記は不要になるように、イイ具合に対象の文字が選定されているのが分かる。
ここで一つポイントなのは、いくつかの実装でsolidus(スラッシュ)がエスケープの対象でなかったり、オプションだったりすること。JavaScriptの規格であるECMA-262ではエスケープ表記の対象にsolidus(スラッシュ)は含まれていなかったけど、最初の独立したJSON規格であるRFC 4627以降では対象に含まれている、という違いが有る。
正規表現の開始と終了の文字としてよく使われることからエスケープ対象になったのか……正確な経緯はわかりませんでした。ご存知の方、教えてください。
また、JavaのStringクラスもスラッシュを除くと、同じ2文字表記のエスケープを扱える(6文字の表記も扱える)。しかし、スラッシュの2文字表記は扱えないので、注意が必要(エラーとなる)。
To escape an extended character that is not in the Basic Multilingual Plane, the character is represented as a 12-character sequence, encoding the UTF-16 surrogate pair. So, for example, a string containing only the G clef character (U+1D11E) may be represented as "\uD834\uDD1E".
基本多言語面を越えるものはUTF-16のサロゲートペアに該当するものは12文字で表現される。
では、おなじみPerlでの実装を見てみましょう。
https://metacpan.org/release/JSON-PP/source/lib/JSON/PP.pm
エスケープに関する変換を行なっているのは、string_to_json
という関数です。
my %esc = ( "\n" => '\n', "\r" => '\r', "\t" => '\t', "\f" => '\f', "\b" => '\b', "\"" => '\"', "\\" => '\\\\', "\'" => '\\\'', ); sub string_to_json { my ($self, $arg) = @_; $arg =~ s/([\x22\x5c\n\r\t\f\b])/$esc{$1}/g; $arg =~ s/\//\\\//g if ($escape_slash); $arg =~ s/([\x00-\x08\x0b\x0e-\x1f])/'\\u00' . unpack('H2', $1)/eg; if ($ascii) { $arg = JSON_PP_encode_ascii($arg); } ... if ($utf8) { utf8::encode($arg); } return '"' . $arg . '"'; }
- 最初に、2文字形式のエスケープ処理する文字を優先的にreverse solidusでエスケープ
- 次に、solidusがエスケープ対象と設定されていれば、2文字形式でエスケープを実施
- 続いて、必ずエスケープが必要なthe control characters (U+0000 through U+001F)のうち、先の二つのステップでエスケープ処理をしていない文字を6文字の形式でエスケープ
- 最後に、文字コードに合わせて処理の振り分け…US-ASCIIだけで表現したい場合は、U+0080以降の文字を全て6文字の形式でエスケープする
- サロゲートペアに該当する文字は、12文字の形式でエスケープ
*JSON::PP::JSON_PP_encode_ascii = \&_encode_ascii; sub _encode_ascii { join('', map { $_ <= 127 ? chr($_) : $_ <= 65535 ? sprintf('\u%04x', $_) : sprintf('\u%x\u%x',_encode_surrogates($_)); } unpack('U*', $_[0]) ); } sub _encode_surrogates { # from perlunicode my $uni = $_[0] - 0x10000; return ($uni / 0x400 + 0xD800, $uni % 0x400 + 0xDC00); }
サロゲートペアの場合の、UTF-8からの変換方法がスマートだ。
ただし、JavaやScalaなどの言語では、最初からUTF-16のコードポイントで取り出すので、1コードポイントずつ変換していけば良い。
意外と知らないことが有って調べてみると、学びが有った。