@reiwa

Node.jsでサイズの大きいCSVファイルを読み取る

typescript
nodejs

Node.jsではある程度の大きなファイルをreadFileで読み取ろうとするとエラーが発生します。

$ node
> await require("fs/promises").readFile("large.csv", "utf-8")
Uncaught RangeError: Invalid string length
    at readFileHandle (node:internal/fs/promises:539:25)
    at async REPL20:1:33
>

大きなCSVファイルを取り扱う場合は工夫が必要みたいです。

CSVデータを読み取る

CSV文字列は csv-parse というライブラリを用いてオブジェクトに変換できます。

https://www.npmjs.com/package/csv-parsehttps://www.npmjs.com/package/csv-parse

型定義を省略するとこのように書けます。

import { parse } from "csv-parse/sync"
import { readFile, readdir } from "fs/promises"

const fileText = await readFile(`parts/${fileName}`, "utf-8")

const recordKeys = [
  "id",
  "name",
]

const records = parse(fileText, {
  columns: recordKeys,
  skip_empty_lines: true,
})

for (const record of records) {
  console.log(record)
}

ライブラリは csv-parse ではなく csv-parse/sync を使用しています。

Streamを使用する

Streamを使用することでも大きなファイルを読み取ることが出来ます。

import { createReadStream } from "fs"

const stream = await createReadStream("sample.csv", "utf-8")

for await (const text of stream) {
  console.log(text)
}

更に読み取った文字列をCSVデータに変換する必要があります。

import { parse } from "csv-parse"

const parser = parse({
  columns: recordKeys,
  trim: true,
  skip_empty_lines: true,
})

for await (const record of stream.pipe(parser)) {
  console.log(record)
}

ファイルを分割する

10万件ごとのようなまとまった件数のCSVデータを作成したい場合は、事前にファイルを分割して読み取る方法が考えられます。

macOSではこのコマンドを実行してファイルを分割できます。この場合は100000行ごとにファイルを分割します。

split -l 100000 large.csv

これはNode.jsのREPLからも実行することもできます。

$ node
> require("child_process").execSync("split -l 100000 large.csv")

100000行ごとにファイルであれば、readFileで読み取ることができます。

例えば parts というディレクトリに分割されたファイルが保存されている場合は以下のように読み取ることができます。

import { readFile, readdir } from "fs/promises"

const fileNames = await readdir(`${process.cwd()}/parts`)

for (const fileName of fileNames) {
  const fileText = await readFile(`parts/${fileName}`, "utf-8")

  console.log(fileText)
}

このように関数化することもできそうです。

import { exec } from "child_process"
import { promisify } from "util"
import { mkdir, mkdtemp, readFile, readdir } from "fs/promises"
import { join } from "path"
import { tmpdir } from "os"

const execPromise = promisify(exec)

type Options = {
  count: number
}

export async function* readLargeFile(filePath: string, options: Options) {
  const tmpDirectory = await mkdtemp(tmpdir())

  await mkdir(tmpDirectory, { recursive: true })

  await execPromise(
    `cd ${tmpDirectory} && split -l ${options.count} ${filePath}`,
  )

  const files = await readdir(tmpDirectory)

  for (const file of files) {
    console.log(file)
    const filePath = join(tmpDirectory, file)
    yield await readFile(filePath, "utf-8")
  }
}

ただ分割できる数に制限があり小さ過ぎるとこのようなエラーが発生します。

split: too many files

このように使用できます。

const texts = readLargeFile(filePath, { count: 10000 })

for await (const text of texts) {
  console.log(text.length)
}

最後に、このようにCSVデータに変換できます。

import { parse } from "csv-parse/sync"

const texts = readLargeFile(filePath, { count: 10000 })

const recordKeys = [
  "id",
  "name",
]

for await (const text of texts) {
  const record = parse(text, {
    columns: recordKeys,
    skip_empty_lines: true,
  })
  console.log(record)
}

その他のタグ

関連する記事