【Unity】【MasterMemory】MetaDatabaseを使ってCSVからバイナリを作る汎用クラスを作成する

UnityでMasterMemoryのMetaDatabaseを使ってCSVからバイナリを作る汎用クラスを作成する方法についてまとめました。

MasterMemoryとは?

MasterMemoryは、マスタデータを効率よく取り扱うためのシステムで、以下の特徴を持っています。

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

基礎知識については以下の記事にまとめていますのでこちらを参照してください。

light11.hatenadiary.com

本記事の内容は、上記の記事の内容を前提知識とします。

MetaDatabaseとは?

MasterMemoryにおいて、MetaDatabaseを使うと指定した名前のテーブルの型やプロパティ名、プロパティの型といった情報を取得できます。

var metaDatabase = MemoryDatabase.GetMetaDatabase();

var metaTable = metaDatabase.GetTableInfo("Stage");
Debug.Log($"Table Type: {metaTable.DataType}"); // テーブルの型

foreach (var metaProperty in metaTable.Properties)
{
    var name = metaProperty.Name; // プロパティ名
    var type = metaProperty.PropertyInfo.PropertyType; // プロパティの型
    Debug.Log($"{name} {type}");
}

また、以下のようにobject型で作成したレコードからバイナリをビルドすることができます。

var metaDatabase = MemoryDatabase.GetMetaDatabase();
var metaTable = metaDatabase.GetTableInfo("Stage");

// object形でレコードを作成
var records = new List<object>();
records.Add(new StageMaster("stage-01-001", "初心者の森", 100, "enemy-01-001", 100, "resource-01-001")); // レコードを追加
records.Add(new StageMaster("stage-01-002", "迷いの湿地帯", 200, "enemy-01-002", 200, "resource-01-002"));

// レコードをデータベースに追加してビルド
var databaseBuilder = new DatabaseBuilder();
databaseBuilder.AppendDynamic(metaTable.DataType, records);
var binary = databaseBuilder.Build();

本記事ではこのMetaDatabaseを使ってCSVからバイナリを作る汎用クラスを作成します。

MasterMemoryの以下のドキュメントを参考に作っているので、必要に応じてこちらも参照してください。

github.com

Csvからバイナリを作る

それでは早速Csvからバイナリを作るクラスを作っていきます。
以下にコード全文を示します。長いですが、本記事において重要なのはRunメソッドになります。

using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Runtime.Serialization;
using System.Text;
using Example;
using MasterMemory;

public sealed class BinaryBuilder
{
    public byte[] Run(string csv, string tableName)
    {
        var databaseBuilder = new DatabaseBuilder();
        var metaDatabase = MemoryDatabase.GetMetaDatabase();
        var table = metaDatabase.GetTableInfo(tableName);
        var records = new List<object>();

        using (var memoryStream = new MemoryStream(Encoding.UTF8.GetBytes(csv)))
        using (var streamReader = new StreamReader(memoryStream, Encoding.UTF8))
        using (var csvReader = new TinyCsvReader(streamReader))
        {
            // Csvを一行ずつ読むループ
            while (true)
            {
                var columnNameToValueMap = csvReader.ReadValuesWithHeader();

                // これ以上CSVにデータがなかったら終了
                if (columnNameToValueMap == null)
                    break;

                // 各プロパティの名前に一致するカラムを探して値を設定する(一致するカラムがないものはスキップ)
                var record = FormatterServices.GetUninitializedObject(table.DataType);
                foreach (var property in table.Properties)
                {
                    var columnName = property.Name;
                    if (!columnNameToValueMap.TryGetValue(columnName, out var rawValue))
                        continue;

                    var value = ParseValue(property.PropertyInfo.PropertyType, rawValue);
                    if (property.PropertyInfo.SetMethod == null)
                    {
                        var message =
                            $"Target property does not exists set method. If you use {{get;}}, please change to {{ get; private set; }}, Type: {property.PropertyInfo.DeclaringType} Prop: {property.PropertyInfo.Name}";
                        throw new Exception(message);
                    }

                    property.PropertyInfo.SetValue(record, value);
                }

                records.Add(record);
            }
        }

        // AppendDynamicでレコードを追加する
        databaseBuilder.AppendDynamic(table.DataType, records);

        // バイナリ生成
        return databaseBuilder.Build();
    }

    #region この部分はMasterMemoryのドキュメントのまま

    private static object ParseValue(Type type, string rawValue)
    {
        if (type == typeof(string)) return rawValue;

        if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>))
        {
            if (string.IsNullOrWhiteSpace(rawValue)) return null;
            return ParseValue(type.GenericTypeArguments[0], rawValue);
        }

        if (type.IsEnum)
        {
            var value = Enum.Parse(type, rawValue);
            return value;
        }

        switch (Type.GetTypeCode(type))
        {
            case TypeCode.Boolean:
                // True/False or 0,1
                if (int.TryParse(rawValue, out var intBool)) return Convert.ToBoolean(intBool);
                return bool.Parse(rawValue);
            case TypeCode.Char:
                return char.Parse(rawValue);
            case TypeCode.SByte:
                return sbyte.Parse(rawValue, CultureInfo.InvariantCulture);
            case TypeCode.Byte:
                return byte.Parse(rawValue, CultureInfo.InvariantCulture);
            case TypeCode.Int16:
                return short.Parse(rawValue, CultureInfo.InvariantCulture);
            case TypeCode.UInt16:
                return ushort.Parse(rawValue, CultureInfo.InvariantCulture);
            case TypeCode.Int32:
                return int.Parse(rawValue, CultureInfo.InvariantCulture);
            case TypeCode.UInt32:
                return uint.Parse(rawValue, CultureInfo.InvariantCulture);
            case TypeCode.Int64:
                return long.Parse(rawValue, CultureInfo.InvariantCulture);
            case TypeCode.UInt64:
                return ulong.Parse(rawValue, CultureInfo.InvariantCulture);
            case TypeCode.Single:
                return float.Parse(rawValue, CultureInfo.InvariantCulture);
            case TypeCode.Double:
                return double.Parse(rawValue, CultureInfo.InvariantCulture);
            case TypeCode.Decimal:
                return decimal.Parse(rawValue, CultureInfo.InvariantCulture);
            case TypeCode.DateTime:
                return DateTime.Parse(rawValue, CultureInfo.InvariantCulture);
            default:
                if (type == typeof(DateTimeOffset))
                    return DateTimeOffset.Parse(rawValue, CultureInfo.InvariantCulture);
                if (type == typeof(TimeSpan))
                    return TimeSpan.Parse(rawValue, CultureInfo.InvariantCulture);
                if (type == typeof(Guid)) return Guid.Parse(rawValue);

                // or other your custom parsing.
                throw new NotSupportedException();
        }
    }

    // Non string escape, tiny reader with header.
    public class TinyCsvReader : IDisposable
    {
        private static readonly char[] trim = { ' ', '\t' };

        private readonly StreamReader reader;

        public TinyCsvReader(StreamReader reader)
        {
            this.reader = reader;
            {
                var line = reader.ReadLine();
                if (line == null) throw new InvalidOperationException("Header is null.");

                var index = 0;
                var header = new List<string>();
                while (index < line.Length)
                {
                    var s = GetValue(line, ref index);
                    if (s.Length == 0) break;
                    header.Add(s);
                }

                Header = header;
            }
        }

        public IReadOnlyList<string> Header { get; }

        public void Dispose()
        {
            reader.Dispose();
        }

        private string GetValue(string line, ref int i)
        {
            var temp = new char[line.Length - i];
            var j = 0;
            for (; i < line.Length; i++)
            {
                if (line[i] == ',')
                {
                    i += 1;
                    break;
                }

                temp[j++] = line[i];
            }

            return new string(temp, 0, j).Trim(trim);
        }

        public string[] ReadValues()
        {
            var line = reader.ReadLine();
            if (line == null) return null;
            if (string.IsNullOrWhiteSpace(line)) return null;

            var values = new string[Header.Count];
            var lineIndex = 0;
            for (var i = 0; i < values.Length; i++)
            {
                var s = GetValue(line, ref lineIndex);
                values[i] = s;
            }

            return values;
        }

        public Dictionary<string, string> ReadValuesWithHeader()
        {
            var values = ReadValues();
            if (values == null) return null;

            var dict = new Dictionary<string, string>();
            for (var i = 0; i < values.Length; i++) dict.Add(Header[i], values[i]);

            return dict;
        }
    }

    #endregion
}

説明はコメントに書いた通りです。

リフレクションを使って値を設定しているので、あらゆるクラスに対して汎用的に使うことができます。

ビルドする

次に前節のBinaryBuilderを使ってバイナリを生成していきます。

まずはマスタデータの構造を定義するクラスを作成します。
コメントにも書きましたが、今回はBinaryBuilder内でリフレクションを使って値をセットするため、各プロパティにsetter(privateでOK)が必要です。

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; private set; } // バイナリビルド時にReflectionを使って値を入れるのでprivateなsetter必要

    public string Name { get; private set; }

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

次にMasterMemoryとMessagePackのコード生成を行います。この辺りは以下の記事を参照してください。

light11.hatenadiary.com

次に前節のBinaryBuilderを使ってバイナリを生成します。

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,
            GeneratedResolver.Instance,
            StandardResolver.Instance
        );
        var options = MessagePackSerializerOptions.Standard.WithResolver(messagePackResolvers);
        MessagePackSerializer.DefaultOptions = options;

        // csv文字列
        var stageMasterCsv =
            "Id, Name, Difficulty, EnemyGroupId, Exp, ResourceId\n" +
            "\"stage-01-001\", \"初心者の森\", 100, \"enemy-01-001\", 100, \"resource-01-001\"\n" +
            "\"stage-01-002\", \"迷いの湿地帯\", 200, \"enemy-01-002\", 200, \"resource-01-002\"\n" +
            "\"stage-01-003\", \"炎の山脈\", 400, \"enemy-01-003\", 400, \"resource-01-003\"\n" +
            "\"stage-02-001\", \"氷結の洞窟\", 500, \"enemy-02-001\", 500, \"resource-02-001\"\n" +
            "\"stage-02-002\", \"幽霊の墓地\", 600, \"enemy-02-002\", 600, \"resource-02-002\"\n" +
            "\"stage-02-003\", \"竜の城塞\", 1000, \"enemy-02-003\", 1000, \"resource-02-003\"";

        // バイナリを作成
        var databaseBuilder = new BinaryBuilder();
        var binary = databaseBuilder.Run(stageMasterCsv, "Stage");

        // バイナリを永続化
        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();
    }
}

Example > Generate Binaryメニューからバイナリファイルを生成しておきます。
ファイルが出力されればOKです。

ロードして確認する

最後にこれをロードして、正常にバイナリができているか確認します。

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

public static class BinaryLoader
{
    [MenuItem("Example/Load Binary")]
    private static void Run()
    {
        var messagePackResolvers = CompositeResolver.Create(
            MasterMemoryResolver.Instance,
            GeneratedResolver.Instance,
            StandardResolver.Instance
        );
        var options = MessagePackSerializerOptions.Standard.WithResolver(messagePackResolvers);
        MessagePackSerializer.DefaultOptions = options;

        var path = "Assets/Example/Binary/StageMaster.bytes";
        var asset = AssetDatabase.LoadAssetAtPath<TextAsset>(path);
        var binary = asset.bytes;

        var memoryDatabase = new MemoryDatabase(binary);

        foreach (var stage in memoryDatabase.StageMasterTable.All)
            Debug.Log(stage.Id);
        
        var metaDb = MemoryDatabase.GetMetaDatabase();
        foreach (var table in metaDb.GetTableInfos())
        {
            // for example, generate CSV header
            var sb = new StringBuilder();
            foreach (var prop in table.Properties)
            {
                if (sb.Length != 0) sb.Append(",");

                // Name can convert to LowerCamelCase or SnakeCase.
                sb.Append(prop.NameSnakeCase);
            }
            File.WriteAllText(table.TableName + ".csv", sb.ToString(), new UTF8Encoding(false));
        }
    }
}

Example > Load Binaryを選択するとログが出力され、正常にバイナリが生成されていることを確認できます。

バイナリの生成を確認

関連

light11.hatenadiary.com

参考

github.com