🎮

C++でjsonのschemaを使ったvalidationをする

2023/12/06に公開

「神戸電子専門学校 ゲーム技研部 Advent Calendar 2023」6日目の記事です。

https://qiita.com/advent-calendar/2023/kdgamegiken

はじめに

C++でnlohmann_jsonを使ってjsonをロードし、要素にアクセスする際、このようなエラーに遭遇したことがあると思います。

そしてこのエラーが何なのか、Visual Studioの出力をみてもわかりません。つまりjsonを変更した部分を戻しながらデバッグしなければならないのです。私のC++でのゲーム開発ではこれに結構悩まされていました。それを回避するために今回はjson-schema-validatorというものを導入しました。

予備知識

みなさんはC++でjsonを使っているでしょうか?C#(System.Text.JsonやJsonUtility(Unity))やPython(json)なら標準ライブラリでパーサーが搭載されているので使ったこともあるのではないかと思いますが、C++で利用するには外部ライブラリを導入するしかありません。代表的なjsonのライブラリはnlohmann_jsonrapidjsonなどがあります。

jsonとは

そもそもjsonとはなんなのかを少しだけ説明します。よく見かけるのは、アプリケーションの設定ファイルで'*.json'として使われたり、あとはAPI通信でのデータのやり取りで使われたりしています。

{
  "shirokuma1101": {
    "income": 99999999,
    "skill": "nai"
  }
}

このようなフォーマットで、文字列や数値を辞書形式、配列形式でテキストファイルとして保存できます。

シリアライズ化とは

少し話は変わりますが、シリアライズ化はしているでしょうか?そしてそもそもシリアライズとはなんでしょうか?シリアライズとは、プロセス(プログラム)中の変数をそのプロセス(プログラム)以外でも参照できるようにすることを指します。(ちゃんと説明するとちょっと違いますが、、、)
具体的な例を挙げると、前述したjsonの使用例がそれにあたります。要約すると、変数などのプログラム内でしか利用できないデータをテキストなどに保存することをシリアライズ化、そして、逆にそのテキストデータなどからプログラム内で利用できるようにすることをデシリアライズ化と言います。
そしてゲーム開発においてはシリアライズ/デシリアライズを頻繁に使用します。例えばアクションゲームのプレイヤーのパラメーターだけでも、攻撃力、防御力、歩行速度、走る速度、体力等々、、、結構な量のパラメーターが存在します。そして、パラメータのシリアライズ化をしていないと、例えばプレイヤーの攻撃力を増やしたい場合に

void Init() {
-   m_playerAttackDamage = 10;
+   m_playerAttackDamage = 100;
}

とソースコードを変更し、変更を適用するために毎回コンパイルする必要があります。これでは値の調整に時間がかかってしまいます。これを回避するには、この数値を動的に外部ファイルから読み込むようにすればプログラムはコンパイルせず外部ファイルでの数値の変更だけで値の変更が適用されるようになります。

void Init() {
  m_settingFile = json::load("settings.json");
  m_playerAttackDamage = m_settingFile["player_attack_damage"];
}

このように、変数にアクセスできる処理を書いておくだけで済むようになりました。そして、このm_settingFileがデシリアライズされたデータです。

ではこのシリアライズ化したデータはどのように保存するのがいいでしょうか。今回は話の本筋でもあるjsonを利用します。前述したプログラムの場合、

settings.json
{
  "player_attack_damage": 100
}

と記述すればOKになります。これでソースファイルを直接触らなくても、パラメーター調整ができるようになりました。そして私の学校でも一時期jsonによるシリアライズ化が流行っていました。(多分私のせいです)
ちなみにUnityではYAMLというものを利用しています。

Schemaとは

jsonに限らない話ですが、schema(スキーマ)という概念が存在します。これは、「このschemaに従って書いてね」みたいな仕様書のようなものです。schemaをきちんと書いていると、一貫性があるjsonを書くことができ、またvalidationをすることで問題個所を特定しやすくできます。そしてschemaはjson形式で書きます。

{
  "$schema": "http://json-schema.org/draft-04/schema#",
  "type": "object",
  "properties": {
    "schema": {
      "type": "string",
      "additionalProperties": false
    },
    "transform": {
      "type": "object",
      "additionalProperties": false,
      "properties": {
        "fixed": {
          "type": "boolean",
          "additionalProperties": false
        },
        "position": {
          "type": "array",
          "additionalProperties": false,
          "minItems": 3,
          "maxItems": 3,
          "items": [
            {
              "type": "number"
            },
            {
              "type": "number"
            },
            {
              "type": "number"
            }
          ]
        }
      },
      "required": [
        "fixed"
      ]
    }
  },
  "required": [
    "transform"
  ]
}

schemaの一例です。ゲーム開発にちなんで、UnityのTransformのようなschemaを書いてみました。要所を説明すると

  • "type"
    型の指定。
  • "additionalProperties"
    指定された以外の値の設定を許容するかどうか。
  • "required"
    同じ階層にあるキーを必須にするか。

です。このschemaの場合、transformfixedが必須として指定されているので最小構成は

{
  "transform": {
    "fixed": false
  }
}

となります。

jsonのインストール

今回はWindows&Visual Studio2022という環境で行っていきます。

前述したとおり、C++には標準でjsonのパーサーはないので、外部ライブラリであるnlohmann_jsonを導入します。

https://github.com/nlohmann/json

こちらのリポジトリURLの右側のReleasesにある最終リリースをダウンロードします。jsonを利用するだけなら、Assetsの中にあるinclude.zipで良いのですが、あとで他のファイルも使うので今回はSource codeをダウンロードします。
今回は適当にプロジェクトフォルダの中にLibraryというフォルダを作り、その中にzipを展開したフォルダを入れます。そして最後にVisual Studio側でパスの設定を行います。
画面上部のプロジェクトの一番下の{プロジェクト名}のプロパティをクリックします。そして、C/C++全般を選択し、右に表示された表の一番上にある追加のインクルードディレクトリを選択し、右の欄にある下矢印のアイコンをクリックし<編集...>をクリックします。すると新しく追加のインクルードディレクトリというウィンドウが表示されます。ここにincludeへのパス(私の環境では.\Library\json-3.11.3\include)と入力したらOKを押して画面を閉じます。

実行確認

実際に導入できているかどうか試してみます。以下のコードを入力してコンパイルしてみてください。

#include "nlohmann/json.hpp"

int main()
{
    using Json = nlohmann::json;
}

コンパイルエラーが起きていなければ導入完了です。
次にjsonのロードもできるかどうか試してみます。プロジェクトフォルダにAssetsというフォルダを作成し、その中にtest.jsonを作成します。そしてjsonの中身を適当に書いていきます。

{
  "suuzi": 10,
  "moziretu": "mozimozi"
}

次にプログラム側で取得できるかどうか確認します。以下のコードを入力してください。

#include <fstream>
#include <iostream>

#include "nlohmann/json.hpp"

int main()
{
    using Json = nlohmann::json;

    Json j;
    std::ifstream ifs("Assets/test.json");
    if (ifs) {
        ifs >> j;
    }

    auto suuzi = j["suuzi"];
    auto moziretu = j["moziretu"];
    std::cout << suuzi << std::endl;
    std::cout << moziretu << std::endl;
}

実行したらコンソールにこのように表示されていれば正常です。

json-schema-validatorのインストール

次にjson-schema-validatorをインストールします。こちらはライブラリをコンパイルする必要があるので少し手順が複雑です。
(そして今後アップデートによって手順が変更されるかもしれません。)

まず、CMakeが必要なのでCMakeをインストールします。インストール手順は割愛しますが、コマンドプロンプト等でcmake --versionが実行できCMake-guiが使える状態であればOKです。

https://github.com/pboettch/json-schema-validator

こちらのリポジトリURLの右側にあるReleasesにある最終リリースのSource codeをダウンロードし、Libraryフォルダに展開します。次に展開したフォルダの中にbuildというフォルダを作成します。

次に、Cmake-guiを実行して、Where is the source code:にはプロジェクトのパスを、Where to build the vinaries:には作成したbuildディレクトリを指定します。

左下のConfigureをクリックすると、オプションを選択できます。私は、Visual Studio 17 2022x64を指定しました。(後者に関しては、指定しなくてもx64になるようです。)

Finishをクリックすると、エラーが起きました。これは依存関係であるnlohmann_jsonのパスが見つからなかったため表示されています。

次にnlohmann_jsonを指定できるようにするための準備を行います。nlohmann_jsonの方で同じくbuildフォルダを作成します。コマンドプロンプト等でカレントディレクトリがbuildになっている状態でcmake ..を実行します。これでnlohmann_json側の準備は完了です。

先ほど作成したnlohmann_json側のbuildのパスをnlohmann_json_DIRに設定します。

これでConfigureをクリックするとエラーなくコンソールにConfigure doneと表示されました。そして最後にGenerateをクリックします。すると、json_schema_validator側のbuildフォルダにファイルやフォルダが生成できているのが確認できると思います。ビルドするにはその中のnlohmann_json_schema_validator.slnを開きます。

プロジェクトが開くので、Ctrl+Bでビルドを開始します。エラーが出ていなければ成功です。
(Releaseビルドのライブラリが欲しい場合は、画面上部からReleaseを選択してビルドしてください。)
ビルドの成果物はbuild/Debug'orbuild/Release'に保存されています。今回はその中のnlohmann_json_schema_validator.libを使います。
ライブラリのコンパイルは以上です。

プロジェクトの方へ戻ります。ライブラリへのパスを設定するために、プロパティを開きます。C/C++全般追加のインクルードディレクトリsrcへのパス(私の環境では.\Library\json-schema-validator-2.2.0\src)を追加します。次に、リンカー全般追加のライブラリディレクトリDebugへのパス(私の環境では.\Library\json-schema-validator-2.2.0\build\Debug)を追加します。これでパスの設定は終わりです。

実行確認

こちらも実際に導入できているかどうか試してみます。以下のコードを入力してコンパイルしてみてください。

#include "nlohmann/json-schema.hpp"

#pragma comment(lib, "nlohmann_json_schema_validator.lib")

int main()
{
    using JsonValidator = nlohmann::json_schema::json_validator;
}

コンパイルエラーが起きていなければ導入完了です。

使い方

ここからは実際にValidatorというものを使ってみます。Assetsフォルダにschema.jsonを作成します。このファイルにはSchemaとはで紹介したschemaをそのまま書いてみます。そしてtest.jsonも紹介した最小構成に書き換えます。

#include <fstream>
#include <iostream>

#include "nlohmann/json.hpp"
#include "nlohmann/json-schema.hpp"

#pragma comment(lib, "nlohmann_json_schema_validator.lib")

int main()
{
    using Json = nlohmann::json;
    using JsonValidator = nlohmann::json_schema::json_validator;

    Json j;
    std::ifstream ifs("Assets/test.json");
    if (ifs) {
        ifs >> j;
    }

    JsonValidator validator;
    Json schema;
    std::ifstream ifs2("Assets/schema.json");
    if (ifs2) {
        ifs2 >> schema;
    }
    try {
        validator.set_root_schema(schema);
    }
    catch (const std::exception& e) {
        std::cout << e.what() << std::endl;
    }

    try {
        validator.validate(j);
    }
    catch (const std::exception& e) {
        std::cout << e.what() << std::endl;
    }

    auto fixed = j["transform"]["fixed"];
    std::cout << fixed << std::endl;
}

プログラム全体です。これで実行するとfalseと表示されるだけでおわります。validatorの威力はここからです。test.json"fixed": falseを消してみます。

{
  "transform": {
  }
}

これで実行すると、std::cout << e.what() << std::endl;の部分で何が不足しているかを具体的に表示してくれるようになりました。今回はschemaでfixedを必須(required)としているのに、読み込んだjsonでは存在しないため、このようなエラー内容になっています。

https://json-schema.org/understanding-json-schema/reference

schemaで何を指定できるかはこちらの公式リファレンスに書いてあります。

おわりに

2023/12/06 一部文章を修正

実はrapidjsonにはvalidation機能がデフォルトで入っているのですが、nlohmann_jsonのほうが好きなのでこちらで紹介してみました。schemaの構文は共通なのでどちらでも利用できると思います。
余談ですが、就活中にお話ししたゲーム会社の方で、「マジックナンバー絶対許さないマン」もいたので、そういう意味でも外部ファイル化によるシリアライズ化はしておいたほうが良いと思います。

神戸電子専門学校ゲーム技術研究部

Discussion