テストで巨大で固定長なファイルを扱うtips

最近、リプレースしたWeb系システムとレガシーな伝票システムとの連携プログラムのお仕事をした際に、
ありがちですが以下のようなファイルをテストで散々扱うことになり、とてもウンザリしました。

* 文字コードがShift-JIS(cp932)、半角カタカナが混在
* 固定長、改行なし
* ファイルサイズが巨大、かつDisk空き容量が少ない

結論としては、TSV(タブ区切りテキスト)や**LTSV**(ラベル付きタブ区切りテキスト)にしてしまうのが吉。
ということで、作業イメージをご紹介します。


## 環境とテスト概要

CentOS上で連携プログラムが実行され、ローカルDiskの作業ディレクトリに固定長ファイルを出力し、その後、NFSサーバにファイルを配置するというような連携の流れです。
テストでは、作業ディレクトリ上に生成した固定長ファイルの中身を検索したり、集計して期待する値が出力されていることを確認するといったイメージです。
実データやそれに準ずるデータを用いてプログラムを実行し、非常に大きなファイルが出力がされることを想定しています。

なお、固定長ファイルは伝票処理に回すためのSJISテキストであり、改行コードやタブ、EOF、NULLなどの制御コードは含まれていない想定です。


## 基本方針

以下では、対象ファイルを以下のように段階的に加工して行きます。
その際、全件検査が不要ならば、先頭の1000件のみ、などのようにサンプル抽出してデータ量を減らします。
また、中間ファイルとしてTSVやLTSVの形式にして検索・集計しやすくします。

```sh:イメージ
cat 入力ファイル | 加工コマンド1 > 中間ファイル1
cat 中間ファイル1 | 加工コマンド2 > 中間ファイル2
...
```

なお上記のように都度中間ファイルを生成しても良いですが、中間ファイルを生成せずに、パイプラインで処理することも可能です。

```sh:イメージ
cat 入力ファイル | 加工コマンド1 | 加工コマンド2 | ... | 加工コマンドn > 出力ファイル
```

また、入力が通常のファイルではなく、ネットワーク越しのパイプラインやFIFOのようなストリームデータでも同様の処理が可能です。


## step0: 対象ファイルをありのままに眺める

まずは、データをざっくり確認してみます。
以下のコマンド例を参考ください。
対象ファイルを file1 とします。
また、確認観点も併記しています。


### ファイルサイズ確認

まずはファイルサイズの確認。

```sh
# ファイルサイズを確認
# -> ファイルサイズがゼロでないこと
ls -la file1
```

### レコード数算出

ファイルサイズとレコード長からレコード数を算出します。
以下の例ではレコード長は512bytesとしています。
割り切れないレコード数、つまり、レコード長の倍数でない半端なファイルサイズということは、
ファイル生成の途中であるか、生成に失敗した可能性があります。

```sh
# ファイルサイズとレコード長からレコード数を算出
# -> 想定レコード数とあっていること
perl -e 'print((-s shift) / 512)' file1
```

perlの`-s`演算子はファイルサイズを取得する演算子です。


### ファイルの中身を目視確認

まずはそのままファイルの中身を確認します。
端末がutf-8などだと、日本語は文字化けしているでしょう。
これは何らかの意味のあるデータが入っていそうか、
という見当が付けばよいでしょう。

```sh
# そのまま眺める
cat file1 | less
```

日本語の場合は以下で、文字コードを変換してから眺めてみます。

```sh
# 文字コード変換して見る
# -> 対象ファイルがSJISで、意図した文字コード(UTF-8)に変換できていること
cat file1 | iconv -f cp932 -t utf8 | less
```


## step1: レコード分割

レガシーシステムでは、文字幅と使用バイト数が一致することからも、日本語を扱う際にいわゆるShift-JISコードがよく利用されてきました。

しかし、近年のUnicode対応, サロゲートペア対応で使用できる文字(絵文字なども含め)が飛躍的に増えたため、
データベースなどの一次データがUnicodeで管理されていて、連携データ出力がShift-JISの場合は、以下を確認する必要がありますね。

* Shift-JISにマッピングできない文字が含まれないか
* マッピングできない場合の変換仕様


### レコード切り出し

さて、固定長データを扱いやすくするために、行毎に切り出しましょう。
Shift-JIS前提でレコード長が定められていると思いますので、
バイナリデータとして単純にbyte数でカットし、LF改行とともに出力します。

```sh
# レコード長が512bytesとする
cat file1 | perl -e 'while(read(STDIN, $buf, 512)){print $buf, "\n"}' > file1.rows
```

### windows環境

windowsなどでは binmode()を使って、CRLF<->LF変換が行われないように制御する必要があります。

```sh
# windowsの場合はbinmodeを
cat file1 | perl -e 'binmode STDIN; binmode STDOUT; while(read(STDIN, $buf, 512)){print $buf, "\n"}' > file1.rows
```

### 先頭のみ切り出し

上記は入力ファイルを全て切り出す場合ですが、
巨大なファイルで先頭から少しだけ検査したい場合は、headコマンドにパイプして先頭のみを取得します。

```sh
# headコマンドで先頭の1000行のみ
cat file1 | perl -e 'while(read(STDIN, $buf, 512)){print $buf, "\n"}' \
| head -1000 > file1.rows.1000
```

※ここではパイプラインでheadコマンドに渡す方法で説明していますが、もちろん、headコマンドを使わずに、perlコマンドで先頭のみを取得するようにしてもOKです。

headコマンドにより先頭を取得した後はパイプが閉じられ、バッファリングのキリの良いところでperlコマンドは処理を中断されますので、巨大なファイルを全て処理するわけではありません。


### 中間のみ切り出し

巨大なファイルの任意の中間を切り出したい場合は、パイプラインでデータを渡さずに、perlで直接ファイルをオープンしてからseek関数でファイルの途中から読み込みを始めます。

```sh
# 中間の5001レコード目から切り出し始め、6000レコード目までをファイルに出力
# つまり、先頭の5000レコード分 == 512bytes * 5,000 = 2,560,000bytesをスキップし、その位置から1000レコード分を取得
perl -e 'open(IN, shift); seek(IN, 512 * 5000, 0); while(read(IN, $buf, 512)){print $buf, "\n"}' file1 | head -1000 > file1.rows.5001-6000
```


### 行数確認

レコード分割して改行つきのテキストにしてあるので、テキスト行数がレコード数となります。

```sh
# 行数確認
# -> ファイルサイズ / レコードサイズ で想定する行数と一致するか
cat file1.rows | wc -l
```


### 内容確認

文字コード変換してからlessコマンドで確認してみます。
フィールドが固定長なので綺麗に並んで見えるのではないでしょうか。
`-S`オプションは行を折り返さないので、長い行の確認に便利です。

```sh
# 内容確認
cat file1.rows | iconv -f cp932 -t utf8 | less -S
```


## step2: フィールド分割、LTSV化

まだ、1レコード内の各フィールドは区切られておらず固定長のままですので、扱いやすくするためにフィールド分割をします。
レコード分割の際にperlで一度にやっても良いのですが、ここでは段階毎にパイプライン処理する想定で説明しています。


### タブ区切りテキスト(TSV)化

フィールド分解のためにperlのunpack関数を利用しています。
例えば、各フィールドのバイト数が{10, 1, 8, 6}の長さで並ぶ場合は、
unpack関数のテンプレートの文字列`a10a1a8a6`を指定します。

```sh
# TSV化
cat file1.rows | perl -lane 'print(join "\t", unpack "a10a1a8a6")' > file1.tsv
```

出力はタブ区切りのテキストですが、改行コードはLFのままです。
改行コードをCRLFに変換してから、Excelなどの表計算ツールで処理しても良いでしょう。

```sh
# 改行コードをCRLFに変換
cat file1.tsv | perl -pe 's/$/\r/' > file1.tsv.crlf
```


### ラベル付きTSV(LTSV)化

ここでは、後の検索・集計の利便性のために、ラベルを付与してフィールドを特定しやすくしておきます。
フィールドの並び順をそのまま1, 2, ...という単純なラベルにするだけで十分な場合は以下で。
cutコマンドやsortコマンドではフィールドの順序で指定するので、後述のLTSV用のコマンドを使わない場合はこちらが便利かもしれません。

```sh
# LTSV化 (ラベルはフィールド順)
cat file1.rows | perl -lane '$i = 0; print(join "\t", map { ++$i . ":" . $_ } unpack "a10a1a8a6");' > file1.ltsv.num
```

意味のあるラベルを付けたい場合は以下で。
`@l=qw()`の中に空白区切りで並べてください。

```sh
# LTSV化 (ラベル名を指定)
cat file1.rows | perl -lane 'BEGIN{@l=qw(name paid created code)} $i = 0; print(join "\t", map { $l[$i++] . ":" . $_ } unpack "a10a1a8a6");' > file1.ltsv
```


### utf-8

フィールドも分解してしまったので、もうSJISのまま扱う必要はありませんね。
utf-8に変換しておきます。

```sh
# utf8
cat file1.ltsv | iconv -f cp932 -t utf8 > file1.ltsv.utf8
```

### 内容確認

lessコマンドで確認します。

```sh
# 内容確認
cat file1.ltsv.utf8 | less -S
```


## 検索・集計

LTSVになってしまえば、もう一安心ですね。
検索・集計を施して、DBの想定した情報とファイルに出力されていることをテストします。

### LTSVワンライナー、ツール

LTSVを扱う上手いワンライナーやユーティリティコマンドなどが多数存在しますので、
幾つかご紹介します。

[LTSVログをパースする最強のワンライナー集 · DQNEO起業日記](http://dqn.sakusakutto.jp/2014/02/ltsv_parser_oneliner.html)
[lltsv という LTSV の特定キーだけ取り出す golang アプリケーションを書いた \- sonots:blog](http://blog.livedoor.jp/sonots/archives/39628163.html)
[LTSV のログを jq でフィルタする \- Qiita](https://qiita.com/yuya_takeyama/items/f29b37302db0ce70de0c)

### Unixの伝統的なテキスト処理コマンド

なお、前述の流れで生成したLTSVはラベル位置が変わりませんので、
unixの伝統的なテキスト処理用コマンド(grep, cut, sort, uniq, ...)で扱うことも可能です。

```sh
# 例: 1, 3番目のカラムのみを切り出す
cat file1.ltsv.utf8 | cut -f1,3 > file1.ltsv.utf.cut-1,3

# 例: 3番目のカラムでソート
cat file1.ltsv.num.utf8 | sort -t$'\t' -k3 | less -S
```

cutコマンドはデフォルトのデリミタ(区切り文字)はタブなのでそのまま利用できますが、
sortコマンドはデリミタを-tオプションで指定することに注意ください。
タブの指定は$'\t'で。
参考: [\[ sort \]tab区切りデリミタ指定 \- Qiita](https://qiita.com/penguin_dream/items/0de59b6682c84b4d9395)


## Disk残量がなくて中間ファイルが作成できない

ありがちですが、対応方法が幾つかありますので、ご参考ください。


### パイプラインでつなげる

前述では、都度中間ファイルを生成していましたが、
パイプラインで全てつなげて処理することも可能です。

```sh
cat file1 \
| perl -e 'while(read(STDIN, $buf, 512)){print $buf, "\n"}' \
| perl -lane 'BEGIN{@l=qw(name paid created code)} $i = 0; print(join "\t", map { $l[$i++] . ":" . $_ } unpack "a10a1a8a6");' \
| (集計コマンドなど)
```


### 応用: ネットワーク越しにパイプライン

(中間ファイルを生成せずに)抽出結果をパイプラインで直接リモートマシンの受けコマンドに送ることも可能です。

```sh
# 手元のファイルを加工してリモートに送る
cat file1 \
| perl -e 'while(read(STDIN, $buf, 512)){print $buf, "\n"}' \
| head -1000 \
| ssh -C remote-host 'cat > /var/tmp/file1.rows.1000'
```

逆も可能です。

```sh
# リモートのファイルを加工して手元に持ってくる
ssh -C remote-host "cat /var/tmp/file1 | perl -e 'while(read(STDIN, \$buf, 512)){print \$buf, \"\\n\"}' | head -1000" \
> /var/tmp/file1.rows.1000
```

いずれの場合も、sshにコマンドをパラメタ渡しする際の文字列エスケープに注意が必要です。

また、ネットワーク帯域への負担を考慮する場合は以下を注意ください。

* 可能なら前加工で絞り込みするなどして転送データを少なくする
* sshのデータ圧縮転送オプション`-C`を活用する


## 最後に
固定長ファイルをタブ区切りファイルにしてしまえば、伝統的なUnixコマンドで処理しやすくなります。
また、LTSV化してしまえば、ラベルの分ファイルサイズが増加してしまうものの、値の取り回しは格段に楽になりますので、活用いただければと。