このエントリーは、KLab Advent Calendar 2015 の12/5の記事です。

5番手も緊張しますね。KLab 新卒4年目の @mecha_g3 です。よろしくお願いします。

5日目ですがGoの話ではありません。

はじめに

最近のモバイルオンラインゲームはリアルタイム性の高いマルチプレイができるものが増えてきました。
Unity を使用してゲームを作る場合の通信ライブラリは、Photon Unity Networking が有名です。
Photon を使えばマルチプレイのゲームを比較的簡単に動かすことができますが、便利な反面思い通りに行かない所も出てきます。
ここでは Unity C# を使用して開発するゲームの通信部分を自分で実装したい人のために、protobuf-net の話をします。

protobuf-net とは

protobuf-net は ProtocolBuffers の非公式 C# 実装です。
ProtocolBuffers とは、Google が開発したシリアライズフォーマットとそのライブラリおよび IDL(インターフェース定義言語) とそのコンパイラなどの総称です。

Google 公式の C# 版のソースコードは version3 から提供されるようになりましたが、Status: Alpha と書かれてあるうえに、リポジトリの issue を見る限り Unity ではまだ動かないようです。

protobuf-net というOSSライブラリが Apacheライセンスで使用しやすく、Unityで使用できそうという事で使ってみることにしました。

最初は MessagePack を使うか悩んだのですが、IDL を使って定義を記述し、サーバ用のコードとクライアント用のコードをそれぞれ生成できる点を評価して ProtocolBuffers を使用することにしました。

protobuf-net の入手

コンパイル済みのバイナリは GoogleCode の Downloads のページからダウンロードできます。
ただ執筆時点で最新が2013年9月と古いうえに、GoogleCode は 2016年1月 25日にサービス終了されるのでダウンロードできなくなると思われます。

protobuf-net のビルド

Github からソースを入手します。
最新のmasterブランチでビルドできない場合、issue やコミットログを見ながら適当なバージョンを探してください。

コンパイルには、VisualStudio をインストールした Windows 環境が必要です。
私は VisualStudio 2013 for Desktop を使用しました。
protobuf-net は様々なプラットフォーム用にプロジェクトファイルが存在しますが、すべてのプラットフォーム向けにビルドする環境を整えるのが非常に困難かつそもそもコンパイル通るか怪しいので、今回は Unity 向けビルドのみ着目します。
リポジトリ直下にある all.build を参考にしながら、Unity 用のビルドファイル (ここでは仮に unity.build とします) を作ります。

私の場合以下の様になりました。

MSBuild を使用してビルドします。

MSBuild.exe unity.build

ビルドしてできた protobuf-net.dll を Unity プロジェクトの Plugins ディレクトリにコピーします。

簡単な使い方

protobuf-net は .proto ファイルを使用せずに手動で C# の class に属性を追加する使い方をサポートしているようですが、ここでは .proto ファイルに定義を記述する使い方を紹介します。
私は MacOSX版 の Unity 5.2.1p3 を使用して実験しました。Windows 環境の方は適宜読み替えてください。

C++版の Protocol Buffers をインストール

protobuf-netが version2系なので私は protobuf 2.x 系を使用していますが、最新でも互換性があるようなので動くかもしれません。

mono のインストール

mono をインストールしてPATHを通しておきます。

.proto ファイルを記述

sample.proto ファイルとして以下の内容を保存します。

package sample;
message Person {
  required string name = 1;
  required int32 id = 2;
  optional string email = 3;
}

protoc

protoc コマンドを使用して sample.proto の descriptor_set を出力します。

protoc --descriptor_set_out=sample.pb sample.proto

protogen

ビルドした protobuf-net の中にある protogen.exe を使用して、C#のソースファイルを生成します。

mono /path/to/protogen.exe -i:sample.pb -o:sample.pb.cs

これで生成される sample.pb.cs は以下のような内容になっています。

//------------------------------------------------------------------------------
// <auto-generated>
//     This code was generated by a tool.
//
//     Changes to this file may cause incorrect behavior and will be lost if
//     the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------

// Generated from: sample.proto
namespace sample
{
  [global::System.Serializable, global::ProtoBuf.ProtoContract(Name=@"Person")]
  public partial class Person : global::ProtoBuf.IExtensible
  {
    public Person() {}

    private string _name;
    [global::ProtoBuf.ProtoMember(1, IsRequired = true, Name=@"name", DataFormat = global::ProtoBuf.DataFormat.Default)]
    public string name
    {
      get { return _name; }
      set { _name = value; }
    }
    private int _id;
    [global::ProtoBuf.ProtoMember(2, IsRequired = true, Name=@"id", DataFormat = global::ProtoBuf.DataFormat.TwosComplement)]
    public int id
    {
      get { return _id; }
      set { _id = value; }
    }
    private string _email = "";
    [global::ProtoBuf.ProtoMember(3, IsRequired = false, Name=@"email", DataFormat = global::ProtoBuf.DataFormat.Default)]
    [global::System.ComponentModel.DefaultValue("")]
    public string email
    {
      get { return _email; }
      set { _email = value; }
    }
    private global::ProtoBuf.IExtension extensionObject;
    global::ProtoBuf.IExtension global::ProtoBuf.IExtensible.GetExtensionObject(bool createIfMissing)
      { return global::ProtoBuf.Extensible.GetExtensionObject(ref extensionObject, createIfMissing); }
  }

}

シリアライズ/デシリアライズ

protobuf-net.dll がうまくインポートできていれば、上記の sample.pb.cs をプロジェクトに入れると、(Unity Editor上で) 以下のようにシリアライズ/デシリアライズができます。

...
var person1 = new sample.Person();
person1.id = 123;
person1.name = "taro";
person1.email = "taro@example.com";

System.IO.MemoryStream ms = new System.IO.MemoryStream();
ProtoBuf.Serializer.Serialize<sample.Person>(ms, person1);

ms.Seek(0, System.IO.SeekOrigin.Begin);
var person2 = ProtoBuf.Serializer.Deserialize<sample.Person>(ms);

UnityEngine.Debug.Log(person2.id);     // => 123
UnityEngine.Debug.Log(person2.name);   // => "taro"
UnityEngine.Debug.Log(person2.email);  // => "taro@example.com"

事前コンパイル

ここまでの手順のままでは、少なくとも iOS 向けビルドではうまくシリアライズ/デシリアライズができません。
動かない理由は、protobuf-net は実行時型情報を使いながら、Reflection.Emit() を使って シリアライズ/デシリアライズするプログラムを動的に生成しているからです。
Reflection.Emit() は Unity iOS 向けビルドではサポートされていません。

.protoファイルで定義したすべての型に対して、そのシリアライザ/デシリアライザを事前にコンパイルし、dll化しておくことによりこれを回避します。
まず、生成した .cs ファイルを一つの dll ファイルにまとめます。

gmcs \
     -target:library \
     -langversion:ISO-2 \
     -r:/path/to/protobuf-net.dll \
     -out:proto.dll \
     $(find . -name "*.pb.cs")

precompile.exe を使用して、シリアライザ/デシリアライザの関数を dll ファイルとして出力します。

mono /path/to/precompile.exe proto.dll -o:proto_serializer.dll -t:ProtoSerializer

proto.dll と proto_serializer.dll を同様に Unity のプロジェクトにインポートし、Protobuf.Serializer の代わりに ProtoSerializer クラスを使うことで、事前コンパイルしたシリアライザ/デシリアライザを使用することができます。

問題点とその回避

protobuf-net を使用していて気づいた問題点とその回避方法を紹介します。これらの問題点はいずれも protobuf-net の issue 等で報告されているものです。
最初は PullRequest を送るつもりで修正を開始しましたが、他の機能の互換性を壊す部分があるので控えています。

Enum型のデフォルト値の問題

protobuf-net では指定がない限り、インスタンスを生成した際に default(T) で初期化されます。
.protoファイルで型を定義する際、フィールドの後ろに [default = 123]; というオプションをつけることで明示的にデフォルト値を指定することができます。

  • C# では Enum型の値のデフォルト値は 0 です。
  • ProtocolBuffers の仕様では、Enum型のデフォルト値は明示的な指定がない場合、定義した最初の定数です。

protobuf-net の protogen.exe のテンプレート内では、ProtocolBuffers の仕様に合う様に、最初の定数をデフォルト値にしようとしている部分がありますが、別ファイルで定義されたEnum型の定数一覧を取得することができずコンパイルエラーになる C# のソースコードを出力してしまうという問題があります。

この問題を修正するためには protogen.exe において、依存する他の descriptor_set ファイルも読み込み、Enumの定義を見て適切にデフォルト値を選択する必要があります。
入力を複数ファイルにする必要があり、コマンド自体に大きな変更が必要になってきます。

そもそも C# と Protobuf でデフォルト値の扱いが違うので、.proto ファイルでデフォルト値を必ず明示的に設定する運用で回避することにしました。

フィールドデフォルト値の問題

Enum型のデフォルト値の問題 で紹介した、明示的に指定するデフォルト値ですが、protobuf-net では required フィールドでは無視され、optional フィールドにしか適用されません。
さらに別の問題として、boolint, double などの数値型を値で持っているため、明示的に代入した値なのか、デフォルト値として初期化された値なのかがわからなくなります。

この問題を調査するため、protogen.exe のソースを読んでいくと、-p:detectMissing というオプションがあることに気づきました。このオプションをつけることで、定義した型の各フィールドの値を nullable で持つようになり、「値が設定されていない状態」を持つことができるようになります。
ただし required フィールドでは無視されているので、これを required フィールドにも適用されるように生成されるコードのテンプレートを編集します。
C# のテンプレートは protobuf-net/ProtoGen/csharp.xslt です。
フィールドの default オプションで設定したデフォルト値と、 protogen.exe の -p:detectMissing オプション
を required フィールドに対しても反映するように修正しました。

シリアライズ失敗の検出

本家 Protocol Buffer の実装では、.proto ファイルで、required として定義したフィールドでは、シリアライズ/デシリアライズ時に値が設定されていない場合、例外が投げられます。
しかし protobuf-net では required フィールドに値が入っていなくても、シリアライズ/デシリアライズ時に例外を投げてくれません。
例えばクライアント-サーバ型のゲームで、クライアント側で required フィールドに値を設定し忘れた場合にもシリアライズの失敗が検出できず、サーバ側に壊れたバイナリを送りつけてしまう事になります。

required フィールドへの値の設定漏れはゲーム開発中には頻繁に起こりえるので、なんとかして検出したいです。
シリアライズの直前、デシリアライズの直後に、required フィールドが設定されていなかった場合に例外を投げるように修正します。

挿入する場所は悩みましたが、precompile.exe により生成される各型のシリアライザ/デシリアライザの関数の中に一緒に書いてしまうのが楽そうです。
小さな修正でしたが、IL を生成する部分なので普段から IL で喋っていない私にとっては凄く難しかったです。
生成された dll をデコンパイルして思い通りのプログラムになるまで試行錯誤した結果、なんとか動くようになりました。

これら修正の問題点として

  • Serializer.DeepClone<T>() が内部でシリアライズ/デシリアライズを行っているため例外が出てしまう。
  • precompile.exe を通さない場合にシリアライズ失敗の例外が投げられない。
  • C# 用のテンプレートしか修正していない。vb.xsltxml.xslt も修正する必要がある。

などが挙げられます。上手く修正できれば、protobuf-net リポジトリに PullRequest を送りたいと思っています。

終わりに

この記事が、 protobuf-net を使っている方/使ってみたいと思っている方に少しでも参考になれば幸いです。
私が勘違いして書いてある部分があるかもしれません。指摘していただけると嬉しいです。

明日は kokukuma さんの カーネル空間までコード追っていこう@Linux です。