Sekwencje tekstowe JSON (RFC 7464): JSON strumieniowy rozdzielany RS
Podsumowanie: RFC 7464 używa separatora 0x1E do strumieniowania tekstów JSON, które można czytać i zapisywać jednocześnie. Omawia reguły, różnice względem NDJSON i implementację dzielenia, a potem parsowania.

Najważniejsze: używaj JSON-seq, gdy kontrolujesz oba końce, potrzebujesz zarejestrowanego typu nośnego i chcesz, aby czytnik resynchronizował się po obcięciu; używaj NDJSON, gdy dane zasilają systemy logów, muszą być czytelne dla człowieka lub muszą działać z istniejącymi narzędziami — nigdy nie wymuszaj JSON-seq na partnerze rozumiejącym tylko wejście liniowe.
NDJSON dzieli rekordy po nowej linii, ale nowa linia to konwencja ludzka. RFC 7464 zapisuje granicę bezpośrednio w strumieniu bajtów: niewidoczne 0x1E przed każdym rekordem.
1. Problem rozwiązany przez pojedynczy bajt
RFC 7464 opublikowano w lutym 2015 roku pod tytułem JSON Text Sequences i rejestruje ono typ nośny application/json-seq. Nie dodaje żadnych nowych typów danych. Określa tylko, jak sekwencja tekstów JSON jest ułożona w jednym strumieniu bajtów. Cała reguła mieści się w jednym zdaniu: postaw jeden RS przed każdym tekstem JSON i jeden LF po nim.
- RS to ASCII separator rekordów, punkt kodowy
0x1E, znany w Unicode jako INFORMATION SEPARATOR TWO (U+001E); - LF to przesunięcie wiersza,
0x0A.
Dwa rekordy wyglądają tak na poziomie bajtów:
1E 7B 22 69 64 22 3A 31 7D 0A ← 0x1E {"id":1} + LF
1E 7B 22 69 64 22 3A 32 7D 0A ← 0x1E {"id":2} + LF
RS znajduje się przed rekordem, co jest przeciwieństwem układu NDJSON „najpierw treść, potem separator”. Ten kierunek zmienia zachowanie odzyskiwania: czytnik, który widzi 0x1E, wie, że poprzedni rekord się zakończył, więc nawet rekord obcięty w trakcie zapisu nie zaciera granicy następnego.
To nie jest projekt teoretyczny. RFC wyraźnie wspomina rekordy dopisywane do plików logów, które zostają obcięte przez system plików po awarii lub administracyjnym zabiciu procesu, i mówi, że parser nie powinien z tego powodu przerywać całego odczytu.
2. Dwa zestawy reguł: enkodery i parsery
RFC 7464 nietypowo podaje dwie gramatyki ABNF: jedną ograniczającą writerów, drugą łagodzącą czytników.
| Rola | Gramatyka | Znaczenie |
|---|---|---|
| Enkoder | *(RS JSON-text LF) |
Jeden RS przed każdym tekstem JSON, jeden LF po nim |
| Parser | *(1*RS possible-JSON) |
Kolejne bajty RS są dozwolone, a LF między rekordami jest opcjonalny |
W kategoriach inżynieryjnych istnieje pięć twardych reguł:
- UTF-8 jest obowiązkowy; UTF-16 i UTF-32 są niedozwolone;
- Parser traktuje „dowolny bajt inny niż RS” jako możliwy tekst JSON i próbuje go sparsować; przy porażce przeskakuje do następnego RS i kontynuuje;
- Kolejne bajty RS nie oznaczają pustych elementów i są ignorowane;
- Nie ma znacznika końca sekwencji, więc strumień po prostu się kończy;
- Gdy wartością wierzchołkową jest liczba,
true,falselubnull, rekord musi kończyć się bajtem białego znaku, inaczej zostaje odrzucony jako prawdopodobnie obcięty.
Piątą regułę ludzie zapominają. Obiekty, tablice i łańcuchy są w JSON-ie samoograniczające się; liczby nie. <RS>123<RS> nie mówi, czy zawiera kompletne 123, czy obcięte 1234. RFC wymaga więc, aby writerzy dopisywali LF, używając go jako bajtu kanarka do wykrywania obcięcia. Podobnie <RS>true<RS> mogło pierwotnie być trueish.
3. Wybór między JSON-seq i NDJSON
Oba formaty strumieniują, ale celują w różne problemy:
| Wymiar | Sekwencje tekstowe JSON | NDJSON / JSON Lines |
|---|---|---|
| Położenie separatora | RS (0x1E) przed rekordem |
LF (0x0A) po rekordzie |
| Typ nośny | application/json-seq, zarejestrowany w IANA |
Brak oficjalnej rejestracji, konwencja społeczności |
| Czytelność bajtów | Niewidoczny znak sterujący, cat nic nie pokazuje |
Zwykły tekst, czytelny bezpośrednio |
| Współbieżne dopisywanie | Czytnik potrafi się resynchronizować i odrzucić obcięty ogon | Dopisywanie też działa, złe linie są pomijane |
| Liczby wierzchołkowe | Wymaga końcowego bajtu białego znaku, by budzić zaufanie | Granicą jest przesunięcie wiersza |
| Separator wewnątrz łańcuchów | RS może wystąpić tylko jako \u001e |
LF trzeba zapisać jako \n |
| Narzędzia | jq --seq i kilka bibliotek |
Niemal każde narzędzie logów i big data |
| Rozszerzenie pliku | Nie określone przez RFC | .jsonl / .ndjson |
Decyzja jest krótka: używaj JSON-seq, gdy kontrolujesz oba końce, chcesz formalnego typu nośnego i potrzebujesz resynchronizacji czytnika po obcięciu; używaj JSON Lines i NDJSON, gdy dane zasilają systemy logów, muszą być czytelne dla ludzi lub muszą działać z istniejącymi narzędziami.
4. Parser: podziel po 0x1E, potem JSON.parse
Najbardziej namacalną korzyścią RFC 7464 jest brak potrzeby strumieniowego parsera JSON. Podziel po bajcie, a następnie wywołaj JSON.parse na każdym segmencie:
/** ASCII record separator, defined by RFC 7464 as 0x1E */
const RECORD_SEPARATOR = 0x1e;
/**
* Parses a chunk of JSON Text Sequence bytes.
* @param buffer Bytes that have been read so far
* @returns Successfully parsed records and the number of failed records
*/
function parseJsonSequence(buffer: Buffer): { records: unknown[]; badCount: number } {
const records: unknown[] = [];
let badCount = 0;
let recordStart = buffer.indexOf(RECORD_SEPARATOR);
while (recordStart !== -1) {
const nextRecordStart = buffer.indexOf(RECORD_SEPARATOR, recordStart + 1);
const recordEnd = nextRecordStart === -1 ? buffer.length : nextRecordStart;
const rawText = buffer.subarray(recordStart + 1, recordEnd).toString('utf8').trim();
if (rawText !== '') {
try {
records.push(JSON.parse(rawText));
} catch {
badCount += 1;
}
}
recordStart = nextRecordStart;
}
return { records, badCount };
}
Trzy uwagi implementacyjne:
- Dzielenie na poziomie bajtów jest bezpieczne. Bajty kontynuacji UTF-8 żyją w zakresie
0x80–0xBF, więc nigdy nie mogą równać się0x1E. Dzielenie po 0x1E nie potrafi przeciąć znaku wielobajtowego na pół. - Buforuj ogon przy strumieniowaniu. Czytając w fragmentach, ostatni segment może jeszcze nie osiągnąć następnego RS. Trzymaj go w buforze, aż nadejdzie kolejny fragment; inaczej sparsujesz ten sam półrekord dwa razy.
- Licz porażki, zamiast przerywać. RFC mówi parserom, by przeskakiwały do następnego RS i kontynuowały, więc
badCountjest metryką ostrzegawczą, a nie wyjątkiem. Obcięty końcowy rekord również należy do tego kubełka.
Zużycie pamięci zależy od największego pojedynczego rekordu plus jeden bufor, a nie od całkowitego rozmiaru pliku. To ta sama idea, omówiona w parsowaniu i strumieniowaniu dużego JSON.
5. Znaki sterujące wewnątrz łańcuchów
JSON wymaga, aby każdy znak sterujący od U+0000 do U+001F był escape'owany wewnątrz łańcucha, a RS należy do tego zakresu. Poprawny łańcuch JSON może więc zawierać tylko pięć znaków \u001e, nigdy surowy bajt 0x1E. Dlatego dzielenie po 0x1E jest zawsze bezpieczne dla poprawnych danych.
Niebezpieczeństwo żyje po stronie writera:
- Ręczne budowanie tekstu JSON przez
'\x1e', wstawiające surowy znak sterujący do łańcucha; - Budowanie JSON-a literałami szablonowymi zamiast
JSON.stringify, zostawiające nowe linie, cudzysłowy i znaki sterujące nieescape'owane; - Kopiowanie tekstu zawierającego RS z terminala lub edytora, gdzie znak sterujący zostaje połknięty lub zastąpiony.
Właściwy wzorzec to zawsze najpierw wygenerować tekst, potem dołączyć separator:
const recordText = JSON.stringify(record);
const line = `${String.fromCharCode(RECORD_SEPARATOR)}${recordText}\n`;
JSON.stringify emituje znaki sterujące jako \u00XX, więc surowy RS nigdy nie może pojawić się wewnątrz rekordu. Gdy ten etap jest poprawny, logika dzielenia ma tylko do czynienia z bajtami i nigdy nie musi rozumieć składni JSON.
Większość porażek w tej kategorii ujawnia się jako JSON.parse rzucający Unexpected token, zwłaszcza mylący przypadek „pierwszy znak to niewidoczny bajt sterujący”. Tabela wyszukiwania komunikatów błędów JSON omawia tę klasę błędów linia po linii.
6. Transport HTTP i typ nośny
application/json-seq to zarejestrowany typ nośny bez wymaganych lub opcjonalnych parametrów, a zestaw znaków jest ustalony na UTF-8. W HTTP liczą się trzy praktyki:
- Wysyłaj
Accept: application/json-seqi odpowiadajContent-Type: application/json-seq; - Aby strumieniować naprawdę, serwer musi używać transferu chunked i wyłączyć buforowanie odpowiedzi w pośrednikach, takich jak reverse proxy, CDN i narzędzia do testów obciążeniowych;
- Klient czytający fragment po fragmencie może otrzymać fragment kończący się w środku rekordu, więc parser musi buforować do następnego RS, zanim wyemituje rekord.
Jeśli druga strona rozumie tylko wejście liniowe, nie wymuszaj na niej JSON-seq. To standard, ale jego penetracja ekosystemowa jest znacznie niższa niż NDJSON, więc zgodność należy ustalić podczas negocjacji protokołu, a nie po błędzie parsowania.
7. Wiersz poleceń, logi i wsparcie narzędzi
jq jest jednym z nielicznych narzędzi z natywnym wsparciem; flaga --seq istnieje właśnie dla RFC 7464:
printf '\x1e{"id":1}\n\x1e{"id":2}\n' | jq --seq -c '.id'
To wypisuje 1 i 2 w osobnych wierszach. Przy strumieniowym punkcie końcowym pamiętaj, aby wyłączyć buforowanie curl:
curl -sN https://api.example.com/events | jq --seq -c '.type'
jq --seq czyta wejście RFC 7464, ignoruje złe rekordy i kontynuuje parsowanie. Jest to szczególnie użyteczne przy śledzeniu pliku logu, który wciąż jest zapisywany: ostatni rekord może być na wpół zapisany, a wyjście nie pokaże fałszywego końcowego obiektu.
7.1 Natywne wsparcie według środowiska
| Środowisko | Wbudowane wsparcie | Uwagi |
|---|---|---|
| jq | --seq |
Najłatwiejsza opcja wiersza poleceń |
| Node.js | Brak | Buffer.indexOf plus JSON.parse |
| Python | Brak | Podział po b'\x1e' |
| Go | Brak | Własny SplitFunc dla bufio.Scanner |
| Przeglądarka | Brak | fetch plus ReadableStream, dziel bajty ręcznie |
7.2 Częste pułapki
Błędy skupiają się po stronie odczytu:
- Odczyt przez
readline, który zostawia niewidoczny prefiks RS na pierwszej linii, więcJSON.parsezawsze zawodzi; - Otwieranie pliku
.json-seqw edytorze, gdzie znaki sterujące są niewidoczne, więc wygląda jak jeden dziwaczny dokument JSON; - Dzielenie przez
split(/\n/), które myli escape'owane nowe linie wewnątrz rekordu z LF między rekordami; - Traktowanie RS jako zwykłego znaku w dokumentacji, przez co znika po kopiowaniu i wklejaniu.
8. Kiedy warto stosować
Dobre dopasowania:
- Jesteś właścicielem zarówno producenta, jak i konsumenta, bez narzędzi zewnętrznych pośrodku;
- Dane to stale dopisywany strumień zdarzeń, a proces może się wysypać, więc potrzebne odzyskiwanie z obcięcia;
- Protokół zasługuje na formalny typ nośny, a nie konwencję taką jak
.jsonl; - Pojedyncze rekordy mogą być liczbami lub wartościami logicznymi wierzchołkowymi, gdzie jawna reguła obcięcia ma znaczenie.
Słabe dopasowania:
- Logi muszą trafić do ELK, Loki lub innego systemu rozumiejącego tylko wejście liniowe;
- Ludzie muszą bezpośrednio używać
cat,grepiawkna danych; - Druga strona potrafi parsować tylko
application/json.
9. Lista kontrolna
- [ ] Writer generuje rekordy przez
JSON.stringify, a następnie poprzedza0x1Ei dopisuje\n; - [ ] Rekordy zawierające liczby, wartości logiczne lub
nullwierzchołkowe kończą się LF; - [ ] Reader dzieli po bajtach, zamiast czytać linia po linii;
- [ ] Odczyt strumieniowy trzyma niezamknięty ogon w buforze, aż nadejdzie następny RS;
- [ ] Porażki parsowania są liczone i pomijane, nigdy śmiertelne dla sekwencji;
- [ ] Publiczne interfejsy jawnie deklarują
application/json-seqi zachowują fallback NDJSON.
FAQ
Czy przyjąć JSON-seq dla publicznego punktu końcowego API?
Tylko gdy oba końce można zmienić albo format jest negocjowany z góry. JSON-seq to zarejestrowany standard z typem nośnym application/json-seq, ale jego penetracja ekosystemowa jest znacznie niższa niż NDJSON, więc wymuszanie go na kliencie zewnętrznym zwykle zawodzi. Ustal zgodność podczas negocjacji protokołu i zachowaj fallback NDJSON.
Czy konwertować istniejący potok NDJSON na JSON-seq?
Zwykle nie. NDJSON już strumieniuje, dopisuje i pomija złe linie, a niemal każde narzędzie logów i big data je akceptuje. Konwertuj tylko wtedy, gdy potrzebujesz typu nośnego zarejestrowanego w IANA albo resynchronizacji zapewnianej przez wiodący separator, i tylko gdy producenta i konsumenta można zmienić razem.
Co jest częstsze: sekwencje tekstowe JSON czy NDJSON?
NDJSON. Jego reguły są prostsze, zasięg narzędzi znacznie szerszy, a systemy logów i potoki big data zwykle go akceptują. JSON-seq wygrywa zarejestrowanym w IANA typem nośnym i resynchronizacją wynikającą z umieszczenia separatora z przodu, co pasuje do kontrolowanych protokołów strumieniowych, a nie do ogólnej wymiany danych.
Dlaczego separator umieszcza się przed rekordem, a nie za nim?
Ponieważ rekordy mogą zostać obcięte. Z wiodącym separatorem czytnik, który widzi RS, wie, że zaczął się nowy rekord, więc poprzedni rekord nie może uszkodzić następnego niezależnie od tego, czy był kompletny. Z separatorem końcowym przerwany zapis zostawia końcowy segment bez znacznika końca i bez znacznika następnego początku, więc granicę można tylko zgadywać.
Co się stanie, jeśli 0x1E pojawi się wewnątrz rekordu?
Poprawny łańcuch JSON musi escape'ować RS jako \u001e, więc surowy 0x1E nigdy nie powinien wystąpić. Jeśli wystąpi, tekst nie jest już poprawnym JSON-em: dzielenie po 0x1E pęknie w złym miejscu, a JSON.parse zawiedzie. Poprawka należy do strony writera: użyj JSON.stringify zamiast ręcznie budowanego tekstu.
Czy przeglądarka potrafi parsować application/json-seq bezpośrednio?
Nie. Przeglądarki nie dostarczają parsera JSON-seq; fetch tylko przekazuje bajty. Trzeba użyć response.body.getReader(), akumulować fragmenty do następnego 0x1E i wywołać JSON.parse na każdym segmencie. Dla małych ładunków działa też wczytanie całego ciała jako łańcucha i późniejszy podział.
Dlaczego liczba wierzchołkowa wymaga końcowego LF?
Obiekty, tablice i łańcuchy są w JSON-ie samoograniczające się, a liczby nie: <RS>123<RS> mogło być kompletnym 123 lub obciętym 1234. RFC 7464 wymaga więc bajtu białego znaku po każdym rekordzie, a LF działa jako kanarek do wykrywania obcięcia. Ta sama reguła dotyczy true, false i null wierzchołkowego.