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をダウンロードしてきます。

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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を解凍します。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
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」を実行します。

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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
});
}
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
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}");
}
}
}

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
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」を実行します。

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
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回目以降に怒られます。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
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に次のように。

1
2
3
4
5
6
7
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に次のように記述します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
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を作成します。

1
2
3
4
5
6
7
8
9
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を作成します。

1
2
3
4
5
6
7
8
9
10
11
12
13
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」を実行します。

Generatedにコードが増えます。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
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」を実行しマスタデータを生成します。

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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}");
}
}
}

正しく実行できました。

おわりに

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

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

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

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