MemcachedTranscoder - C#のMemcached用シリアライザライブラリ

今年もAdvent Calendarの季節がやってきました。というわけで、この記事はC# Advent Calendar 2012用の話となります。去年はModern C# Programming Style Guideという記事を書きまして、結構好評でした。また、去年は他Silverlight Advent Calendar 2011で.NETの標準シリアライザ(XML/JSON)の使い分けまとめというシリアライザの話をしました。今年も路線は引き続きで、モダンなシリアライザの話をしましょう。

MemcachedTranscoder

そんなわけで、表題のものを作りました。dllのインストールはNuGet経由でお願いします。

Memcachedは言わずと知れた分散キャッシュ。C#で最もメジャーなMemcachedのライブラリはEnyim.Memcachedです。これを使って、オブジェクトをGet、Setするわけだー。さて、オブジェクトをSetするというのは、最終的にbyte[]に落とす必要があります。ただたんにポーンとオブジェクト投げたらSetできたー、にはなりませんですのよ。では、どうやってbyte[]に変換しているの?というと、シリアライザが内部で動いてます。

シリアライザについては以前に.NET(C#)におけるシリアライザのパフォーマンス比較という記事も書いたりしていて、結構うるさいんで割と気にするほうです。さて、そんなEnyim.Memcachedのシリアライザは、デフォルトではBinaryFormatterです。はい、これは、あまり速くないしファイルサイズも結構かさんでゲンニョリ系シリアライザ。

ただしEnyim.MemcachedはそれらをTranscoderと呼んでいて、自由に差し替えが可能になっています。つまりBinaryFormatterがゲンニョリならば自分で差し替えればいいじゃない!ちなみに純正オプションとしてNetDataContractSerializerも用意されているのですが、これは……話にならないぐらいサイズがデカくなるので、ないわー。

そんなわけで.NET最速シリアライザのProtobuf-netと、やっぱ時代はJSONよねということで、.NETで最もスタンダードなJSONライブラリであるJSON.NETと、新進気鋭のMsgPack-Cliの3種のTranscoderを作りました。

使い方

app.configかweb.configのMemcachedのTranscoderの設定行に、それぞれ使いたいTranscoderのものを指定して、dllを実行ファイルと同ディレクトリにでも置いてください。

<?xml version="1.0" encoding="utf-8" ?>
<configuration>
    <configSections>
        <sectionGroup name="enyim.com">
            <section name="memcached" type="Enyim.Caching.Configuration.MemcachedClientSection, Enyim.Caching" />
        </sectionGroup>
    </configSections>
    <enyim.com>
        <memcached protocol="Binary">
            <servers>
                <add address="127.0.0.1" port="11211"/>
            </servers>
            <transcoder type="MemcachedTranscoder.ProtoTranscoder, ProtoTranscoder" />
        </memcached>
    </enyim.com>
</configuration>

Transcoderのバリエーションは以下の感じ。

<transcoder type="MemcachedTranscoder.ProtoTranscoder, ProtoTranscoder" />
<transcoder type="MemcachedTranscoder.JsonTranscoder, JsonTranscoder" />
<transcoder type="MemcachedTranscoder.MessagePackTranscoder, MessagePackTranscoder" />
<transcoder type="MemcachedTranscoder.MessagePackMapTranscoder, MessagePackMapTranscoder" />

ProtoTranscoderはProtocol Buffers、JsonTranscoderはJSON、MessagePackTranscoderはMsgPackをArrayモードで、MessagePackMapTranscoderはMsgPackをMapモードでオブジェクトを変換します。

型とデシリアライズ

使い方を説明して終わり、というのもつまらないので、もっと深く見ていきましょう。Enyim.MemcachedはGetもSetもobjectでしかできません。ジェネリックなのもあるように見せかけて、最終的にはobjectに落ちます。ITranscoderのところには型が伝達されないのです。以下のがITranscoderインターフェイスね。

public interface ITranscoder
{
    object Deserialize(CacheItem item);
    CacheItem Serialize(object value);
}

何が困るって?シリアライザは型が必要なんですよ!デシリアライズの時に!DataContractSerialize作るのにtypeofで型を渡しているでしょう?Protobuf.Serialize<T>でしょう?MessagePackSerializer.Create<T>でしょう?(JsonConvert.DeserializeObjectは、一見デシリアライズ可能にみえて、それJObjectが帰ってくるから意味ないです)

例えばMyClassクラスというint MyProperty{get;set;}だけがある、なんてことのないクラスがあるとして、ふつーにJSONにシリアライズした結果は

{"MyProperty":100}

こんな感じになります。が、これだとこれがMyClassという情報は一切ありません。HogeClassかもしれないしHugaClassかもしれない。つまりデシリアライズ不能です。よって、外から型を与える必要があります。Deserialize<MyClass>、といったように。これがもし

{
    "Type" : "MyClass",
    "Properties" : [
        {"MyProperty":100}
    ]
}

このように、値が型情報も持っていれば、型がMyClassだと分かるので、型を渡すのは不要になります。BinaryFormatterやNetDataContractSeiralizerが型不要でSerialize/Deserializeできているのは何故か、というと、シリアライズした後の形に型が付与されているからなのです。そして、なぜEnyim.Memcachedが標準でBinaryFormatterとNetDataContractSerializerを用意しているのか、あるいは何故他のものが用意できないのか、というと、型情報が必要だからです。

じゃあ型入れとけばいいじゃーん、といったところですが、こうすると型情報の分だけファイルサイズが嵩んでしまいます。また、.NET固有の型を埋め込むというのは、他の言語と通信するのにあたっては、かなりビミョウです。

だから、理想的には型は外から与えられるといいな、って思うのです。とはいえ、実際問題、Transcoderは型の渡せないインターフェイスなので、どうにかしなきゃあいけません。

型を埋める

そんなわけで、解法は、手動で型を埋める、になります。(他には全てのAPIを型付きにラップしてそれ経由でしかアクセスさせないで、Serializeを呼ぶときはbyte[]に崩してから呼ぶとかいう方法もあるですかしらん)。どういうこっちゃ、というと、伝わりやすいであろうJSON版のTranscoderで見てみましょうか。

protected override ArraySegment<byte> SerializeObject(object value)
{
    var type = value.GetType();
    var typeName = writeCache.GetOrAdd(type, TypeHelper.BuildTypeName); // Get type or Register type

    using (var ms = new MemoryStream())
    using (var tw = new StreamWriter(ms))
    using (var jw = new Newtonsoft.Json.JsonTextWriter(tw))
    {
        jw.WriteStartArray(); // [
        jw.WriteValue(typeName); // "type",
        jsonSerializer.Serialize(jw, value); // obj

        jw.WriteEndArray(); // ]

        jw.Flush();

        return new ArraySegment<byte>(ms.ToArray(), 0, (int)ms.Length);
    }
}

["型名", {objectのシリアライズ結果}]といった風に埋めてます。長さ2の配列で決め打ち!0番目は型名の文字列!1番目が実態!これなら、まあ他の言語で触るのも問題ないし(多少は不恰好ですけどね)、ファイルサイズ増大もほぼほぼ型名だけで抑えられています。MessagePack用のTranscoderも同じような実装です。このアイディアはMsgPack-Cli作者の @yfakariyaさんから頂きました。

JSON, MsgPackはそうなのですけれど、Protocol Buffers版は……違います。

ProtoTranscoder

Enyim.Memcached用のProtocol BuffersなTranscoderは、もともとprotobuf-net作者のMarc Gravell氏が作成し公開しています。Distributed caching with protobuf-net

しかし、幾つかの理由により、このコードを使用することはお薦めしません、というかやめたほうがいいです。

  • 1.対応しているProtobufやEnyim.Memcachedが古いので若干手直しが必要
  • 2.配列や辞書など、効果の高いコレクション系に対してシリアライズしてくれない(BinaryFormatterが使われる)
  • 3.そもそもバグっていて、ジェネリックなクラスを突っ込むと壊れる

1はそのまま。2は、そういうif文が入っているからです。別にコレクションだけ避けるようになっている、というわけじゃなくて、ある種の保険でそういう条件分岐があるのですが、結果としてコレクションが避けられることになってしまっていて、効果が薄くなってしまうな、と。そして3ですが、これは致命的です。どこがバグってるかというと、以下のところ。

string typeName = type.AssemblyQualifiedName;
int i = typeName.IndexOf(','); // first split
if (i >= 0) { i = typeName.IndexOf(',', i + 1); } // second split
if (i >= 0) { typeName = typeName.Substring(0, i); } // extract type/assembly only

型情報を埋め込む、つまりは型から型情報の文字列を取ってこなければなりません。それ自体はAssemblyQualifiedNameを呼ぶだけの、造作もないことなのですけれど

// System.Int32, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
Console.WriteLine(typeof(int).AssemblyQualifiedName);

Versionとか、Cultureとか、PublicKeyTokenとか、いらないね。型名とアセンブリ名、それだけ分かればそれでいい、それがいい。なので、それら無駄な情報を除去しようとしているのが↑↑のコードです。

実際うまくいきます。ジェネリックを含まなければ。

var type = typeof(List<int>);

string typeName = type.AssemblyQualifiedName;
int i = typeName.IndexOf(','); // first split
if (i >= 0) { i = typeName.IndexOf(',', i + 1); } // second split
if (i >= 0) { typeName = typeName.Substring(0, i); } // extract type/assembly only

// ↓のtypeNameは壊れてる
// System.Collections.Generic.List`1[[System.Int32, mscorlib
Console.WriteLine(typeName);

見事に欠落してしまいます。AssemblyQualifiedNameが、ジェネリックを含むクラスだと形が若干変わるので、この決め打ちSubstringでは対応しきれてません。

でもバグってるから使えない、というだけじゃ勿体ない!.NET最速シリアライザが使えないとか!というわけかで、私の作成したProtoTranscoder半分は氏のコードをベースにしています。また、型情報を埋め込むといったことの元ネタもこのコードからです。

んで、このバグッてた型情報を削るところですが、AssemblyQualifiedNameが実際どういう形を取るのか、もしくはどういう形が読み込めるものなのか、というのはMSDNのType.GetTypeメソッド解説に例付きで詳しく書いてあります。非常に複雑で正面からきっちりパースしようとすると苦戦します。なので、正規表現でサクッと削ることにしました。

internal static class TypeHelper
{
    static readonly Regex SubtractFullNameRegex = new Regex(@", Version=\d+.\d+.\d+.\d+, Culture=\w+, PublicKeyToken=\w+", RegexOptions.Compiled);

    internal static string BuildTypeName(Type type)
    {
        return SubtractFullNameRegex.Replace(type.AssemblyQualifiedName, "");
    }
}

一応テストは書いてありまして、TypeHelperTest.cs、色々並べたてた限り問題ないようなので、問題ないと思われます。

あと、型情報の埋め込みですが、JsonTranscoderは配列にして型情報を入れていましたが、ProtoTranscoderはbyte[]の先頭に直接埋め込んでいます。先頭4バイトが型情報の長さを表し(int)、その後に続く長さの分だけ型情報の文字列(UTF8)があり、その後ろが実体。配列がどうこうとかないので、サイズ的にも処理的にも有利です。ただ、Memcachedに格納された値自体は不正なProtocol Buffersの値となるわけで、相互運用性には難ありといったところ(他のデシリアライズするもの側でもストリーム先頭の型情報部分をスキップするようにすれば、回避できるといえばできます)。最初から相互運用性ゼロのBinaryFormatter(他の言語ではこれでシリアライズされた後の形を解釈できない)よりは遥かにマシ、ではありますね。

Memcached is dead. Long live Redis!

バグってるとか、いーのかよー、という感じですが、そもそも、使われてないんですよね。Stackoverflowのキャッシュ層はRedisですので。完全にノーメンテ。(StackoverflowのアーキテクチャはStack Overflow Architecture Update - Now At 95 Million Page Views A Monthで。これも2011/3のものなので、今は更に進化してるんだろうねえ。StackoverflowはかなりRedis好きみたいで、Memcached is dead. Long live Redis!ってStackoverflowのエンジニア(Marc氏ではない)が言ってた。

私もRedis好きですね。超好き。アレは超良いものだ……。ちなみにRedisのライブラリはBookSleeveServiceStack.Redisがありまして、この辺に関して詳しくは、そのうち書きましょう。いや、ほんとRedis良いしC#との相性もいいし、たまらんです。

そんなわけで放置されていたんですが、昨日の今日で、新しいのがリリースされました。protobuf-net.Enyim。そして、バグはそのままでした……。というわけで、そのことはTwitterで伝えたので、そのうち直るでしょう(Twitterは連絡手段として非常に気楽でいいですなあ)。でも、プリミティブ型の配列などにProtobufが使われない、とかTypeCacheからのTypeの取得部分がforeachぐるぐるるーぷ、などはそのままなので、私の作ったもののほうが良いです。多分ね。

パフォーマンス

性能ですが、まず、シリアライザはシリアライズする対象によって速度は変わります。だから、一概にどれが速いとか遅いとか言いにくいところはあります。そのうえで、以下のクラスと、それの配列(長さ10)を用意しました。

[ProtoContract]
[Serializable]
public class TestClass
{
    [ProtoMember(1)]
    [MessagePackMember(0)]
    public string MyProperty1 { get; set; }
    [ProtoMember(2)]
    [MessagePackMember(1)]
    public int MyProperty2 { get; set; }
    [ProtoMember(3)]
    [MessagePackMember(2)]
    public DateTime MyProperty3 { get; set; }
    [ProtoMember(4)]
    [MessagePackMember(3)]
    public bool MyProperty4 { get; set; }
}

// シンプルなPOCOとしての対象
var obj = new TestClass
{
    MyProperty1 = "hoge",
    MyProperty2 = 1,
    MyProperty3 = new DateTime(1999, 12, 11),
    MyProperty4 = true
};

// オブジェクト配列としての対象
var array = Enumerable.Range(1, 10)
    .Select(i => new TestClass
    {
        MyProperty1 = "hoge" + i,
        MyProperty2 = i,
        MyProperty3 = new DateTime(1999, 12, 11).AddDays(i),
        MyProperty4 = i % 2 == 0
    })
    .ToArray();

これを100000回シリアライズ/デシリアライズした速度と、一個のファイルサイズの検証結果が以下になります。あと、これはTranscoderを介した速度検証であって、決してシリアライザ単体での速度測定ではないことには留意してください。

Simple POCO************************
S DefaultTranscoder:735
D DefaultTranscoder:750
Size:305
S DataContractTranscoder:775
D DataContractTranscoder:1642
Size:746
S ProtoTranscoder:99
D ProtoTranscoder:142
Size:88
S JsonTranscoder:772
D JsonTranscoder:892
Size:167
S MessagePackTranscoder:256
D MessagePackTranscoder:535
Size:89
S MessagePackMapTranscoder:327
D MessagePackMapTranscoder:783
Size:137

Array******************************
S DefaultTranscoder:4234
D DefaultTranscoder:4186
Size:712
S DataContractTranscoder:3874
D DataContractTranscoder:9532
Size:4525
S ProtoTranscoder:2189
D ProtoTranscoder:3040
Size:255
S JsonTranscoder:5618
D JsonTranscoder:6275
Size:1043
S MessagePackTranscoder:752
D MessagePackTranscoder:2696
Size:256
S MessagePackMapTranscoder:1453
D MessagePackMapTranscoder:5088
Size:736

単体ではProtobufが最速。これは予想通り。配列にすると、MsgPack-Cliが爆速。ほええー。理由は分かりません!また、BinaryFormatterが決して悪くないのね。速度もそうだし、サイズも、特に配列にしたときにそんなにサイズが膨れないのは偉い、結果的にJSONより小さくなってるしね。これは、JSONは律儀に全部の配列の値に対してプロパティ名を入れますが、BinaryFormatterは先頭に型情報を一つ定義し、あとはその定義への参照という形で廻しているから、でしょうね。BinaryFormatterのデータ構造の仕様は.NET Remoting: Binary Format Data Structureにありますが、別に読まなくてもいいと思いますん。

私はバイナリアンじゃないのでバイナリと睨めっこはあんましたくないですね、前々職TrueType Fontの仕様と睨めっこしてバイナリほじほじした時は、それはそれで楽しくはあったけれど、好んでやりたくない感はあったり。ゆるふわゆとり世代ですものー。

Azure Caching

Windows Azure CachingもMemcachedプロトコルをサポートということなので、今回の話はまんま使えますね!まあ、既存のものの移し替え、とかでなければ、Enyim... よりもAzure Cachingのライブラリ使ったほうがいいとは思いますが。「Enyim cache client API で入れたデータを Windows Azure caching API (Client Api) で取得すると、例外が発生します。(その逆も同様です。)」というのは、書いてある通りにシリアライザが違うからですねー。デフォルトはNetDataContractSerializerということで、まあ、アレですね、悲しいですね、Azure Caching使うならCustom Serializer作ったほうがいいんじゃないですかね(これがEnyim...のTranscoderにあたる)。まあ、Memcached ProtocolにしてEnyim... を使ってもいいでしょうけれど、Enyim...もビミョいといえばビミョいので、その辺は何とも。

まあ、私はAzureは知らないので、きっとAzureの誰かが言ってくれるでせう。あ、 Azure Cachingのシリアライズコストが発生しない云々は ローカルキャッシュのみの話で、外側に行くなら原理的にシリアライズ/デシリアライズが発生するのは当たり前です、というのは一応。

まとめ

NetDataContractSerializerは論外として、BinaryFormatterは決して悪くはないので、エクストリームなパフォーマンスを求めないなら、そのまんまでいい気がしました。求めるんなら、やっぱProtobufに安定感ありますねえ。しかしMsgPackも良いんですね。可搬性ならJSONにしちゃうのも良いかなー。結局、アレだ、好きなもの選ぶのがいいと思いますですよ、と。

ところで、これはもともと、前職のgloopsで使うつもりで用意していたのですが、辞めちゃったとかあったので、投入するところまでは行きませんでした。というわけで今のところ利用実績はないです!まあ、多分大丈夫だと思うんですがその辺は投下してみてもらわないと何とも言えません。要は勇気が自己責任。ともあれ、コードの公開を許可してくれたgloopsに感謝します。

そんなこんなで、謎社でもC#でエクストリームな性能を求めたい方を求めております。パブリックに詳しく言えるのは予定は未定なので、そういったことをやりたいという方は、こっそり私のほうに聞いてくれると嬉しいですね。あ、これは割とマジな話ですよ。それとAzureの営業かけるなら今のうちなのでそれも私のほうまで(謎)

Profile

Yoshifumi Kawai

Cysharp, Inc
CEO/CTO

Microsoft MVP for Developer Technologies(C#)
April 2011
|
July 2024

Twitter:@neuecc GitHub:neuecc

Archive