Studyplus Engineering Blog

スタディプラスの開発者が発信するブログ

seedのベストプラクティス

こんにちは、ForSchoolチームでエンジニアをしている島田です。最近の好きな漫画は「白山と三田さん」です。

今回はStudyplus For School(以下FS)のseed運用について説明します。

FSは以下で構成されたSPAです。

  • サーバーサイド:Ruby on Rails
  • フロントエンド:React.js

Railsの環境構築時などで初期データをローカルのDBへ投入する場合に、Railsのseed( $ rails db:seed )を利用している方が多いのではないでしょうか?

FSチームでもRailsのseedを利用していたのですが、以下のようなことが発生していました。

  • モデルが増えたことで seed.rb が肥大化し複雑になっていた
  • テーブルスキーマの定義を変更した場合に、 seed.rb の変更がされない
  • 上記が原因で保守コストが上がり、新規のデータが追加されなくなっていた
  • 初期データ設定方法がメンバーによって、seedを利用するか開発用DBからダンプしてインポートするかに分かれていた

このような状況でしたので以下のようなデメリットがありました。

  • 新しいメンバーが入った時の環境構築コストが高い
  • 新機能開発などでテーブルが追加となった場合の確認方法がメンバーそれぞれで変わる

これらを改善するために、以下のように要件を整理してseedの運用を見直すことにしました。

  • 実行コストを抑える(DBのダンプではなくRakeタスク)
  • seedファイル( seed.rb )をモデル毎に分割できる
    • 複数のseedファイルを一度に実行できる
    • 特定のseedファイルを指定して実行できる(依存するseedがカスケード的に実行される)
  • 実行環境(development,production, etc)ごとにseedを分けられる
  • スキーマ定義と乖離が出ないようにする

上記の改善案としては、seed-fuの導入も検討したのですが長期間メンテナンスされていないことから導入を見送りました。

結論としては、以下のような方針としました。

  • 自前で seed.rbをカスタマイズして、 $ rails db:seed で実行できるようにする
  • スキーマの変更とseedとの乖離が出ないように、なるべくfactory_botを利用する(テストで利用しているので、スキーマ変更がされればFactoryファイルも変更されるため乖離がでない)
  • 集計テーブルの集計処理などを実装と共通化したい

実装例

改善後のseedを簡略化したコードで説明します。

Gemfile

seedを改善するにあたって利用した主だったgemです。

  • factory_bot_rails:テスト(rspec)で利用しているgemです。スキーマとseedで乖離が出ないようにfactory_botを利用しています
  • gimei:生徒データの名前をランダムに生成するために利用しています
  • parallel:seedの実行を並列化してパフォーマンスを向上させるために利用しています
group :development, :test do
  ...
  gem "factory_bot_rails"
  gem "gimei", require: false
  gem "parallel", require: false
  ...
end

seedを実行するメインのクラスです。

Seed#seedは各seedファイルのデータ生成処理をブロックに受けます。

require "gimei"
require "parallel"

class Seeder
  class << self
    def seed(&block)
      instance = self.new
      ActiveRecord::Base.transaction do
        # メソッドの定義などのスコープを閉じたいのでinstance_evalする
        instance.instance_eval(&block)
      end
    end
  end

  private

  def parallel(&block)
    @_tasks ||= []
    @_tasks << block
  end

  def execute_parallel!
    # DBのpool数からメインプロセス分を引いた数を並列数とする
    parallels = ApplicationRecord.connection_config[:pool] - 1
    Parallel.each(@_tasks, in_threads: parallels) do |task|
      ApplicationRecord.connection_pool.with_connection do
        task.call
      end
    end
    @_tasks = []
  end
end

SEED_FILE_PATH = ENV.fetch("SEED_FILE_PATH") { "*.seeds.rb" }
Rails.application.eager_load!
Dir[Rails.root.join("db/seeds/#{Rails.env}/#{SEED_FILE_PATH}")].sort.each do |file|
  puts "Processing #{file.split("/").last}"
  require file
end

Seederを利用してデータを生成するサンプルコードです。

Seeder.seed do
  def create_school(name:)
    school = School.find_by(name: name) || FactoryBot.create(:school, name: name)
    school
  end

  def create_section(school_name:, name:, public_id:)
    school = School.find_by!(name: school_name)
    section = Section.find_by_public_id(public_id) || FactoryBot.create(:section, school: school, public_id: public_id)
    section.update!(school: school, name: name)
    section
  end

  parallel { create_school(name: "スタプラ学園") }
  parallel { create_school(name: "スタプラ個別指導") }
  parallel { create_school(name: "スタプラゼミ") }
  execute_parallel!

  parallel do
    create_section(school_name: "スタプラ学園", name: "代々木校", public_id: "916db11009")
  end

  parallel do
    create_section(school_name: "スタプラ学園", name: "渋谷校", public_id: "e494ea9fc7")
  end

  parallel do
    create_section(school_name: "スタプラ個別指導", name: "新宿校", public_id: "8332fe8b0f")
  end

  parallel do
    create_section(school_name: "スタプラゼミ", name: "代々木校", public_id: "097f6f3176")
  end

  execute_parallel!
end
require_relative "schools.seeds"

Seeder.seed do
  def create_student(section:, last_name:, first_name)
    FactoryBot.create(:student, section: section, last_name: last_name, first_name: first_name)
  end

  def create_random_student(section, num)
    num.times do |n|
      parallel do
        last = Gimei.last
        first = Gimei.first
        create_student(section: section, last_name: last.kanji, first_name: first.kanji)
      end
    end
  end

  Section.all.each do |section|
    create_random_student(section, 100)
  end
  execute_parallel!
end

seedに関連するRakeタスクです。

namespace :db do
  namespace :seed do
    desc "seedをまとめて実行する"
    task all: [:seed, :postprocess]

    desc "db:seedに付随した処理"
    task postprocess: :environment do
      require "parallel"

      parallels = ApplicationRecord.connection_config[:pool] - 1
      Rails.application.eager_load! # Circular dependencyのエラーが発生するのであらかじめ読み込んでおく
      Parallel.each(Student, in_threads: parallels) do |student|
        # TODO: seed を実行した後に処理したい共通処理があれば記載
      end
    end
  end
end

実行する場合には以下のコマンドとなります。

$ bin/rails db:seed # seedのみの実行
$ bin/rails db:seed SEED_FILE_PATH=students.seeds.rb # ファイルを指定して実行
$ bin/rails db:seed:all # seed実行後に共通処理も実行

上記の構成とする事でこれまでよりスッキリして、柔軟にデータを追加していくこともできるようになりました。また実装と乖離していく事も最小限にする事が出来たと思っています。

以上がFSチームで利用しているseedの紹介となります。もし、もっと良いプラクティスがあれば是非フィードバックをいただけると助かります。