今回のお題

相変わらずチマチマと製作中の MP3 プレイヤーで、アーティスト別に SD カードに保存された全アルバムから、「JPOP」や「ROCK」などのカテゴリでお気に入りを選択し、プレイリストとして JSON データを SD カードに保存する機能を実装しようとしています。

例えば、JSON データはこんな感じです。

[
  {
    "name" : "JPOP",
    "path" : {
      "BoA" : [
        "BEST OF SOUL"
      ],
      "Superfly" : [
        "Amazing"
      ]
    }
  },
  {
    "name" : "ROCK",
    "path" : {
      "サザンオールスターズ" : {
        "2018" : [
          "海のOh, Yeah!!"
        ],
        "2014" : [
          "KAMAKURA",
          "SOUTHERN ALL STARS",
          "バラッド3 ~the album of LOVE~"
        ]
      },
      "RADWIMPS" : [
        "FOREVER DAZE",
        "余命10年 〜Original Soundtrack〜"
      ],
      "ONE OK ROCK" : [
        "35xxxv",
        "DETOX",
        "人生 x 僕="
      ]
    }
  }
]

"path" としているのは、ディレクトリ名を表す「キー」や「値」を / で繋げてアルバムへのパスとするためです。

ということで、SD カードに保存された JSON データを走査し、お気に入りのアルバムへのパスを抽出することが、今回のお題です。

試した JSON ライブラリの概要

Arduino IDE のライブラリマネージャで json を検索すると、様々なライブラリがヒットしますが、今回は次の2つをピックアップしました。

Arduino_JSON

Arduino_JSON
Arduino_JSON

Arduino Libraries に収められた Arduino 謹製のライブラリです。DaveGamble/cJSON のバージョン 1.7.14 を元に Arduino での使用を容易にする、Javascript ライクな(というか意図的に寄せた)ラッパーとして作られています。AVR 系用に独自に追加されたコードが含まれているので、8ビット系の CPU でもそれなりに動作すると思われます。

ただし、元の cJSON に存在した セキュリティ問題への対策 に追従できていません。現バージョン(0.2.0)はセキュリティが問題となるアプリケーションには使わないか、最新版 へのパッチを当てるのが吉です。

cJSON 1.7.19 へのパッチについて

https://github.com/embedded-kiddie/Arduino_JSON/tree/patch にパッチを当てたバージョンを作成しました。拡張された AVR 系のコードを含めて本家のテストコードを実機で検証したいのですが、何せ AVR 系は手持ちがないので PR できていません。ご参考まで。

最もよく使う JSONVar クラス は、「オブジェクト」、「配列」、「数値や文字列など」の3つのタイプで構成されていると考えれば理解し易いと思います。まずはよく使うメソッドです。

  • JSONVar parse(str)
    str (const char* または const String&) から JSON オブジェクトを生成します。

  • String stringify(const JSONVar& value)
    JSON オブジェクトを文字列化します。

  • String typeof(const JSONVar& value)
    "object""array""boolean""number""string""null" など、JSON オブジェクトのタイプを返します。

  • int length(void)
    オブジェクト {"key1" : value1, ...} や 配列 [...] の要素数を返します。

  • JSONVar keys(void)
    オブジェクト中のキーの集合を配列として返します。

  • size_t printTo(Print& output)
    Printable クラス を継承するデバイスに JSON オブジェクトを文字列化して出力します。

  • オーバライドされた “=” 演算子とキャスト演算子
    JSONVar の中身は cJSON 構造体 で管理されており、JSON オブジェクトの「キー」は文字列、「値」は文字列か数値(整数または浮動小数点)を取り得ます。特に キャスト演算子 は、「値」を char*intdouble など適切な型に変換するため使います。

また次のメソッドはβ版のためか、機能する条件がかなり限られているようです。

  • bool hasOwnProperty(key)
    key (const char* または const String&) が含まれているか否かを返します。

  • bool hasPropertyEqual(key, value)
    key (const char* または const String&) に value (const char*const String& または const JSONVar&) と一致する値が設定されているかを返します。

  • JSONVar filter(key, value)
    key (const char* または const String&) と value (const char*const String& または const JSONVar&) が含まれる JSON データを部分的に抽出します。

ドキュメントが存在せず、また 例題 が全メソッドを網羅していないので不完全にはなりますが、上記をテストするスケッチを添付しておきます。

Arduino_JSON の各メソッドをテストするスケッチ
#include <Arduino_JSON.h>
#include <SD.h>

const char *input = R"(
{
  "code": "200",
  "message": 0,
  "list": [
    {
      "main": {
        "temp": 3.23,
        "pressure": 1014,
        "humidity": 58
      },
      "weather": {
        "main": "Clear",
        "description": "clear sky"
      },
      "dt": "2020-02-12 09:00:00"
    },
    {
      "main": {
        "temp": 6.09,
        "pressure": 1015,
        "humidity": 48
      },
      "weather": {
        "main": "Clear",
        "description": "clear sky"
      },
      "dt": "2020-02-12 12:00:00"
    }
  ],
  "city": {
    "name": "London",
    "coord": {
      "lat": 51.5085,
      "lon": -0.1257
    }
  }
}
)";

void Test_hasOwnProperty(JSONVar &obj) {
  Serial.println("===== Test_hasOwnProperty =====");
  Serial.println(obj.hasOwnProperty("code"));             // 1
  Serial.println(obj.hasOwnProperty("main"));             // 0
  Serial.println(obj["list"][0].hasOwnProperty("main"));  // 1
}

void Test_hasPropertyEqual(JSONVar &obj) {
  JSONVar var = "London";

  Serial.println("===== Test_hasPropertyEqual =====");
  Serial.println(obj.hasPropertyEqual("code", "200"));            // 1
  Serial.println(obj["city"].hasPropertyEqual("name", "London")); // 1
  Serial.println(obj["city"].hasPropertyEqual("name", var));      // 1
}

void Test_filter(JSONVar &obj) {
  JSONVar var;
  var["name"] = "London";

  Serial.println("===== Test_filter =====");
  Serial.println(JSON.stringify(obj.filter("code", "200")));            // OK
  Serial.println(JSON.stringify(obj.filter("city", var)));              // null
  Serial.println(JSON.stringify(obj["city"].filter("name", "London"))); // OK
}

void Test_printTo(JSONVar &obj) {
  Serial.println("===== Test_printTo =====");
  if (!SD.begin()) {
    Serial.println("SD initialization failed.");
    return;
  }

  File myFile = SD.open("/test.txt", FILE_WRITE);
  if (!myFile) {
    Serial.println("Error opening test.txt");
    return;
  }

  size_t len = obj.printTo(myFile);
  myFile.close();

  char* buf = (char*)malloc(len + 1);
  if (!buf) {
    Serial.println("malloc failed.");
    return;
  }

  myFile = SD.open("/test.txt", FILE_READ);
  len = myFile.read((uint8_t*)buf, len);
  myFile.close();
  buf[len] = '\0';

  JSONVar tmp = JSON.parse(buf);
  Serial.println(JSON.stringify(tmp));
  free((void*)buf);
}

void setup() {
  Serial.begin(115200);
  while (millis() < 1500);

  Serial.println("\nStart.");

  JSONVar myObject = JSON.parse(input);
  String type = JSON.typeof(myObject);
  if (type == "undefined") {
    Serial.println("Parsing input failed.");
    return;
  }

  Test_hasOwnProperty   (myObject);
  Test_hasPropertyEqual (myObject);
  Test_filter           (myObject);
  Test_printTo          (myObject);

  Serial.println("Done.");
}

void loop() {}

ArduinoJson

ArduinoJson
ArduinoJson

Arduino 用 JSON ライブラリでは最もよく使われていると思われます。

まずセキュリティについてですが、cve.org で検索すると、バージョン 4.5 以前の CVE-2015-4590 がヒットしますが、現在は 6 または 7 が主要なバージョンなので、現時点で心配する必要はなさそうです 😉

そのバージョンについては、6 は省メモリに特化、7 は使い勝手を改善した版に位置付けられています。特に 7 は 6 に比べて 相当にデカくなった ので、8 ビット系のマイコンでは 6 がお薦めとのことです。

本記事では 7 を取り上げますが、ArduinoでJsonを生成、解析する に 6 の詳しい解説があり、7 を使う場合にも一読すると概要が掴み易いと思います。また API や例題のドキュメント が完備しているためココでの解説は割愛しますが、以下は知っておくと良いでしょう 🤟

  • 大元のクラスである JsonDocument と、その構成要素として3つのクラス JsonArrayJsonObjectJsonVariant がある。

  • 上記3クラスには、メモリの消費量を半分程度に抑えられる read-only の派生クラス JsonArrayConstJsonObjectConstJsonVariantConst がある。

  • JsonArrayJsonObject にはイテレータ begin() / end() が、また JsonObject では JsonPair が使える。

  • JsonDocumentJsonVariant には、型を調べるテンプレート is<T>() と、適切な型にキャストするための as<T>() がある。

ArduinoJson Assistant

ArduinoJson Assistant STEP1
ArduinoJson Assistant STEP1
ArduinoJson Assistant STEP2
ArduinoJson Assistant STEP2
ArduinoJson Assistant STEP3
ArduinoJson Assistant STEP3

ターゲットボードと JSON データを入力すると、予想されるメモリ量や各要素にアクセスする C++ コードを生成してくれるツールです。filter を試したり、as<T>() の使い方が分かるので、一度試してみると良いと思います 👍

JSON データの走査

今回のお題では JSON データを頭からお尻まで走査する必要があるため、前章で紹介した2つのライブラリでそのようなスケッチを作成しました。本来「キー」や「値」を処理するところはシリアルモニタへの出力としています。

本来は処理時間も計測すべきですが…、ターゲットはバカっ速な ESP32 なので問題とならないと踏んで省略です 😅

また再帰呼び出しで実装しているため、スタックの消費量は多めです。

Arduino_JSON 版

再帰的関数の引数を参照型にしたかったのですが、上手くいかず、コピーで引き回してます。そのため ArduinoJson 版 に比べ、ヒープメモリの消費量が2倍ほどになっちゃいました 😅

#include <Arduino_JSON.h>

// JSON サンプルデータ
const char *input = R"(
  // ここに JSON データを貼り付けてください
)";

// 整形用インデントの出力
inline void Indent(int depth) {
  while (depth-- > 0) {
    Serial.print("  ");
  }
}

// JSON データの走査
void Traverse(JSONVar obj, int depth = 0) {
  String type = JSON.typeof(obj);

  if (type == "array") {
    for (int i = 0; i < obj.length(); i++) {
      Traverse(obj[i], depth); // 配列の各要素を走査
    }
  }

  else if (type == "object") {
    for (int i = 0; i < obj.keys().length(); i++) {
      Indent(depth);
      obj.keys()[i].printTo(Serial);  // 何か処理する代わりにシリアルモニタに出力
      Serial.println();
      Traverse(obj[obj.keys()[i]], depth + 1);  // 次のオブジェクトを走査
    }
  }

  else if (type != "undefined") {
    Indent(depth);
    obj.printTo(Serial);              // 何か処理する代わりにシリアルモニタに出力
    Serial.println();
  }
}

void setup() {
  Serial.begin(115200);
  while (millis() < 1500);

  JSONVar obj = JSON.parse(input);

  String type = JSON.typeof(obj);
  if (type == "undefined") {
    Serial.println("Parsing input failed!");
    return;
  }

  Traverse(obj);                       // JSON データの走査
  Serial.println(JSON.stringify(obj)); // ライブラリによるシリアライズ
}

void loop() {}

ArduinoJson 版

再帰呼び出しする関数への引数を JsonVariant ではなく JsonVariantConst とすることで、ヒープメモリの消費量が Arduino_JSON 版 の半分程度になっています。

#include <ArduinoJson.h>

// JSON サンプルデータ
const char *input = R"(
  // ここに JSON データを貼り付けてください
)";

// JSON オブジェクトの型
typedef enum {
  JSON_ARRAY,
  JSON_OBJECT,
  JSON_VARIANT,
} JsonType_t;

// JSON オブジェクトの型判定
JsonType_t TypeOf(JsonVariantConst doc) {
  if (doc.is<JsonArrayConst>()) {
    return JSON_ARRAY;
  }

  else if (doc.is<JsonObjectConst>()) {
    return JSON_OBJECT;
  }

  return JSON_VARIANT;
}

// 整形用インデントの出力
inline void Indent(int n) {
  while (n-- > 0) {
    Serial.print("  ");
  }
}

// JSON データの走査
void Traverse(JsonVariantConst doc, int depth = 0) {
  switch (TypeOf(doc)) {
    case JSON_ARRAY:
      for (JsonVariantConst value : doc.as<JsonArrayConst>()) {
        Traverse(value, depth); // 配列の各要素を走査
      }
      break;

    case JSON_OBJECT:
      for (JsonPairConst kv : doc.as<JsonObjectConst>()) {
        Indent(depth);                    // 何か処理する代わりにシリアルモニタに出力
        Serial.println(kv.key().c_str()); // 「キー」は JsonString 型
        Traverse(kv.value(), depth + 1);  // 次のオブジェクトを走査
      }
      break;

    case JSON_VARIANT:
    default:
      Indent(depth);                      // 何か処理する代わりにシリアルモニタに出力
      serializeJson(doc, Serial);         // 「値」は JsonVariant::as<T>() で取得
      Serial.println();
      break;
  }
}

void setup() {
  // Initialize serial port
  Serial.begin(115200);
  while (millis() < 1500);

  JsonDocument doc;
  DeserializationError error = deserializeJson(doc, input);
  if (error) {
    Serial.print("deserializeJson() failed: ");
    Serial.println(error.c_str());
    return;
  }

  Traverse(doc);                    // JSON データの走査
  serializeJsonPretty(doc, Serial); // ライブラリによるシリアライズ
}

void loop() {}

今回の比較結果 まとめ

Arduino 本家のライブラリには、そのシンプルな API 故に期待を寄せていたのですが、β版に止まっていることもあり、ArduinoJson に軍配が挙がると思います。Arduino 本家にも頑張って欲しいところですが、人気の程からしても順当というところでしょう。

Arduino 用 JSON ライブラリの比較
Arduino 用 JSON ライブラリの比較

おまけ