Node.js で大きいファイルを指定行数で分割する

2022.07.10

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

はじめに

おはようございます、もきゅりんです。

Shall we stream ?

タイトルのような要件があって、その際に雑にG先生で検索したところ、いい感じの見当たらないなーと思ったため、いそいそと書いたのでした。

ところが、このブログにする前に色々とワードを変えて検索したらそれっぽいのが出てきました。

うん、このブログいらねーじゃん、と思いましたが、アプローチはそれぞれ違ったりすると思うので、お焚き上げさせて下さい。

作ったもの

分割したいファイルを配置するディレクトリをよしなに設定して、環境変数 NUMBER_TO_DIVIDED_LINES に分割したい行数(e.g. 10000)を設定してから実行します。

import * as util from 'util';
import * as fs from 'fs';
import * as readline from 'readline';
import * as childProcess from 'child_process';

// 分割したいファイルを配置するディレクトリを指定
const targetDir = `${__dirname}/target-files`;
const targetFileNameList = fs.readdirSync(targetDir);

const exec = util.promisify(childProcess.exec);

const fileLineCount = async ({ fileLocation }: FileLocation) => {
  const { stdout } = await exec(`cat ${fileLocation} | wc -l`);
  return parseInt(stdout, 10);
};

const execFileLineCount = async (targetFile: string) => {
  const lineCount = await fileLineCount({
    fileLocation: targetFile,
  });
  return lineCount;
};

// 拡張子の前にファイル番号を付与させる
const prefixNumberFileExtension = (targetFile: string, i: number) => {
  const fileExtension = /\.*\.\w{3}$/.exec(targetFile)![0];
  return targetFile.replace(fileExtension, `_${i}${fileExtension}`);
};

const writeLiner = (
  outputFile: fs.WriteStream,
  documentSrc: fs.ReadStream,
  numberToDivideLines: number,
  turn: number
) => {
  const startLine = (turn - 1) * numberToDivideLines + 1;
  let readCounter = 1;
  const reader = readline.createInterface({ input: documentSrc });
  // 書き出しポイントから分割する行数×周回までファイルに出力
  reader.on('line', (data) => {
    if (startLine <= readCounter && readCounter < turn * numberToDivideLines) {
      outputFile.write(`${data.trim()}\n`);
    } else if (
      startLine <= readCounter &&
      readCounter === turn * numberToDivideLines
    ) {
      outputFile.write(`${data.trim()}`);
    }
    readCounter += 1;
  });
  outputFile.on('error', (err) => {
    if (err) console.log(err.message);
  });
};

type FileLocation = {
  fileLocation: string;
};

const main = async () => {
  const numberToDivideLines: number | undefined = Number(
    process.env.NUMBER_TO_DIVIDED_LINES
  );

  try {
    if (!numberToDivideLines) throw Error(`${numberToDivideLines}is undefined`);
    for (const targetFileName of targetFileNameList) {
      const targetFile = `${targetDir}/${targetFileName}`;

      const documentSrc = fs.createReadStream(targetFile, 'utf8');

      const fileLines = await execFileLineCount(targetFile);
      // 最終行÷分割行数の商に余りがあればファイル1つ増やして余り分を書き込む
      const dividedFiles: number =
        fileLines % numberToDivideLines === 0
          ? fileLines / numberToDivideLines
          : fileLines / numberToDivideLines + 1;
      for (let i = 1; i <= dividedFiles; i += 1) {
        const dividedFileName = /\.*\.\w{3}$/.test(targetFile)
          ? prefixNumberFileExtension(targetFile, i)
          : `${targetFile}_${i}`;
        const dividedFile = fs.createWriteStream(dividedFileName);
        writeLiner(dividedFile, documentSrc, numberToDivideLines, i);
      }
      console.info(`${targetFileName}を分割しました`);
    }
  } catch (err) {
    console.log(err);
  }
};

main();

やってること

読めばすぐ理解できてしまうと思いますが、要点だけまとめておきます。

  • シェルコマンド実行してファイル最終行を同期的に取得
    const exec = util.promisify(childProcess.exec);
    
    const fileLineCount = async ({ fileLocation }: FileLocation) => {
      const { stdout } = await exec(`cat ${fileLocation} | wc -l`);
      return parseInt(stdout, 10);
    };
    
    const execFileLineCount = async (targetFile: string) => {
      const lineCount = await fileLineCount({
        fileLocation: targetFile,
      });
      return lineCount;
    };
  • 最終行を指定行で割ったファイル数を用意する、最終行を指定した行数で割り切れなかったら、追加の1ファイルを用意する
          const fileLines = await execFileLineCount(targetFile);
          // 最終行÷分割行数の商に余りがあればファイル1つ増やして余り分を書き込む
          const dividedFiles: number =
            fileLines % numberToDivideLines === 0
              ? fileLines / numberToDivideLines
              : fileLines / numberToDivideLines + 1;
  • txt, tsv, csvなどの3文字の拡張子が付いている場合、拡張子前にナンバリングを付与する
    // 拡張子の前にファイル番号を付与させる
    const prefixNumberFileExtension = (targetFile: string, i: number) => {
      const fileExtension = /\.*\.\w{3}$/.exec(targetFile)![0];
      return targetFile.replace(fileExtension, `_${i}${fileExtension}`);
    };
    ...
          for (let i = 1; i <= dividedFiles; i += 1) {
            const dividedFileName = /\.*\.\w{3}$/.test(targetFile)
              ? prefixNumberFileExtension(targetFile, i)
              : `${targetFile}_${i}`;
            const dividedFile = fs.createWriteStream(dividedFileName);
  • 対応するファイル枚数目と指定行数とで、始点行と終止行を readCounter で捕捉してファイルに行を書き込む
      const startLine = (turn - 1) * numberToDivideLines + 1;
      let readCounter = 1;
      const reader = readline.createInterface({ input: documentSrc });
      // 書き出しポイントから分割する行数×周回までファイルに出力
      reader.on('line', (data) => {
        if (startLine <= readCounter && readCounter < turn * numberToDivideLines) {
          outputFile.write(`${data.trim()}\n`);
        } else if (
          startLine <= readCounter &&
          readCounter === turn * numberToDivideLines
        ) {
          outputFile.write(`${data.trim()}`);
        }
        readCounter += 1;
      });

さいごに

結果的にお勉強にはなりましたが、検索ワードはもう少しいろいろとバリエーションを換えて粘り強くトライするようにします。

以上です。

どなたかの参考になれば幸いです。

参考