シンクロ・フード エンジニアブログ

飲食店ドットコムを運営する、株式会社シンクロ・フードの技術ブログです

手続き的なCSVパーサーをDSLで宣言的に書き換えて保守性を改善した話

こんにちは。開発部 HR事業管理開発チームの田中です。
主に求人飲食店ドットコムの社内・代理店向け管理機能を担当しています。
今回は、チームで開発・保守しているCSV一括処理機能を技術的に改善しましたので、そのときのことについてお話しします。

なぜCSV一括処理の改善をしたのか

「求人飲食店ドットコム」に掲載される求人情報やその他関連情報は、シンクロ・フードの営業メンバーや提携している代理店の方々によって日々大量に作成・更新されています。
そのため、管理システムの入力フォームから一件ずつデータを入力するよりも、CSVファイルに情報をまとめて入力し、一括でデータを操作する方が効率的に業務を進められます。

こうした背景から、要望が挙がるたびにCSV一括処理機能の開発を繰り返し、その数を増やしていきました。

開発の際には、既存の実装をコピーし、その機能独自の仕様に合わせて修正を加えるという手法がとられていました。
一見すると、「コピーして少し修正するだけ」の単純な作業に思えますが、実際には無駄な工数の発生や、意図しない不具合を誘発するリスクを抱えていました。

例えば、CSVにはヘッダーの検証やデータ型のチェックといった、多くの機能で共通する特有のバリデーションが存在します。
しかし、それらが共通化されていなかったため、開発のたびに同じバリデーションの要件を再検討する手間がかかり、さらに考慮漏れが原因で不具合が発生するリスクを常に抱えていました。

これまでの実装と課題

まずは、これまでの実装がどのようなものだったかを紹介します。
これまでの実装を参考に、サンプルを書き起こしました。
CSVのヘッダをチェックしてから、1行ごとに入力値をチェックして、ハッシュに変換する流れで処理を行っていました。

class OldCsv
  def parse(csv_data)
    errors = []
    hashed_csv_data = []

    # CSVのヘッダなどのバリデーション
    raise << '空のCSVファイルが入力されました。' if csv_data.empty?
    errors << 'IDの列が存在しません。' if csv_data.headers.exclude?('ID')
    errors << 'ステータスの列が存在しません。' if csv_data.headers.exclude?('ステータス')
    # ... このようなバリデーション処理が続く ...

    CSV.parse(csv_data, headers: true).each_with_index do |row, index|    
      # CSVに入力された値のバリデーション
      errors << "#{index + 2}行目: IDは必須です。" if row['ID'].blank?
      errors << "#{index + 2}行目: 存在しないIDです。" unless User.exists?(row['ID'])
      # ... このようなバリデーション処理が続く ...

      # データをハッシュに変換
      hashed_csv_data << { id: row['ID'], status: row['ステータス'], ...
    end

    raise errors.join("\n") if errors.present?
    hashed_csv_data
  end
end

このような実装スタイルには、主に3つの課題がありました。

  1. 処理が逐次的で、CSVの全体像を把握しづらい
    処理が逐次的なため、一つのCSV列に対するバリデーションがコードの複数箇所に散らばっており、CSV全体の仕様(どんな入力値を求めているのかなど)を把握するのが大変でした。
    例えば、サンプルの「ID」列に対するバリデーションは、序盤のヘッダー存在チェックと、ループ内の必須チェックやDB存在チェックといったように、離れた場所に記述されています。
    CSVの列が増え、仕様が複雑になればなるほど、この傾向は悪化し、読みづらさは増す一方でした。
  2. 共通化の余地がある
    先述の通り、上記のようなパーサークラスが機能の数だけ存在していました。
    どのクラスも、ヘッダのバリデーション、行毎のバリデーション、ハッシュに変換する処理で構成されており、バリデーションの内容も共通化できるものが多く含まれていました。
  3. 後続処理との接続があまり良くない
    私の所属するチームで開発した一括処理機能はRuby on Railsで開発しています。
    なので、登録・編集機能の実装ではActive Recordのモデルを扱うのが基本ですが、これまでのパーサーの出力はハッシュの配列でした。
    そのため、この後の処理で、ハッシュの配列を一つずつモデルに変換し直す必要がありました。
    一見すると、ハッシュをnewupdateのattributesとして渡すだけで済むので、あまり大変ではないように思えます。
    しかし、enumやマスタテーブルを使う属性を持つモデルを扱う場合、CSV上での値と、DB上での値が異なることがあるため、ハッシュをそのままモデルに渡す前に、値を置き換えるというもう一手間が必要になっていました。
    例えば、CSV上では「正社員」という分かりやすい文字列で入力されていても、DBではenumで管理しているregularという内部的な値に変換する必要がある、といったケースです。

改善策の選択

先述の課題を解決するために、以下の要件を挙げました。

  • CSVの仕様をコードではなく、外部ファイルに定義できるようにする
  • 共通化できる処理は、可能な限りベースとなるクラスに集約して、責務を分離する
  • CSVに入力されたデータは、指定したモデルの配列に変換して出力する

これらを実現するためには、DSL (Domain-Specific Language)を導入した実装が最も良い方法だと考えました。

その理由は3つほどあります。

  1. 仕様を宣言的に記述できる
    DSLの最大のメリットは、「何を(What)」したいかを宣言的に記述できる点です。
    これまでの「どのように(How)」処理するかを一行ずつ記述する手続き的なコードとは異なり、「この列は整数型で、このCSVヘッダーに対応する」といった形式で、仕様を宣言的に記述できます。
  2. Ruby (Rails) はDSLと相性が良い
    Rubyはその柔軟な文法から、自然で読みやすいDSLを構築しやすい言語です。
    Railsのroutes.rbやActive Recordのhas_manyなども、DSLの一例です。
    私たちは日頃からRailsを使って開発しており、このRubyの特性を活かさない手はありませんでした。
  3. DSLをやってみたかった
    正直なところ、これが一番の動機かもしれません。

DSLを導入して、どう変わったか

DSLを導入することで、パーサークラスをスッキリさせることができました。
以前の複雑なロジックは消え、CSVの仕様を宣言的に記述するだけの、非常にシンプルなクラスになり、可読性が向上しました。

# DSLを用いてCSVの仕様を定義するクラス
# 例)求人一括登録CSVの場合
class JobCsv < CsvBase
  # 特定の値のみを入力値として受け付けたい場合は、このように定義する
  # '入力値として受け付ける値' => 'モデル変換時に属性に設定される値'
  EMPLOYMENT_TYPE_OPTIONS = { '正社員' => 'regular', 'アルバイト' => 'part_time' }.freeze

  # 変換先モデル(やフォームオブジェクト)を指定
  model Form::JobCsv

  # CSVの各列の仕様を定義
  column :draft_job_id,      '求人ID',    type: :integer
  column :employment_system, '雇用形態',  type: EMPLOYMENT_TYPE_OPTIONS
  column :salary,            '給与',      type: :string
end

まずmodelメソッドで、CSVの入力情報をどのモデルに変換するか指定します。
そして、columnメソッドで「モデルの属性」「CSVのヘッダ名」「データ型(もしくは、別途選択肢を定義)」を指定するだけで、必要なバリデーションやデータ変換が行われる仕組みです。

DSLを支える裏側の仕組み

先述のDSLのベースクラスです。
ここに、CSVの各種バリデーションとモデルへの変換機能を実装しました。

# CSVパーサーの共通処理を担うベースクラス
class CsvBase
  class << self
    attr_accessor :model_class
    
    # DSL: 扱うモデルを指定する
    def model(model_class)
      @model_class = model_class
    end

    # DSL: CSVの列と属性のマッピングを定義する
    def column(attribute_name, csv_header, type:)
      @mappings ||= {}
      @mappings[csv_header] = { attribute: attribute_name, type: type }
    end
    
    def mappings
      @mappings || {}
    end
  end

  # 読み込んだCSV、指定したモデル、定義した列・属性のマッピングをもとに、インスタンスを生成する
  def initialize(csv_data)
    @csv_data = csv_data
    @mappings = self.class.mappings
    @model_class = self.class.model_class
  end

  # ヘッダの過不足チェックや、入力値の型検証
  def validate
    errors = []
    # ... ヘッダーの過不足チェックなどの処理 ...

    @csv_data.each_with_index do |row, index|
      @mappings.each do |header, mapping|
        value = row[header]
        type = mapping[:type]
        case type
        when :integer
          errors << "#{index + 2}行目: 「#{header}」は整数で入力してください" unless value.blank? || value.match?(/\A-?\d+\z/)
        when Hash # 別途定義した選択肢を型として指定した場合
          errors << "#{index + 2}行目: 「#{header}」の値が不正です" unless value.blank? || type.key?(value)
          # ... (略) ...
        end
      end
    end
  end

  # モデルの配列への変換
  def to_models
    @csv_data.map do |row|
      attributes = @mappings.each_with_object({}) do |(header, mapping), attributes|
        value = row[header]
        type = mapping[:type]
        attribute = mapping[:attribute]

        # 別途定義した選択肢を型として指定した場合は、指定した値に置き換える
        converted_value = type.is_a?(Hash) ? type[value] : value
        attributes[attribute] = converted_value
      end
      @model_class.new(attributes)
  end
end
  • model, column
    これらがDSLの本体です。個別の定義クラスで呼び出され、modelは変換先のモデルクラスを、columnはCSVの各列の仕様を、それぞれインスタンス変数に記憶します。
  • validate
    columnで定義された情報を元に、バリデーションを実行します。
    ヘッダーの過不足チェックや、type:で指定されたデータ型(integerなど)に基づく型チェックといった、汎用的な検証をすべてこのメソッドが引き受けます。
  • to_models
    CSVデータを指定したモデルの配列に変換するメソッドです。
    CSVの各行を、columnmodelで定義された情報に従って、modelクラスのインスタンスに変換し、その配列を返します。
    このとき、typeにハッシュ(選択肢)が指定されていれば、CSV上の表示名(例:「正社員」)を、DBで管理している内部的な値(例:「regular」)に置き換えるといったことも同時に行います。

一括処理全体への改善効果

この改善によって、CSV一括処理全体の流れも非常にシンプルになりました。

# パーサーによる形式的なチェック
csv = JobCsv.new(csv_data)
errors = csv.validate
return if errors.present?

# モデルへの変換と、ビジネスロジックのチェック
products = csv.to_models # ここでモデルの配列が一括で手に入る
products.each(&:valid?) # ActiveRecordのバリデーションを実行
return if products.any?(&:invalid?)

# 保存処理
Product.import(products)

複雑なパースとバリデーションのロジックはパーサークラスに委譲され、処理を実行するクラスは「パーサーを呼び出し、モデルに変換し、保存する」という責務だけを行う、非常にシンプルで見通しの良いクラスになりました。

まとめ

今回は、手続き的な実装で保守性に課題があったCSVパーサーを、DSLを用いて宣言的に書き換えることで保守性を改善した事例をご紹介しました。
この記事が、同じような課題に直面している方々の参考になれば幸いです。