Дачный узел учёта на Uno с Ethernet-шилдом раз в час забирал у домашнего сервера журнал температуры за сутки: сервер копил показания по десять минут и отдавал их одним пакетом — 144 значения, около 1800 байт вместе со скобками и запятыми. Мне из всего журнала нужны были три числа: минимум ночи, максимум дня и среднее. На макетке рядом с компьютером узел разбирал пакет целиком и не жаловался; на объекте он прожил два дня и начал перезагружаться — тишина на пару секунд и снова онлайн. Диагноз был известен заранее, я его просто не считал: ОЗУ Uno — 2048 байта, а пакет с документом просил вчетверо больше.
Фильтры, которые мы прошли в уроке про разбор нужных полей и в уроке про большие ответы, экономят документ, но не приём: строка всё равно ложится в память целиком ещё до первого deserializeJson. На ESP32 с её сотнями килобайт я про этот этаж даже не думал — перенос на Uno сломал ровно одно место, и сломал тихо. Вывод, к которому я шёл два вечера: когда ответ больше памяти, договариваться надо не с библиотекой, а с источником — библиотека не может разобрать то, что не влезло в приём.
Здесь три приёма с разными границами: фильтр прямо из потока — коротко, он был в большом уроке; постраничный протокол, когда сервер отдаёт журнал кусками по параметрам запроса; и мой любимый построчный, когда каждая запись приходит самостоятельным JSON-документом на одной строке. В симуляторе соберём агрегатор: чанки по четыре показания, на выходе минимум, максимум и среднее — шесть чисел вместо ста сорока четырёх, и каждая цифра будет видна в мониторе, а не в чужом обещании.
Почему два килобайта не проходят через два килобайта
Полный разбор стоит двух копий. Первая — строка: приёмный буфер UART на Uno вмещает 64 байта, мы раскладывали его устройство в уроке про буфер UART, поэтому пакет на 1800 байт в железе целиком не придёт никогда — его собирают в String, и это 1800 байт кучи. Вторая копия — документ: каждый ключ, значение и служебный слот занимают своё место, и на пакете такого состава пул просит от полутора до двух размеров строки. Итого пик — около четырёх килобайт при двух доступных. Строка и документ живут одновременно, ждать друг друга они не станут.
Чанковый протокол меняет уравнение не в два раза, а в десятки. Если журнал приходит строками по четыре показания, самый большой кусок — 50 байт, документ на него — пара сотен, а сводка из шести чисел сидит в статике и не растёт ни при каком объёме журнала. Пик памяти больше не зависит от длины суток: сутки могут длиться хоть неделю, агрегатор этого не заметит. Заодно уходит вторая String-копия: строку-чанк собирает тот же 64-байтный буфер, и она умирает сразу после разбора.
Границу приёма приём, что характерно, держит честно: чанки идут через тот же буфер на 64 байта, поэтому строку собирают посимвольно до перевода строки — механика из урока про приём по ограничителю переезжает без единой правки. Разница в том, где живёт собранное: одна длинная строка на весь журнал или короткая на одну запись, которая умирает сразу после разбора. Первая съедает кучу, вторая — не успевает. Провальную строку агрегатор пропускает: один битый чанк стоит четырёх точек, а не всего журнала — с целым пакетом та же трещина отняла бы всё сразу.
#include <ArduinoJson.h> // Протокол: сервер шлёт записи построчно, каждая строка — самостоятельный JSON// {"n":4,"t":[18.2,19.1,20.4,21.0]}. Кроме сводки не храним ничего. JsonDocument doc; // v6: StaticJsonDocument<256> doc;String line = "";float tmin = 1000.0;float tmax = -1000.0;float tsum = 0.0;int tcount = 0; void slozhi(String pkt) { DeserializationError err = deserializeJson(doc, pkt); if (err) { Serial.print("stroka sorvana: "); Serial.println(err.c_str()); return; // битый чанк пропускаем, агрегат живёт } int n = doc["n"]; for (int i = 0; i < n; i++) { float t = doc["t"][i]; if (t < tmin) tmin = t; if (t > tmax) tmax = t; tsum = tsum + t; tcount = tcount + 1; }} void svodka() { Serial.print("chtenij="); Serial.println(tcount); Serial.print("min="); Serial.println(tmin); Serial.print("max="); Serial.println(tmax); Serial.print("srednee="); Serial.println(tsum / tcount);} void setup() { Serial.begin(9600);} void loop() { while (Serial.available() > 0) { char c = Serial.read(); if (c == 10) { // перевод строки: запись кончилась if (line.length() > 0) slozhi(line); line = ""; } else if (c != 13) { line = line + c; } }}Договориться с сервером: страницы и строки
Постраничный вариант — самый мягкий для чужого API: если сервис умеет параметры offset и limit, журнал запрашивается кусками по десять-двадцать записей, каждая страница — обычный JSON-ответ, и разобрать её может даже Uno. Я начинал именно с этого: три запроса вместо одного, документ на страницу, сшивка значений на стороне платы. Цена — канал: три запроса несут трижды заголовки HTTP, а пустой HTTP-заголовок весит полсотни байт без всякого JSON.
Построчный вариант лучше, когда сервер ваш или его автор готов на минуту разговора: каждая запись уходит отдельной строкой без общей обвязки — формат иногда зовут NDJSON, и он же человекочитаем в мониторе порта, что я при отладке ценю не меньше байтов. Плате документ нужен на одну запись, серверу — не собирать массив вовсе. Если источник чужой и без настроек, остаётся фильтр из потока: deserializeJson читает прямо из http.getStream(), и опция Filter режет документ на лету — связку показывали в уроке про JSON по HTTP.
Выбор у меня простой, с условиями: свои узлы — построчно; чужой API, из которого нужны два-три поля, — фильтр из потока; чужой API, из которого нужен весь объём, — постранично. Канал во всех трёх случаях не ужимается: байты журнала придут полностью, платится только памятью и временем. Это не баг метода, а его честность: память экономится на вашей стороне, а не по дороге.
| Приём | Что держит в памяти | Когда беру |
|---|---|---|
| Целиком в String и полный разбор | Весь ответ дважды: строка и документ | ESP32 с сотнями КБ и короткие ответы |
| Поток плюс фильтр | Только отобранные поля, строковой копии нет | Один HTTP-ответ, нужны 2-3 поля |
| Постранично: offset и limit | Один маленький документ на страницу | Сервис умеет параметры запроса |
| Построчно: запись — строка | Документ на одну запись, сводка в статике | Журналы и длинные серии измерений |
Агрегатор в симуляторе
Движок книги не умеет ни HTTP, ни потоковый приём — говорю честно заранее, — зато умеет deserializeJson по строкам, и этого хватает на весь метод: три чанка заданы константами, разбор идёт по одному, документ один и тот же на все. Строка vse в начале setup() — это журнал одним куском: её длина печатается рядом с длиной каждого чанка, и разница — ровно то, что чанковый протокол снял с пика памяти.
В скетче выше обратите внимание на то, чего в loop() нет: блокирующих задержек и второго разбора. Приём и разбор живут в одном месте, сводку печатает отдельная функция по команде из монитора. Такой агрегатор я оставляю в узлах навсегда — он же диагностический: минимум и максимум сразу показывают, дышит ли датчик или висит на одном значении с вторника.
LED на D12 загорается, если максимум дня перевалил за 20.0 — агрегатор отдаёт вердикт без экрана и без журнала. Попробуйте добавить четвёртый чанк или подправить одно значение: сводка пересчитается, а документ по-прежнему нужен только на одну строку.
- Добавить четвёртый чанк. Заведите chank[4], допишите строку с двумя показаниями — сводка пересчитается, а размер каждого разбора останется в тех же десятках байт.
- Сломать один чанк. Вырвите закрывающую скобку в chank[2]: движок честно откажется разбирать строку — на плате за то же отвечает код из err.c_str(), а агрегатор потеряет четыре точки, но выживет.
- Поднять порог. Замените 20.0 в проверке LED на 21.5 — отметка погаснет: вердикт агрегатора меняется одной константой, журнал трогать не нужно.
- На настоящей плате. Перенесите code-блок на Uno, скормите ему три строки из монитора порта и напечатайте freeRam() до и после: разница покажет, что пик памяти привязан к чанку, а не к журналу.
- Полный разбор стоит двух копий: строка приёма и документ живут одновременно — пик примерно вдвое больше ответа, на Uno это около 4 КБ при 2 КБ SRAM
- Чанковый протокол привязывает пик к самому большому чанку: документ нужен на одну запись, сводка из шести чисел сидит в статике и не растёт
- Постранично (offset, limit) — для чужих API, построчно (NDJSON) — для своих: каждая запись — самостоятельный JSON, человекочитаем в мониторе
- Битый чанк пропускается с кодом разбора: теряются четыре точки, а не весь журнал
- Фильтр экономит документ, поток — строковую копию, но приём целиком они не отменяют: когда ответ больше SRAM, резать надо протокол
- 1. Пакет — 1800 байт, ОЗУ Uno — 2048. Почему полный разбор не проходит даже без других библиотек?
- 2. Что именно меняет построчный протокол в бюджете памяти?
- 3. Второй чанк из двенадцати пришёл битым, deserializeJson сорвался. Что делает честный агрегатор?
- 4. Сервис умеет параметры offset и limit. Что вы получаете, запрашивая журнал страницами по двадцать записей?
- 5. Фильтр из потока с опцией Filter на ответе в 2 КБ. Что экономит связка?
Метод частями снимает потолок памяти, но добавляет в бюджет одну строку: между версиями библиотеки байты считаются по-разному, и старые скетчи с ёмкостью в угловых скобках надо переносить аккуратно, а не заменой одной цифры. Чем седьмая версия отличается от шестой и как переехать без переписывания логики — следующий урок: ArduinoJson 6 и 7: переносим старые скетчи. Домашнее задание: возьмите свой журнал — показания датчика, лог кнопок, курсы валют из урока про курсы — и перепишите приём на построчный: сводка из шести чисел обычно покрывает весь спрос на журнал целиком.
Частые вопросы по теме урока
- Пакет — 1800 байт, ОЗУ Uno — 2048. Почему полный разбор не проходит даже без других библиотек?
- Собранная строка лежит в куче, пока разбирается в документ, который просит ещё полтора-два размера строки. Две копии в 2 КБ SRAM не помещаются — куча доходит до стека, и плата молча перезагружается. Строка и документ живут одновременно, пик — около 4 КБ
- Что именно меняет построчный протокол в бюджете памяти?
- Канал не ужимается, документ нужен на одну запись, сводка из шести чисел живёт в статике. Пик памяти перестаёт зависеть от длины журнала — сутки хоть на неделю. Пик привязывается к самому большому чанку, а не ко всему журналу
- Второй чанк из двенадцати пришёл битым, deserializeJson сорвался. Что делает честный агрегатор?
- Один битый чанк стоит четырёх точек из ста сорока четырёх — не всю сводку. Пропуск с печатью кода лучше ретрая: канал, рвущийся на полуслове, ретраем не лечится, а журнал не забивается. Пропускает чанк и продолжает сводку по остальным
- Сервис умеет параметры offset и limit. Что вы получаете, запрашивая журнал страницами по двадцать записей?
- Каждая страница — обычный маленький ответ, и документ под неё заранее известен. Трафик при этом растёт: заголовки HTTP едут с каждой страницей — это цена метода. Документ фиксированного размера на любую страницу
- Фильтр из потока с опцией Filter на ответе в 2 КБ. Что экономит связка?
- deserializeJson читает прямо из потока — String-копии нет, а Filter кладёт в документ только отобранные поля. Канал по-прежнему несёт весь ответ: экономится память, не трафик. И строку приёма, и документ — вторая копия не строится