バイナリ シリアライザについて

オブジェクトをシリアライズする時に、どのシリアライザを使用するかによってデータの互換性を含めて、様々な問題が発生することがあります。.NET Framework や Mono を使った開発で、利用するシリアライザの種類を列挙します。

  • バイナリ シリアライザ
    BinaryFormatter が標準で組み込まれていますので、使われるケースが多いと思われます。
    標準以外にも様々なシリアライザが OSS として開発されていたりします。
  • XML シリアライザ
    DataContractFormatter が標準で組み込まれています。
  • JSON シリアライザ
    標準で用意されているシリアライザを使用したり、Json.net を使用することが多いと思います。

バイナリ シリアライザを使用する時の資料は、MSDN ライブラリに掲載されています。バイナリ シリアライザを使用する時の注意は、以下の点になります。

  • シリアライズするクラスの型を持つアセンブリをライブラリにすること。
  • 後日にクラスにメンバーを追加した場合は、バージョン トレラントを確保すること。

バイナリ シリアライズで作成されたバイナリ データには、「アセンブリ識別子(アセンブリ名、バージョン、カルチャ、パブリック トークン)」と「型の完全名(名前空間。クラス名)」、「シリアライズするフィールド名」と「データ」が記録されています。クラスという型の情報は、アセンブリのType Definition で定義されており、デシリアライズする時に型情報が一致しないとデシリアライズが失敗します。この理由で、シリアライズする型を定義するアセンブリをライブラリにしておくことで、デシリアライズのエラーを削減できるようになります。

シリアライズの基本的な使い方

シリアライズ対象になるクラスが、SerializeSample というアセンブリで、Person クラスが以下のように定義されていたとします(シリアライザは、内部でリフレクションを使用しています。リフレクション API が変更されると、その影響を受けることもあります)。

 using System;

namespace SerialSample
{
    [Serializable]
    public class Person
    {
        public Person() { }

        public string Name { get; set; }
        public string Address { get; set; }
        public DateTime BirthDate { get; set; }
    }
}

このクラスを BinarryFormatter でシリアル化する場合は、次のコードで行うことができます。

 // BinaryFormatterの作成
var formatter = new BinaryFormatter();
FileStream v1File = null;
try
{
    v1File = new FileStream(@"..\..\..\Output\v1Output.bin", FileMode.Create);
    var person = new Person()
{
    Name = "Johnatha",
    Address = "102 Main Street",
    BirthDate = new DateTime(1980, 1, 31, 0, 0, 0, DateTimeKind.Local)
};
// シリアライズ
formatter.Serialize(v1File, person);
isSuccess = true;
}
catch (Exception ex)
{
    Console.WriteLine("エラー={0}", ex.Message);
}
finally
{
    if (v1File != null)
       v1File.Close();
}

デシリアライズは、以下のコードで行います。

 // BinaryFormatterの作成
var formatter = new BinaryFormatter();
FileStream v1File = null;
try
{
    v1File = null;
    v1File = new FileStream(@"..\..\..\Output\v1Output.bin", FileMode.Open);
    var data = formatter.Deserialize(v1File);
    var person = data as Person;
    Console.WriteLine("Name={0} , Address={1}, BirthDate={2}", person.Name, person.Address, person.BirthDate.ToString());
}
catch (Exception ex)
{
    Console.WriteLine("エラー={0}", ex.Message);
}
finally
{
    if (v1File != null)
        v1File.Close();
}
Console.WriteLine("Complete deseriazed!");

このコードでは、Person クラスに定義された Public なメンバーがシリアライズ/デシリアライズされます。何故、Public なメンバーかということを説明すれば、BinarryFormatter クラスが定義されているアセンブリがこのプログラムとは別に存在するからです。つまり、他のアセンブリからアクセスできる情報は public である必要があるからです。private や internal のアクセシビリティを持っていると、シリアライズの対象にならないということが一般的な制限となっています。

バージョン トレラントなシリアライズ

今度は、Person クラスにメンバーを追加した時に、すでにシリアル化したデータとの互換性をどのように保つかということを考えてみましょう。このことを実現するのが、バージョン トレラントなシリアル化になります。

 using System;
using System.Runtime.Serialization;

namespace SerialSample
{
    [Serializable]
    public class Person
    {
        public Person() { }

        public string Name { get; set; }
        public string Address { get; set; }
        public DateTime BirthDate { get; set; }

        [OptionalField(VersionAdded = 2)]
        private int _age;
        public int Age
        {
            get { return _age; }
            set { _age = value; }
        }

        [OnDeserializing]
        private void OnDeserializing(StreamingContext context)
        {
            Console.WriteLine("デシリアライズ中 Person v2クラス ");
            _age = 0;
            Console.WriteLine("デフォルトを設定 age={0}", _age);
        }
        [OnDeserialized]
        private void OnDeserialized(StreamingContext context)
        {
            Console.WriteLine("デシリアライズ完了  Person v2クラス ");
            if (_age == 0)
            {
                _age = DateTime.Now.Year - BirthDate.Year;
                if (DateTime.Now.DayOfWeek < BirthDate.DayOfWeek) _age--;
            }
            Console.WriteLine("Name={0} , Address={1}, BirthDate={2}, Age={3}",
                Name, Address, BirthDate.ToString(), Age);
        }
        [OnSerializing]
        private void OnSerializing(StreamingContext context)
        {
            Console.WriteLine("シリアライズ中");
        }
        [OnSerialized]
        private void OnSerialized(StreamingContext context)
        {
            Console.WriteLine("シリアライズ完了");
        }

    }
}

このPersonクラスでは、次のようなコードを追加しています。

  • Age プロパティ と _ageフィールドを追加し、OptionalField属性を使用しています。
  • OnDeserializing メソッドで、_age フィールドの初期値を設定しています。
    Age プロパティが設定されている場合は、OnDeserialized メソッドが呼び出されるまでに _age フィールドは正しい値に設定されます。
  • OnDeserialized メソッドで、_age フィールが初期値であれば値を計算しています。
  • OnSerializing メソッドと OnSerialized メソッドは、呼び出されるタイミングを確認するために記述しています。

このようなコードを追加した Person クラスであれば、最初のPerson クラスでシリアライズしたデータを読み込んでも、問題なくデシアライズすることができます。このPerson クラスを使って、シリアライズしたデータは、古いPerson クラスを使用したプログラムでは読み込むことはできません。つまり、追加したAgeプロパティの情報を持っていることが理由です。新しい Person クラスを持つプログラムだけが、古いシリアライズ データと新しいシリアライズ データの両方に対応できることになります。この手法を、バージョン トレラントなシリアライズと呼んでいます。

シリアライズをカスタマイズする

シリアライズを行う上で、private なメンバーなどをシリアライズできるようにするには、シリアライズを ISerializable インターフェイスを使ってカスタマイズする必要があります。Person クラスをカスタマイズした例を示します。

 using System;
using System.Runtime.Serialization;

namespace SerialSample
{
    [Serializable]
    public class Person : ISerializable
    {
        public Person() { }

        internal string Name { get; set; }
        internal string Address { get; set; }
        internal DateTime BirthDate { get; set; }

        public void SetData(string name, string address, DateTime birthDate)
        {
            Name = name;
            Address = address;
            BirthDate = birthDate;
        }

        public override string ToString()
        {
            return string.Format("Name ={0}, Address={1}, BirthDate{2}", Name, Address, BirthDate.ToString());
        }
        protected Person(SerializationInfo info, StreamingContext context)
        {
            Console.WriteLine("デシアライズ");
            Name = info.GetString("name");
            Address = info.GetString("address");
            BirthDate = info.GetDateTime("birthDate");
        }
        public void GetObjectData(SerializationInfo info, StreamingContext context)
        {
            Console.WriteLine("シリアライズ");
            info.AddValue("name", Name);
            info.AddValue("address", Address);
            info.AddValue("birthDate", BirthDate);
        }
    }
}

この Person クラスの特徴は、次のようになります。

  • コンストラクタは public ですが、プロパティは internal に設定しています。
    プロパティを設定する public メソッドとして SetData を用意して、ToString メソッドをオーバライドしています。
  • protected な コンストラクタ が、SerializationInfo と StreamingContext を引数に取ります。
    デシリアライズ時に、このコンストラクタが呼びだれますので、internal なプロパティを設定しています。
  • GetObjectData メソッドが、シリアライズ時に呼び出されます。
    SerializationInfo に internal なプロパティを格納しています。

このようにシリアライズ対象のクラスをカスタマイズすることで、BinarryFormatter を呼び出すコードを変更することなく、シリアライズをカスタマイズすることができます。また、BinaryFormatter の特徴として public なプロパティやフィールドがなくても利用できるメリットもあります。

UWP や .NET Core におけるシリアライズ

バイナリ シリアライズを行う BinaryFormatter クラスは、Windows ストア アプリ(Windows Runtime)、UWP アプリ、.NET Core ではサポートされていません。このため、別のシリアライザを使用する必要があります。これらの用途で私が良く使うのが、MsgPack for CLI というバイナリ シリアライザになります。MsgPack はプロジェクトのホームページによると、JSON のように使えて、早くて小さいバイナリ シリアライザであり、多くのプログラミング言語で使用できるようになっているという特徴があります。このMessagePack の .NET 版が、MsgPack for CLI ということになり、利用できる環境としては以下のものがあります。

  • .NET Framework (デスクトップ Windows)
  • .NET Core
  • Windows ストア アプリ(Windows Runtime)
  • UWP アプリ
  • Windows Phone と Silverlight
  • Xamarin と Mono
  • Unity 3D

UWP と Unity の場合は、ソース コードからビルドする必要がありますが、それ以外のプラットフォームでは nuget パッケージから導入することも可能になっています。BinaryFormatter と比較しても、遜色なく使えるのが特徴ですが、異なる点が 1 つだけあります。それは、

  • public なメンバー(フィールド、あるいはプロパティ) が 1つ以上は必要

という制限があることです。まあ、この制限は、シリアライザとしては一般的なので特に問題になるようなものではありません。それよりも、UWP、Xamarin、Unity に対応していることの方が重要だと考えます。なぜなら、マルチ プラットフォーム向けのアプリにおける共通のシリアライザとして使用できるようになるからです。BinaryFormatter を採用していると、UWP へ移行するのに手間がかかり、移行しないで、作り直すということもありますので、シリアライザの選択には、ご注意ください。

MsgPack を使った基本的なシリアライズ

BinaryFormatter の最初の説明である「基本的な使い方」で説明した「Person」クラスをシリアライズするコードを、次に示します。

 using System;
using System.IO;
using MsgPack.Serialization;
using SerialSample;

namespace MsgPackV1
{
    class Program
    {
        static void Main(string[] args)
        {
            // MsgPackのシリアライザの作成
            var formatter = MessagePackSerializer.Get<person>();
            FileStream v1File = null;
            try
            {
                v1File = new FileStream(@"..\..\..\Output\MsgPackV1Output.bin", FileMode.Create);
                var person = new Person()
                {
                    Name = "Johnatha",
                    Address = "102 Main Street",
                    BirthDate = new DateTime(1980, 1, 31, 0, 0, 0, DateTimeKind.Local)
                };
                // シリアライズ
                formatter.Pack(v1File, person);
            }
            catch (Exception ex)
            {
                Console.WriteLine("エラー={0}", ex.Message);
            }
            finally
            {
                if (v1File != null)
                    v1File.Close();
            }

            Console.WriteLine(@"Person v1 written out to ..\..\..\Output\MsgPackV1Output.bin");
        }
    }
}

MsgPack を使用する上で、特徴的なのは次の2つになります。

  • MessagePackSerializer.Get メソッドで、対象クラスのシリアライザを取得すること。
  • シリアライザの Packメソッドで、オブジェクトをシリアライズすること。

MsgPack では、シリアライズを「Pack」と呼び、デシリアライズを「Unpack」と呼んでいます。シリアライズしたデータをデシリアライズするコードを示します。

 // MsgPackのシリアライザの作成
var formatter = MessagePackSerializer.Get<Person>();
FileStream v1File = null;
try
{
    v1File = null;
    v1File = new FileStream(@"..\..\..\Output\MsgPackV1Output.bin", FileMode.Open);
    var data = formatter.Unpack(v1File);
    var person = data as Person;
    Console.WriteLine("Name={0} , Address={1}, BirthDate={2}", person.Name, person.Address, person.BirthDate.ToString());
}
catch (Exception ex)
{
   Console.WriteLine("エラー={0}", ex.Message);
}
finally
{
if (v1File != null)
    v1File.Close();
}

すでに説明した、Unpackメソッドを使用してデシリアライズしているだけで、シリアライズと含めて BinaryFormatter と同様の使い方ができます。

MsgPack によるカスタムのシリアライズ

BinaryFormatter がシリアライズをカスタマイズできるように、MsgPackもカスタマイズを行うことができます。この用途で用意しているのが、IPackable と IUnpackable インターフェイスになります。このインターフェイスを使って、定義した Person クラスを示します。

 using System;
using MsgPack;
using MsgPack.Serialization;

namespace SerialSample
{
    public class Person : IPackable, IUnpackable
    {
        public Person() { }

        public string Name { get; set; }
        internal string Address { get; set; }
        internal DateTime BirthDate { get; set; }

        public void SetData(string name, string address, DateTime birthDate)
        {
            Name = name;
            Address = address;
            BirthDate = birthDate;
        }

        public override string ToString()
        {
            return string.Format("Name ={0}, Address={1}, BirthDate{2}", Name, Address, BirthDate.ToString());
        }

        public void PackToMessage(Packer packer, PackingOptions options)
        {
            // フィールドサイズを指定
            packer.PackArrayHeader(3);
            // フィールドを指定
            packer.Pack(BirthDate);
            packer.Pack(Address);
            packer.Pack(Name);
        }
        public void UnpackFromMessage(Unpacker unpacker)
        {
            string name;
            string address;
            Int64 birthDate;

            if (!unpacker.IsArrayHeader)
                throw new Exception("ArrayHeaderがありません");
            if (unpacker.ItemsCount != 3)
                throw new Exception("フィールド数が一致しません");

            // 読みだす順序は、PackToMessage で指定した順序にします
            if (!unpacker.ReadInt64(out birthDate))
            {
                throw new Exception("BirthDate が存在しません");
            }
            BirthDate = DateTime.FromBinary(birthDate);

            if (!unpacker.ReadString(out address))
            {
                throw new Exception("Address が存在しません");
            }
            Address = address;
            if (!unpacker.ReadString(out name))
            {
                throw new Exception("Name が存在しません");
            }
            Name = name;
        }
    }
}

バイナリ シリアライザのカスタマイズと異なるのは、以下の点になります。

  • public なプロパティである Name を定義している。
    MsgPack の制限を満たすためです。
  • PackToMessage メソッドで、シリアライズの動作を定義する。
  • UnpackFromMessage メソッドで、デシアライズの動作を定義する。
    BirthDate はDateTime ですが、Int64 にシリアライズされるので、ここでは Int64 で定義しています。

バージョン トレラント なシリアライズを実装するには、カスタムなシリアライズを使用して、ArrayHeader の大きさで判断すれば実装することは可能だと考えます。もちろん、ここで説明した IPackable と IUnpackable インターフェイス以外にも方法はありますが、BinaryFormatter で説明した GetObjectData メソッドと protected なコンストラクタ に対応するには、IPackable と IUnpackable インターフェイスで十分だと私は考えています。

作成するプログラムがマルチ プラットフォームを前提とするのであれば、シリアライザがマルチ プラットフォームに対応しているかどうかも重要な要素になります。何故なら、回線が切断された場合にデータをローカルに保存するしかなく、そのコードがターゲット プラットフォームごとに異なるのは、デバッグ工数が増えることを意味しますので、シリアライザの選択にも注意をするようにしてください。

本情報の内容(添付文書、リンク先などを含む)は、作成日時点でのものであり、予告なく変更される場合があります。