こんにちは、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の紹介となります。もし、もっと良いプラクティスがあれば是非フィードバックをいただけると助かります。