📜

NDJSONが凄い理由:ストリーミング処理とログに最適なデータフォーマット

に公開

NDJSONとは

NDJSON(Newline Delimited JSON)は、各行が独立したJSONオブジェクトとなるシンプルなフォーマットだ。

{"id": 1, "name": "Alice", "action": "login"}
{"id": 2, "name": "Bob", "action": "purchase"}
{"id": 3, "name": "Charlie", "action": "logout"}

通常のJSON配列との違いを比較してみよう。

// 通常のJSON配列
[
  {"id": 1, "name": "Alice", "action": "login"},
  {"id": 2, "name": "Bob", "action": "purchase"},
  {"id": 3, "name": "Charlie", "action": "logout"}
]

この単純な違いが、実際の運用では大きなメリットをもたらす。

NDJSONが優れている5つの理由

1. ストリーミング処理が可能

通常のJSONは、ファイル全体を読み込んでパースする必要がある。

# 通常のJSON - 全体をメモリに読み込む
data = JSON.parse(File.read("huge_file.json"))
data.each { |record| process(record) }

NDJSONなら1行ずつ処理できる。

# NDJSON - 1行ずつ処理
File.foreach("huge_file.ndjson") do |line|
  record = JSON.parse(line)
  process(record)
end

10GBのログファイルでも、メモリ使用量は1行分だけで済む。

2. 追記が簡単

通常のJSON配列に要素を追加するには、ファイル全体を読み込んで書き直す必要がある。

# 通常のJSON - 追記が面倒
data = JSON.parse(File.read("data.json"))
data << new_record
File.write("data.json", JSON.pretty_generate(data))

NDJSONなら単純に追記するだけ。

# NDJSON - 追記が簡単
File.open("data.ndjson", "a") do |f|
  f.puts(new_record.to_json)
end

ログ収集やイベント記録に最適だ。

3. Unix ツールとの相性が抜群

各行が独立しているため、標準的なUnixツールがそのまま使える。

# 最新10件を取得
tail -n 10 events.ndjson

# 特定のユーザーを検索
grep '"user_id": 42' events.ndjson

# エラーだけ抽出
grep '"level": "error"' logs.ndjson | head -n 100

# 行数 = レコード数
wc -l events.ndjson

# 特定フィールドを抽出(jqと組み合わせ)
cat events.ndjson | jq -r '.action' | sort | uniq -c

通常のJSON配列では、こうした操作に専用のパーサーが必要になる。

4. 障害耐性が高い

通常のJSONは、途中で破損すると全体が読めなくなる。

[
  {"id": 1, "name": "Alice"},
  {"id": 2, "name": "Bob"   // ← ここで破損
  {"id": 3, "name": "Charlie"}
]
// 全体がパースエラー

NDJSONなら、壊れた行をスキップして残りを処理できる。

File.foreach("data.ndjson") do |line|
  begin
    record = JSON.parse(line)
    process(record)
  rescue JSON::ParserError
    # この行だけスキップ、他は処理続行
    log_error("Invalid JSON: #{line}")
  end
end

5. 並列処理との相性が良い

行単位で分割できるため、並列処理が容易だ。

# Rubyでの並列処理例
require 'parallel'

lines = File.readlines("large_data.ndjson")
results = Parallel.map(lines, in_processes: 4) do |line|
  record = JSON.parse(line)
  heavy_processing(record)
end
# GNU parallelでの処理
cat huge.ndjson | parallel --pipe -L 1000 'process_batch.rb'

実践的なユースケース

ログ収集

# config/initializers/structured_logging.rb
class NdjsonLogger
  def initialize(path)
    @file = File.open(path, "a")
  end

  def log(level:, message:, **metadata)
    entry = {
      timestamp: Time.current.iso8601,
      level: level,
      message: message,
      **metadata
    }
    @file.puts(entry.to_json)
  end
end

# 使用例
logger = NdjsonLogger.new("logs/app.ndjson")
logger.log(level: "info", message: "User logged in", user_id: 42)

APIのバルクレスポンス

# app/controllers/exports_controller.rb
class ExportsController < ApplicationController
  def users
    response.headers["Content-Type"] = "application/x-ndjson"

    User.find_each do |user|
      response.stream.write(user.to_json + "\n")
    end
  ensure
    response.stream.close
  end
end

データパイプライン

# ETLパイプラインでの使用
class DataPipeline
  def extract(source_path)
    Enumerator.new do |y|
      File.foreach(source_path) do |line|
        y << JSON.parse(line)
      end
    end
  end

  def transform(records)
    records.lazy.map do |record|
      # 変換処理
      record.merge(processed_at: Time.current.iso8601)
    end
  end

  def load(records, dest_path)
    File.open(dest_path, "w") do |f|
      records.each { |r| f.puts(r.to_json) }
    end
  end

  def run(source, dest)
    load(transform(extract(source)), dest)
  end
end

大規模データのインポート

# db/seeds/import_users.rb
File.foreach("users.ndjson").with_index do |line, i|
  user_data = JSON.parse(line)
  User.create!(user_data)

  puts "Imported #{i + 1} users" if (i + 1) % 1000 == 0
end

NDJSONを扱うツール

jq

# 各行を整形して表示
cat data.ndjson | jq .

# 特定フィールドを抽出
cat data.ndjson | jq -r '.email'

# フィルタリング
cat data.ndjson | jq 'select(.age > 30)'

# 集計
cat data.ndjson | jq -s 'group_by(.status) | map({status: .[0].status, count: length})'

Ruby標準ライブラリ

特別なgemは不要。標準のJSONライブラリだけで十分。

require 'json'

# 読み込み
records = File.readlines("data.ndjson").map { |line| JSON.parse(line) }

# 書き込み
File.open("output.ndjson", "w") do |f|
  records.each { |r| f.puts(r.to_json) }
end

Node.js

const fs = require('fs');
const readline = require('readline');

async function processNdjson(filePath) {
  const fileStream = fs.createReadStream(filePath);
  const rl = readline.createInterface({ input: fileStream });

  for await (const line of rl) {
    const record = JSON.parse(line);
    console.log(record);
  }
}

通常のJSONとの使い分け

用途 推奨フォーマット 理由
APIレスポンス(単一) JSON クライアントの互換性
設定ファイル JSON 人間が編集しやすい
ログファイル NDJSON 追記・検索が容易
大量データ転送 NDJSON ストリーミング可能
バックアップ NDJSON 追記・分割が容易
イベント記録 NDJSON 時系列での追記
データ交換 NDJSON 並列処理しやすい

MIMEタイプ

NDJSONのMIMEタイプは application/x-ndjson だ。

# Railsでの設定
Mime::Type.register "application/x-ndjson", :ndjson

# コントローラーでの使用
respond_to do |format|
  format.ndjson { render_ndjson(@records) }
end

まとめ

NDJSONの強み:

  • メモリ効率: 巨大ファイルも1行ずつ処理
  • 追記容易: ログやイベントの記録に最適
  • Unix親和性: grep, tail, wc がそのまま使える
  • 障害耐性: 部分的な破損に強い
  • 並列処理: 行単位で分割可能

シンプルな改行区切りという設計が、実運用で大きな価値を発揮する。ログ収集、データパイプライン、バルクエクスポートなど、大量データを扱う場面ではNDJSONを検討してみよう。


参考リンク

GitHubで編集を提案

Discussion