LoginSignup
2
4

More than 3 years have passed since last update.

長年育て続けた秘蔵の一括ファイル変換ライブラリを晒してみる

Last updated at Posted at 2019-09-11

時々、複数のCSVファイルやJSONファイルを変換したい場面に遭遇します。そういうときはよくRubyで使い捨てのスクリプトを書いて済ませていました。しかし、複数のファイルを読み込む処理や行を分割して改行コードを取り除いたり等、同じ処理を何回も書いていることに気づきました。そして次第に「変換処理」だけに集中したいと思うようになり、自前のライブラリfileconvを作り始めました・・・

(本記事は自分のブログからの転載記事です。)

はじめに

本記事は拡張可能な一括ファイル変換ライブラリfileconvの紹介記事です。fileconvを使えばファイルのオープンや読み書きに手を煩わせることなく、簡単に複数ファイルの変換処理を実装することができます。またデフォルトでCSVやJSONフォーマットにも対応しており、他のフォーマットに対応するのも簡単です。

また、記事の最後の方でこのライブラリを育てる途中の失敗談も紹介したいと思います。

インストール

以下の行をGemfileに加えてください。

gem 'fileconv'

それから以下を実行してください。

$ bundle

もしくは以下のようにgemコマンドで直接インストールしてください。

$ gem install fileconv

使い方

「コンバータ」を作成するには以下の2つをする必要があります。

  • MetaConvertor(例: Fileconv::Line)をincludeする
  • 必要に応じていくつかのフック(e.g. input_ext)を定義する

まずは簡単な例から紹介します。以下はテキストファイル(拡張子がtxtのもの)を選択して行番号を付加するコンバータです。

require 'fileconv'

class AddLinenoConvertor
  include Fileconv::Line
  def input_ext
    "txt"
  end

  def init_acc(acc)
    acc[:lineno] = 0
  end

  def convert_line(line, acc)
    acc[:lineno] += 1
    "#{acc[:lineno]}: #{line}"
  end
end

あとは以下のようにインスタンスを生成して#convメソッドを実行するだけです。このスクリプトを実行するとカレントディレクトリのテキストファイル(拡張子がtxtのもの)を選択して、カレントディレクトリの"output"ディレクトリ配下にファイルの変換結果(行番号を付加したもの)を同じファイル名で出力します。

convertor = AddLinenoConvertor.new
convertor.conv

つまり以下の2つのファイルがあるとすると、

aaa.txt
aaa
bbb
ccc
bbb.txt
111
222
333

コンバータの実行後には以下のような2つのファイルが変換結果として生成されます。

output/aaa.txt
1: aaa
2: bbb
3: ccc
output/bbb.txt
1: 111
2: 222
3: 333

コンバータのフック

前出の例では最低限のフックしかオーバーライドしていませんでしたが、必要に応じて様々なフックをオーバーライドできます。フックを一つもオーバーライドしない場合のデフォルトのアクションでは、カレントディレクトリのすべてのファイルを"output"ディレクトリにコピーします1

フック デフォルト 説明
input_dir "." 入力元ディレクトリ
input_ext nil 入力ファイルの拡張子
output_dir "output" 出力先ディレクトリ
input_files(files) files 入力ファイル
init_conv nil コンバータの初期化用フック
init_acc(acc) nil アキュームレータ(acc)の初期化用フック
read_file(filename, acc) nil (デフォルトのリーダ) ファイル読み込み用フック
convert_line(line, acc) line 行変換用フック
convert_file(file, acc) file ファイル変換用フック
output_filename(filename, acc) filename 出力ファイル名変更用フック
result_filename "result.txt" 結果ファイル変更用フック
conv_result nil 変換結果出力用フック

よく使われるフックは以下のとおりです。

  • #input_ext
  • #convert_line
  • #convert_file
  • #conv_result

#input_extは対象ファイルの拡張子を返します。オーバーライドしない場合のデフォルトはnilでこの場合は「全ファイル」が対象となります。ディレクトリは対象外です。このフックをオーバーライドして"csv"を返すと拡張子がcsvのファイルが選択されます。自分で選択ファイルを直接したい場合はinput_filesフックで上書きでききます。

#convert_lineフックは前述の例でも利用されていましたが、基本的に引数のlineをそのまま返せば「コピー」と同じ動作になります。そして行を変更したければlineを加工して戻り値として返せば行が変更されます。もし行を削除したければnilを返すか空の配列([])を返してください。行を増やしたい場合は必要な行を配列で返してください。

#convert_fileフックにはファイル全体に対する処理を記述します。引数のfileには読み込んだ一つのファイル全体のデータが入っているので2、これを加工して戻り値にします。

#conv_resultフックは複数のファイルを処理した後の一番最後に呼ばれるフックです。デフォルトではnilを返して何も出力しませんが、このフックをオーバーライドして文字列を返すと、それが"output/result.txt"に出力されます。出力先ディレクトリと出力結果のファイル名はそれぞれ#output_dirフックと#result_filenameフックで変更可能です。

コンバータの変数

コンバータ内で利用できる主な変数はacc,@meta,@optsの3つで、全てHash型です。これらの変数はスコープを持っています。accはいくつかのフックの引数として渡されますが、スコープとしては一つのファイルを処理する間の共通の変数として使えます。そして一つのファイルの処理が終わると初期化されます。@opts@metaはコンバータ全体で有効な変数です。 @optsはオプション引数を保持するのに使われます。オプションは#convメソッドの引数として渡されます。@metaはどんな目的にも使える変数として用意しています。一般的にはファイル処理全体に関わる情報を保持しておいて#conv_resultフックの出力用に利用します。

変数 スコープ 説明
acc ファイル 単一ファイル用の変数
@meta コンバータ 多目的変数
@opts コンバータ オプション用変数

デフォルトのメタコンバータ

メタコンバータは主にコンバータにincludeして利用されることを目的にしたコンバータです。fileconvにデフォルトで用意されているメタコンバータは以下のとおりです。

メタコンバータ モード 説明
Line 行の生データを取得
CSV CSVの1行を取得(ArrayまたはRow)
Data ファイル ファイルの生データを取得
File ファイル Fileオブジェクトを取得
Stat ファイル Statオブジェクトを取得
JSON ファイル JSONオブジェクトを取得

コンバータ(メタコンバータも含む)は「モード」を持っており、主に2つに分けられます。

  • ラインモード
    • #convert_lineフックが呼ばれる
    • #convert_fileフックが呼ばれる
    • 例) Line, CSV
  • ファイルモード
    • #convert_lineフックが呼ばれない
    • #convert_fileフックが呼ばれる
    • 例) Data, File, JSON

Lineコンバータはファイルを読み込んで改行コードで区切って#convert_lineに渡してくれるコンバータです。改行コードは指定がなければ維持されます3@opts[:new_line]を指定することで明示的に改行コードを変換することもできます。

CSVコンバータはその名の通りCSV形式のファイルを扱います。Ruby標準のCSVモジュールを用いておりオプションもそのまま使えます。#convert_lineにはCSVモジュールでパースされたCSVの各行が渡ってくるので行単位で処理を行いたい場合はこのフックを利用してください。#convert_fileにはパースされたファイル全体が渡されるのでファイル単位で処理したい場合はこちらに処理を書いてください。

Dataコンバータはファイルの中身を全て読み込んで処理したい場合に利用します。データの中身は直接#convert_fileに渡されるのでここに変換処理を書くことができます。バイナリファイルの処理をしたい場合やファイルを自前でパースしたい場合に用います。

Fileコンバータはファイルは読み込まれず#convert_fileFileオブジェクトが渡ってくるので直接ファイルを読み込めます。大きなファイルを分割して読み込んで処理したい場合などに用います。

Statコンバータもファイルは読み込まず、代わりにStatオブジェクトが#convert_fileに渡されます。ファイルの更新日時やサイズ等のメタ情報だけが必要な場合に用います。

一番最初の例はラインモードの例だったので次はファイルモードであるJSONメタコンバータの利用例を紹介します。

require 'fileconv'

class ModifyJSONConvertor
  include Fileconv::JSON

  def input_ext
    "json"
  end

  def convert_file(data, acc)
    data.map do |e|
      e["country"] = "USA"
      e
    end
  end
end

ModifyJSONConvertor.new.conv

オリジナルファイル :

address.json
[{"name": "Mike", "Age": "21"}, {"name": "Jon", "Age": "33"}]

変換後のファイル:

output/address.json
[{"name":"Mike","Age":"21","country":"USA"},{"name":"Jon","Age":"33","country":"USA"}]

さらに多くのサンプルはここで見ることが可能です。

メタコンバータを作ってみる

メタコンバータは簡単に作ることができます。
以下はfileconvジェムのJSONメタコンバータの例です。

require "json"

module Fileconv
  module JSON
    include Fileconv::Base

    def pre_init_conv
      @opts[:read_json_opts] ||= {}
      @opts[:write_json_opts] ||= {}
    end

    def pre_convert_file(data, acc)
      ::JSON.parse(data, @opts[:read_json_opts])
    end

    def post_convert_file(obj, acc)
      return unless obj
      if @opts[:pretty_json]
        ::JSON.pretty_generate(obj, @opts[:write_json_opts])
      else
        ::JSON.generate(obj, @opts[:write_json_opts])
      end
    end
  end
end

メタコンバータを作成するには「Fileconv::Base」をincludeしてコンバータで呼び出されるフックの事前(pre_)もしくは事後(post_)に呼び出される以下のフックを必要に応じてオーバーライドするだけです。

  • pre_init_conv
  • post_init_conv
  • pre_input_files
  • post_input_files
  • pre_init_acc
  • post_init_acc
  • pre_convert_file
  • pre_convert_line
  • post_convert_line
  • post_convert_file
  • pre_conv_result
  • post_conv_result

リポジトリ

以下のリポジトリで開発しています。バグ報告、ご要望はIssuesへどうぞ。プルリクエストも歓迎です。

このライブラリの今後についてですが、最近は複雑なコンバータを書くのをやめて単純なコンバータをつなげて処理を書くことが多くなっています。従ってそのうちその知見を生かしてfileconvにコンバータの「合成」を実装するかもしれません。

fileconvの失敗談

fileconvを長年使い続ける中で色々と失敗を重ね、少しずつ改良していきました。以下は主な失敗した点です。

  1. 継承ベースにする
  2. メタコンバータを本当に「メタプログラミング」で実装する
  3. 例外を途中でキャッチする

1.は分かりやすいですが、最初は継承ベースで設計していたため、別の親クラスを持つクラスと一緒に使うことができなかったためMixinベースに変更しました。

2.に関してはメタコンバータをeval系やdefile_methodsendmethod_missingを使いまくって実装していた時期がありました・・・ あの頃は若かった・・・。 当時は「かっこいい」と本気で思っていましたが、普段使いのライブラリにとっては地獄以外の何物でもなかったです。どこでエラーが起こったのか分かり辛く、処理も追い辛いので実装して半年で全て現在のpre, postのフック形式に書換えました。メタコンバータの「メタ」はメタプログラミングで実装していた頃の残滓であり、自分への戒めのために残してあります。まぁ、一見すると「pre-」はダサいですがフックのライフサイクルが分かりやすくなったのと、コンバータを簡単にメタコンバータに昇格できるようになりました。つまり、最初はコンバータで書いたものを、ちょっと汎用的に使いたければフックをprepostに移すだけでメタコンバータに変換できるので非常に使い勝手がよくなりました。

3.に関してはファイル変換の途中で失敗したら例外をレスキューして続きのファイル変換を継続するような処理を入れたこともありました。しかし、fileconvは「コンバータ」や「メタコンバータ」を作る基盤なので薄いレイヤーに徹して例外処理は上位に委譲するのが正解だと使い続けて気づきました。

他にも色々失敗した点はありますが、上記の3つは似たような基盤ライブラリを作る上でも参考になるのではと思っています。

最後に

fileconvを作ってからファイルを開いたりファイルを書き戻したりする「余計な一手間」を考えずに済むようになり、書きたいと思った変換処理をすぐに書けるようになりました。ここ数年はライブラリのインターフェースも安定しており、シンプルに実装するのが一番だと実感しています。メタプログラミングコワイ・・・

この手の薄いユーティリティ系のライブラリにどれほどニーズがあるかは謎ですが、せっかく育てたので晒してみました。

ファイル変換の際に試して頂けると幸いです。


  1. カレントディレクトリに"output"ディレクトリが無かった場合には作成します。"output"ディレクトリに同名のファイルが存在した場合は上書きします。 

  2. fileに格納されるものは後述するメタコンバータの種類によって異なります。 

  3. 最初に読み込んだファイルの最初の改行コードが採用されます。 

2
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
4