【Unity】マスタデータを効率よく取り扱うMasterMemoryの概要と使い方まとめ

マスタデータを効率よく取り扱うMasterMemoryの概要と使い方についてまとめました。

MasterMemoryとは?

ゲーム開発では、キャラクターごとのパラメータやステージごとの情報など、全ユーザ共通の情報をマスタデータとして扱います。

マスタデータの例

マスタデータは膨大な量になりがちで、この膨大な量のデータから必要なデータをできるだけ早く検索することが求められます。
クライアントサイドではマスタデータの検索時間、メモリ使用量、ロード時間がしばしば問題になります。
さらにこれらの問題は、技術選定はプロジェクト前半に行われるにもかかわらず、問題はプロジェクト後半になって表面化しがちという厄介な特性を持っています。

MasterMemoryはこのような問題を解決するためのマスタデータ管理システムで、以下の特徴を持っています。

  • 省メモリ
  • 高速なロード(初期化を含む)
  • 高速な検索

これを使うことで、上述のような問題が起こりがちなマスタデータを効率よく処理することができます。

なお、MasterMemoryはOSSなので、どなたでもライセンス(MIT)の範囲内で使用することができます。
開発はCysharp社です。

github.com

MasterMemoryの仕組み

次に、MasterMemory の仕組みのポイントを掴むためにざっくりまとめます。

MasterMemory を使うには、まずマスタデータのカラム構造を定義した以下のようなクラスを用意します(実際には MasterMemory 用のアトリビュートをつけるのであくまでイメージです)。

public sealed class StageMaster
{
    public int Id { get; }
    public string Name { get; }
    public int Difficulty { get; }
    public string EnemyGroupId { get; }
    // その他のカラムたち(省略)...
}

MasterMemory にはクラスの自動生成機能があります。
これを使うと上記のクラスからDatabaseBuilderMemoryDatabaseというクラスが生成されます。

自動生成

このうち、DatabaseBuilderはマスタデータが入ったバイナリデータをビルドするためのクラスです。
MasterMemory では独自の形式のバイナリデータを使用することで、読み込みなどの処理を効率よく行えるようにしています。

ビルド

こうして作られたバイナリデータをMemoryDatabaseに渡すと、実際にランタイムでマスタデータを読み込むことができます。

読み込み

基本的な使い方

次に、MasterMemoryの基本的な使い方についてまとめます。
MasterMemoryは.NETプロジェクトでも使えますが、本記事ではUnityにおける基本的な使い方についてまとめます。

インストール

まずMasterMemoryと、MasterMemoryが依存するMessagePack for C#のunitypackageをダウンロードし、Unityにインポートします。
それぞれ以下のページから入手できます。

次にクラスのコード生成(これについての概要は上記「MasterMemoryの仕組み」を参照)を行うためのツール(Generator)をインストールします。
これらは.NETのCLIになるので、まず以下のコマンドで.NETがインストールされているか調べます。
(普段Unityを使っている方は慣れてない可能性があるので少し丁寧に説明しています)

$ dotnet --version

インストールされていなかった場合は以下からインストールしてください。
手元のMasterMemoryでは.NET6が必要でした。(バージョン間違えてもCLIを使用するときにエラーになるので、インストールしなおせばOKです)

dotnet.microsoft.com

.NETをインストールしたら、以下のコマンドでMasterMemoryとMessagePackのジェネレータをそれぞれインストールします。

$ dotnet tool install --global MasterMemory.Generator
$ dotnet tool install --global MessagePack.Generator

インストールされている.NETツールは以下のようにlistコマンドで一覧表示できます。
正常に上記の二つがインストールされていれば完了です。

$ dotnet tool list --global
Package Id                  Version      Commands    
-----------------------------------------------------
mastermemory.generator      2.4.4        dotnet-mmgen
messagepack.generator       2.5.129      mpc
テーブル構造定義クラスを作る

次に、テーブル構造を定義するクラスを作ります。
以下は冒頭のSpreadsheetと同じ構造を表現するクラスの例です。

using MasterMemory;
using MessagePack;

// クラスにMemoryTableアトリビュートをつける(引数の文字列+MasterTableという名前のクラスが生成される)
// クラスにMessagePackObjectアトリビュートをつける
[MemoryTable("Stage"), MessagePackObject(true)]
public sealed class StageMaster
{
    public StageMaster(string id, string name, int difficulty, string enemyGroupId, int exp, string resourceId)
    {
        Id = id;
        Name = name;
        Difficulty = difficulty;
        EnemyGroupId = enemyGroupId;
        Exp = exp;
        ResourceId = resourceId;
    }

    // プライマリキーにはPrimaryKeyアトリビュートをつける
    [PrimaryKey]
    public string Id { get; }

    public string Name { get; }

    public int Difficulty { get; }
    
    public string EnemyGroupId { get; }
    
    public int Exp { get; }
    
    public string ResourceId { get; }
}

クラスとプライマリキーのプロパティに、それぞれ上記のコメントの通りにアトリビュートをつけておきます。

また、各プロパティに値をセットするコンストラクタを定義します。
コンストラクタの代わりに各プロパティにpublicなセッターを作ってもいいのですが、マスタデータは本質的にイミュータブルであるべきなのでコンストラクタの方が適切だと思います。

関連クラスを生成する

次にこのクラスから関連クラスを自動生成します。
これを行うには先ほどインストールした.NETツールを使います。

まずMasterMemory関連のコードを以下のように生成します。
-inputDirectoryには前節で作ったクラスが入ったディレクトリを指定します。
-outputDirectory-usingNamespaceで指定した名前空間に所属するクラスが生成されます。

dotnet-mmgen -inputDirectory ./Example -outputDirectory ./Example/Generated -usingNamespace "Example"

次にMessagePack関連のコードを以下のように生成します。
-inputには前節で作ったクラスが入ったディレクトリを指定します。
-outputには生成したコードを格納するフォルダを指定します。

mpc -input ./Example -output ./Example/Generated

下図のような感じで一式生成できていれば完了です。

生成されたファイル

バイナリをビルドする

次に実際のデータが入ったバイナリをビルドします。

バイナリは以下のようにビルドします。

using System.IO;
using Example;
using MessagePack;
using MessagePack.Resolvers;
using UnityEditor;

public static class BinaryGenerator
{
    [MenuItem("Example/Generate Binary")]
    private static void Run()
    {
        // MessagePackの初期化(ボイラープレート)
        var messagePackResolvers = CompositeResolver.Create(
            MasterMemoryResolver.Instance, // 自動生成されたResolver(Namespaceごとに作られる)
            GeneratedResolver.Instance, // 自動生成されたResolver
            StandardResolver.Instance // MessagePackの標準Resolver
        );
        var options = MessagePackSerializerOptions.Standard.WithResolver(messagePackResolvers);
        MessagePackSerializer.DefaultOptions = options;

        // Csvとかからデータを入れる(今回はテストのためコードで入れる)
        var stageMasters = new StageMaster[]
        {
            new("stage-01-001", "初心者の森", 100, "enemy-01-001", 100, "resource-01-001"),
            new("stage-01-002", "迷いの湿地帯", 200, "enemy-01-002", 200, "resource-01-002"),
            new("stage-01-003", "炎の山脈", 400, "enemy-01-003", 400, "resource-01-003"),
            new("stage-02-001", "氷結の洞窟", 500, "enemy-02-001", 500, "resource-02-001"),
            new("stage-02-002", "幽霊の墓地", 600, "enemy-02-002", 600, "resource-02-002"),
            new("stage-02-003", "竜の城塞", 1000, "enemy-02-003", 1000, "resource-02-003")
        };

        // DatabaseBuilderを使ってバイナリデータを生成する
        var databaseBuilder = new DatabaseBuilder();
        databaseBuilder.Append(stageMasters);
        var binary = databaseBuilder.Build();

        // できたバイナリは永続化しておく
        var path = "Assets/Example/Binary/StageMaster.bytes";
        var directory = Path.GetDirectoryName(path);
        if (!Directory.Exists(directory))
            Directory.CreateDirectory(directory);
        File.WriteAllBytes(path, binary);
        AssetDatabase.Refresh();
    }
}

説明はコメントを参照してください。

出力されたバイナリファイルはAddressableやらで配布したりアプリに組み込んだりするとして、次節でこれを読み込みます。

バイナリを読み込む

バイナリからマスタデータを読み込むには、以下のようにMemoryDatabaseを使います。
MemoryDatabase内のXxxMasterTableから、マスタデータを検索することができます。

using Example;
using MessagePack;
using MessagePack.Resolvers;
using UnityEditor;
using UnityEngine;

public static class BinaryLoader
{
    [MenuItem("Example/Load Binary")]
    private static void Run()
    {
        // MessagePackの初期化(ボイラープレート)
        var messagePackResolvers = CompositeResolver.Create(
            MasterMemoryResolver.Instance, // 自動生成されたResolver(Namespaceごとに作られる)
            GeneratedResolver.Instance, // 自動生成されたResolver
            StandardResolver.Instance // MessagePackの標準Resolver
        );
        var options = MessagePackSerializerOptions.Standard.WithResolver(messagePackResolvers);
        MessagePackSerializer.DefaultOptions = options;

        // ロード(テスト用にAssetDatabaseを使っているが実際にはAddressableなどで)
        var path = "Assets/Example/Binary/StageMaster.bytes";
        var asset = AssetDatabase.LoadAssetAtPath<TextAsset>(path);
        var binary = asset.bytes;

        // MemoryDatabaseをバイナリから作成
        var memoryDatabase = new MemoryDatabase(binary);
        // テーブルからデータを検索
        var stage = memoryDatabase.StageMasterTable.FindById("stage-01-002");
        Debug.Log(stage.Name); // 迷いの湿地帯
    }
}

なお上記ではmessagePackResolversMessagePackSerializer.DefaultOptionsに登録していますが、MemoryDatabaseを複数使用する際にはこのmessagePackResolversの設定が全てのMemoryDatabaseに使われます。
これでも大抵の場合は問題ないのですが、もしMemoryDatabaseごとにIFormatterResolverを分けたい場合には、MemoryDatabaseのコンストラクタ引数に渡すこともできます(引数に渡さなかった場合にはDefaultOptionsが使われます)。

基本的な使い方は以上です。

その他覚えておくべきこと

セカンダリキー

PrimaryKey以外でも検索したい場合には、SecondaryKeyアトリビュートを使うとセカンダリキーを設定できます。
引数にインデックスを指定することで複数指定可能です。

using MasterMemory;
using MessagePack;

[MemoryTable("Stage"), MessagePackObject(true)]
public sealed class StageMaster
{
    public StageMaster(string id, string name, int difficulty, string enemyGroupId, int exp, string resourceId)
    {
        Id = id;
        Name = name;
        Difficulty = difficulty;
        EnemyGroupId = enemyGroupId;
        Exp = exp;
        ResourceId = resourceId;
    }

    [PrimaryKey]
    public string Id { get; }

    [SecondaryKey(0)] // セカンダリキー1
    public string Name { get; }

    public int Difficulty { get; }
    
    [SecondaryKey(1), NonUnique] // セカンダリキー2(ユニークじゃない)
    public string EnemyGroupId { get; }
    
    public int Exp { get; }
    
    public string ResourceId { get; }
}

これを元にコード生成すると以下のようにセカンダリキーで検索できるコードが生成されます。
NonUniqueアトリビュートをつけたプロパティに関しては、複数個の結果が返ってくるインターフェースになります。

var memoryDatabase = new MemoryDatabase(binary);

var stage = memoryDatabase.StageMasterTable.FindByName("迷いの湿地帯");
Debug.Log(stage.Id); // stage-01-002 

// NonUniqueなキーで検索すると複数個返ってくる
var stages = memoryDatabase.StageMasterTable.FindByEnemyGroupId("enemy-01-001");
foreach (var s in stages){
    Debug.Log(s.Name); // 初心者の森
}
複合キー

複数のプロパティに同一のセカンダリキーのインデックスを割り当てると、それらを使った複合キー検索ができます。

using MasterMemory;
using MessagePack;

[MemoryTable("Stage"), MessagePackObject(true)]
public sealed class StageMaster
{
    public StageMaster(string id, string name, int difficulty, string enemyGroupId, int exp, string resourceId)
    {
        Id = id;
        Name = name;
        Difficulty = difficulty;
        EnemyGroupId = enemyGroupId;
        Exp = exp;
        ResourceId = resourceId;
    }

    [PrimaryKey]
    public string Id { get; }

    [SecondaryKey(0)] // 単一キーでも検索できるように
    [SecondaryKey(2, 0)] // 複合キー(第二引数は検索時の引数の順序)
    public string Name { get; }

    public int Difficulty { get; }
    
    [SecondaryKey(1), NonUnique]
    [SecondaryKey(2, 1)] // 複合キー(第二引数は検索時の引数の順序)
    public string EnemyGroupId { get; }
    
    public int Exp { get; }
    
    public string ResourceId { get; }
}

これを元にコード生成すると以下のように複合キーで検索できるコードが生成されます。

var memoryDatabase = new MemoryDatabase(binary);

// 複合キー検索
var stage = memoryDatabase.StageMasterTable
    .FindByNameAndEnemyGroupId((Name: "迷いの湿地帯", EnemyGroupId: "enemy-01-002"));
Debug.Log(stage.Id); // stage-01-002
いろんな検索&文字列の場合の比較方法の指定

キーを用いた検索の他にも以下のような検索方法がいくつかあります。

// 範囲検索
var stages = memoryDatabase.StageMasterTable
    .FindRangeById("stage-01-002", "stage-02-002");

// 一番近いものを検索
var stage = memoryDatabase.StageMasterTable
    .FindClosestById("stage-01-004");

// ソートされたものを取得
var sortedStages = memoryDatabase.StageMasterTable
    .SortByName;

また、上記のように文字列でソートや比較をする場合には、以下のように対象のプロパティにStringComparisonOptionを設定することで比較方法を指定することができます。

[StringComparisonOption(StringComparison.InvariantCultureIgnoreCase)]
public string Name { get; }
コンストラクタ自動生成

繰り返しになりますが、マスタデータは本質的にイミュータブルであるべきです。
したがって、各プロパティはpublicなgetter(と、privateなsetter)だけを持ち、プロパティへの値の代入はコンストラクタを通して行われるべきです。

public sealed class StageMaster
{
    // コンストラクタで値を入れる
    public StageMaster(string id, string name)
    {
        Id = id;
        Name = name;
    }

    // イミュータブル
    public string Id { get; }
    public string Name { get; }
}

マスタデータにおけるこのコンストラクタの記述はボイラープレートであるといえます。
MasterMemoryでは、コードを自動生成する際に-addImmutableConstructorオプションを指定することでこのコンストラクタも自動生成する機能があります。

dotnet-mmgen -inputDirectory ./Example -outputDirectory ./Example/Generated -usingNamespace "Example" -addImmutableConstructor
テーブルの拡張方法

MasterMemoryにより生成されるテーブルクラスはpartialクラスになっているので、同名かつ同じ名前空間のpartialクラスを定義することで拡張することができます。

namespace Example.Tables
{
    public partial class StageMasterTable
    {
        public StageMaster DefaultStage { get; private set; }

                // 初期化後に呼ばれる
        partial void OnAfterConstruct()
        {
            DefaultStage = FindById("stage-01-001");
        }
    }
}
生成後のMemoryDatabaseの値を変更する

MemoryDatabase.ToImmutableBuilderによりImmutableBuilderを作ると、一度生成したMemoryDatabaseに格納されているマスタデータに変更を加えることができます。

var memoryDatabase = new MemoryDatabase(binary);

// MemoryDatabaseからImmutableBuilderを作る
var immutableBuilder = memoryDatabase.ToImmutableBuilder();

// データベースの値を変更する
immutableBuilder.RemoveStageMaster(new[]
{
    "stage-01-001",
    "stage-01-002",
    "stage-01-003"
});

// 改めてビルド
memoryDatabase = immutableBuilder.Build();
継承には非対応なので注意

マスタデータ構造を定義したクラスについては、継承を使ってベースクラスに値を定義することはできません。

// これはNG
public class MasterBase
{
    public string Id { get; }
}

public sealed class StageMaster : MasterBase
{
    public string Name { get; }
}

上記のようなことをやりたい場合には、以下のように抽象クラスとabstractなプロパティを定義することで実現することができます。

public abstract class MasterBase
{
    public abstract string Id { get; }
}

public sealed class StageMaster : MasterBase
{
    public override string Id { get; }
    public string Name { get; }
}

もっとも、上記のようなケースでは以下のようにinterfaceを使う方がより適切かもしれません(当然これはできます)

public interface IMaster
{
    string Id { get; }
}

public sealed class StageMaster : IMaster
{
    public string Id { get; }
    public string Name { get; }
}

その他

その他、MasterMemoryには以下のような機能がありますが、これらは長くなりそうなので他の記事にまとめる予定です。

  • 入力バリデーションを行えるValidator
  • Csvなどからバイナリを構築する際に使うMetaDatabase

参考

github.com