На настенной панели погоды — Uno, LCD 16×2, кнопка смены экрана — я однажды решил посчитать свободную память классическим freeRam(). Панель жила сама: раз в десять минут ходила в погодный сервис, брала температуру и влажность, рисовала их на экране. Ответ сервиса был подробный: город, часовой пояс, почасовой прогноз на десять значений, восход, закат, ультрафиолет, видимость. Мне из всего ответа нужны были два числа — а в документе после разбора лежало всё.
Разница оказалась не косметической. Из восьми полей ответа на экран попадали два; остальное документ держал в ОЗУ просто потому, что так работает deserializeJson без фильтра: разбор кладёт в документ всё, что пришло. На ESP32 с её сотнями килобайт это была бы безделица, на Uno с двумя килобайтами SRAM — заметная строка в бюджете, который мы считали ещё в первом уроке раздела. А по смыслу мне нужен был документ фиксированного состава: два ключа, ни больше ни меньше.
Такой документ ArduinoJson умеет делать из коробки: к deserializeJson добавляется третий аргумент — фильтр, второй документ-образец, в котором вы перечисляете, что брать. Приём появился в версии 6.15 весной 2020 года и с тех пор стал штатным способом разбирать подробные ответы на маленьких платах. В этом уроке разберём механику: как описать фильтр, что попадает в документ, а что нет, как отфильтровать вложенность и массив. Про ответы, которые сами не влезают в ОЗУ, — отдельный урок дальше по разделу; здесь — базовый случай, когда места хватает, но жалко.
Фильтр — второй документ со списком желаний
Фильтр описывается тем же документом, что и результат: завели JsonDocument filter, записали в него ключи — filter["temp"] = true; filter["humi"] = true; — и передали в разбор третьим аргументом: deserializeJson(doc, packet, DeserializationOption::Filter(filter)). Читается так: из packet в doc возьми то, что перечислено в filter. Ключ со значением true — брать; ключа нет в фильтре или он false — пропустить. В шестой версии фильтр заводят как StaticJsonDocument<192> с явной ёмкостью, в седьмой — обычным JsonDocument, он растёт сам; отличие версий мы разбирали в уроке про JsonDocument в седьмой версии. Сам вызов разбора и его коды — тема урока про deserializeJson; здесь считаем, что строка доходит целой и корректной.
Главное свойство: в doc после такого разбора лежат только выбранные поля. Не «лежат все, но лишние помечены» — именно только выбранные. containsKey("wind") после разбора с фильтром без ветра честно ответит «нет» — и это не сбой, а результат: состав документа повторяет состав фильтра, как списку и положено. С фиксированным составом удобнее проверять данные после разбора: приёмы из урока про чтение полей и оператор | перестают ловить сюрпризы, которых вы сами не заказывали.
Фильтр умеет работать и глубже корня. Хотите ветку целиком — вложите объект: filter["forecast"]["day"] = true заберёт поле day из вложенного объекта forecast. Пустой фильтр {} разберёт строку в пустой документ — так дёшево проверяют, что источник вообще JSON, не тратя места под значения. Рабочий скетч для настоящей платы — ниже, а шпаргалка по вариантам — таблицей под конец урока.
Фильтр удобно настроить один раз и не трогать: заводите filter глобально, заполняете в setup() — и дальше каждый разбор в loop() идёт с одним и тем же списком желаний. Мой панельный скетч после переделки стал короче на пару строк и потерял одну сущность: раньше после разбора я мысленно игнорировал поля, которые не собирался показывать, теперь их в документе просто нет. Проверка containsKey для ветра возвращает ноль — и это не отсутствие данных, а осознанное решение, принятое на уровне фильтра.
#include <ArduinoJson.h> // Ответ сервиса; в реальном скетче приходит по HTTP или SerialString packet = "{\"city\":\"Omsk\",\"temp\":-3.5,\"humi\":88,\"wind\":4.2,\"press\":1013,\"uv\":0.8}"; JsonDocument filter; // v6: StaticJsonDocument<192> filter;JsonDocument doc; // v6: StaticJsonDocument<192> doc; void setup() { Serial.begin(9600); // Список желаний: ключи с true библиотека заберёт, остальные пропустит filter["temp"] = true; filter["humi"] = true; DeserializationError err = deserializeJson(doc, packet, DeserializationOption::Filter(filter)); if (err) { Serial.print("Razbor sorvan: "); Serial.println(err.c_str()); return; } // В документе теперь только temp и humi — чего не просили, того нет float t = doc["temp"]; int h = doc["humi"]; Serial.print("t="); Serial.println(t); Serial.print("h="); Serial.println(h); Serial.print("est_veter="); Serial.println(doc.containsKey("wind")); // 0} void loop() {}Что фильтр экономит, а что нет
Разберёмся с ценой честно. Фильтр экономит память под документ: значения неперечисленных полей не копируются в doc, на них не уходит ни байта ёмкости. Время разбора тоже падает — библиотека пропускает размещение значений, — но не до нуля: по строке парсер всё равно идёт целиком, от первой скобки до последней, чтобы не потерять структуру. И канал фильтр не ужимает: ответ приходит по сети полностью, два килобайта останутся двумя килобайтами в проводе.
Поэтому моя связка такая: фильтр плюс приём по возможности без промежуточной String. На ESP32 мы разбирали связку HTTPClient и ArduinoJson: deserializeJson умеет читать прямо из потока http.getStream(), и опция Filter передаётся туда же третьим аргументом — тогда ни полная строка, ни полный документ память не занимают. Если приём идёт в String, а на Uno часто иначе не выходит, выигрыш от фильтра всё равно есть: строка одна, а документ — половинный или четвертной. Приём строки по кускам, если сервис шлёт пакеты порциями, — приёмы из урока про Serial.available: собрали полный пакет, потом один deserializeJson с фильтром.
Второй эффект я заметил позже и ценю не меньше памяти: документ фиксированного состава упрощает отладку. Когда в doc лежит всё подряд, ветка «ключа нет» означает сбой сервиса. Когда в doc только отфильтрованное, «ключа нет» почти всегда означает, что фильтр описан не так, как ответ. Круг подозреваемых сужается до одной строки — той, где заполняется filter.
Практическая деталь: фильтр живёт в памяти постоянно, как и основной документ, — под него тоже выделяется ёмкость. Поэтому фанатизм тут вреден: фильтр на сорок ключей ради двух полей удержит больше места, чем сам выигрыш. Мой ориентир: фильтр в десять-двадцать раз меньше ожидаемого полного ответа — тогда он окупается с первого же разбора. Заполняется он теми же присваиваниями, что и обычный документ: true под ключом — то же присваивание, только семантика другая.
Разбор в симуляторе
Движок книги опцию DeserializationOption::Filter не поддерживает — говорю честно заранее — и вместо неё показывает эффект: урезанная строка перед разбором. То, что на плате отобрал бы фильтр, здесь вырезано из строки руками: на вход подаются два пакета, полный и отобранный, и оба разбираются в свои документы. Урезанная строка — это ровно результат работы фильтра, только выполненный заранее.
Размер показывает measureJson: он считает длину печати документа, и разница между полной и отобранной версией видна в байтах: в скетче ниже полный документ печатается на 70 символов, отобранный — на 23, меньше трети. На настоящей плате разница в ОЗУ ещё заметнее: под каждый ключ и значение в документе тратится место сверх самой печати, поэтому экономия фильтра там растёт, а не падает. Чтение полей в сим-скетче — точечной формой doc.temp: на плате она равнозначна doc["temp"], а движок понимает только точку.
Проверьте сами: добавьте в отобранную строку поле press — и measureJson подрастёт ровно на его длину; уберите humi — упадёт. На плате ту же работу делает filter-документ: добавили строку filter["press"] = true; — поле поехало в результат.
| Фильтр | Что попадёт в doc | Когда беру |
|---|---|---|
| filter["temp"] = true | один скаляр temp | нужен единственный показатель из плоского ответа |
| temp и humi по true | два поля, состав фиксирован | данные на экран или в журнал |
| filter["fc"]["day"] = true | ветку fc.day | из вложенного ответа нужен один этаж |
| filter["hourly"][2] = true | первые три элемента массива | ближайшие часы прогноза, не весь список |
- Фильтр — второй документ-образец: ключи с true попадают в результат, остальное пропускается; третий аргумент — DeserializationOption::Filter(filter)
- После разбора с фильтром в doc лежат только отобранные поля: состав документа повторяет состав фильтра, и containsKey ловит расхождение за один шаг
- Экономия — память под документ и часть времени разбора; парсер идёт по всей строке, канал фильтр не ужимает
- Вложенность фильтруется цепочкой filter["fc"]["day"] = true, массив — лимитом элементов filter["hourly"][2] = true
- Приём появился в ArduinoJson 6.15; в v6 фильтр — StaticJsonDocument с ёмкостью, в v7 — обычный JsonDocument
- 1. В ответе восемь полей, фильтр перечисляет temp и humi. Что окажется в doc после deserializeJson с опцией Filter?
- 2. Как заводится фильтр-документ в шестой версии библиотеки?
- 3. По какой части строки проходит парсер при разборе с фильтром?
- 4. Пришло десять часов прогноза, а в фильтре задано filter["hourly"][2] = true. Сколько элементов массива попадёт в doc?
- 5. Ответ сервиса — 2 КБ, приём идёт в String на Uno со 2 КБ SRAM. Спасёт ли фильтр?
Дальше — вторая половина темы: ответы, которые сами не влезают в ОЗУ. Фильтр на два поля с письма на два килобайта снимает сотни байт, но если строка занимает всю SRAM ещё при приёме, нужна более грубая артиллерия: разбор из потока, фильтр с лимитом на массивы и честный подсчёт байтов. Об этом — урок про большие ответы. Домашнее задание: возьмите свой любимый API, посчитайте глазами, сколько полей вам реально нужно, напишите filter-документ и сравните на плате длину печати полного и отобранного документа через measureJson — разница обычно отрезвляет. И когда вернётесь к панели с экраном — попробуйте добавить третий отобранный показатель: фильтр расширится одной строкой, а документ останется фиксированным.
Частые вопросы по теме урока
- В ответе восемь полей, фильтр перечисляет temp и humi. Что окажется в doc после deserializeJson с опцией Filter?
- Фильтр отбирает содержимое документа: в doc попадают только перечисленные в filter поля. Состав результата повторяет состав фильтра. Только temp и humi — остальное пропущено
- Как заводится фильтр-документ в шестой версии библиотеки?
- В v6 — StaticJsonDocument с явной ёмкостью, как и основной документ. JsonDocument без ёмкости — форма седьмой версии. StaticJsonDocument<192> filter;
- По какой части строки проходит парсер при разборе с фильтром?
- Структуру ответа надо прочитать целиком, чтобы не потерять вложенность. Экономятся память под значения и часть времени размещения — но не чтение строки. По всей строке: лишние значения пропускаются, не копируясь в документ
- Пришло десять часов прогноза, а в фильтре задано filter["hourly"][2] = true. Сколько элементов массива попадёт в doc?
- Индексы в фильтре-массиве работают лимитом: сохраняются элементы с нулевого по указанный — здесь первые три. Первые три
- Ответ сервиса — 2 КБ, приём идёт в String на Uno со 2 КБ SRAM. Спасёт ли фильтр?
- Фильтр экономит документ, но не приём: String на 2 КБ кончит ОЗУ до deserializeJson. Выход — разбор из потока или плата с большей SRAM. Нет: строка займёт всю ОЗУ ещё до разбора; нужен поток или плата побольше