UnityのマスタデータにMasterMemoryを使ってみる

はじめに

この記事は、マスタデータ用に作られた型安全なデータベース、「MasterMemory」をUnityで使ってみた記事です。

Cysharp/MasterMemory: Embedded Typed Readonly In-Memory Document Database for .NET Core and Unity.

  • Unity: 2019.2.1f1
  • MasterMemory: 1.1.7

MasterMemoryを使う

ReadMeがとても丁寧なのでそれに従うだけです。

まず最初にMasterMemoryのReleaseページからMasterMemory.Generator.zipとMasterMemory.Unity.unitypackageをダウンロードしてきます。

2019 08 22 14 19 29

また、MasterMemoryが依存しているMessagePack-CSharpのReleaseページからMessagePack.Unity.1.7.3.5.unitypackage と MessagePackUniversalCodeGenerator.zip をダウンロードしてきます。

2019 08 22 14 25 55

unitypackageを両方とも読み込みます。

2019 08 22 14 26 59

2019 08 22 14 26 33

テスト用にAssets/Scrpts/Tables/Person.csを作成します。

using MasterMemory;
using MessagePack;

public enum Gender {
    Male,
    Female,
    Unknown
}

// table definition marked by MemoryTableAttribute.
// database-table must be serializable by MessagePack-CSsharp
[MemoryTable ("person"), MessagePackObject (true)]
public class Person {
    // index definition by attributes.
    [PrimaryKey]
    public int PersonId { get; set; }

    // secondary index can add multiple(discriminated by index-number).
    [SecondaryKey (0), NonUnique]
    [SecondaryKey (1, keyOrder : 1), NonUnique]
    public int Age { get; set; }

    [SecondaryKey (2), NonUnique]
    [SecondaryKey (1, keyOrder : 0), NonUnique]
    public Gender Gender { get; set; }

    public string Name { get; set; }
}

Unityのプロジェクトの直下にGeneratorTools/というディレクトリを作り、そこにzipを解凍します。

2019 08 22 14 37 56

Assets/Editor/CodeGenerator.csを作成します。

using System.Diagnostics;
using UnityEditor;
using UnityEngine;

public class MenuItems : MonoBehaviour {
    [MenuItem ("MasterMemory/CodeGenerate")]
    private static void Generate () {
        ExecuteMasterMemoryCodeGenerator ();
        ExecuteMessagePackCodeGenerator ();
    }

    private static void ExecuteMasterMemoryCodeGenerator () {
        UnityEngine.Debug.Log ($"{nameof(ExecuteMasterMemoryCodeGenerator)} : start");

        var exProcess = new Process ();

        var rootPath = Application.dataPath + "/..";
        var filePath = rootPath + "/GeneratorTools/MasterMemory.Generator";
        var exeFileName = "";
#if UNITY_EDITOR_WIN
        exeFileName = "/win-x64/MasterMemory.Generator.exe";
#elif UNITY_EDITOR_OSX
        exeFileName = "/osx-x64/MasterMemory.Generator";
#elif UNITY_EDITOR_LINUX
        exeFileName = "/linux-x64/MasterMemory.Generator";
#else
        return;
#endif

        var psi = new ProcessStartInfo () {
            CreateNoWindow = true,
            WindowStyle = ProcessWindowStyle.Hidden,
            RedirectStandardOutput = true,
            RedirectStandardError = true,
            UseShellExecute = false,
            FileName = filePath + exeFileName,
            Arguments = $@"-i ""{Application.dataPath}/Scripts/Tables"" -o ""{Application.dataPath}/Scripts/Generated"" -n ""MasterData""",
        };

        var p = Process.Start (psi);

        p.EnableRaisingEvents = true;
        p.Exited += (object sender, System.EventArgs e) => {
            var data = p.StandardOutput.ReadToEnd ();
            UnityEngine.Debug.Log ($"{data}");
            UnityEngine.Debug.Log ($"{nameof(ExecuteMasterMemoryCodeGenerator)} : end");
            p.Dispose ();
            p = null;
        };
    }

    private static void ExecuteMessagePackCodeGenerator () {
        UnityEngine.Debug.Log ($"{nameof(ExecuteMessagePackCodeGenerator)} : start");

        var exProcess = new Process ();

        var rootPath = Application.dataPath + "/..";
        var filePath = rootPath + "/GeneratorTools/MessagePackUniversalCodeGenerator";
        var exeFileName = "";
#if UNITY_EDITOR_WIN
        exeFileName = "/win-x64/mpc.exe";
#elif UNITY_EDITOR_OSX
        exeFileName = "/osx-x64/mpc";
#elif UNITY_EDITOR_LINUX
        exeFileName = "/linux-x64/mpc";
#else
        return;
#endif

        var psi = new ProcessStartInfo () {
            CreateNoWindow = true,
            WindowStyle = ProcessWindowStyle.Hidden,
            RedirectStandardOutput = true,
            RedirectStandardError = true,
            UseShellExecute = false,
            FileName = filePath + exeFileName,
            Arguments = $@"-i ""{Application.dataPath}/../Assembly-CSharp.csproj"" -o ""{Application.dataPath}/Scripts/Generated/MessagePack.Generated.cs""",
        };

        var p = Process.Start (psi);

        p.EnableRaisingEvents = true;
        p.Exited += (object sender, System.EventArgs e) => {
            var data = p.StandardOutput.ReadToEnd ();
            UnityEngine.Debug.Log ($"{data}");
            UnityEngine.Debug.Log ($"{nameof(ExecuteMessagePackCodeGenerator)} : end");
            p.Dispose ();
            p = null;
        };
    }
}

「MasterMemory>CodeGenerate」を実行します。

2019 08 22 14 44 20

2019 08 22 14 45 25

Assets/Scripts/Generated/にコードが生成されます。 名前空間MasterDataでコードを生成するようにしていますが、必要に応じて適宜変更してください。

2019 08 22 14 46 25

Assets/Scripts/Initializer.csを作成します。

using MasterData;
using MessagePack.Resolvers;
using UnityEngine;

public static class Initializer {
    [RuntimeInitializeOnLoadMethod (RuntimeInitializeLoadType.BeforeSceneLoad)]
    public static void SetupMessagePackResolver () {
        CompositeResolver.RegisterAndSetAsDefault (new [] {
            MasterMemoryResolver.Instance, // set MasterMemory generated resolver
                GeneratedResolver.Instance, // set MessagePack generated resolver
                StandardResolver.Instance // set default MessagePack resolver
        });
    }
}

適当なスクリプトを作成して試してみます。

using MasterData;
using MasterMemory;
using UnityEngine;

public class Test : MonoBehaviour {
    void Start () {
        // to create database, use DatabaseBuilder and Append method.
        var builder = new DatabaseBuilder ();
        builder.Append (new Person[] {
            new Person { PersonId = 0, Age = 13, Gender = Gender.Male, Name = "Dana Terry" },
            new Person { PersonId = 1, Age = 17, Gender = Gender.Male, Name = "Kirk Obrien" },
            new Person { PersonId = 2, Age = 31, Gender = Gender.Male, Name = "Wm Banks" },
            new Person { PersonId = 3, Age = 44, Gender = Gender.Male, Name = "Karl Benson" },
            new Person { PersonId = 4, Age = 23, Gender = Gender.Male, Name = "Jared Holland" },
            new Person { PersonId = 5, Age = 27, Gender = Gender.Female, Name = "Jeanne Phelps" },
            new Person { PersonId = 6, Age = 25, Gender = Gender.Female, Name = "Willie Rose" },
            new Person { PersonId = 7, Age = 11, Gender = Gender.Female, Name = "Shari Gutierrez" },
            new Person { PersonId = 8, Age = 63, Gender = Gender.Female, Name = "Lori Wilson" },
            new Person { PersonId = 9, Age = 34, Gender = Gender.Female, Name = "Lena Ramsey" },
        });

        // build database binary(you can also use `WriteToStream` for save to file).
        byte[] data = builder.Build ();

        // -----------------------

        // for query phase, create MemoryDatabase.
        // (MemoryDatabase is recommended to store in singleton container(static field/DI)).
        var db = new MemoryDatabase (data);

        // .PersonTable.FindByPersonId is fully typed by code-generation.
        Person person = db.PersonTable.FindByPersonId (10);

        // Multiple key is also typed(***And * **), Return value is multiple if key is marked with `NonUnique`.
        RangeView<Person> result = db.PersonTable.FindByGenderAndAge ((Gender.Female, 23));

        Debug.Log ("result");
        foreach (var p in result) {
            Debug.Log ($"{p.Name}");
        }

        // Get nearest value(choose lower(default) or higher).
        RangeView<Person> age1 = db.PersonTable.FindClosestByAge (31);

        Debug.Log ("age1");
        foreach (var p in age1) {
            Debug.Log ($"{p.Name}");
        }

        // Get range(min-max inclusive).
        RangeView<Person> age2 = db.PersonTable.FindRangeByAge (20, 29);

        Debug.Log ("age2");
        foreach (var p in age2) {
            Debug.Log ($"{p.Name}");
        }
    }
}

正しく動作していることが確認できます。

2019 08 22 14 55 34

マスタデータのBuildを事前にエディタ上で行うようにします。 Assets/Editor/MasterDataBuilder.csを作成します。

using System.IO;
using MasterData;
using UnityEditor;
using UnityEngine;

public static class MasterDataBuilder {

    [MenuItem ("MasterMemory/Build")]
    static void BuildMasterData () {
        var builder = new DatabaseBuilder ();
        builder.Append (new Person[] {
            new Person { PersonId = 0, Age = 13, Gender = Gender.Male, Name = "Dana Terry" },
            new Person { PersonId = 1, Age = 17, Gender = Gender.Male, Name = "Kirk Obrien" },
            new Person { PersonId = 2, Age = 31, Gender = Gender.Male, Name = "Wm Banks" },
            new Person { PersonId = 3, Age = 44, Gender = Gender.Male, Name = "Karl Benson" },
            new Person { PersonId = 4, Age = 23, Gender = Gender.Male, Name = "Jared Holland" },
            new Person { PersonId = 5, Age = 27, Gender = Gender.Female, Name = "Jeanne Phelps" },
            new Person { PersonId = 6, Age = 25, Gender = Gender.Female, Name = "Willie Rose" },
            new Person { PersonId = 7, Age = 11, Gender = Gender.Female, Name = "Shari Gutierrez" },
            new Person { PersonId = 8, Age = 63, Gender = Gender.Female, Name = "Lori Wilson" },
            new Person { PersonId = 9, Age = 34, Gender = Gender.Female, Name = "Lena Ramsey" },
        });

        byte[] data = builder.Build ();

        var resourcesDir = $"{Application.dataPath}/Resources";
        Directory.CreateDirectory (resourcesDir);
        var filename = "/master-data.bytes";

        using (var fs = new FileStream (resourcesDir + filename, FileMode.Create)) {
            fs.Write (data, 0, data.Length);
        }

        Debug.Log ($"Write byte[] to: {resourcesDir + filename}");

        AssetDatabase.Refresh ();
    }
}

「MasterMemory > Build」を実行します。

2019 08 22 15 00 29

Registerされていないと言って怒られます。

2019 08 22 15 01 34

次のようにコードを変えます。

using System.IO;
using MasterData;
using MessagePack.Resolvers;
using UnityEditor;
using UnityEngine;

public static class MasterDataBuilder {

    [MenuItem ("MasterMemory/Build")]
    static void BuildMasterData () {
        CompositeResolver.RegisterAndSetAsDefault (new [] {
            MasterMemoryResolver.Instance,
                GeneratedResolver.Instance,
                StandardResolver.Instance
        });

        var builder = new DatabaseBuilder ();
        builder.Append (new Person[] {
            new Person { PersonId = 0, Age = 13, Gender = Gender.Male, Name = "Dana Terry" },
            new Person { PersonId = 1, Age = 17, Gender = Gender.Male, Name = "Kirk Obrien" },
            new Person { PersonId = 2, Age = 31, Gender = Gender.Male, Name = "Wm Banks" },
            new Person { PersonId = 3, Age = 44, Gender = Gender.Male, Name = "Karl Benson" },
            new Person { PersonId = 4, Age = 23, Gender = Gender.Male, Name = "Jared Holland" },
            new Person { PersonId = 5, Age = 27, Gender = Gender.Female, Name = "Jeanne Phelps" },
            new Person { PersonId = 6, Age = 25, Gender = Gender.Female, Name = "Willie Rose" },
            new Person { PersonId = 7, Age = 11, Gender = Gender.Female, Name = "Shari Gutierrez" },
            new Person { PersonId = 8, Age = 63, Gender = Gender.Female, Name = "Lori Wilson" },
            new Person { PersonId = 9, Age = 34, Gender = Gender.Female, Name = "Lena Ramsey" },
        });

        byte[] data = builder.Build ();

        var resourcesDir = $"{Application.dataPath}/Resources";
        Directory.CreateDirectory (resourcesDir);
        var filename = "/master-data.bytes";

        using (var fs = new FileStream (resourcesDir + filename, FileMode.Create)) {
            fs.Write (data, 0, data.Length);
        }

        Debug.Log ($"Write byte[] to: {resourcesDir + filename}");

        AssetDatabase.Refresh ();
    }
}

1回目はうまく生成できますが2回目以降に怒られます。

2019 08 22 15 03 46

try-catchで握りつぶすことにします。

using System.IO;
using MasterData;
using MessagePack.Resolvers;
using UnityEditor;
using UnityEngine;

public static class MasterDataBuilder {

    [MenuItem ("MasterMemory/Build")]
    static void BuildMasterData () {
        try {
            CompositeResolver.RegisterAndSetAsDefault (new [] {
                MasterMemoryResolver.Instance,
                    GeneratedResolver.Instance,
                    StandardResolver.Instance
            });
        } catch { }

        var builder = new DatabaseBuilder ();
        builder.Append (new Person[] {
            new Person { PersonId = 0, Age = 13, Gender = Gender.Male, Name = "Dana Terry" },
            new Person { PersonId = 1, Age = 17, Gender = Gender.Male, Name = "Kirk Obrien" },
            new Person { PersonId = 2, Age = 31, Gender = Gender.Male, Name = "Wm Banks" },
            new Person { PersonId = 3, Age = 44, Gender = Gender.Male, Name = "Karl Benson" },
            new Person { PersonId = 4, Age = 23, Gender = Gender.Male, Name = "Jared Holland" },
            new Person { PersonId = 5, Age = 27, Gender = Gender.Female, Name = "Jeanne Phelps" },
            new Person { PersonId = 6, Age = 25, Gender = Gender.Female, Name = "Willie Rose" },
            new Person { PersonId = 7, Age = 11, Gender = Gender.Female, Name = "Shari Gutierrez" },
            new Person { PersonId = 8, Age = 63, Gender = Gender.Female, Name = "Lori Wilson" },
            new Person { PersonId = 9, Age = 34, Gender = Gender.Female, Name = "Lena Ramsey" },
        });

        byte[] data = builder.Build ();

        var resourcesDir = $"{Application.dataPath}/Resources";
        Directory.CreateDirectory (resourcesDir);
        var filename = "/master-data.bytes";

        using (var fs = new FileStream (resourcesDir + filename, FileMode.Create)) {
            fs.Write (data, 0, data.Length);
        }

        Debug.Log ($"Write byte[] to: {resourcesDir + filename}");

        AssetDatabase.Refresh ();
    }
}

これでEditor上からデータを生成できるようになりました。 今回はひとまず生成したデータをAssets/Resources/に配置しています。

次にこの生成したmaster-data.bytesを利用するコードを書きます。

MasterDataDB.csに次のように。

using MasterData;
using UnityEngine;

public class MasterDataDB {
    private static MemoryDatabase _db = new MemoryDatabase ((Resources.Load ("master-data") as TextAsset).bytes);
    public static MemoryDatabase DB => _db;
}

Test.csに次のように記述します。

using MasterData;
using MasterMemory;
using UnityEngine;

public class Test : MonoBehaviour {
    void Start () {
        var db = MasterDataDB.DB;

        Person person = db.PersonTable.FindByPersonId (10);
        Debug.Log ($"{person}");

        RangeView<Person> result = db.PersonTable.FindByGenderAndAge ((Gender.Female, 23));

        Debug.Log ("result");
        foreach (var p in result) {
            Debug.Log ($"{p.Name}");
        }

        RangeView<Person> age1 = db.PersonTable.FindClosestByAge (31);

        Debug.Log ("age1");
        foreach (var p in age1) {
            Debug.Log ($"{p.Name}");
        }

        RangeView<Person> age2 = db.PersonTable.FindRangeByAge (20, 29);

        Debug.Log ("age2");
        foreach (var p in age2) {
            Debug.Log ($"{p.Name}");
        }
    }
}

Resources/から読み込んだデータをMemoryDatabaseのコンストラクタに渡しています。

MemoryDatabaseのインスタンスはシングルトンなりDIなりで使い回すことが推奨されているようです。 今回は適当なシングルトンに放り込んでいます。


いくつかテーブルを追加してみます。 RPGのスキルをイメージしたテーブルを追加します。 スキルにはスキルレベルが存在する、といった想定です。

Assets/Scripts/Tables/Skill.csを作成します。

using MasterMemory;
using MessagePack;

[MemoryTable ("skill"), MessagePackObject (true)]
public class Skill {
    [PrimaryKey]
    public int SkillID { get; set; }
    public string SkillName { get; set; }
}

Assets/Scripts/Tables/SkillParameter.csを作成します。

using MasterMemory;
using MessagePack;

[MemoryTable ("skillParameter"), MessagePackObject (true)]
public class SkillParameter {
    [PrimaryKey, NonUnique]
    [SecondaryKey (0)]
    public int SkillID { get; set; }

    [SecondaryKey (0)]
    public int SkillLv { get; set; }
    public int Damage { get; set; }
}

Skill.csのスキルIDはユニークなものです。 同じスキルのレベル違いのパラメータを表現するためにSkillParameter.csを用意しています。 SkillParameter.csのスキルIDにはNonUniqueをつけています。 スキルに対応するパラメータはレベル違いで存在します。 SkillParameter.csのスキルIDとスキルレベルを組み合わせた場合にはUniqueとしています。

「MasterMemory > CodeGenerate」を実行します。

2019 08 22 15 23 22

Generatedにコードが増えます。

2019 08 22 15 24 24

Assets/Editor/MasterDataBuilder.csのマスタデータの生成部分を変更します。

using System.Collections.Generic;
using System.IO;
using MasterData;
using MessagePack.Resolvers;
using UnityEditor;
using UnityEngine;

public static class MasterDataBuilder {

    [MenuItem ("MasterMemory/Build")]
    private static void BuildMasterData () {
        try {
            CompositeResolver.RegisterAndSetAsDefault (new [] {
                MasterMemoryResolver.Instance,
                    GeneratedResolver.Instance,
                    StandardResolver.Instance
            });
        } catch { }

        var builder = new DatabaseBuilder ();
        builder = BuildParson (builder);
        builder = BuildSkill (builder);
        builder = BuildSkillParameter (builder);

        byte[] data = builder.Build ();

        var resourcesDir = $"{Application.dataPath}/Resources";
        Directory.CreateDirectory (resourcesDir);
        var filename = "/master-data.bytes";

        using (var fs = new FileStream (resourcesDir + filename, FileMode.Create)) {
            fs.Write (data, 0, data.Length);
        }

        Debug.Log ($"Write byte[] to: {resourcesDir + filename}");

        AssetDatabase.Refresh ();
    }

    private static DatabaseBuilder BuildParson (DatabaseBuilder builder) {

        builder.Append (new Person[] {
            new Person { PersonId = 0, Age = 13, Gender = Gender.Male, Name = "Dana Terry" },
            new Person { PersonId = 1, Age = 17, Gender = Gender.Male, Name = "Kirk Obrien" },
            new Person { PersonId = 2, Age = 31, Gender = Gender.Male, Name = "Wm Banks" },
            new Person { PersonId = 3, Age = 44, Gender = Gender.Male, Name = "Karl Benson" },
            new Person { PersonId = 4, Age = 23, Gender = Gender.Male, Name = "Jared Holland" },
            new Person { PersonId = 5, Age = 27, Gender = Gender.Female, Name = "Jeanne Phelps" },
            new Person { PersonId = 6, Age = 25, Gender = Gender.Female, Name = "Willie Rose" },
            new Person { PersonId = 7, Age = 11, Gender = Gender.Female, Name = "Shari Gutierrez" },
            new Person { PersonId = 8, Age = 63, Gender = Gender.Female, Name = "Lori Wilson" },
            new Person { PersonId = 9, Age = 34, Gender = Gender.Female, Name = "Lena Ramsey" },
        });
        return builder;
    }

    private static DatabaseBuilder BuildSkill (DatabaseBuilder builder) {
        builder.Append (new Skill[] {
            new Skill { SkillID = 0, SkillName = "スキル0" },
            new Skill { SkillID = 1, SkillName = "スキル1" },
            new Skill { SkillID = 2, SkillName = "スキル2" },
            new Skill { SkillID = 3, SkillName = "スキル3" },
        });
        return builder;
    }

    private static DatabaseBuilder BuildSkillParameter (DatabaseBuilder builder) {
        var skillParameters = new List<SkillParameter> ();
        for (int i = 0; i < 4; i++) {
            for (int lv = 1; lv < 10; lv++) {
                skillParameters.Add (new SkillParameter {
                    SkillID = i,
                        SkillLv = lv,
                        Damage = lv * 100
                });
            }
        }
        builder.Append (skillParameters);
        return builder;
    }
}

「MasterMemory > Build」を実行しマスタデータを生成します。

2019 08 22 15 37 39

場合によってはGeneratedフォルダを削除してもう一度生成する必要があるかもです。

適当にスキルとレベル、ダメージを表示するスクリプトを作ってみます。

using MasterData;
using MasterMemory;
using UnityEngine;

public class Test : MonoBehaviour {
    void Start () {
        var db = MasterDataDB.DB;

        foreach (var skill in db.SkillTable.All) {
            Debug.Log ($"Skill Name: {skill.SkillName}");

            var lv = (int) Random.Range (1, 10);
            Debug.Log ($"Skill Lv: {lv}");

            var parameter = db.SkillParameterTable
                .FindBySkillIDAndSkillLv ((skill.SkillID, lv));
            Debug.Log ($"Skill Damage: {parameter.Damage}");
        }
    }
}

正しく実行できました。

2019 08 22 15 49 23

おわりに

MasterMemoryを使ってみました。 型安全にアクセスできるのは便利です。 適当にコードを書けばIntelisenceで候補が出てきて楽にかけます。

現状ではマスタデータがソースコードにベタ書きの状況ですが、 そこらへんでなにか工夫をしてみてもよいかもですね。

MasterMemoryとAirtableでいい感じにマスタを高速に読む - Qiita

今回のソースコードを含むGitリポジトリ

  • Unity
  • C#