wiprog

C#, .NET, Scala ... について勉強したことのメモ

Utf8Json で JSONP を書く

Utf8Json.JSONP というライブラリを作りました。 ざっくりしたライブラリの紹介と、どうやって実装したかのメモです。

簡単な紹介

nuget でインストールできます。

NuGet Gallery | Utf8Json.Jsonp 1.1.0

nuget パッケージを入れると、 Utf8Json の名前空間Jsonp というクラスが追加されます。
これが JSONP 版の JsonSerializer です。

使いごこちは JsonSerializer と同じになるようにしてあります。 QuickStart にも載せたのですが、こんな感じでかけます。

var p = new Person { Age = 99, Name = "foobar" };
var callback = "callbackFunc";

// result: callbackFunc({"Age":99,"Name":"foobar"});

// obj -> byte[]
byte[] bytes = Jsonp.Serialize(callback, p);

// write to stream
using (var stream = new MemoryStream())
{
    Jsonp.Serialize(stream, callback, p);
}

ソースはこちら。

github.com

対応してないもの

とりあえず仕事ですぐに使いたくて週末にざざっと作ったので最低限の機能にしています。
落ち着いたら対応していく予定です。

  • 型なしのシリアライズにまだ対応していません
  • ASP.NET Core への組み込みもまだです(仕事では webforms だったので。。。。。。。)

作った目的

ちょうど仕事で Utf8Json への置換えを進めていて、 JSONP に対応してる API どうしよう・・・って考えたのがきっかけです。 せっかく Utf8Json で超高速な JSON シリアライズができるのに、 JSONP のために文字列とか byte 配列の結合はやりたくなかった。

最初の実装

まず Utf8Json 置き換え前のコードはこんな感じでした。 (Utf8Json に比べると) すごく遅そう。

string json = Newtonsoft.Json.JsonConvert.SerializeObject(obj);
string jsonp = string.Format("{0}({1})", callback, obj);

で、 Utf8Json に置き換えて最初にやった実装がだいたいこんなかんじ。

// json と callback の文字列をそれぞれ byte[] に変換
byte[] jsonBytes = Utf8Json.JsonSerializer.Serialize(obj);
byte[] callbackBytes = Encoding.UTF8.GetBytes(callback);

// 結果の byte[] を作る
int byteCount = jsonBytes.Length + callbackBytes.Length + 2;
var jsonpBytes = new byte[byteCount];

// コピーしていく
Array.Copy(callbackBytes, 0, jsonpBytes, 0, callbackBytes.Length);
jsonpBytes[callbackBytes.Length] = (byte) '(';
Array.Copy(jsonBytes, 0, jsonpBytes, callbackBytes.Length + 1, jsonBytes.Length);
jsonpBytes[byteCount - 1] = (byte) ')';

Utf8Json を使っているのでなんとなく速そうに見えますが、 byte[] を 3 つもつくっています。そしてこのあとさらに stream に書き込んだりするのです。これは遅い。
utf8json の SerializeUnsafe() を使えば多少は良くなりそうですがそれにしても完璧ではなさそうです。

Utf8Json の primitive API

配列のコピーをなくして、そのまま JSONP の形になっている byte[] がとれればいちばんよいです。
で、それをやるには Primitive API として公開されている JsonWriter を使って、 シリアライザが内部で持っているバッファに直接書き込んじゃうのが簡単そうでした。

単純にやればとても簡単で、まずやったのはこんな実装でした。 v1.0.0 の実装はこれです。

public static byte[] Serialize<T>(string callback, T value, IJsonFormatterResolver resolver)
{
    // MemoryPool, BufferPool は自分で用意する必要があった
    var writer = new JsonWriter(MemoryPool.GetBuffer());
    
    // callback の文字列と ( を書く
    writer.WriteRaw(Encoding.UTF8.GetBytes(callback));
    writer.WriteRaw((byte) '(');

    // Utf8Json の API をつかって json を書く
    Utf8Json.JsonSerializer.Serialize(ref writer, value, resolver);

    // ) を書く
    writer.WriteRaw((byte) ')');

    return writer.ToUtf8ByteArray();
}

callback 文字列のシリアライズでの配列確保を避ける

これで満足していたのですが、さっきのコードのこの部分

// callback の文字列と ( を書く
writer.WriteRaw(Encoding.UTF8.GetBytes(callback));

これが気になりはじめました。
これだと callbackbyte[] に変換するためだけに新しく配列を作ってしまいますし、それを JsonWriter 内部の配列にコピーしないといけません。
最初の実装とそこまで違うの?という感じです。

で、 GetBytes() には任意の配列に書けるオーバーロードがあるのでそれを使ってこんなふうにしました。 すこし無理やりな実装ですが、これで配列が作られるのを防ぎます。

public static byte[] Serialize<T>(string callback, T value, IJsonFormatterResolver resolver)
{
    // MemoryPool, BufferPool は自分で用意する必要があった
    var writer = new JsonWriter(MemoryPool.GetBuffer());
    
    // writer の内部バッファを取得する
    ArraySegment<byte> buffer = writer.GetBuffer();
    
    // バッファに callback の文字列を書いて、その分だけバッファの現在位置を進めてあげる
    // ここがずれると大変なので、渡すのが byte[] でいい時は WriteRaw() とかをつかうのが良さそう。
    var filledCount = Encoding.UTF8.GetBytes(callback, 0, callback.Length, buffer.Array, buffer.Count);
    writer.AdvanceOffset(filledCount);

    writer.WriteRaw((byte) '(');

    // ( を書く
    writer.WriteRaw((byte) '(');

    // Utf8Json の API をつかって json を書く
    Utf8Json.JsonSerializer.Serialize(ref writer, value, resolver);

    // ) を書く
    writer.WriteRaw((byte) ')');

    return writer.ToUtf8ByteArray();
}

簡単なベンチマークをとってみたらかなり速くなってました。

Method Mean Error StdDev Gen 0 Allocated
OldVersion 205.1 ns 3.017 ns 3.816 ns 0.0608 96 B
NewVersion 159.0 ns 2.347 ns 2.196 ns 0.0405 64 B

おまけ: SerializeUnsafe について

Utf8Json の APISerializeUnsafe() というメソッドがあります。 (もちろん Jsonp でもサポートしてます)
このメソッドは何が unsafe かというと、スレッドごとに一個しかないシリアライズ用のバッファをそのまま返してきます。
つまり、結果を受け取ったらすぐに使ってしまわないと、他のオブジェクトをシリアライズした場合に書き換えられてしまう可能性があるということです。
unsafe でない API はこれを避けるために、新しく byte[] を作って返してくれます。

どんなときに使うと良いか

たとえば結果をすぐに使って捨ててしまう場合 (たとえば stream に書くとか) の場合は SerializeUnsafe() をつかったほうがお得です。
逆に結果を引き回したり、スレッドをまたいで共有したり、複数のオブジェクトをシリアライズしてあとでまとめて何かする、という時には使えません。

実際、今回も stream に書き込みをする API はこんな感じで実装していますが、これで安全に速く動きます。
この API は本家の Utf8Json にもあるものなので、stream に書いてしまう場合はこれを呼んでもらえれば大丈夫だと思います。

public static void Serialize<T>(Stream stream, string callback, T value, IJsonFormatterResolver resolver)
{
    var buffer = SerializeUnsafe(callback, value, resolver);
    stream.Write(buffer.Array, buffer.Offset, buffer.Count);
}