Неделя телеметрии на SD-карте в виде одного большого JSON-массива — и я снова переписывал файл целиком. Чтобы дописать событие, надо открыть документ, найти закрывающую скобку, вставить перед ней запятую и новую запись и сохранить всё заново: сотни килобайт чтения и записи ради одного события в 50 байт. А сбой питания посреди такой переписи оставляет файл, который не разбирается ни целиком, ни по частям — жертвой становилась вся неделя, а не одна запись. Формат JSON Lines (он же NDJSON, он же jsonl) переворачивает схему: одна строка — один самостоятельный JSON-объект, и файл растёт только в конец. Никаких закрывающих скобок в хвосте нет и не будет: последняя строка — просто последняя строка, и к ней нечего дописывать.
Из этого следует три практических свойства, ради которых формат жив. Дописывание: новое событие — это append одной строки, старые байты не трогаются никогда. Живучесть: обрезанный хвост портит одну строку, остальные разбираются как ни в чём не бывало. Инструменты: grep, tail и wc работают по строкам — лог JSONL попадает в родную среду командной строки без единого скрипта-конвертера. Схема выживает и на плате, и на ноутбуке: плата пишет строки, ноутбук их читает — между ними не нужна ни общая схема, ни синхронизация версий формата. Ниже собираю генератор такого лога и показываю, как он читается с той стороны.
Почему не массив и не CSV
Три соседних формата решают одну задачу по-разному. Общий JSON-массив держит все события внутри одних квадратных скобок: каждая запись стоит запятой, и дописать хвост — значит переписать его. CSV берёт колонками: заголовок фиксирует состав, а любое поле с запятой внутри требует кавычек и парсера с правилами экранирования — ровно на этом я собрал свой первый лог датчика с комментарием в поле. Их смешивать тоже можно: заголовок CSV фиксирует колонки навсегда, и «добавить поле только в новые события» в нём выглядит как дырки в старых строках, а в JSONL — как отсутствующий ключ, который читающий трактует по своему правилу. JSON-строка несёт состав события в себе: {"n":41,"ts":18234,"ev":"press"} — и не зависит ни от заголовка, ни от соседей по файлу.
Цена вопроса в байтах. Событие из четырёх коротких полей — строка под 40-60 байт, из них треть — кавычки, ключи и скобки: это честная плата за самонесущность. На SD-карте с записью блоками по 512 байт такой лог — десяток событий на блок; в Serial на 9600 бод строка в 50 байт уезжает примерно за 50 миллисекунд. Точный размер своей строки посчитайте до запуска: методика через measureJson разобрана в уроке про оценку памяти — одна строка кода, зато сразу видна вилка на сутки работы.
Приёмная сторона у JSONL проста: читаем файл или порт построчно, каждую строку разбираем независимо. Битая строка не рушит разбор — пропускаем её, прибавляем единицу к счётчику потерь и идём дальше; поле n со сквозной нумерацией потом честно покажет дырку. Построчный цикл с json.loads на каждой итерации держит в памяти ровно одну строку — гигабайтный лог парсится на машине с четырьмя гигабайтами RAM, потому что целиком он никогда не нужен. Такой лог читается с середины, отдаётся в таблицы одной командой и живёт в git без вечных конфликтов слияния: строки дописываются, а не переписываются. Обратная сторона того же конвейера — приём JSON построчно из порта — разобрана в уроке про JSON через UART, там плата читает, а не пишет.
Отдельная история — метка времени. В строках выше ts — это millis, миллисекунды от старта платы: для отладки хватает, для журнала — нет, потому что рестарт обнуляет отсчёт. На плате с часами или NTP пишите unixtime — число секунд с начала 1970 года, и лог становится сортируемым между устройствами. Компромисс, который я применяю на платах без часов: писать и millis, и счётчик перезагрузок — по паре полей всегда понятно, к какой сессии относится событие, а порядок строк восстанавливает хронологию.
Как вытащить лог с платы на ноутбук по проводу — в уроке про Excel и лог через порт; если хотите обрабатывать поток скриптом, урок про pyserial даёт каркас на Python с буферизацией и записью в файл. На самой плате вся сборка строки умещается в четыре присваивания документу и один serializeJson — скетч ниже можно копировать в свой логгер как есть.
#include <ArduinoJson.h> DynamicJsonDocument doc(768);int n = 0; // сквозной номер событияbool alarm = false;bool btnPrev = true;unsigned long lastBeat = 0; void setup() { Serial.begin(9600); pinMode(2, INPUT_PULLUP); // кнопка события pinMode(3, INPUT); // тумблер тревоги pinMode(12, OUTPUT); logEvent("boot", "start"); // первая строка лога} void logEvent(String ev, String val) { doc.clear(); n = n + 1; doc["n"] = n; // дырка в нумерации = потерянная строка doc["ts"] = millis(); // метка в мс от старта doc["ev"] = ev; doc["val"] = val; serializeJson(doc, Serial); // сама строка Serial.println(); // терминатор: одна строка = одно событие} void loop() { bool btn = digitalRead(2) == LOW; if (btn != btnPrev) { btnPrev = btn; if (btn) logEvent("press", "D2"); } bool now = digitalRead(3) == HIGH; if (now != alarm) { alarm = now; digitalWrite(12, alarm ? HIGH : LOW); logEvent("alarm", alarm ? "on" : "off"); } if (millis() - lastBeat >= 5000) { // пульс: лог жив, даже когда всё спокойно lastBeat = millis(); logEvent("beat", "ok"); }}Как лог читается дальше
Разбор на ноутбуке умещается в три команды терминала, и это рабочий процесс, а не украшение. grep по ключу ev отбирает одни тревоги из тысяч строк; tail -1 показывает последнее событие; wc -l считает, сколько всего произошло за сутки. Пульс раз в пять секунд даёт 17280 строк за сутки и примерно 700-900 КБ файла: для SD-карты это копейки, для git-репозитория с конфигами — терпимо, для пересылки почтой — уже нет. Если событий больше, файл режут по суткам: имя с датой внутри, старые куски сжимаются без разборки — строки сшиваются командой cat в любой момент, потому что файл и есть конкатенация строк. Ни один из этих инструментов не требует конвертера: строка JSON — это текст, и текстовые инструменты его едят напрямую.
На плате два правила записи, и оба выстраданы. Первое: строку целиком собирает serializeJson и отправляет один println — печать кусочками через Serial.print рвёт запись пополам при любом таймауте на приёмной стороне, а сборка своего документа разобрана в уроке про serializeJson. Второе: на SD-карте держите файл открытым и flush-те раз в несколько событий — накопитель честно предупреждает, что записи без flush живут в его собственном буфере. И храните в событии номер n: он дешевле любой контрольной суммы, потому что дырка в нумерации видна даже в блокноте.
Буфер под строку тоже считается заранее: serializeJson пишет в Serial без буфера вообще, а строку, которую вы хотите сложить в файл на SD, сначала собирают в char-массив — его размер берите от measureJson с запасом в полтора раза. На документе 768 байт и событии в шесть полей такой буфер — сотня с лишним байт, и на Uno он помещается в стек без тревоги. На ESP32 экономить и вовсе незачем: там RAM в десятках килобайт, и узким местом становится скорость карты, а не размер строки.
| Формат лога | Дописать событие | Сбой питания посреди записи |
|---|---|---|
| JSON Lines: строка-объект | append одной строки, старые байты не трогаются | теряется последняя недописанная строка, файл цел |
| Общий JSON-массив | переписать хвост: запятая, запись, скобка | документ не разбирается ни целиком, ни по частям |
| CSV с заголовком | append возможен, состав колонок фиксирован | битая строка ломает выравнивание всех полей |
| Двоичный блок с длиной | быстро и компактно | не читается глазами, нужен отдельный дампер |
- 1. Чем дописывание события в JSONL отличается от общего JSON-массива?
- 2. Сбой питания оборвал запись посреди строки. Что потеряно в JSONL-логе?
- 3. Что делает команда grep по ключу ev в JSONL-логе?
- 4. Зачем в событии поле n со сквозным номером?
- 5. Сколько байт занимает строка события из четырёх коротких полей?
- JSON Lines: одна строка — один самостоятельный JSON; файл растёт только в конец, старые байты не трогаются
- Обрыв хвоста стоит одной строки: парсер пропускает битую запись и продолжает — счётчик потерь обязателен
- Поле n со сквозной нумерацией дешевле контрольной суммы: дырка видна даже в блокноте
- Строку собирает serializeJson и отправляет один println: печать кусочками рвёт запись пополам
- grep, tail и wc работают с JSONL без конвертеров; beat-пульс держит лог живым при тишине
Частые вопросы по теме урока
- Чем дописывание события в JSONL отличается от общего JSON-массива?
- В JSONL каждая строка самостоятельна: append не требует правки старых байтов. В общем массиве запись стоит запятой, и дописывание — это перепись хвоста документа. В JSONL строка дописывается в конец, скобки и запятые соседей не трогаются
- Сбой питания оборвал запись посреди строки. Что потеряно в JSONL-логе?
- Каждая строка разбирается независимо, поэтому обрыв стоит ровно одной записи. Остальной файл продолжает разбираться, а дырку покажет сквозной номер n. Только последняя недописанная строка
- Что делает команда grep по ключу ev в JSONL-логе?
- grep работает построчно и не разбирает JSON вообще — он ищет подстроку. Этого хватает, потому что каждая строка самонесущая и ключ лежит в ней текстом. Отбирает строки по образцу — например, только alarm
- Зачем в событии поле n со сквозным номером?
- Стандарт номера не требует. Номер — самая дешёвая проверка целостности: пропущенное значение видно глазами, без контрольных сумм и дописывания к каждой строке. Дырка в нумерации честно показывает потерянную строку
- Сколько байт занимает строка события из четырёх коротких полей?
- Треть строки уходит на служебные символы JSON — это цена самонесущности. Скорость порта влияет на время доставки, а не на размер; блок SD задает шаг буферизации, не длину строки. Под 40-60: кавычки, ключи и скобки съедают треть