これはドリコム Advent Calendar 2016 20日目です。

皆さんこんにちは。
新卒1年目のエンジニアはっとりと申します。

静的ファイルフォーマット検討

社内ではUnity向けのマスターデータのモデルクラス、パース処理を自動的に作成してくれるIDLの仕組みが求められていました。IDLの導入によりクライアント側のコードを書く手間を省くことができます。また、IDLスキーマに型が明示されるのでサーバー/クライアント間のコミュニケーションコストを削減できるといったメリットもあります。
現状の社内では、MessagePackやJSONが使われてきておりIDL導入の知見がありませんでした。
新たなIDL付きのファイルフォーマットを導入できるかの検討の結果、Avroを採用することとなりました。
今回はその採用のいきさつと簡単にAvroの使い方をご紹介したいと思います。

環境

  • Unity 5.5.0
  • .NET 2.0
  • mono 4.4.0

要件

まず、今回は静的ファイルフォーマットに求められるものとして以下の要件が挙げられました。

  • IDLがあるもの
  • サーバーサイド、クライアントサイド共にワークフローを変えなくて良いこと
  • Unityで動作すること
  • IL2CPPでビルド、動作すること

選択の候補

IDL付きの選択の候補として以下の2つが挙がりました。

Protocol Buffers

  • Google製
  • データ本体はバイナリ
  • IDLがある
  • 今年v3がリリースされたが.NET4.5以上が必要でv2バージョンでないとUnityで動作しない
  • v2ではC#のサポートがサードパーティ製になってしまう(protobuf-net)
  • protobuf-netではReflection.Emitを使用している箇所があり、IL2CPPで動かしたい場合には事前コンパイルが必要(*1)でワークフローの再検討が必要である

Avro

  • データ本体はバイナリ
  • IDLがありスキーマファイルがJSON形式で書ける
  • C#のサポートが公式である
  • 後方互換性がある(詳しくは後述)
  • avro-toolsなどのツール周りが充実している(詳しくは後述)
  • Reflection.Emitを使用している箇所があったが、局所的なので修正して使えそうだった(詳しくは後述)
  • MessagePackに比べるとパースに時間がかかる
  • 日本語の情報が少ない

要件との照らし合わせの結果、Avroを採用することに決定しました。
MessagePackに比べるとパース速度が劣るというデメリットもありましたが、マスターデータという性質上、そこまで速度を気にしなくても大丈夫という判断を行いました。

Avroの使い方

ここからはAvroの実際のUnityにおける使い方とAvroを使う利点についてご紹介していきたいと思います。

スキーマ用意

以下のようなJSON形式のスキーマを用意します。

{
  "type" : "record",
  "name" : "twitter_schema",
  "namespace" : "com.example.avro",
  "fields" : [ {
    "name" : "id",
    "type" : "long"
  },
    {
    "name" : "username",
    "type" : "string"
  }, {
    "name" : "tweet",
    "type" : "string"
  }, {
    "name" : "tweet_id",
    "type" : ["int", "null"]
  }
  ]
}

詳しいスキーマファイルの書き方はこちらを参考にしてください。

http://avro.apache.org/docs/1.8.1/spec.html

Avroでは後方互換性を保つことができるため、クライアント/サーバー同じタイミングでデータ構造を更新する必要がありません。サーバーサイド主導でモデルに対して新規作成やカラム追加を行うことが可能で、クライアント側では実際にモデルデータを利用するタイミングで作成されたパーサークラスを差し替えれば良いことになります。

スキーマから自動パーサー作成

上記のスキーマファイルからパーサーを作成する方法です。
まずはAvroのソースコードをGitHubからクローンしてきます。

git clone https://github.com/apache/avro

クローンしてきたレポジトリ以下の${AVRO_DIR}/lang/csharpの中にあるslnファイルを利用しxbuildを行います。

xbuild Avro.sln

作成されたexeを使いパーサーを作成します。

mono ${AVRO_DIR}/lang/csharp/build/codegen/Release/avrogen.exe -s twitter.avsc 出力先DIR

実際に作成されるパーサーは以下のようなものになります。

// ------------------------------------------------------------------------------
// <auto-generated>
//    Generated by avrogen.exe, version 0.9.0.0
//    Changes to this file may cause incorrect behavior and will be lost if code
//    is regenerated
// </auto-generated>
// ------------------------------------------------------------------------------
namespace com.example.avro
{
	using System;
	using System.Collections.Generic;
	using System.Text;
	using Avro;
	using Avro.Specific;

	public partial class twitter_schema : ISpecificRecord
	{
		public static Schema _SCHEMA = Avro.Schema.Parse("{\"type\":\"record\",\"name\":\"twitter_schema\",\"namespace\":\"com.example.avro\",\"fields\":[{\"name\":\"id\",\"type\":\"long\"},{\"name\":\"username\",\"type\":\"string\"},{\"name\":\"tweet\",\"type\":\"string\"},{\"name\":\"tweet_id\",\"type\":[\"int\",\"null\"]}]}");
		private long _id;
		/// <summary>
		/// Name of the user account on Twitter.com
		/// </summary>
		private string _username;
		/// <summary>
		/// The content of the user's Twitter message
		/// </summary>
		private string _tweet;
		private System.Nullable<int> _tweet_id;
		public virtual Schema Schema
		{
			get
			{
				return twitter_schema._SCHEMA;
			}
		}
		public long id
		{
			get
			{
				return this._id;
			}
			set
			{
				this._id = value;
			}
		}
		/// <summary>
		/// Name of the user account on Twitter.com
		/// </summary>
		public string username
		{
			get
			{
				return this._username;
			}
			set
			{
				this._username = value;
			}
		}
		/// <summary>
		/// The content of the user's Twitter message
		/// </summary>
		public string tweet
		{
			get
			{
				return this._tweet;
			}
			set
			{
				this._tweet = value;
			}
		}
		public System.Nullable<int> tweet_id
		{
			get
			{
				return this._tweet_id;
			}
			set
			{
				this._tweet_id = value;
			}
		}
		public virtual object Get(int fieldPos)
		{
			switch (fieldPos)
			{
			case 0: return this.id;
			case 1: return this.username;
			case 2: return this.tweet;
			case 3: return this.tweet_id;
			default: throw new AvroRuntimeException("Bad index " + fieldPos + " in Get()");
			};
		}
		public virtual void Put(int fieldPos, object fieldValue)
		{
			switch (fieldPos)
			{
			case 0: this.id = (System.Int64)fieldValue; break;
			case 1: this.username = (System.String)fieldValue; break;
			case 2: this.tweet = (System.String)fieldValue; break;
			case 3: this.tweet_id = (System.Nullable<int>)fieldValue; break;
			default: throw new AvroRuntimeException("Bad index " + fieldPos + " in Put()");
			};
		}
	}
}

ご覧のように自動生成されたパーサーはpartialとして定義されているので、
独自実装を加えたい場合でも別ファイルとして切り出すことが可能となりメンテナンス性が良い仕様となっています。

avro-tools(*2)

Avroにはランダムデータの作成やJSON Avro binaryの変換を行うことができる便利なツール群が用意されています。

Macの場合にはbrewコマンドでインストールすることができます。

brew install avro-tools

ランダムデータ作成

以下のコマンドを実行することでスキーマファイルを元にAvro binaryのサンプルデータを1000件作成することができます。

avro-tools random --schema-file twitter.avsc --count 1000 twitter.avro

Avro -> JSON

avro-tools tojson twitter.avro > twitter.json

JSON -> Avro

avro-tools fromjson --schema-file twitter.avsc twitter.json > twitter.avro

こうした補助ツール群の充実もAvroの魅力の一つです。

組み込み

Avroのデータ自体をstreamingAssetsPath以下に配置、自動生成されたパーサークラスをUnityのAssets以下に設置します。以下のスクリプトを組み込むことで正しくデータが取得できていることが確認できます。

twitter_schema ts = new twitter_schema();
Avro.Schema schema = ts.Schema;

#if Unity_ANDROID && !Unity_EDITOR
string filepath = Application.streamingAssetsPath + "/twitter.avro";
#else
string filepath = "file://" + Application.streamingAssetsPath + "/twitter.avro";
#endif

using(WWW www = new WWW(filepath)){
	while (!www.isDone) {};
	MemoryStream ms = new MemoryStream(www.bytes);

	var reader = Avro.File.DataFileReader<twitter_schema>.OpenReader(ms, schema);

	try {
		while (reader.HasNext()) {
			var value = reader.Next();
			Debug.LogFormat ("id: {0}, username: {1}", value.id, value.username);
			outMessage += value.id + System.Environment.NewLine;
			outMessage += value.username + System.Environment.NewLine;
		}
	} catch(Exception e) {
		Debug.Log (e);
	}
}

AOT対応

ここまででUnityEditor、Android monoビルドは行うことができますが、先述したようにAvroのソースコード中にReflection.Emitが含まれるため、IL2CPPビルドを行うことができません。

今回はランタイム中のコードを修正することで回避しています。
https://github.com/drecom/avro/tree/feature/aot_support

本家のAvroレポジトリにもアップストリーム中ですが、現時点ではマージされていませんので自己責任で参考にしてください。

まとめ

ここまでファイルフォーマットの選定、そして採用したAvroの使い方とそれを使う利点を見てきました。

ファイルフォーマットの選定は悩ましいところではありますが必要とされている要件をしっかり洗い出すことで、自ずとそれにマッチしていく選択が絞り込めていくのではないかと思います。Unityの場合はAvroを選択肢の候補にするのも面白いと思います。
また、今まで使ったことのないツールやフローの導入には抵抗を感じてしまうものですが、使い方やそれを使うメリットを事前に精査、理解していくことでその障壁は下がっていくものと感じました。最終的にはAvroを導入して良かったと感じています。
この経験を生かし今後も良いものは積極的に取り入れていく姿勢を忘れないようにしていきたいです。

参考

*1: http://klabgames.tech.blog.jp.klab.com/archives/1046707170.html
*2: http://www.michael-noll.com/blog/2013/03/17/reading-and-writing-avro-files-from-the-command-line/