この記事は Akatsuki Advent Calendar 2020 の11日目の記事です。
前回は Kazuma Sakamoto さんの [風来のシ○ン風] いい感じにランダムで、いい感じに恣意的なランダムダンジョンを生成する [Unity] でした!
概要
マスターデータの整合性を保つために Rails のバリデーションを使っていましたが、そのままだとすっごく遅いので頑張って速くしたよ、というお話です。
そのためにいろいろ工夫した点をご紹介します!
注:いくつかコード片を載せてますが、イメージです。たぶん動きません。説明も雑です。
マスターデータってなにさ
聞いたことあるような無いような、という言葉ですよね。詳しい定義にはここでは触れませんが、Google検索して一番最初に出てきたページ(マスターデータとは - IT用語辞典)を引用すると、
マスターデータとは、企業内データベースなどで、業務を遂行する際の基礎情報となるデータのこと。また、それらを集約したファイルやデータベースのテーブルなど。単に「マスタ」と省略するのが一般的である。
というようなものであるらしいです。
まあそういう一般的な定義がある一方で、僕はBtoCなwebサービス(具体的にはソシャゲ)運営のエンジニアです。そんな僕の立場から雑に言ってしまうと、マスターデータとはユーザーの行動によって記録されるユーザーデータとは異なり、サービスの運営側が用意するデータであるというイメージです。上の引用とは微妙に異なる気もしますが、この記事ではそんな感じでいきます!
これまでのマスターデータ作成フロー
僕たちのサービスの場合、マスターデータの内容を考えるのはプランナーさんのお仕事です。プランナーのみなさんがExcelとかGoogleスプレッドシートなどの表計算ソフトで頑張って設計したデータをゴニョゴニョして、CSVファイルの形でGit管理しています。
- プランナー軍団が表計算ソフトでデータ入力
- CSVに変換
- CSVの内容をテスト環境のDBに読み込む
- RailsでDBに対して全件バリデーションを走らせて整合性チェック
- 大丈夫ならマスターデータのリポジトリに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)
一番のネックだったバリデーションがこのとおり
ほら!はやい!
プランナーさんからもうれしい言葉が。
よかった〜!
さいごに
この記事、あまり一般的ではないケースの話だと思いますし、コードや方法論もたくさん端折っているので雰囲気しかわかりません。誰かの役に立つものか見当もつきませんが、一介のエンジニアがどうやってつらみ業務を改善したかというエピソードのひとつとして読み飛ばしていただけたらうれしいです。お付き合いいただいてありがとうございました〜!
Akatsuki Advent Calendar 2020 の次回は、yunon_physさんです!
参考
調べたら、似たようなことなさってる先達さまがいらっしゃいました。やっぱり意外と必要になることがあるアプローチなんだろうか…。