マスタデータを効率よく取り扱うMasterMemoryの概要と使い方についてまとめました。
MasterMemoryとは?
ゲーム開発では、キャラクターごとのパラメータやステージごとの情報など、全ユーザ共通の情報をマスタデータとして扱います。
マスタデータは膨大な量になりがちで、この膨大な量のデータから必要なデータをできるだけ早く検索することが求められます。
クライアントサイドではマスタデータの検索時間、メモリ使用量、ロード時間がしばしば問題になります。
さらにこれらの問題は、技術選定はプロジェクト前半に行われるにもかかわらず、問題はプロジェクト後半になって表面化しがちという厄介な特性を持っています。
MasterMemoryはこのような問題を解決するためのマスタデータ管理システムで、以下の特徴を持っています。
- 省メモリ
- 高速なロード(初期化を含む)
- 高速な検索
これを使うことで、上述のような問題が起こりがちなマスタデータを効率よく処理することができます。
なお、MasterMemoryはOSSなので、どなたでもライセンス(MIT)の範囲内で使用することができます。
開発はCysharp社です。
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 にはクラスの自動生成機能があります。
これを使うと上記のクラスからDatabaseBuilder
とMemoryDatabase
というクラスが生成されます。
このうち、DatabaseBuilder
はマスタデータが入ったバイナリデータをビルドするためのクラスです。
MasterMemory では独自の形式のバイナリデータを使用することで、読み込みなどの処理を効率よく行えるようにしています。
こうして作られたバイナリデータをMemoryDatabase
に渡すと、実際にランタイムでマスタデータを読み込むことができます。
基本的な使い方
次に、MasterMemoryの基本的な使い方についてまとめます。
MasterMemoryは.NETプロジェクトでも使えますが、本記事ではUnityにおける基本的な使い方についてまとめます。
インストール
まずMasterMemoryと、MasterMemoryが依存するMessagePack for C#のunitypackageをダウンロードし、Unityにインポートします。
それぞれ以下のページから入手できます。
- MasterMemoryのReleasesページ
- MessagePackのReleasesページ
- 最新のリリースになかったら過去のリリースに遡ってください
次にクラスのコード生成(これについての概要は上記「MasterMemoryの仕組み」を参照)を行うためのツール(Generator)をインストールします。
これらは.NETのCLIになるので、まず以下のコマンドで.NETがインストールされているか調べます。
(普段Unityを使っている方は慣れてない可能性があるので少し丁寧に説明しています)
$ dotnet --version
インストールされていなかった場合は以下からインストールしてください。
手元のMasterMemoryでは.NET6が必要でした。(バージョン間違えてもCLIを使用するときにエラーになるので、インストールしなおせばOKです)
.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); // 迷いの湿地帯 } }
なお上記ではmessagePackResolvers
をMessagePackSerializer.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