Метеостанция у меня была одна, и телеметрия для неё родилась плоской: {"temp":21.4,"humi":58,"press":1013}. Сервер принимал, дашборд рисовал, я забыл о формате как о детали. Потом на крыше появился второй датчик — дождемер, — и я поступил очевидно: добавил в тот же документ ещё один temp. Пакет {"temp":21.4,"temp":0} ушёл на сервер без единой жалобы: синтаксис чист, ответ 200. А на графике дождя с того утра стояли значения температуры с крыши — сервер при разборе брал последнее поле с этим именем, и первое молча исчезало. Час я сравнивал свой скетч с документацией API, и оба выглядели безупречно — оба просто молчали о том, что два одинаковых ключа в одном объекте это жребий, а не структура.
Вложенные структуры существуют ровно для таких сюжетов: каждому датчику — свой объект, каждой серии значений — свой массив. План урока такой: собираем документ с вложенным объектом и списком показаний, считаем цену скобок в байтах, разбираемся, почему дубль ключа не ловит ни компилятор, ни сервер, и воспроизводим оба сюжета в симуляторе — с честными оговорками о том, что движок умеет, а что остаётся за настоящей платой.
Разговор о вложенности уместен и на приёме: чужие API отдают вложенные ответы сплошь и рядом, и поддерживать рядом с ними плоский формат — значит писать два разбора вместо одного. На бумаге всё просто: объект внутри объекта — это папка внутри папки, где у каждой своя пара ключей; массив — полка с одинаковыми папками, где порядок важнее имён. Библиотека работает с обеими формами одним набором приёмов, и когда сборка освоена, чтение чужой вложенности из урока про чтение полей станет тем же самым задом наперёд: вместо createNestedObject — обращение по цепочке ключей.
Объект внутри объекта: createNestedObject
Плоскую сборку мы освоили в уроке про свою телеметрию: doc["temp"] = t, doc["humi"] = h и печать. Для вложенности манера та же, на ступень глубже: JsonObject sensors = doc.createNestedObject("sensors"); — вызов создаёт внутри документа пустой объект под ключом sensors и возвращает на него ссылку. Дальше поля кладутся уже в неё: sensors["temp"] = 21.4; sensors["humi"] = 58;. Кто владеет ссылкой, тот и пишет: пары, записанные через sensors, попадут внутрь вложенного объекта, а не в корень документа. Так один ключ группирует сколько угодно полей, и у каждого датчика появляется собственное пространство имён.
Есть и короче: doc["dht"]["temp"] = 21.4; — библиотека сама заведёт промежуточный объект, если его ещё нет. Я всё же предпочитаю именованную ссылку, когда полей больше двух: строка sensors["press"] = 1013; читается как «кладу давление в датчики», а цепочка скобок на третьем уровне вложенности уже заставляет перечитывать. Обе формы описывают одну и ту же структуру, выбирать — вкусовщина с одним условием: в команде из двух человек выигрывает та, что читается быстрее.
Глубина вложенности не ограничена библиотекой — ограничена здравым смыслом. Два уровня, датчик и его поля, читаются с одного взгляда; третий — doc["config"]["wifi"]["ssid"] — уже требует привычки, а четвёртый я видел в чужом проекте и помню как предупреждение: автор сам не мог без подсказки сказать, где лежит ключ. Правило, которое я вывел для своих конфигов: сколько людей будет читать документ глазами, столько уровней и допустимо, и обычно это два. Глубже двух — только если принимающая сторона диктует структуру, и переписать её не в вашей власти.
Массив значений собирается зеркально: JsonArray hours = doc.createNestedArray("hours"); — и дальше add() на каждый элемент: hours.add(10); hours.add(11);. Массив — это значения одного смысла под одним ключом: показания за час, номера дней недели, коды режимов. Плоский вариант t0, t1, t2 я встречал в живых проектах — 60 ключей вместо одного; работает, но журнал с такими полями не читается, а принимающий код обрастает циклами по именам. Массив заодно честнее по типам: все его элементы — числа, и путаться не в чем.
#include <ArduinoJson.h> JsonDocument doc; // в v6 то же самое: DynamicJsonDocument<768> void setup() { Serial.begin(9600); doc["device"] = "meteo1"; doc["uptime"] = 12345; // Вложенный объект: у датчиков своё пространство имён JsonObject sensors = doc.createNestedObject("sensors"); sensors["temp"] = 21.4; sensors["humi"] = 58; // Массив показаний за последние четыре часа JsonArray hours = doc.createNestedArray("hours"); int log[4] = {21, 22, 20, 19}; for (int i = 0; i < 4; i++) { hours.add(log[i]); } Serial.print("bait: "); Serial.println(measureJson(doc)); serializeJson(doc, Serial); Serial.println(); // {"device":"meteo1","uptime":12345,"sensors":{"temp":21.4,"humi":58},"hours":[21,22,20,19]}} void loop() {}Цена скобок и дубль ключа
Вложенность ест байты, и цена счётная: каждая пара скобок — два символа, имя группы — его длина плюс кавычки и двоеточие, и это в каждом пакете. Моя телеметрия из четырёх полей после переезда полей во вложенный объект выросла с 58 до 92 байт; при отправке раз в минуту прирост — 34 байта каждую минуту, около 49 КБ в месяц, что на GSM-тарифе с пошаговой оплатой уже заметно. Замер measureJson до отправки мы разбирали в уроке про serializeJson — здесь он нужен по той же причине: скобки размножаются тихо, и без счётчика вы замечаете это только по счёту от оператора связи.
Вернёмся к дождемеру. Пакет с двумя ключами temp — синтаксически валидный JSON: стандарт не говорит, какое из двух значений настоящее, и каждый парсер решает сам. ArduinoJson оставляет последнее: поле перезаписывается, и первое значение исчезает без следа — без предупреждения, без пометки в логе, без кода в ответе. Парсер дашборда на другом языке мог бы взять первое. Мы неделю смотрели на одну телеметрию и видели разные числа — обоим повезло с выбором правила, а корень странности я нашёл, только распечатав сырой пакет целиком.
Отсюда правило, которое я вынес из той недели: два значения одного смысла — это либо вложенный объект с разными ключами, либо массив. Плоский документ с двумя одинаковыми ключами — не структура, а лотерея. В симуляторе ниже воспроизводятся все три сюжета: дубль, съедающий поле; сборка со списком значений; вложенный пакет, разобранный и напечатанный с отступами. Упрощения движка я перечислил в подписи к скетчу — там, где симулятор не дотягивается до платы, это сказано прямо.
Второй сюжет, где вложенность незаменима, — исходящий конфиг. Серверу, который ждёт {"config":{"tmin":18,"tmax":27}}, плоский пакет с tmin и tmax в корне бесполезен: парсер принимающей стороны ищет поля по пути, а не по наличию. Я один раз решил, что «сервер разберётся», и слал плоский вариант — не разобрался: писал в журнал отсутствие полей и молчал в ответе, пока я не заглянул в его код. Структуру запроса диктует тот, кто принимает, и спорить с ней дороже, чем один раз написать createNestedObject.
- Дубль по вашему вкусу. Поменяйте в первом пакете значения местами: {"temp":0,"temp":21.4} — в печати останется 21.4, последнее всегда выигрывает, и это теперь видно руками.
- Длиннее журнал. Добавьте в массив hours пятый элемент и посмотрите на строку len=: каждый элемент массива стоит от одного до трёх байтов плюс запятая.
- Глубже конфиг. В третьем пакете допишите в sensors пару "press":1013 и найдите её в красивой печати; est_sensors при этом не изменится — containsKey смотрит только верхний уровень.
- На настоящей плате. Перенесите скетч для платы, добавьте sensors["press"] = 1013; и сравните measureJson до и после — цена пары ключ-значение станет наглядной до последнего байта.
| Структура | Когда беру | Цена |
|---|---|---|
| Плоский документ | один датчик, четыре-пять полей, телеметрия раз в минуту | минимальная: только пары ключ-значение |
| Вложенный объект | несколько датчиков или группы полей: sensors, config | плюс две скобки и имя группы в каждом пакете |
| Массив значений | серия однотипных значений: журнал показаний, список дней | плюс две скобки и по запятой на элемент |
| Два одинаковых ключа | никогда | одно значение исчезает молча: у парсеров своя воля |
- doc.createNestedObject("sensors") создаёт вложенный объект и возвращает ссылку; поля кладутся уже в неё
- doc.createNestedArray("hours") плюс add() на каждый элемент — список значений вместо ключей t0, t1, t2
- Цепочка doc["dht"]["temp"] = t тоже работает: библиотека сама заведёт промежуточный объект
- Каждая пара скобок — два байта: телеметрия из четырёх полей после вложенности выросла с 58 до 92 байт
- Дубль ключа — валидный JSON без определённого поведения; ArduinoJson оставляет последнее значение, первое исчезает молча
- containsKey проверяет только верхний уровень: вложенный ключ корню документа не принадлежит
- 1. В пакете {"temp":21.4,"temp":0} ключ temp встречается дважды. Что вернёт чтение doc["temp"] после разбора ArduinoJson?
- 2. Что возвращает doc.createNestedArray("hours")?
- 3. Телеметрия из четырёх полей занимала 58 байт; после переноса полей во вложенный объект печать выросла до 92. Откуда прирост?
- 4. Пакет {"sensors":{"temp":18.5}} разобран. Что вернёт doc.containsKey("temp")?
- 5. Какой приём соберёт показания за час в одном поле?
Вложенный документ — половина разговора двух машин, потому что его же надо уметь прочитать: чтение цепочкой ключей и защита от молчаливых нулей разобраны в уроке про чтение полей. Готовый документ чаще всего уходит в сеть: телом POST-запроса из урока про отправку телеметрии на ESP32 или строкой serializeJson(doc, client) в брокер MQTT. Домашнее задание с платой: соберите телеметрию двух датчиков в один документ с вложенным объектом, замерьте measureJson до и после вложения и отправьте пакет на тестовый сервер — ответ 200 при живых обоих датчиках станет лучшим доказательством, что структура дошла целиком. И проверьте себя на обратный сюжет: если в скетче два датчика пишут в один корень документа — вы уже на полпути к дублю ключа, даже если сейчас повезло.
Частые вопросы по теме урока
- В пакете {"temp":21.4,"temp":0} ключ temp встречается дважды. Что вернёт чтение doc["temp"] после разбора ArduinoJson?
- Дублирующийся ключ перезаписывается: ArduinoJson оставляет последнее значение. Стандарт JSON поведение с дублями не определяет — другие парсеры могут выбрать иное. 0 — последнее значение, первое перезаписано
- Что возвращает doc.createNestedArray("hours")?
- Метод создаёт массив под ключом и возвращает на него ссылку: JsonArray hours = doc.createNestedArray("hours"); дальше hours.add(...) наполняет его. Ссылку на массив, в который дальше кладут значения через add()
- Телеметрия из четырёх полей занимала 58 байт; после переноса полей во вложенный объект печать выросла до 92. Откуда прирост?
- Вложенность стоит два символа скобок плюс имя группы и её двоеточие — и всё это в каждом пакете. Отступы добавляет только serializeJsonPretty, а не вложенность. Две пары скобок и имя вложенного объекта в каждом пакете
- Пакет {"sensors":{"temp":18.5}} разобран. Что вернёт doc.containsKey("temp")?
- containsKey проверяет корень документа: temp принадлежит вложенному объекту sensors, поэтому на верхнем уровне его нет. Для вложенных полей проверяют doc["sensors"].is<JsonObject>(). 0 — containsKey смотрит только верхний уровень
- Какой приём соберёт показания за час в одном поле?
- Серия однотипных значений — это массив: один ключ, add() на элемент. Плоские нумерованные ключи не читаются в журнале, а дубли ключей съедают значения. Массив через createNestedArray и add() на каждое значение