Метеосервис, из которого я брал прогноз для поливалки, отдавал поле rain не всегда: если дождя в прогнозе не предвиделось, ключа в ответе просто не было. Мой скетч читал int rain = doc["rain"] и писал результат в журнал на SD-карте. Отсутствующий ключ давал ноль — и ноль для осадков значение легитимное: дождя нет. Отличить «осадков ноль миллиметров» от «данных не присылали» я не мог, и не пытался: неделями журнал честно рисовал нули, а я строил по ним статистику сухих дней.
Сломалось всё на споре с соседом по даче: он утверждал, что в среду был ливень, а мой журнал за среду утверждал — ноль. Вскрытие показало: в тот день сервис вообще не ответил на один из запросов, разбор сорвался, документ опустел, чтения дали нули — и нули легли в журнал как «сухой день». Семь суток по 48 замеров — около трёхсот записей, из которых осмысленных было меньше половины.
Грабли эти общие для любого числового поля, где ноль — возможное значение: осадки, счётчики, ошибки, проценты. Чтение без защиты не отличает честный ноль от молчания. В этом уроке разберём, как читать поля разных типов, чем помогает оператор | со значением по умолчанию, когда брать isNull, а когда containsKey — и как всё это выглядит в симуляторе, где поддержка неполная, о чём скажу прямо.
Считайте этот урок третьим слоем раздела. Первый — разбор чужой строки из урока про deserializeJson, второй — сборка своего документа из урока про serializeJson. Здесь мы работаем с тем, что осталось за кадром обоих: не «как получить документ» и не «как его напечатать», а «как честно прочитать из него значение, когда документ не обязан содержать всё, что вы ждёте». Это навык, который отличает скетч для стола от скетча, которому вы готовы доверить теплицу на две недели отъезда.
Чтение поля: тип решает
Чтение устроено через приведение: doc["temp"] — это не число и не строка, а универсальная обёртка JsonVariant, из которой значение извлекается в зависимости от того, куда вы её складываете. float t = doc["temp"]; даст число, int h = doc["humi"]; — целое, const char* city = doc["city"]; — указатель на строку. Тип слева определяет, как трактовать значение; те же правила приведения, что мы разбирали в уроке про типы переменных, только применяет их библиотека. Если тип несовместим — например, в поле строка, а читаете во float — получите ноль, снова молча.
Со строками есть нюанс, который стоит проговорить: const char* указывает внутрь документа. Пока doc жив — строка жива; отчистили документ или вышли из функции, где он был локальным — указатель повис. Нужна строка на свой век — копируйте: String(city) или в свой буфер. Я один раз поймал этот висящий указатель, когда собирал документы в функции и сохранял из них имена устройств для лога.
Вложенность читается цепочкой: doc["dht"]["temp"] — объект внутри объекта, doc["forecast"][0] — первый элемент массива. Если промежуточного объекта нет, цепочка молча вернёт ноль — та же история, что и с одиночным ключом, только звеньев больше. Для явного приведения есть метод as: doc["temp"].as<float>() — то же приведение, но с возможностью проверить is<float>() заранее, когда тип поля заранее не известен.
Булевы читаются в bool: bool online = doc["online"]; — из true/false в истина-ложь без сюрпризов, если поле действительно булево; строковое yes при этом даст ноль, а не истину, — ещё одна ловушка в копилку. Массивы читаются индексом: doc["forecast"][0] — первый день прогноза, doc["forecast"][2] — третий. Мнение с условием: массивы в API — самые хрупкие места, потому что сервер меняет их длину без предупреждения; я всегда проверяю размер до обращения, а на плате после успешного разбора доку проверяю через doc["forecast"].is<JsonArray>() — прежде чем лезть внутрь с индексами.
#include <ArduinoJson.h> JsonDocument doc; void setup() { Serial.begin(9600); // rain в пакете нет — сервис шлёт его только при дожде: DeserializationError err = deserializeJson( doc, "{\"temp\":19.2,\"city\":\"Omsk\",\"rain\":null}"); if (err) return; float t = doc["temp"] | -50.0; // ключа нет — возьмётся -50.0 const char* city = doc["city"] | "?"; int wind = doc["wind"] | -1; // увидим -1, а не молчаливый ноль Serial.print("t="); Serial.println(t); Serial.print("city="); Serial.println(city); Serial.print("wind="); Serial.println(wind); if (doc["rain"].isNull()) { // здесь попадает и отсутствующий ключ, и явный null в пакете Serial.println("rain: dannyh net"); } else { Serial.print("rain="); Serial.println(doc["rain"].as<float>()); }} void loop() {}Оператор | — запасное значение
Запись doc["temp"] | -50.0 читается как «temp, а если его нет — минус пятьдесят». Это не побитовое ИЛИ, хотя символ тот же: оператор перегружен библиотекой и возвращает значение из документа, если оно есть и совместимо с типом слева, иначе — запасное. Срабатывает он в двух случаях: ключа нет вообще и тип несовместим — например, сервер прислал температуру строкой, а вы читаете число. Второй случай особенно ценен: API-и меняют представление полей без предупреждения, и запасное значение превращает тихий ноль в заметный маркер. Сама ловушка тихого нуля при сорванном разборе подробно разобрана в первом уроке раздела: пустой документ тоже отдавал нули.
Правило выбора запасного — оно должно лежать вне диапазона реальных значений. Для температуры за окном — минус пятьдесят; для направления ветра — минус один; для города — вопросительный знак. Если поле способно принять любое число, | не спасает: тогда уже isNull и containsKey. И наоборот: если ключ обязателен и его отсутствие меняет логику скетча, не растекайтесь по значениям — спросите containsKey и уйдите в явную ветку с печатью в лог.
isNull и containsKey отвечают на разные вопросы. containsKey("rain") спрашивает: был ли ключ в пакете — true, если сервер прислал поле, даже со значением null. doc["rain"].isNull() спрашивает: есть ли здесь пригодное значение — true и когда ключа нет, и когда пришло явное null. Разница тонкая, но реальная: некоторые API шлют "rain": null вместо отсутствия поля, и containsKey тогда честно скажет «поле было» — если вам важно отличать пустое значение от отсутствующего, у вас есть инструмент.
Моё рабочее разделение такое: | для чтения, containsKey для логики. | — когда значение нужно в любом случае, вопрос лишь в подмене; containsKey — когда от наличия поля зависит поведение: печать строки целиком, ветка алгоритма, запись в журнал. Пример второго: если ключа pressure нет, я не печатаю строку «pressure: 0» — я не печатаю ничего, потому что прибор с пустым местом на экране честнее прибора с выдуманным нулём. Такая дисциплина стоит двух лишних строк, а экономит час разбора странного журнала.
Защита в симуляторе
В симуляторе из этого набора работает containsKey — проверки ключей и ветвления настоящие. Оператор | и isNull не эмулируются, поимённое чтение значений — тоже, поэтому скетч ниже построен на containsKey: пока ключа нет, скетч подставляет запасное значение обычной переменной и печатает его с пометкой, что это запас. На настоящей плате ту же работу делает одна строка doc["temp"] | -50 — механика та же, форма короче.
Ещё одно следствие упрощений: отсутствующий ключ в симуляторе читается как ноль — ровно та ловушка, от которой этот урок предостерегает. Поэтому в скетче я не полагаюсь на чтение вообще: наличие ключа проверяется, значение показывается явно. Запустите скетч и посмотрите на оба пакета — с ключом и без. Для эксперимента добавьте во второй пакет ещё один ключ и заведите для него такую же ветку с проверкой: защита расширится на одну проверку containsKey, не больше — в этом и сила дисциплины.
| Способ | Что проверяет | Когда беру |
|---|---|---|
| doc["k"] | запасное | ключ есть и тип совместим, иначе запасное | скаляры с понятным значением вне диапазона: -50, -1, "?" |
| doc["k"].isNull() | есть ли пригодное значение (нет ключа или null) | сервер шлёт явные null; поля, где ноль легитимен |
| doc.containsKey("k") | был ли ключ в пакете вообще | отсутствие ключа меняет логику: не печатать поле, уйти в ветку |
| чтение без проверки | ничего | только для полей, где ноль невозможен и ложь видна сразу |
- doc["ключ"] — JsonVariant: тип слева решает, как трактовать значение; несовместимый тип даёт молчаливый ноль
- const char* из документа указывает в его память: документ отчистили — указатель повис; нужна копия — String(city)
- Оператор | подставляет запасное значение, когда ключа нет или тип несовместим; запасное выбирайте вне диапазона реальных значений
- containsKey отвечает «был ли ключ», isNull — «есть ли пригодное значение»; различие важно, когда сервер шлёт явные null
- Для полей, где ноль легитимен (осадки, счётчики, ошибки), защита обязательна: | или containsKey — иначе журнал врёт
- В симуляторе из набора работает containsKey; | и isNull не эмулируются — на плате та же логика занимает одну строку
- 1. В пакете нет ключа wind. Что напечатает строка Serial.println(doc["wind"] | -1) на плате?
- 2. Сервер прислал "temp": "21.4" — температуру строкой. Что вернёт float t = doc["temp"] | -50.0?
- 3. Чем doc["k"].isNull() отличается от doc.containsKey("k")?
- 4. const char* city = doc["city"]; — документ потом отчистили. Что с city?
- 5. Журнал осадков показывает нули и там, где данных не было. Какое исправление корректно?
На этом основа раздела собрана целиком: разбор строки, сборка своего документа, чтение с защитой от молчаливых нулей. Дальше по разделу — фильтры разбора, которые экономят память на Uno, и вложенные конфиги; а применить связку целиком удобнее всего в сети: возьмите HTTP-запрос с ESP32, разберите ответ с запасными значениями и отправьте узлу встречный документ — полный цикл общения двух машин в одном скетче. А если в проекте уже есть брокер, собранный документ публикуется в MQTT одной строкой serializeJson(doc, client).
Частые вопросы по теме урока
- В пакете нет ключа wind. Что напечатает строка Serial.println(doc["wind"] | -1) на плате?
- Оператор | видит, что ключа нет, и возвращает запасное значение -1. Без него чтение дало бы молчаливый ноль. -1
- Сервер прислал "temp": "21.4" — температуру строкой. Что вернёт float t = doc["temp"] | -50.0?
- | срабатывает не только при отсутствии ключа, но и при несовместимом типе: строка в float не приводится, берётся запасное -50.0. -50.0 — тип несовместим, сработало запасное значение
- Чем doc["k"].isNull() отличается от doc.containsKey("k")?
- Пакет с "k": null пройдёт containsKey (ключ был), но провалит isNull (пригодного значения нет). Выбор зависит от того, что важно отличить. isNull истинно и для отсутствующего ключа, и для явного null; containsKey — только про наличие ключа
- const char* city = doc["city"]; — документ потом отчистили. Что с city?
- Строки хранятся внутри документа; чтение даёт указатель, а не копию. Нужна строка на свой век — копируйте в String или свой буфер. Указатель повис: строка жила в документе
- Журнал осадков показывает нули и там, где данных не было. Какое исправление корректно?
- Ноль для осадков легитимен, поэтому отличать его от отсутствия данных нужно явно: запасное значение через | или ветка по containsKey. Читать через | с запасным вне диапазона или проверять containsKey