土屋つかさの技術ブログは今か無しか

土屋つかさが主にプログラミングについて語るブログです。

#unity (C#)でFlatBuffersを使い倒す(4) シリアライズ/デシリアライズのサンプルコード

 いよいよデータのシリアライズ/デシリアライズを実行します。テストなので一個のプログラムで「データの作成→シリアライズしてファイルを保存→保存したファイルを読み込み→デシリアライズしてデータを再構築する」という手順を一気に行います。

Unityでの作業手順

 Unityで新規のプロジェクトを作成し、Assestsフォルダ直下にStreamingAssetsフォルダを作成してください。シリアライズされたバイナリデータファイルがそのフォルダに作成されます。
 また、適当なオブジェクトをシーンに配置しておきます。
 最後に、以下のサンプルコードをAssets内に配置し、先ほど配置したオブジェクトにバインドします。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

using System.IO;
using FlatBuffers;
using MyGame2.Sample;

public class FlatBuf : MonoBehaviour
{
    void Start()
    {
        //ビルダーの生成
        FlatBufferBuilder builder = new FlatBufferBuilder(1);

        //キャラ名作成
        StringOffset nameOffset0 = builder.CreateString("Orc");
        StringOffset nameOffset1 = builder.CreateString("Goblin");

        //MonsterのVector作成
        Offset<Monster>[] Charctors = new Offset<Monster>[2];

        //Monster2体作成
        Charctors[0] = Monster.CreateMonster(builder,0,1,2,nameOffset0);
        Charctors[1] = Monster.CreateMonster(builder,3,4,5,nameOffset1);

        //MonsterListのItemsVector作成
        VectorOffset test = MonsterList.CreateItemsVector(builder, Charctors);

        //MonsterList作成
        Offset<MonsterList> reslut = MonsterList.CreateMonsterList(builder, test);

        //データバッファを完了する
        MonsterList.FinishMonsterListBuffer(builder, reslut);

        //バイナリファイルに書き込み
        using (FileStream fs = new FileStream(Application.streamingAssetsPath + "/MonsterList.bin", FileMode.Create))
        {
            using (BinaryWriter bw = new BinaryWriter(fs))
            {
                bw.Write(builder.SizedByteArray());
            }
        }

        byte[] readBuffer;
        //バイナリファイル読み込み
        using (FileStream fs2 = new FileStream(Application.streamingAssetsPath + "/MonsterList.bin", FileMode.Open))
        {
            readBuffer = new byte[fs2.Length];
            fs2.Read(readBuffer, 0, readBuffer.Length);
        }

        //バイト列からルートの要素の取得
        MonsterList monsterList = MonsterList.GetRootAsMonsterList(new ByteBuffer(readBuffer));

        //各Itemにアクセス
        Monster? monster0 = monsterList.Items(0);
        Monster? monster1 = monsterList.Items(1);
    }

    void Update()
    {
        
    }
}

 UnityをPlayにすると、シリアライズ/デシリアライズ処理が実行されます。デバッグトレースすると、最後の2行でオブ絵ジェクトが再構築されるのが確認できます。

シリアライズ処理

 コードを解説していきます。

//ビルダーの生成
FlatBufferBuilder builder = new FlatBufferBuilder(1);

 FlatBuffersのシリアライズにはまずFlatBufferBuilderクラスのインスタンスを生成します。引数には開始時のバッファサイズを指定します(1以上。0以下だと例外発生)。これはシリアライズされたバイナリデータを格納するメモリ領域で、不足すると自動的に倍々で確保され直されるので、最初は1で良いかと思います(公式のサンプルコードがこうなってる)。
 ちなみに、実際にメモリバッファを保持管理しているのは、Builder内で生成されるByteBufferというクラスインスタンスです。

//MonsterのVector作成
Offset<Monster>[] Charctors = new Offset<Monster>[2];

 Monsterのデータを2体登録するために、Offsetの配列を作成します。
 ジェネリクスで型名を設定していますが、内部的には使用していません(コンパイル時の型チェックのみ行われると思われる)。

//キャラ名作成
StringOffset nameOffset0 = builder.CreateString("Orc");
StringOffset nameOffset1 = builder.CreateString("Goblin");

 ここからMonsterのデータを2個分バッファに格納していきます。まず先にNameに格納する文字列を格納します。あるテーブルが可変長のデータ(ここでは文字列)を持つ場合、そのデータは先に配置する必要があります。vectorについても同じですし、あるテーブルが別のテーブルを参照する場合も先の配置が必要です。
 FlatBufferBuilder.CreateString()は指定したバッファに文字列を配置するメソッドで、配置した文字列の先頭オフセット値を返します。StringOffsetはOffsetのString版で、やはりオフセットを保持するだけの構造体です。
 FlatBufferBuilderは、メモリバッファの最後(つまり、最終的には一番先頭のアドレス)から要素を配置していきます。ここで帰って来るオフセットは、恐らく先頭アドレスからの相対アドレスだと思いますが、検証はしていません。

//Monster2体作成
Charctors[0] = Monster.CreateMonster(builder,0,1,2,nameOffset0);
Charctors[1] = Monster.CreateMonster(builder,3,4,5,nameOffset1);

 Monsterのデータを2体作成します。Monster.CreateMonster()はMonsterのデータを作成するヘルパーメソッドです。第1引数はFlatBufferBuilderを渡し、残りはスキーマファイルで定義した各フィールドに対応しています。文字列はStringOffsetを渡します。
 CreateMonster()は、内部的にはAddHP()とかAddName()という、各フィールドに対応したメソッドを呼び出します。これらのメソッドはFlatBufferBuilderとフィールドの値を引数に取ります。AddHPの実装を見てみましょう。

public static void AddHp(FlatBufferBuilder builder, short hp) {
    builder.AddShort(1, hp, 100);
}

 引数として受け取ったFlatBufferBuilderのメンバであるAddShort()を呼び出して、hpをメンバに追加しています(第1引数はテーブル内でのインデックス番号)。
 やたらと面倒なロジックになってますが、builderをWindowsプログラミングで言う所のハンドルのような物だと思うと分かりやすいでしょうか(そうか?)。CreateMonsterもAddHpもヘルパーメソッドに過ぎず、シリアライズ作業は全てFlatBufferBuilder内で完結するように設計されているのです。ゲーム実行環境をビルドする際には、これらのメソッドは最適化処理で除去され、余計なリソースを消費しません(とはいえその為にこんな複雑なコーディングにしてしまうのは妥当なのか? とも思うけども)。

//MonsterListのItemsVector作成
VectorOffset test = MonsterList.CreateItemsVector(builder, Charctors);

 ItemsのVectorをバッファに確保します。CreateItemsVector()ではCharctors配列の要素数(ここでは2)分の参照ポインタメモリ(同2×4バイト=8バイト)を確保し、配列に格納されているオフセットアドレスを格納します。
 戻り値はこのvectorのオフセットアドレスです。VectorOffsetは例によってオフセット値を1個格納するだけの構造体です。

//MonsterList作成
Offset<MonsterList> reslut = MonsterList.CreateMonsterList(builder, test);

 全てのデータを格納できたので、MonsterListの作成を完了します。CreateMonsterList()はitemsを参照するvtableを作成し、そのオフセットアドレスを返します。ちょっとわかりにくいですが、もしMonsterListに他にもプロパティがある場合、引数の数が増えていきます。

//データバッファを完了する
MonsterList.FinishMonsterListBuffer(builder, reslut);

 最後にデータバッファをFIXします。FinishMonsterListBuffer()はrootテーブル(ここではMonsterList)のオフセットアドレスを指定してFlatBufferBuilder.Finish()を呼び出しているだけです。Finish()ではデータバッファの0番地にrootテーブルへのオフセットアドレスを書き込みます。

//バイナリファイルに書き込み
using (FileStream fs = new FileStream(Application.streamingAssetsPath + "/MonsterList.bin", FileMode.Create))
{
    using (BinaryWriter bw = new BinaryWriter(fs))
    {
        bw.Write(builder.SizedByteArray());
    }
}

 作成したデータバッファをファイルに書き込みます。ファイルアクセスのロジックは提携処理です。
 FlatBufferBuilder.SizedByteArray()は作成したデータバッファをbyte[]に変換します。以上でシリアライズが完了しました。

デシリアライズ処理

 保存したバイナリファイルからデータ列を読み込み、それをデシリアライズしてみます。

byte[] readBuffer;
//バイナリファイル読み込み
using (FileStream fs2 = new FileStream(Application.streamingAssetsPath + "/MonsterList.bin", FileMode.Open))
{
    readBuffer = new byte[fs2.Length];
    fs2.Read(readBuffer, 0, readBuffer.Length);
}

 ファイルからデータを読み込み、readBufferに格納します。この辺は定型処理です(もっと簡潔な記述方法がある気がするけど知らない)。

//バイト列からルートの要素の取得
MonsterList monsterList = MonsterList.GetRootAsMonsterList(new ByteBuffer(readBuffer));

 バイナリ列でByteBufferを初期化し、GetRootAsMonsterList()に与えると、デシリアライズされたMonsterListオブジェクトを取得できます(ただし、実際にはバイト列をそのまま持っているだけで、デシリアライズされているわけではありません。これは別記事で解説します)。

//各Itemにアクセス
Monster? monster0 = monsterList.Items(0);
Monster? monster1 = monsterList.Items(1);

 生成されたMonsterListオブジェクトは、通常の構造体のようにアクセスできます。ただし、vectorは配列ではないので、Itemsへのアクセスはメソッド呼び出しになるので注意してください。Monster?の"?"は、null許容型を示します。flatc.exeによる自動生成では、Items()の戻り値にはnull許容型しか生成されていませんでした。

続く

 うへえ、長かった……。試行錯誤しながらコードを書いていたので一時変数名がいちいち適当で恐縮ですが、とはいえなんという名前にするのが良いのかイマイチ決めきれないので、これは運用しつつ考えたいと思います。
 次回は生成されたバイナリファイルを見ながら、FlatBuffersのデータ仕様について解説したいと思っています。