マスターデータにおけるパラメーター検証のためのMasterMemory v2


Cy#の河合です。以前に「MasterMemory – Unityと.NET Coreのための読み取り専用インメモリデータベース」という記事で、弊社で開発しOSSにて公開しているMasterMemoryというライブラリを紹介しました。

[GitHub – Cysharp/MasterMemory]

最初のバージョンでは、コンセプト実証と性能の追求が主軸でしたが、最初の公開から半年を経た今回はVersion 2として、実アプリケーションに適合するにあたっての便利な周辺機能の拡充を図りました。その一つがパラメーター検証のためのバリデーターです。

Cygamesでは以前、「ユーザを飽きさせない高頻度の更新を可能にする開発運用ノウハウ ~ハイスピードな開発、リリースを実現するために~」としてDSLによる検証システムを紹介しました。

DSLベースにすることで、スキーマ定義によるサーバー用(PHP)とクライアント用(C#)コードの生成に加えて、簡潔な記法によるバリデーションを可能にしました。

これに対しMasterMemoryは、C#で完結させることでワークフローを簡潔にするという目的で検証を進めました。
また、C#という汎用言語を使用する利点としては、

  • 完全なシンタックスハイライトが効く
  • すべて型付けされていて入力ミスをリアルタイムで検知できる
  • 入力補完が効く
  • 複雑なフローをコードでシンプルに記述できる

などが挙げられます。

こうした、汎用言語を使うというアプローチは一昔前までは禁忌とされていましたが、最近ではAWS Cloud Development Kitによる、TypeScriptやJavaでAWS CloudFormationのテンプレートを生成する手法であったり、PulumiによるTypeScript、Python、Go、C#でTerraformのテンプレートを生成するといった、特にInfrastructure as Codeの文脈では、最近よく見かけるようになりました。

これは、特にVisual Studio CodeとLanguage Server Protocolの発展によって、エディタがより強力になり、汎用言語のメリットを甘受しやすくなったことと、GitHubのPull Requestなどのレビュー環境や文化が成熟してきたことによる、汎用言語ならではの「なんでもできてしまう」性質を抑えやすくなったことが理由だと考えています。いずれにせよ、今だからこそ、汎用言語によるアプローチを試みる価値があるでしょう。​

MasterMemory v2による検証コードは、上記スライドにおける例を使って書くと、以下のようになります。

// IValidatableを実装すると検証対象になる
[MemoryTable("quest_master"), MessagePackObject(true)]
public class Quest : IValidatable<Quest>
{
    // UniqueKeyの場合はValidate時にデフォルトで重複かの検証がされる
    [PrimaryKey]
    public int QuestId { get; }
    public string Name { get; }
    public int RewardId { get; }
    public int Cost { get; }

    void IValidatable<Quest>.Validate(IValidator<Quest> validator)
    {
        // 外部キー的に参照したいコレクションを取り出せる
        var items = validator.GetReferenceSet<Item>();

        // RewardIdが0以上のとき
        // (0は報酬ナシのための特別なフラグとするため入力を許容する)
        if (this.RewardId > 0)
        {
            // Itemsのマスタに必ず含まれてなければ検証エラー
            items.Exists(x => x.RewardId, x => x.ItemId);
        }

        // コストは10..20でなければ検証エラー
        validator.Validate(x => x.Cost >= 10);
        validator.Validate(x => x.Cost <= 20);

        // 以下で囲った部分は一度しか呼ばれない
        // データセット全体の検証をしたい時に使う
        if (validator.CallOnce())
        {
            var quests = validator.GetTableSet();
            // インデックス生成したもの以外のユニークどうかの検証
            quests.Where(x => x.RewardId != 0)
                  .Unique(x => x.RewardId);
        }
    }
}

[MemoryTable("item_master"), MessagePackObject(true)]
public class Item
{
    [PrimaryKey]
    public int ItemId { get; }
}

void Main()
{
    var db = new MemoryDatabase(bin);
    // 検証を開始し、結果を取得する。
    var validateResult = db.Validate();
    if (validateResult.IsValidationFailed)
    {
        // 検証失敗データを文字列形式でフォーマットして出力
        Console.WriteLine(validateResult.FormatFailedResults());
        // List<(Type, string, object)> で検証データを取得して、
        // 自分でカスタムで出力することも可能
        // MDやHTMLに整形してSlackやレポーターに投げるなど自由に使える
        // validateResult.FailedResults
    }
}


スキーマとなるC#クラスにIValidatable<T>を実装することにより、スキーマの近くに検証コードを書く、という見立てとなっています。基本的にはvalidatorに対してラムダ式で1行で指定するだけで済むので、DSLと比べても、十分に簡潔なものを簡潔に書くことが可能です。

また、C#の「Expression Trees」を活用することにより、検証に失敗した時のメッセージを、特に追加で文字列で説明を記述しなくても、十分理解しやすい内容で出力できます。

Quest - Exists failed: Quest.RewardId -> Item.ItemId,
                       value = 99, PK(QuestId) = 3
Quest - Validate failed: (this.Cost <= 20), Cost = 30, PK(QuestId) = 4
Quest - Validate failed: (this.Cost <= 20), Cost = 40, PK(QuestId) = 9
Quest - Unique failed: .RewardId, value = 100, PK(QuestId) = 10


ExistsValidateといったアサート関数のラムダ式はFuncではなくExpression Treesで表現されていて、検証失敗時には式を解釈して、理解しやすいメッセージに整形しています。

なお、バリデーションにあたってはオンメモリでロード済みのMasterMemoryのデータそのものを使用して検証を行うため、動作も非常に高速に完了します。

メタデータ取得API

繰り返しますが、MasterMemoryではC#コードがスキーマ定義となっていますが、データの入力に関しては、別途CSVから行うかもしれませんし、データベースから取得するかもしれません。CSVでデータ入力を行うなら雛形となるCSVを作りたいし、データーベースも関係するならテーブル生成SQLを作りたいかもしれません。MasterMemoryではメタデータ取得APIとして、こうしたテーブル情報、プロパティ情報、インデックス情報をそのまま取り出すことができます。

// テーブル情報、プロパティ情報、インデックス情報が取れる
var metaDb = MemoryDatabase.GetMetaDatabase();
foreach (var table in metaDb.GetTableInfos())
{
    // CSVのヘッダ生成
    var sb = new StringBuilder();
    foreach (var prop in table.Properties)
    {
        if (sb.Length != 0) sb.Append(",");

        // Original, LowerCamelCase, SnakeCaseに変換した名前を取得
        sb.Append(prop.NameSnakeCase);
    }
    File.WriteAllText(table.TableName + ".csv", sb.ToString(), 
                      new UTF8Encoding(false));
}


つまるところ、スキーマのエクスポーター、あるいはデータのインポーターの開発は別途必要なわけですが、それらの開発に役立つのではないかと思います。そうしたプログラムの作成には、弊社で開発している[ConsoleAppFramework]を併せて使うことによって全体のワークフローをシンプルに作り込んでいくことが可能です。また、UnityEditor上でのエディタ拡張から作ってみるのも良いでしょう。

まとめ

今回のバージョン2で、より実践的に踏み込めたと思っています。標準のインポーターのようなものも用意してみたくはあったのですが、そこは各社でワークフローが全然違うところなので、逆に自由にできたほうが良いかな、という判断もありました。

弊社ではクライアントサイド(Unity)とサーバーサイド(.NET Core)の両方で採用し、実際の開発も進めています。ぜひ、皆さんもお試しいただければ幸いです。