JSON Text Sequences(RFC 7464):RS で区切るストリーミング JSON
要点:RFC 7464 は 0x1E の区切り文字で JSON テキストをストリーミングし、読み書きを同時に行えます。規則、NDJSON との違い、分割してからパースする実装を扱います。

要点:両端を自分で制御でき、登録済みのメディアタイプが必要で、途中で切れた後に読み取り側が再同期できる必要があるなら JSON-seq を使いましょう。データがログシステムに流れ、人間が読める必要があり、既存ツールと動かす必要があるなら NDJSON を使います。行区切りしか理解しない相手に JSON-seq を押し付けないでください。
NDJSON は改行でレコードを区切りますが、改行は人間の慣習です。RFC 7464 は境界をバイトストリーム自体に書き込みます。各レコードの前に見えない 0x1E を置くのです。
1. 1 バイトが解決する問題
RFC 7464 は 2015 年 2 月に JSON Text Sequences という表題で公開され、メディアタイプ application/json-seq を登録しました。新しいデータ型は追加しません。一つのバイトストリーム内で JSON テキストの並びをどう配置するかだけを定めます。規則全体は一文に収まります。各 JSON テキストの前に RS を一つ、後ろに LF を一つ置く。
- RS は ASCII のレコードセパレータで、コードポイント
0x1E。Unicode では INFORMATION SEPARATOR TWO(U+001E)として知られます。 - LF はラインフィード、
0x0A。
二つのレコードはバイトレベルではこうなります。
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 はレコードの前に置かれ、NDJSON の「内容を書いてから区切り文字を書く」配置とは逆です。この向きが復旧の振る舞いを変えます。0x1E を見た読み取り側は前のレコードが終わったことを知るので、書き込み途中で切れたレコードでも次のレコードの境界を曖昧にしません。
これは理論上の設計ではありません。RFC は、クラッシュや管理者による kill の後にファイルシステムが切り落とした、ログファイルへ追記されたレコードを明示的に挙げ、パーサーがそれによって読み取り全体を中断すべきではないと述べています。
2. 二つの規則体系:エンコーダとパーサー
RFC 7464 は珍しく二つの ABNF 文法を提供し、一つは書き込み側を制約し、もう一つは読み取り側を緩和します。
| 役割 | 文法 | 意味 |
|---|---|---|
| エンコーダ | *(RS JSON-text LF) |
各 JSON テキストの前に RS、後ろに LF |
| パーサー | *(1*RS possible-JSON) |
連続する RS バイトを許容し、レコード間の LF は任意 |
実務的には五つの厳格な規則があります。
- UTF-8 は必須。UTF-16 と UTF-32 は使ってはならない。
- パーサーは「RS 以外の任意のバイト」を可能な JSON テキストとして扱いパースを試みる。失敗したら次の RS までスキップして続行する。
- 連続する RS バイトは空要素を表さず、無視される。
- 終端マーカーはなく、ストリームは単に終わる。
- トップレベル値が数値、
true、false、nullのとき、レコードは空白バイトで終わらなければならず、そうでなければ途中切れの可能性があるとして破棄される。
五番目の規則は忘れられがちです。オブジェクト、配列、文字列は JSON 内で自己区切りですが、数値はそうではありません。<RS>123<RS> は完全な 123 か、途中で切れた 1234 かを教えてくれません。そこで RFC は書き込み側に LF の付加を要求し、途中切れ検出のためのカナリアバイトとして使います。同様に <RS>true<RS> はもともと trueish だったかもしれません。
3. JSON-seq と NDJSON の選択
どちらもストリーミングできますが、対象とする問題が異なります。
| 観点 | JSON Text Sequences | NDJSON / JSON Lines |
|---|---|---|
| 区切り文字の位置 | レコードの前に RS(0x1E) |
レコードの後に LF(0x0A) |
| メディアタイプ | application/json-seq、IANA 登録済み |
公式登録なし、コミュニティの慣習 |
| バイトの可読性 | 不可視の制御文字で、cat では何も見えない |
プレーンテキストで直接読める |
| 同時追記 | 読み取り側が再同期し、切れた末尾を捨てられる | 追記も可能で、壊れた行はスキップされる |
| トップレベルの数値 | 信頼するには末尾の空白バイトが必要 | 改行が境界 |
| 文字列内の区切り文字 | RS は \u001e としてのみ現れうる |
LF は \n と書く必要がある |
| ツール | jq --seq と一部のライブラリ |
ほぼすべてのログ・ビッグデータツール |
| 拡張子 | RFC では定義されない | .jsonl / .ndjson |
判断は短いです。両端を自分で制御でき、正式なメディアタイプが欲しく、途中で切れた後に読み取り側が再同期する必要があるなら JSON-seq を使いましょう。データがログシステムに流れ、人間が読める必要があり、既存ツールと動かす必要があるなら JSON Lines と NDJSON を使いましょう。
4. パーサー:0x1E で分割し、JSON.parse する
RFC 7464 の最も具体的な利点は、ストリーミング JSON パーサーが不要なことです。バイトで分割し、各セグメントに JSON.parse を呼びます。
/** ASCII のレコードセパレータ。RFC 7464 により 0x1E と定義される */
const RECORD_SEPARATOR = 0x1e;
/**
* JSON Text Sequence のバイトチャンクをパースする。
* @param buffer これまでに読み取ったバイト
* @returns パースに成功したレコードと失敗したレコード数
*/
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 };
}
実装上の注意は三つ。
- バイトレベルの分割は安全。 UTF-8 の継続バイトは
0x80–0xBFの範囲にあり、0x1Eと等しくなることはありません。0x1E で分割してもマルチバイト文字を途中で切ることはありません。 - ストリーミング時は末尾をバッファする。 チャンク単位で読むと、最後のセグメントがまだ次の RS に到達していないことがあります。次のチャンクが来るまでバッファに保ちます。さもないと同じ半レコードを二度パースしてしまいます。
- 中断せず失敗を数える。 RFC はパーサーに次の RS までスキップして続行するよう求めるので、
badCountは例外ではなく警告指標です。途中で切れた最終レコードもこのバケットに入ります。
メモリ使用量は最大の単一レコードとバッファ 1 つに依存し、ファイル全体のサイズには依存しません。大きな JSON のパースとストリーミングで扱ったのと同じ考え方です。
5. 文字列内の制御文字
JSON は文字列内で U+0000 から U+001F までのすべての制御文字をエスケープすることを要求し、RS はその範囲に入ります。ですから正しい JSON 文字列は 5 文字の \u001e としてのみ RS を含め、生の 0x1E バイトは決して含みません。それが 0x1E での分割が正しいデータに対して常に安全である理由です。
危険は書き込み側にあります。
'\x1e'で JSON テキストを手組みし、生の制御文字を文字列に挿入する。- テンプレートリテラルで JSON を組み、改行、引用符、制御文字をエスケープしない。
- 端末やエディタから RS を含むテキストをコピーし、制御文字が飲み込まれたり置換されたりする。
正しいパターンは常に、先にテキストを生成し、それから区切り文字を付けることです。
const recordText = JSON.stringify(record);
const line = `${String.fromCharCode(RECORD_SEPARATOR)}${recordText}\n`;
JSON.stringify は制御文字を \u00XX として出力するので、生の RS がレコード内に現れることはありません。この手順が正しければ、分割ロジックはバイトだけを扱い、JSON 構文を理解する必要がありません。
このカテゴリの失敗はほとんど JSON.parse が Unexpected token を投げる形で現れます。特に「最初の文字が不可視の制御バイト」という紛らわしいケースです。JSON エラーメッセージ早見表でこの種のエラーを順に追っています。
6. HTTP 転送とメディアタイプ
application/json-seq は必須・任意パラメータを持たない登録済みメディアタイプで、文字集合は UTF-8 に固定されています。HTTP では三つの実践が重要です。
Accept: application/json-seqを送り、Content-Type: application/json-seqで応答する。- 本当にストリーミングするには、サーバーが chunked 転送を使い、リバースプロキシ、CDN、負荷試験ツールなど仲介者のレスポンスバッファリングを無効化する必要がある。
- チャンクごとに読むクライアントは、レコードの途中で終わるチャンクを受け取る可能性があるため、パーサーは次の RS までバッファしてからレコードを出力しなければならない。
相手が行区切り入力しか理解しないなら、JSON-seq を押し付けないでください。標準ではありますが、エコシステムへの浸透度は NDJSON に遠く及びません。互換性はパース失敗の後ではなく、プロトコル交渉の段階で決めるべきです。
7. コマンドライン、ログ、ツール対応
jq はネイティブ対応する数少ないツールの一つで、--seq フラグはまさに RFC 7464 のために存在します。
printf '\x1e{"id":1}\n\x1e{"id":2}\n' | jq --seq -c '.id'
これは 1 と 2 を別々の行に出力します。ストリーミングエンドポイントでは、curl のバッファリングを無効にするのを忘れないでください。
curl -sN https://api.example.com/events | jq --seq -c '.type'
jq --seq は RFC 7464 の入力を読み、壊れたレコードを無視してパースを続けます。まだ書き込み中のログファイルを tail するときに特に有用です。最後のレコードは半分だけ書かれているかもしれませんが、出力に偽の末尾オブジェクトが現れません。
7.1 環境ごとのネイティブ対応
| 環境 | 組み込み対応 | 備考 |
|---|---|---|
| jq | --seq |
最も簡単なコマンドライン手段 |
| Node.js | なし | Buffer.indexOf と JSON.parse |
| Python | なし | b'\x1e' で分割 |
| Go | なし | bufio.Scanner 用の独自 SplitFunc |
| ブラウザ | なし | fetch と ReadableStream でバイトを手動分割 |
7.2 よくある落とし穴
間違いは読み取り側に集中します。
readlineで読み、不可視の RS 接頭辞が最初の行に残るためJSON.parseが必ず失敗する。.json-seqファイルを制御文字が見えないエディタで開き、奇妙な一つの JSON 文書に見える。split(/\n/)で分割し、レコード内のエスケープされた改行とレコード間の LF を混同する。- ドキュメントで RS を普通の文字として扱い、コピー&ペースト後に消える。
8. 使う価値があるとき
適している場面:
- 生産者と消費者を自分で所有し、間にサードパーティツールがない。
- データが継続的に追記されるイベントストリームで、プロセスがクラッシュする可能性があり、途中切れからの復旧が必要。
- プロトコルに
.jsonlのような慣習ではなく、正式なメディアタイプがふさわしい。 - 個々のレコードがトップレベルの数値や真偽値になる可能性があり、明示的な途中切れ規則が重要。
適さない場面:
- ログを ELK、Loki など行区切り入力しか理解しないシステムに入れる必要がある。
- 人間がデータを直接
cat、grep、awkする必要がある。 - 相手が
application/jsonしかパースできない。
9. チェックリスト
- [ ] 書き込み側が
JSON.stringifyでレコードを生成し、先頭に0x1E、末尾に\nを付ける。 - [ ] トップレベルの数値、真偽値、
nullを持つレコードは LF で終わる。 - [ ] 読み取り側は行単位ではなくバイトで分割する。
- [ ] ストリーミング読み取りは閉じていない末尾を、次の RS が来るまでバッファに保つ。
- [ ] パース失敗は数えてスキップし、シーケンス全体を致命的にしない。
- [ ] 公開インターフェースは
application/json-seqを明示し、NDJSON のフォールバックを用意する。
FAQ
公開 API エンドポイントに JSON-seq を採用すべきですか?
両端を変更できるか、形式を事前に交渉できる場合に限ります。JSON-seq は application/json-seq メディアタイプを持つ登録標準ですが、エコシステムへの浸透度は NDJSON に遠く及びません。サードパーティクライアントに押し付けると通常は失敗します。互換性はプロトコル交渉の段階で決め、NDJSON のフォールバックを用意してください。
既存の NDJSON パイプラインを JSON-seq に変換すべきですか?
通常は不要です。NDJSON はすでにストリーミング、追記、壊れた行のスキップを備え、ほぼすべてのログ・ビッグデータツールが受け付けます。IANA 登録のメディアタイプが必要なとき、または先頭の区切り文字がもたらす再同期が必要なときで、かつ生産者と消費者を同時に変更できるときにだけ変換してください。
JSON Text Sequences と NDJSON はどちらが一般的ですか?
NDJSON です。規則がより単純で、ツールの対応範囲がはるかに広く、ログシステムやビッグデータパイプラインが一般に受け付けます。JSON-seq は IANA 登録のメディアタイプと、区切り文字を先頭に置くことによる再同期で勝り、一般的なデータ交換よりも制御されたストリーミングプロトコルに向きます。
区切り文字がレコードの後ではなく前にあるのはなぜですか?
レコードが途中で切れうるからです。先頭の区切り文字があれば、RS を見た読み取り側は新しいレコードが始まったことを知るので、前のレコードが完全でも不完全でも次のレコードを壊しません。末尾の区切り文字では、中断された書き込みが終端マーカーも次の開始マーカーも持たない最終セグメントを残すため、境界は推測するしかありません。
レコード内に 0x1E が現れたらどうなりますか?
正しい JSON 文字列は RS を \u001e とエスケープするので、生の 0x1E は現れないはずです。現れたならそのテキストはもはや正しい JSON ではなく、0x1E での分割は誤った位置で切れ、JSON.parse が失敗します。修正は書き込み側にあり、手組みのテキストではなく JSON.stringify を使うことです。
ブラウザは application/json-seq を直接パースできますか?
いいえ。ブラウザは JSON-seq パーサーを積んでおらず、fetch はバイトを渡すだけです。response.body.getReader() でチャンクを次の 0x1E まで蓄積し、各セグメントに JSON.parse を呼ぶ必要があります。小さなペイロードなら、ボディ全体を文字列として読んでから分割する方法も使えます。
トップレベルの数値に末尾の LF が必要なのはなぜですか?
オブジェクト、配列、文字列は JSON 内で自己区切りですが、数値はそうではありません。<RS>123<RS> は完全な 123 か、途中で切れた 1234 かもしれません。そこで RFC 7464 は各レコードの後に空白バイトを要求し、LF が途中切れ検出のカナリアとして働きます。同じ規則がトップレベルの true、false、null にも当てはまります。