LoginSignup
4
1

More than 3 years have passed since last update.

Rails でマスターデータのバリデーションを速くしたい!!

Posted at

この記事は Akatsuki Advent Calendar 2020 の11日目の記事です。
前回は Kazuma Sakamoto さんの [風来のシ○ン風] いい感じにランダムで、いい感じに恣意的なランダムダンジョンを生成する [Unity] でした!

概要

マスターデータの整合性を保つために Rails のバリデーションを使っていましたが、そのままだとすっごく遅いので頑張って速くしたよ、というお話です。
そのためにいろいろ工夫した点をご紹介します!

注:いくつかコード片を載せてますが、イメージです。たぶん動きません。説明も雑です。

マスターデータってなにさ

聞いたことあるような無いような、という言葉ですよね。詳しい定義にはここでは触れませんが、Google検索して一番最初に出てきたページ(マスターデータとは - IT用語辞典)を引用すると、

マスターデータとは、企業内データベースなどで、業務を遂行する際の基礎情報となるデータのこと。また、それらを集約したファイルやデータベースのテーブルなど。単に「マスタ」と省略するのが一般的である。

というようなものであるらしいです。

まあそういう一般的な定義がある一方で、僕はBtoCなwebサービス(具体的にはソシャゲ)運営のエンジニアです。そんな僕の立場から雑に言ってしまうと、マスターデータとはユーザーの行動によって記録されるユーザーデータとは異なり、サービスの運営側が用意するデータであるというイメージです。上の引用とは微妙に異なる気もしますが、この記事ではそんな感じでいきます!

これまでのマスターデータ作成フロー

僕たちのサービスの場合、マスターデータの内容を考えるのはプランナーさんのお仕事です。プランナーのみなさんがExcelとかGoogleスプレッドシートなどの表計算ソフトで頑張って設計したデータをゴニョゴニョして、CSVファイルの形でGit管理しています。

  1. プランナー軍団が表計算ソフトでデータ入力
  2. CSVに変換
  3. CSVの内容をテスト環境のDBに読み込む
  4. RailsでDBに対して全件バリデーションを走らせて整合性チェック
  5. 大丈夫ならマスターデータのリポジトリにCSVをpush

というようなフローでした。このうち 2~5 の手順はCIで自動化されているのですが、そのCIにめちゃくちゃ時間がかかるんです。20~30分くらい…。プランナーさんはその時間を待たないと入力したマスタが正しいかどうかが分からず、ヤキモキしていまいます。マスタ編集作業もその間進めることができません。

マスタ編集作業はプランナーのみなさんにとっては重要で、かなりの業務時間をその作業に充てます。マスタデータのルールは複雑でわかりにくいためただでさえ間違えやすいのですが、それなのにルールに違反してないか確するため頻繁に回したいCIが、一度回すと30分も待ちぼうけをくらうという代物なのです。

なかなかにつらい話ですね…。

CIに時間かかる原因

ずばりDBアクセスです。

3. CSVの内容をテスト環境のDBに読み込む

まず↑に時間がかかり、

4. RailsでDBに対して全件バリデーションを走らせて整合性チェック

↑でもDBに対してクエリがたくさん走ることになるわけですから、合計すると何万レコードにもなるマスターデータを全てチェックするのはもちろん時間がかかります。

しかしです。よく考えてみると、この3~4の手順の目的は整合性のチェックだけ。最初にseedした後、すべてのレコードはは特に書き換えられることもなく、クエリはデータを読み取るだけなんです。それにバリデーションが終わればDBは破棄されます。CIなので。

…別にわざわざDBに入れていちいちクエリで取ってこなくても、CSVから直にメモリへ展開でよくないですか? その方が速そうです。

DBなしでバリデーションかけるには

そうは言っても、これまでの運用でたくさん蓄積されてきたマスタのルールは、すべてRailsのバリデーションとして書かれています。今からそれに手を入れるのも面倒ですし、すでに運用されているルールを間違って変更しちゃうのが怖いので、なるべくそのまま使いたい。しかしDBは使いたくない。Railsにおいては、つまりActiveRecordを使いたくないということです。

まとめると、ActiveRecordは使いたくないけど既存のバリデーションはそのまま動かした〜い!!ということですね。わがままだ。

なにが必要かを考える

DBの代わりにCSVからそのままデータをモデルとして読み込みたいわけです。

Railsでモデルを作る際によく使うActiveRecord::Baseは使えません。しかし、幸運なことにバリデーションの基本機能はこちらではなく、ActiveModel::Validationsの方で実装されています。なんかそっちだけを継承して、後はCSVから直接読み込んだ値をattributesとして保持する(ActiveModel::Attributesが便利そう)ような、ActiveRecord::Baseに似てるけどちょっと違うクラスを作っちゃえばイケそうですね。どうもコードを読み進めると、そういう便利なモジュールが良い感じにまとまったActiveModel::Modelというものがあるらしい。それ使いましょう。

既存のバリデーションコードでは、find_byみたいなクエリインターフェイスがたくさん使われています。それに、belongs_toみたいな関連も。既存のRailsバリデーションをそのまま動かすなら、それら全部欲しいところです。しかし、これらはActiveRecordの中で実装されているらしいので今回は使えません。仕方がないので、ActiveRecordのコードを参考にはしつつも自前で実装しましょう。メソッドの名前と引数さえ合っていればバリデーションは騙されてくれます。

それに、今回は速度を出すことが目的ですから、DBで言うindexっぽい処理も欲しいです。いくらオンメモリといえども何万件もあるレコードをfind_byとかするたびに全件走査してたら絶対時間かかりますからね。でもこれは単にHashにすれば解決しそう。

さらに、上記の機能を備えるベースモデルクラスを継承するマスタのモデルクラスは、手書きしたくありません。だって本番コードですでにだいたい同じこと書いてあるんですからね。二度手間はいやです。それらもなんか良い感じに、そのまま使えるなり、適した形に自動変換するなりしたいところです。

…たくさんありますね。ひとつずつクリアしていきましょう。

がんばる

ActiveRecord::Baseに似てるけどちょっと違うクラスを書く

上に挙げたような機能をどう実装していくか、なんとなく雰囲気が伝わるコードを書いてみましたが、中略しまくり、それでも長いので折りたたみでいきます。

イメージサンプルのコード
module Dummy
  class Model
    include ActiveModel::Model      # validatorとか使う
    include ActiveModel::Attributes # attributesに使う
    extend FinderMethods            # find_byとかの実装。名前はActiveRecordのマネ
    extend Associations             # belongs_toとかの実装。名前はActiveRecordのマネ
    extend RecordHolder             # レコードを配列で持っておくやつ
  end

  module FinderMethods
    def find_by(attributes, scope = nil)
      result = where(attributes, scope)
      result.empty? ? nil : result.first
    end

    def where(attributes, scope = nil)
      return [] unless attributes
      return [find_from_primary_index(attributes)].compact if primary_index?(attributes)
      return select_from_indexes(attributes) if index?(attributes)  # indexが効いてたらHashから取ってくる
      select_from_all(attributes)                                   # primaryもindexもないなら全走査
    end

    # 中略
  end

  module Associations
    def belongs_to(name, scope = nil, options = {})
      method = build_belongs_to(name, scope, options)  # def user; User.find_by(id: 1); end みたいなメソッドを作る
      self.class_eval method                           # 作ったメソッドを自分にはやす
    end

    # 中略

    private

    def build_method(name, content)
      "def #{name}; #{content}; end"  # メソッドにする
    end

    def build_belongs_to(name, scope = nil, options = {})
      polymorphic = options[:polymorphic] || false
      if polymorphic  # polymorohicだと特殊
        content = <<-CONTENT
          model = Object.const_get(#{name}_type.to_s) rescue nil
          model&.find_by(id: #{name}_id)"
        CONTENT
        return build_method(name, content)
      end
      class_name = options[:class_name]
      primary_key = options[:primary_key] || 'id'
      foreign_key = options[:foreign_key] || name + '_id'

      scope_arg = scope ? ", '#{scope}'" : ''
      content = "#{class_name}.find_by({#{primary_key}: #{foreign_key}} #{scope_arg})"  # 対象クラスから単純にfind_byで取ってくるだけ
      build_method(name, content)
    end
  end

  module RecordsHolder
    def all
      records # 配列をまるっとそのまま返す
    end

    def find_from_primary_index(attributes) # primary_keyを参照してrecordsからお目当てをとってくるやつ
      record_key = primary_index[attributes.values]
      return nil unless record_key
      records[record_key]
    end

    # 中略

    def select_from_all(attributes) # 全件走査するやつ
      records.find do |record|
        attributes.all? do |name, value|
          record.send(name) == value
        end
      end
    end

    def primary_index?(attributes)
      keys = attributes.keys.map(&:to_s)
      primary_index.keys.include?(keys)
    end

    def index?(attributes)
      keys = attributes.keys.map(&:to_s)
      indexes.keys.include?(keys)
    end

    # 中略

    private

    def records
      @records ||= [] # レコードをただの配列で保持する
    end

    def primary_index
      @primary_index ||= {} # primary_indexをただのHashで保持 keyはattributesを文字列にしたやつ、valueはrecordsのインデックス整数
    end

    def indexes
      @indexes ||= {} # indexをただのHashで保持 keyはattributesを文字列にしたやつ、valueはrecordsのインデックス整数
    end

    def register(instance, index = nil)
      if index
        records[index] = instance
      else
        records.push(instance)
      end
      index ||= records.length - 1
      register_primary_key(instance, index) # インスタンスのprimary_indexを登録
      register_indexes(instance, index)     # インスタンスのindexを登録
      instance
    end

    # 中略
  end
end

だいたいこんな感じのを書いて、ちゃんと欲しいインターフェイスをなんちゃって実装します。これでDBを使わずにバリデーターが動く、Dummy::Modelという名前の胡散臭い感じのクラスができました!

Dummy::Modelを自動生成するような仕組みを作る

これで、Dummy::Modelを継承したマスタのモデルにバリデーションを実行させればいいわけです。でもそんなモデルをいちいち書くのめんどくさいので、既存のRailsアプリで実装されてるマスタモデルからYAMLを吐いて、そのYAMLから自動生成できるようにしました。

例えば既存のマスタモデルがこう↓だと、

# == Schema Information
#
# Table name: examples
#
#  id            :integer          not null, primary key
#  group_id      :integer          not null
#  type          :string(255)      not null
#  schedule_id   :integer          not null
#  gift_type     :string(255)
#  gift_id       :integer
#  level         :integer          not null
# 

class Example < ApplicationRecord
  belongs_to :schedule
  belongs_to :gift, polymorphic: true
end

YAMLはこんな感じ

---
table_name: examples
attributes:
  id:
    type: integer
    nullable: false
    limit: 4
  group_id:
    type: integer
    nullable: false
    limit: 4
  type:
    type: string
    nullable: false
    limit: 255
  schedule_id:
    type: integer
    nullable: false
    limit: 4
  gift_type:
    type: string
    limit: 255
  gift_id:
    type: integer
    limit: 4
  level:
    type: integer
    nullable: false
    limit: 4
primary_keys:
- id
class_name: Example
associations:
  belongs_to:
    schedule:
      class_name: Schedule
    gift:
      polymorphic: true

ActiveRecord::Base.connectionからテーブル名を頼りにいろいろ引っ張ってこれるので、このYAMLの出力も自動化できました。

で、このYAMLをもとにDummy::Modelを継承した新たなモデルを動的に生成します。
こんなふう↓に

module Dummy
  class Maker
    def make(schema_yaml)                             # 読み込んだYAMLファイルの内容を渡す
      model = create_model(schema_yaml['class_name']) # Dummy::Modelを継承するモデル作る
      apply_schema(model, schema_yaml)                # YAMLのモデル情報を適用する
      model
    end

    private

    def create_model(class_name)
      create_class(class_name, ::Dummy::Model)
    end

    def apply_schema(model, schema)
      build_attributes(model, schema['attributes'])     # attributesはやす
      build_associations(model, schema['associations']) # associationsはやす
    end

    def create_class(class_name, klass) # ::Deep::Nested::Name::Model みたいな名前のクラスを解釈して、klassを継承させる
      return Object.const_get(class_name) if Object.const_defined?(class_name)
      family = class_name.split('::').reject(&:blank?)
      child = family.pop
      parent = family.join('::')
      parent_class = eval(parent) rescue create_model(parent)
      parent_class ||= Object
      parent_class.const_set child, Class.new(klass)
    end

    # 中略
  end
end

こんな感じのコードを書いていけば、YAMLからいい感じのモデルを動的に生成できます。Rubyっぽい!(勝手なイメージです)

いざ!バリデーション!

あまりない書き方だと思いますが、僕たちのプロジェクトではマスタにバリデーションかけるための工夫として、バリデーションだけを特別なブロックに分けて書いて、あとでモデルに注入して使うという手法がとられていました。
こんなかんじ↓です

class ExampleValidator < Validator::Base
  target ::Example

  validations do
    validates :schedule, presence: true
    validates :type, inclusion: { in: %(soy_sauce salt sugar) }
    validates :gift, presence: true, if: -> { type.present? }
  end
end

このValidator::Baseなるクラスに書かれている情報をもとに、target ::Exampleで指定している::Exampleクラスにブロックを注入してからバリデーションを実行していたわけです。

Validator::Baseはこんなかんじ
module Validator
  class Base
    class << self
      def target(klass = nil)
        @target = klass if klass
        @target
      end

      def validations(&block)
        @validation_block = block
      end

      def inject!
        @target.class_eval(&@validation_block) if @validation_block
      end
    end
  end
end

これを実行するときは

ExampleValidator.inject!
ExampleValidator.target.all.each do |record|
  puts record.valid?(:check?) ? 'OK' : 'NG'
end

という感じで実行すればExampleモデルの全レコードのバリデーションが実行できるわけです。

Dummy::ModelはこのValidator::Baseの継承クラスにtargetとして渡すには十分な機能を持つよう作ったわけですから、もうほぼこのバリデーターがそのまま使えるのです!やった!

…こんな書き方してるところあまりみたことないのでちょっとずるい感じもしますが、僕たちのプロジェクトではそうだったんです。

気になる結果は?

ここまで書いたようなことを実戦投入しました。

やったのがちょっと前なので、証拠というか速度比較できる参考資料みたいなものが用意できなかったんですが、以前は20~30分くらいかかってたCIも、だいたい2~3分で実行できるようになりました! およそ10倍です。
(CIを速くするために、バリデーション以外にもいろいろ頑張ったんです。ありがとうAlpine Linux)

一番のネックだったバリデーションがこのとおり

スクリーンショット 2020-12-10 23.30.46.png

ほら!はやい!

スクリーンショット 2020-09-30 20.39.16.png

プランナーさんからもうれしい言葉が。
よかった〜!

さいごに

この記事、あまり一般的ではないケースの話だと思いますし、コードや方法論もたくさん端折っているので雰囲気しかわかりません。誰かの役に立つものか見当もつきませんが、一介のエンジニアがどうやってつらみ業務を改善したかというエピソードのひとつとして読み飛ばしていただけたらうれしいです。お付き合いいただいてありがとうございました〜!

Akatsuki Advent Calendar 2020 の次回は、yunon_physさんです!

参考

調べたら、似たようなことなさってる先達さまがいらっしゃいました。やっぱり意外と必要になることがあるアプローチなんだろうか…。

4
1
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
4
1