Мой самый обидный отказ в истории с JSON случился в полдень, когда я был в отъезде. Метеоузел на Uno читал погодный API; ёмкость документа я старательно посчитал в онлайн-ассистенте ArduinoJson — 256 байт, с запасом. Через месяц сервер добавил в ответ поле с кодом осадков: пакета стало на десяток байт больше, документ кончился на середине разбора, узел получил NoMemory и честно стал печатать нули. Программа не падала, письма не писала — просто температура в логе застыла на нуле, и я три дня верил в сдохший датчик.
Ошибки в подборе ёмкости — классика шестой версии: библиотека просила план заранее, ассистент его считал, а серверы продолжали менять ответы без спроса. Седьмая версия сняла саму постановку задачи: JsonDocument больше не принимает ёмкость — он берёт память из кучи и растёт, когда данных становится больше. Ассистент из обязательного ритуала превратился в справочный калькулятор.
Разберём, что такое документ как пул памяти, как выглядит рост на Uno с её 2 КБ SRAM и на ESP32 с сотнями килобайт, чем clear() отличается от «просто затереть», и как мигрировать старый код с Static и Dynamic за две минуты. Синтаксис и установка у нас уже за спиной: разбор JSON и постановка библиотеки.
Пул памяти, который растёт сам
JsonDocument — не карта «ключ — значение», а пул памяти с индексом поверх: при разборе библиотека складывает туда слоты элементов, а строки хранит отдельным хозяйством. В шестой версии пул был конечным по построению, и разница классов была разницей источника памяти: StaticJsonDocument<256> вырезал 256 байт из статической области ещё на компиляции — место занято всегда, даже если разбор случается раз в час; DynamicJsonDocument(256) брал те же 256 байт из кучи в момент создания и упирался в свой потолок. В седьмой версии пул один и бесшовно растёт: не хватило — библиотека запрашивает у кучи ещё порцию и продолжает разбор. Отсюда и главная перемена в характере ошибок: тесный документ больше не ругается на размере, ругается на кучу.
Цена вопроса зависит от платы. На ESP32 с 520 КБ ОЗУ рост в куче — неоглядный простор: пакет на 2 КБ и документ под него никто не заметит, и я не трачу на память ни секунды проектирования. На Uno с 2 КБ SRAM уравнение другое: куча общая со Serial-буферами, библиотеками Ethernet и вашими String, и каждый запрос «ещё порцию» проходит через malloc, который на фрагментированной куче может не найти места даже при свободных байтах. Рост не бесплатен — он просто переехал с вашего компилятора в вашу кучу.
Моё правило после истории с полднем такое: глобальный документ один на скетч, создаётся один раз, живёт весь цикл. Не плодите JsonDocument внутри функций на каждой итерации — каждое создание просит кучу, каждое освобождение её рвёт; на ESP32 незаметно, на Uno за месяцы непрерывной работы это лотерея. Повторный deserializeJson сам сбрасывает документ перед разбором — мы проверяли это в прошлом уроке, — так что один глобальный пул обслуживает хоть тысячу пакетов подряд.
Второе правило — замеряйте, а не угадывайте: measureJson(doc) возвращает длину сериализованного JSON в байтах, и в симуляторе она работает. Строку в 300 байт на плате не путайте с весом документа: слоты и служебные записи добавляют сверху, ориентир из практики — полтора-два к размеру полезной нагрузки. Но теперь это инструмент самообслуживания, а не пропуск к компиляции: цифра в мониторе заменяет и онлайн-ассистент, и оценку на глаз.
API не изменился: чтение, запись, замер
Обращение к документу осталось прежним до символа: doc["temp"] читает число, doc["node"] — строку, присвоение кладёт. Проверка наличия ключа — doc.containsKey("humi"): честный ответ, есть ли ключ в корне документа, — спасает от молчаливых нулей, которые мы ловили в первом уроке. Сброс по желанию — doc.clear(): после него документ пуст, measureJson вернёт два байта за фигурные скобки. Для повторных пакетов clear не обязателен — deserializeJson чистит сам; он нужен, когда вы хотите обнулить документ без нового разбора, например перед сборкой своего JSON с нуля.
На реальной плате выручает и дефолт на месте: doc["temp"] | 0.0 вернёт 0.0, если ключа нет, — удобно в однострочных чтениях, где заводить проверку лень. В симуляторе этой книги оператор не эмулируется, а containsKey заведётся: скетч разбора из первого урока с ним собирается без правок. Чтение в сим-скетчах, напомню, показано точечной формой doc.temp — на плате обе формы равнозначны. Порядок работы с любым пакетом получается трёхшаговый, и его стоит заучить как таблицу умножения: разобрать, проверить наличие нужных ключей, прочитать. Каждый шаг — одна строка кода, и каждый защищает от отдельного сорта тишины в логах.
Полный замер бюджетов на плате удобно вести по таймеру на millis(): печатать размер документа раз в секунду — и картина памяти видна в динамике, а не по наитию.
#include <ArduinoJson.h> JsonDocument doc; // ёмкости больше нет — и не нужна void setup() { Serial.begin(9600); Serial.print("size0="); Serial.println(measureJson(doc)); // 2: пустые скобки deserializeJson(doc, "{\"node\":\"kotelnaya\",\"temp\":21.4}"); float t = doc["temp"]; Serial.print("temp="); Serial.println(t); Serial.print("size1="); Serial.println(measureJson(doc)); // длина разобранного JSON} void loop() {}Миграция: Static и Dynamic — в один тип
Перевод старого кода механический, три минуты с перерывом на компиляцию. StaticJsonDocument<256> doc; превращается в JsonDocument doc; — размер в угловых скобках выбрасывается. DynamicJsonDocument doc(512); превращается в то же самое JsonDocument doc; — скобки с числом выбрасываются. Всё остальное — чтение, запись, serializeJson, deserializeJson, проверки ошибок — не меняется ни на букву: мы это уже отмечали в уроке про сериализацию своего JSON, где старый скетч с doc["temp"] = t; собрался без единой правки. Если в проекте документов десять — замена однообразная: имя, минус размер, дальше по списку.
Компилятор подстрахует: устаревшие имена в v7 не выкинуты, а оставлены псевдонимами, поэтому сборка пройдёт, но с предупреждением про deprecated. Не глушите предупреждения — это ваш бесплатный список к миграции: соберите старый проект с v7, выпишите все строки с warning'ами, поправьте имена. Чужие примеры из интернета датой до 2024 года переводите сразу, не оставляя диалект v6 в новом коде: смесь двух стилей в одном проекте — верный источник вопросов «а почему тут размер, а тут нет».
Ниже — симулятор, где рост документа виден в цифрах. Кладём в документ поля по одному и печатаем measureJson после каждого присвоения: документ растёт ровно на вес добавленного, никакой ёмкости мы не объявляли. Затем clear() — и размер падает до пустых скобок: пул освободился, готов к новому пакету.
| Было в v6 | Стало в v7 | Что делать со старым кодом |
|---|---|---|
| StaticJsonDocument<256> doc; | JsonDocument doc; | Заменить имя, выбросить размер в угловых скобках |
| DynamicJsonDocument doc(512); | JsonDocument doc; | Заменить имя, выбросить скобки с числом |
| doc.clear() перед каждым разбором | deserializeJson сбрасывает сам | Убрать лишний clear() или оставить — не повредит |
| Подбор ёмкости в ассистенте | Пул растёт из кучи | Ассистент — справочный калькулятор, не ритуал |
| NoMemory: тесный документ | NoMemory: кончилась куча | Смотреть на весь бюджет памяти проекта |
- JsonDocument — пул памяти с индексом: в v7 он один, ёмкость не задаётся, память берётся из кучи порциями
- Рост переехал с этапа компиляции в кучу: тесный документ больше не ошибка размера, а вопрос свободной SRAM
- На ESP32 рост незаметен, на Uno помните про 2 КБ и общую кучу с Serial и библиотеками
- Один глобальный документ на скетч: повторные deserializeJson обслуживаются без clear — разбор сбрасывает сам
- measureJson(doc) — длина JSON в байтах; containsKey — честная проверка ключа; вес документа больше полезной строки в полтора-два раза
- Миграция с v6 — замена имён: StaticJsonDocument<N> и DynamicJsonDocument(N) превращаются в JsonDocument, логика не трогается
- 1. Что произошло со StaticJsonDocument и DynamicJsonDocument в седьмой версии?
- 2. Что вернёт measureJson для пустого документа?
- 3. Нужен ли doc.clear() перед каждым повторным deserializeJson?
- 4. Куда JsonDocument в v7 берёт дополнительную память, когда данных больше, чем есть?
- 5. В чём главная оговорка про автоматический рост на Uno?
Домашнее задание с платой: возьмите свой старый скетч со StaticJsonDocument — или любой пример с форумов датой до 2024 года — переведите на JsonDocument и сравните список предупреждений до и после. Попутно напечатайте measureJson до и после разбора своего пакета: две цифры заменят вам час гаданий про память, а заодно покажут, сколько стоит каждое поле вашего протокола. Дальше по разделу — вложенные объекты, массивы и фильтры, которые режут чужой большой ответ до нужных вам полей: фильтр экономит и канал, и кучу, что на Uno звучит как аргумент. А навык из этого урока напрямую пригодится в MQTT-телеметрии на ESP32, где один глобальный документ обслуживает канал месяцами без перезагрузки.
Частые вопросы по теме урока
- Что произошло со StaticJsonDocument и DynamicJsonDocument в седьмой версии?
- Оба класса стали устаревшими псевдонимами JsonDocument: старый код собирается с предупреждением, новый пишется в одном типе. Слили в один класс JsonDocument
- Что вернёт measureJson для пустого документа?
- Пустой документ сериализуется в {}. Два символа — два байта: это и есть минимум, который печатает measureJson. Два байта — за фигурные скобки
- Нужен ли doc.clear() перед каждым повторным deserializeJson?
- Разбор начинается с очистки документа. clear() остаётся для случая, когда нужно обнулить пул без нового пакета. Нет: deserializeJson сбрасывает документ сам
- Куда JsonDocument в v7 берёт дополнительную память, когда данных больше, чем есть?
- Пул всегда живёт в куче и растёт запросами к ней. Фиксированный размер остался в шестой версии со Static-документами. Из кучи — порциями через malloc
- В чём главная оговорка про автоматический рост на Uno?
- Рост решает проблему ёмкости, но не памяти: когда куча кончилась, разбор возвращает NoMemory — и бюджет 2 КБ делится с буферами Serial и другими библиотеками. Росту нужна свободная куча, а на Uno всего 2 КБ SRAM