Сосед по даче обновил прошивку своего узла — и моя поливалка перестала переключать режимы. Пакет выглядел целым: {"mode":1,"temp":21.4}. Ключ mode на месте, containsKey радостно отвечал «есть», а скетч ждал строку "auto" — и чтение в const char* возвращало пустоту. Число в строку не превращается: поменялся тип, а не наличие. Ключ и значение — разные сущности, и проверять их надо разными вопросами; containsKey на тот день отвечал честно на вопрос, который я не задавал.
Экономика проверки дешёвая: две строки кода на поле против часа в журналах после чужого обновления. Прошивки серверов и соседних узлов меняются без предупреждений — я это проходил трижды: режим стал числом, давление стало строкой, счётчик переехал в другой ключ. Каждый раз разбор без проверок отвечал одинаково: молчаливый ноль на экране при честном 200 от сети. Guarding — не паранойя, а страховка с точной ценой: сколько рискованных полей — столько пар проверок, и ни одной больше. Считается это честно: пять полей чужого пакета — пять пар, десять строк кода, и больше ничего.
Инструменты проверки отвечают на три разных вопроса, и мы уже сравнивали два из них в уроке про чтение полей: containsKey спрашивает «был ли ключ в пакете», isNull — «есть ли пригодное значение». Третий вопрос задаёт is<T>(): «можно ли это прочитать как T?». is<float>() на целом 18 даст истину — целое читается как 18.00 без потерь; is<int>() на 21.4 в шестой версии библиотеки — ложь: дробь в целое без потерь не сворачивается. is<bool>() истинно только для настоящих true и false, is<const char*>() — для строк. Типовые сюрпризы с кавычками и null мы перечислили в уроке про типы данных — здесь речь о том, как строить защиту, когда тип поля заранее не гарантирован.
as<T>(): то же приведение, но под контролем
Чтение float t = doc["temp"]; — это неявный вызов as<float>(): библиотека приводит значение к типу слева по тем же правилам, что мы разбирали в уроке про типы переменных. При несовместимости — строка вместо числа, null, отсутствие ключа — приведение тихо даёт ноль или пустоту. Явная форма as<T>() даёт тот же ноль; выигрыш не в механике, а в управлении: явный as<T>() принято звать после проверки is<T>(), и тогда ноль появляется только там, где вы его действительно пропустили. Строка с is перед as объявляет каждому читающему: здесь тип не гарантирован.
Паттерн guarding, который я применяю к чужим API и чужим прошивкам, — три ветки. Первая: поле есть и это число — читать и работать. Вторая: поле есть, но строка — конвертировать atof, если строка числовая, и писать в лог, если нет. Третья: поля нет вовсе — запасное поведение и отметка в журнале. Своему протоколу между двумя моими платами такую броню я не вешаю: хватает containsKey, и код остаётся тонким. Чужому API — всегда: прошивки серверов меняются без предупреждений, и guarded разбор переживает чужое обновление молча, а без проверок — с нулями на экране. И это была не гипотеза: поливалка четыре дня поливала по умолчанию, пока я не распечатал чужой пакет.
Пара слов о строгости проверок. В шестой версии is<int>() на 21.4 отвечает ложью; седьмая проверяет мягче — скорее всего, разрешит чтение дроби в целое. Полагаться на конвертацию строк в числа я не советую ни в одной версии: просите сервер слать числа, а строку обрабатывайте явно через atof, когда она пришла. Если в проекте есть спорное поле — проверьте поведение is на своей версии один раз на столе, запишите результат в комментарий рядом с разбором, и спор закрыт навсегда.
is<T>() работает не только со скалярами: is<JsonObject>() и is<JsonArray>() проверяют, что под ключом лежит структура, прежде чем лезть внутрь с цепочкой или индексами. Для массивов я ставлю такую проверку всегда: сервер меняет длину и даже тип элементов без предупреждений, а цикл по несуществующему массиву выдаёт те же тихие нули. Мнение с условием: на первом поле пакета is<T>() у меня не стоит — проверка кода разбора уже гарантирует документ; стоит оно на полях с историей смены типа и на всём, что пришло от чужого устройства. Внутри своего протокола я знаю тип каждого поля наизусть — лишние проверки там только шум.
#include <ArduinoJson.h> JsonDocument doc; // в v6: DynamicJsonDocument<768> void setup() { Serial.begin(9600); // Прошивка соседнего узла v2: mode пришёл числом вместо строки DeserializationError err = deserializeJson(doc, "{\"mode\":1,\"temp\":21.4}"); if (err) return; if (doc["mode"].is<const char*>()) { Serial.print("mode: "); Serial.println((const char*)doc["mode"]); } else if (doc["mode"].is<int>()) { Serial.print("mode (kod): "); Serial.println(doc["mode"].as<int>()); } else { Serial.println("mode: tip neizvesten, rabotayu po umolchaniyu"); } // Температура: число или строка — оба варианта жизнь if (doc["temp"].is<float>()) { Serial.print("t="); Serial.println(doc["temp"].as<float>()); } else if (doc["temp"].is<const char*>()) { Serial.print("t (iz stroki)="); Serial.println(atof(doc["temp"])); } else { Serial.println("t: dannyh net, beregu poslednee polozhenie"); }} void loop() {}Проверка в симуляторе: сравнение вместо is
is<T>() и as<T>() движок симулятора не эмулирует — это честная граница раздела. Но сам сюжет guarding воспроизводится полностью: ключ есть, значение не того типа. В скетче ниже два пакета: с mode строкой и с mode числом. Роль проверки типа играет сравнение значения с ожидаемым: if (doc.mode == "auto") срабатывает только для строкового пакета. Содержание проверки то же, что у is<const char*>() на плате: «значение — та строка, которую я жду?». Запустите и посмотрите на второй пакет: containsKey отвечает 1, а сравнение проходит мимо — ключ на месте, тип чужой, и ветка с запасным поведением забирает управление себе.
Чтение значений в симуляторе идёт точечной формой doc.mode — на плате то же место занимает doc["mode"]: в тексте скетча это оговорено в комментарии, чтобы на живой плате вы не искали, почему точечная форма не компилируется. Ещё одна упрощённость: строка «не того типа» здесь ловится только сравнением с конкретным ожидаемым значением; на плате is<const char*>() пропустит и "night", и пустую строку — поэтому на плате за is следует проверка содержимого, если от строки зависит логика. Запасная проверка стоит строки — и закрывает вопрос «а что именно пришло» без всяких is.
Третье следствие видно в списке экспериментов: сравнение с ожидаемым значением строже, чем is<const char*>() на плате. Сравнение с "auto" отвергнет и "night", и "1" — на плате обе строки прошли бы is как строки. Это не упрощение, а другая семантика: сравнение проверяет значение, is проверяет тип. Выбирайте по смыслу: когда логика зависит от конкретного режима — сравнение; когда от класса значения — is. На плате я часто ставлю обе: сначала is, потом содержимое.
| Вопрос | Инструмент | Когда звать |
|---|---|---|
| Был ли ключ в пакете? | containsKey | отсутствие поля меняет логику: ветка, лог, умолчание |
| Есть ли пригодное значение? | isNull | в пакете бывают явные null |
| Можно ли прочитать как T? | is<T>() | тип поля не гарантирован: чужие API, обновления прошивок |
| Само приведение | as<T>() | после is<T>() или когда тип гарантирован протоколом; сам по себе — источник тихих нулей |
- Свой протокол. Добавьте третий пакет {"mode":"night"} и ветку для него: сравнение с "night" — одна строка, и guarding расширяется на новый режим без переписывания старых.
- Пустой ключ. Пришлите {"mode":""} — пустую строку: containsKey ответит 1, сравнение пройдёт мимо; на плате is<const char*>() тоже пропустит пустоту — содержимое проверяют отдельно.
- Число в строке. Пришлите {"mode":"1"}: containsKey равен 1, сравнение с "auto" мимо — на плате такую строку обслуживает atof или словарь кодов режимов.
- На настоящей плате. Перенесите скетч для платы с is и as, прогоните оба пакета и убедитесь: ветки разойдутся ровно так, как в симуляторе, только проверка будет типом, а не сравнением.
- containsKey спрашивает «был ли ключ», isNull — «есть ли пригодное значение», is<T>() — «можно ли прочитать как T»: три разных вопроса
- Чтение float t = doc["temp"]; — неявный as<float>(); при несовместимости приходит молчаливый ноль
- is<float>() истинно для целых: 18 читается как 18.00; строки числом не становятся ни в одной версии — обрабатывайте atof явно
- Паттерн guarding для чужих API: число — читать, строка — конвертировать и писать в лог, поля нет — умолчание и отметка
- Своему протоколу между своими платами хватает containsKey; чужим прошивкам и API — всегда is<T>() перед чтением
- В симуляторе is/as не эмулируются: тип проверяется сравнением значения с ожидаемым, содержание проверки то же
- 1. В пакете ключ mode есть, но пришёл числом, а скетч ждёт строку. Что ответит doc.containsKey("mode")?
- 2. Что делает as<T>() при несовместимом типе?
- 3. Что вернёт is<float>() на значении 18 (целое число)?
- 4. Какая пара проверок закрывает оба сюрприза чужого API?
- 5. Почему строку "21.4" я не советую конвертировать числом по умолчанию?
Раздел теперь закрывает весь цикл: собрать документ — serializeJson, прочитать — с запасными значениями и проверками, защититься от чужих типов. Дальше по плану раздела — фильтры разбора, которые экономят память на Uno, и JSON поверх UART. Применить guarding удобнее всего там, где встречаются два мира: возьмите HTTP-запрос с ESP32 — чужой сервер, чужие типы, ваш разбор с тремя ветками. Домашнее задание: добавьте guarding к разбору из урока про HTTP-ответ и добейтесь, чтобы вместо нуля на экране скетч печатал в журнале «тип не тот» — тогда следующий чужой апдейт вы заметите по логу, а не по молчащему прибору. И заведите правило расширенной проверки: любое поле, чей тип менялся хоть раз в жизни протокола, получает is<T>() навсегда — вторая смена типа случается быстрее первой. Мой итоговый чек-лист guarding: containsKey на отсутствие, isNull на пустоту, is<T>() на тип, as<T>() на чтение — четыре вопроса в этом порядке.
Частые вопросы по теме урока
- В пакете ключ mode есть, но пришёл числом, а скетч ждёт строку. Что ответит doc.containsKey("mode")?
- containsKey отвечает только за наличие ключа и ничего не знает о типе. Для типа существует отдельный вопрос — is<T>(), и задаётся он другим методом. 1 — ключ был в пакете
- Что делает as<T>() при несовместимом типе?
- as<T>() возвращает ноль или пустоту без сообщений — поэтому его зовут после is<T>(): проверка превращает тихий провал в управляемую ветку. Возвращает нулевое значение молча
- Что вернёт is<float>() на значении 18 (целое число)?
- Целое значение приводится к float без потерь, поэтому is<float>() даёт истину. А вот is<int>() на 21.4 в шестой версии даст ложь: дробь в целое не сворачивается без потерь. Истину: целое читается как 18.00 без потерь
- Какая пара проверок закрывает оба сюрприза чужого API?
- Отсутствие поля и чужой тип — два независимых сюрприза: первый ловит containsKey, второй is<T>(). Оба стоят две строки и работают годами. containsKey на отсутствие и is<T>() на тип
- Почему строку "21.4" я не советую конвертировать числом по умолчанию?
- atof работает и спасает, но постоянная конвертация маскирует причину: сервер шлёт числа строкой. Договоритесь о формате — и ветка со строкой останется страховкой, а не нормой. Лучше попросить сервер слать числа: конвертация — костыль на чужую вольность