プログラム系統備忘録ブログ

記事中のコードは自己責任の下でご自由にどうぞ。

Utf8JsonWriterがエスケープする文字を自作JavaScriptEncoderで減らしたい

.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.DefaultDefaultJavaScriptEncoder(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>(),
        };
    }
}