.NET Core 3.0にてUtf8JsonWriterが追加されました。 Json.NETでjson書き出しをしている箇所をUtf8JsonWriterに変えようとしたら、Utf8JsonWriterの文字列書き出しでハマったので紹介します。
現象
次のコードを実行してみます:
using System; using System.Buffers; using System.Text; using System.Text.Encodings.Web; using System.Text.Json; static class Program { static string WriteStringValueTest(string str, JavaScriptEncoder encoder) { var arrayBufferWriter = new ArrayBufferWriter<byte>(); var options = new JsonWriterOptions() { Encoder = encoder }; // Utf8JsonWriterはIAsyncDisposableを実装しているのでawait usingも出来ます using (var utf8JsonWriter = new Utf8JsonWriter(arrayBufferWriter, options)) { utf8JsonWriter.WriteStringValue(str); } return Encoding.UTF8.GetString(arrayBufferWriter.WrittenSpan); } static void Main() { const string testStr = "a < > ` \" \t \a あ 𩸽"; // Console出力だと非BMP文字が"??"になったのでTrace出力にしています Trace.WriteLine(WriteStringValueTest(testStr, JavaScriptEncoder.Default)); // EncoderやJsonWriterOptions未指定時のデフォルト Trace.WriteLine(WriteStringValueTest(testStr, JavaScriptEncoder.UnsafeRelaxedJsonEscaping)); Trace.WriteLine(WriteStringValueTest(testStr, new MyJavascriptEncoder())); // 自作クラス、後述 } }
"a < > ` \" \t \a あ 𩸽 "
を、各Encoderを使ってUtf8JsonWriter経由でjsonとして書き込み、その結果をMain()で出力しています。
最初2行が標準で用意されているEncoderを使ったもので、最後の1行が自作Encoderを使ったものです。
実行結果は次のとおりです:
"a \u003C \u003E \u0060 \u0022 \t \u0007 \u3042 \uD867\uDE3D" "a < > ` \" \t \u0007 あ \uD867\uDE3D" "a < > ` \" \t \u0007 あ 𩸽"
JavaScriptEncoder.Default
では多くの文字がエスケープされています。
JavaScriptEncoder.UnsafeRelaxedJsonEscaping
は大体はいい感じですが、𩸽だけはサロゲートペアとしてエスケープされています。
自作Encoderを使うと、jsonでエスケープ必須な文字のみエスケープし、エスケープ不要な文字をそのまま出力できます。
標準で提供されているEncoder
1つ目はJavaScriptEncoder.Defaultです。が、本記事執筆時(2019/10/25)ではドキュメントにはほとんど何も説明されていません。
そこでcorefxのソースを見るとJavaScriptEncoder.Default
はDefaultJavaScriptEncoder(UnicodeRanges.BasicLatin)
のシングルトンを返していることがわかります。
DefaultJavaScriptEncoderのコンストラクタを眺めると、以下の要素を禁止(=エスケープ対象)にしていることがわかります。
- コンストラクタ引数の範囲(=UnicodeRanges.BasicLatin)に含まれない文字
- 未定義文字
- HTMLで意味を持つ文字 {'<', '>', '&', '\'', '\"', '+'}
- '\' (U+005C REVERSE SOLIDUS)
- '`' (U+0060 GRAVE ACCENT
jsonとしてはエスケープする必要がない文字でも、多くの文字がエスケープされる挙動となります。
(この挙動をDefault
と名付けるのは果たして適切なのかどうか。ただこのEncoderは.NET Core1.0から存在するものなので、歴史的経緯と呼べるものかもしれません。)
2つ目はJavaScriptEncoder.UnsafeRelaxedJsonEscapingです。NET Core 3.0で追加されたもので、ドキュメントにはDefault
と比較した挙動の差について多く記載されています。
corefxのソースを見てもドキュメント通りの仕様であることがわかります。
ただしWillEncodeの実装を見ると分かる通り、Unicodeの基本多言語面(Basic Multilingual Plane, BMP)に含まれない文字はエスケープされる挙動となります。(Default
と同様の挙動なのでドキュメントに非記載?)
自作Encoder(本題)
Json.NETを使ったjson出力では基本多言語面に含まれない文字でもエスケープされていませんでした。その挙動と合わせたかったのでEncoderを自作しました。override必須のメソッドにポインター型引数があるため、プロジェクト設定でunsafeコードを許可する必要があります。
using System; using System.Buffers; using System.Globalization; using System.Text; using System.Text.Encodings.Web; public class MyJavascriptEncoder : JavaScriptEncoder { public override int MaxOutputCharactersPerInputCharacter => 6; // @"\uxxxx"の6文字 public override unsafe int FindFirstCharacterToEncode(char* text, int textLength) { if (text == null) { throw new ArgumentNullException(nameof(text)); } var utf16Text = new ReadOnlySpan<char>(text, textLength); int index = 0; while (utf16Text.Length > 0) { switch (Rune.DecodeFromUtf16(utf16Text, out var rune, out int charsConsumed)) { case OperationStatus.InvalidData: case OperationStatus.NeedMoreData: return index; case OperationStatus.Done: case OperationStatus.DestinationTooSmall: default: break; } if (this.WillEncode(rune.Value)) { return index; } utf16Text = utf16Text[charsConsumed..]; index += charsConsumed; } return -1; } public override int FindFirstCharacterToEncodeUtf8(ReadOnlySpan<byte> utf8Text) { int index = 0; while (utf8Text.Length > 0) { switch (Rune.DecodeFromUtf8(utf8Text, out var rune, out int charsConsumed)) { case OperationStatus.InvalidData: case OperationStatus.NeedMoreData: return index; case OperationStatus.Done: case OperationStatus.DestinationTooSmall: default: break; } if (this.WillEncode(rune.Value)) { return index; } utf8Text = utf8Text[charsConsumed..]; index += charsConsumed; } return -1; } public override unsafe bool TryEncodeUnicodeScalar(int unicodeScalar, char* buffer, int bufferLength, out int numberOfCharactersWritten) { if (buffer == null) { throw new ArgumentNullException(nameof(buffer)); } var target = new Span<char>(buffer, bufferLength); var encoded = Rune.IsValid(unicodeScalar) ? GetEncodedChars(unicodeScalar) : new char[] { '\uFFFD' }; // WillEncode()がfalseを返す文字に対しても呼び出され、その場合はエンコードせずにそのまま書き込む必要がある if (encoded.Length != 0) { bool success = encoded.TryCopyTo(target); numberOfCharactersWritten = success ? encoded.Length : 0; return success; } else { var rune = new Rune(unicodeScalar); numberOfCharactersWritten = rune.EncodeToUtf16(target); return numberOfCharactersWritten != 0; } } public override bool WillEncode(int unicodeScalar) { if (!Rune.IsValid(unicodeScalar)) { return true; } return GetEncodedChars(unicodeScalar).Length > 0; } private static ReadOnlySpan<char> GetEncodedChars(int unicodeScalar) { // https://www.json.org/ // '/'のエスケープは可能だが必須ではない return unicodeScalar switch { '"' => new char[] { '\\', '"' }, '\\' => new char[] { '\\', '\\' }, '\b' => new char[] { '\\', 'b' }, '\f' => new char[] { '\\', 'f' }, '\n' => new char[] { '\\', 'n' }, '\r' => new char[] { '\\', 'r' }, '\t' => new char[] { '\\', 't' }, var c when c < 0x20 => string.Create(6, 0, (span, _) => { span[0] = '\\'; span[1] = 'u'; c.TryFormat(span[2..], out _, "X4", CultureInfo.InvariantCulture); }), _ => Array.Empty<char>(), }; } }