2020-05-18

【Node.js】巨大なJSONファイルを項目ごとに読み込むJSONStreamを使う

ReadStreamはバイト単位、Readlineは1行ごとだが、JSONStreamはJSONの項目で抽出して順番に読み込むライブラリ。こちらの使い方を説明する

Article Image

JSONStreamの必要性

csvなどの1行ごとに区切って保存できるデータ形式であれば巨大ファイルであってもNode.jsReadlineにより1行ごと順番に処理が可能である。

しかしJSON形式の巨大なファイルを取り扱おうとした場合、行では意味を成さないためNode.jsの標準ライブラリでは取り扱いが難しい(巨大なファイルを一括で読み込まねばならない)

そこで使ってみたいと考えたのがJSONStream

これを使えば巨大なJSONファイルも意味を成す塊に分けて順番にロードできる。

どうやって「塊」を抽出するのか

例えば次のJSONデータを取り扱う

{"total_rows":129,"offset":0,"rows":[
  { "id":"change1_0.6995461115147918"
  , "key":"change1_0.6995461115147918"
  , "value":{"rev":"1-e240bae28c7bb3667f02760f6398d508"}
  , "doc":{
      "_id":  "change1_0.6995461115147918"
    , "_rev": "1-e240bae28c7bb3667f02760f6398d508","hello":1}
  },
  { "id":"change2_0.6995461115147918"
  , "key":"change2_0.6995461115147918"
  , "value":{"rev":"1-13677d36b98c0c075145bb8975105153"}
  , "doc":{
      "_id":"change2_0.6995461115147918"
    , "_rev":"1-13677d36b98c0c075145bb8975105153"
    , "hello":2
    }
  },
]}

JSONの場合は入れ子構造によりどんどん複雑になっていく。

特定の1個を取り出すなら簡単かもしれないが、この例のなかからrowsの配列に入っているオブジェクトのdocだけに絞って一つづつ取り出すのは難しい。

そこでJSONStreamJSONPathという概念を使って取り出していく。

JSONPathとは

例えばSQLのようにクエリを発行してそれに一致するデータを纏めて取り出す方法。

JSONPathの構文については次が公式のようだ。

https://goessner.net/articles/JsonPath/

JSONPathの例

例えば上の例でいくとrows.*.docというJSONPathを発行すると次のデータが帰ってくる。

{
  _id: 'change1_0.6995461115147918',
  _rev: '1-e240bae28c7bb3667f02760f6398d508',
  hello: 1
}
{
  _id: 'change2_0.6995461115147918',
  _rev: '1-13677d36b98c0c075145bb8975105153',
  hello: 2
}

これはJavaScriptで見れば次に一致する。

const data = ファイル読み込み
for( const d of data.rows){
	console.log(d.doc)
}
//rowsを1件づつ読み込み、さらにその中のdocだけを抽出している

普通にオブジェクトのメンバ変数にアクセスするときの.だと思えばそこにワイルドカードなどが使えるようになっている印象だ。思ったよりわかりやすい。

と、ここまで書いてはみたが残念ながら私はJSONPathに関して明るくない。今回はこれを説明するための記事ではないのである程度は他のサイトに譲りたい。興味があれば調べてみて欲しい。

JSONStreamの使い方

では実際に使っていく。まずはデータから。

データ

[
    {
        "No": 1,
        "value": "AAA"
    },
    {
        "No": 2,
        "value": "BBB"
    },
    {
        "No": 3,
        "value": "CCC"
    },
    {
        "No": 4,
        "value": "DDD"
    }
]

あえて一番上の階層が配列になっている形式にした。以前作成した5秒足データに形状を似せている。

必要なライブラリをインポート

公式にはevent-streamのライブラリを合わせて使っているが、使用しなくてもある程度できるようなのでここでは使用しないこととした。

import JSONStream from 'JSONStream' //本体
import fs from 'fs' //ファイルを読み込むため

用意するのはこれだけだ。

次にファイルをfsで読み込んで.pipeJSONStreamにつないでやる。

const stream = fs.createReadStream('./test.json')
    .pipe(JSONStream.parse('$*'))

pipeは入力から直接出力につなぐ時に使うもの。例えば読んだファイルをそのまま別のファイルに出力したり標準出力process.stdout(普通のPCなら≒console.log)などに繋ぐといった利用法が挙げられる。

JSONStreamはこの出力として振る舞っている。

またcreateReadStreamStreamオブジェクトと呼ばれるものを返す。これをstreamという箱に格納した。

JSONStream.parse

ここでparseが現れた。このメソッドがJSONPathを渡してデータを抽出するというコードだ。

ここでは$*JSONPathとして渡っている。$は一番上の要素(ルート)を指し*はワイルドカードなので、親の配列に格納されているデータを1個づつ全てにヒットする、といった感じだろうか。

ちなみに$.*では何もヒットしない。.の次に入る文字列はキーを指す?

データを1個づつ受け取る

ここがメインの処理。次のコードでデータを塊単位で受信する(ファイルを一度に読み込まない)

stream.on('data', (data) => {
     console.log(data.value)
    if (data.value.No === 3) {
        stream.pause()
    }
})

.onこれは先日話したevent-drivenの概念

データの読み込みが1個発生するごとにdataというイベントが発生しデータ1個分が引数に格納されてくる。先程streamを格納しておいたのはこのイベントを受取るため。

単純にconsole.logで表示している。

また、データのNo3になった場合読み込みを中断するpauseメソッドを試している。これはJSONStreamではなくStreamオブジェクトの機能。

実行結果

{ No: 1, value: 'AAA' }
{ No: 2, value: 'BBB' }
{ No: 3, value: 'CCC' }

1行ごとに1回の処理に分けて実行。つまり3回処理が実行されている。

まずは上で解説したpauseが機能するため4件目以降のデータは正しく止められている。

注意点が一つ。

上ではdata.valueとなっているが、これはサンプルデータの中にあるvalueではない。

console.log(data)に変更して実行してみると

{ value: { No: 1, value: 'AAA' }, key: 0 }
{ value: { No: 2, value: 'BBB' }, key: 1 }
{ value: { No: 3, value: 'CCC' }, key: 2 }

どうやら親切なことにヒットしたデータをvalueに格納し、キー番号を付与してくれるようだ。

注意

不思議なことに冒頭の例「rows.*.doc」ではvaluekeyに別れずデータがそのまま中に入ってくる。

このあたりは配列に対して付与されるのか、$(一番親の要素)を使った時に付与されるのか詳しく調べていないのであしからず。

今回のコード全体

全部合わせてもたったこれだけのコードだが、覚えることは多い。

import JSONStream from 'JSONStream'
import fs from 'fs'

const stream = fs.createReadStream('./test.json')
    .pipe(JSONStream.parse('$*'))

stream.on('data', (data) => {
     console.log(data)
    if (data.value.No === 3) {
        stream.pause()
    }
})

おまけ:書き込み

書き込みにも使えるようだが詳しく調べていない。書き込み用のwritable streamを生成するコードはJSONStream.stringifyを使うようだがなんと公式もここは詳細な実例を挙げておらず理解できなかった。

どちらにせよNode.jsは「データの途中に挿入する形での書き込み」はワンランク上のプログラミング実装が必要であるため使うとしても一番うしろに追記するような使い方ぐらいだろう。

さいごに

今回はJSONStreamの特別なコードは殆ど使っていないが他にも複数のメソッドがある。興味があれば公式も参照してほしい(それほど多くない印象)

とはいえこのライブラリはJSONPathが使えるかどうかがほぼ全てといっても過言ではないのでまずはそちらから覚えていったほうが良さそうだ。

相変わらずこの手のコアなライブラリは日本語の情報が少ないためこの記事が少しでも役に立てたら幸いである。



この記事のタグ

この記事をシェア


謎の技術研究部 (謎技研)