こんにちは、開発部の高橋です。
最近弊社のRailsプロジェクトでactive_hashというgemが使われ始めました。
個人的にも結構重宝しているgemでとても便利なのですが、一方で特性を理解せずに使うとハマりやすいgemでもあると思っています。
今回は、ActiveHashのクラシルでの事例と自分の過去の知見に基づくハマりポイントなどを書いていきます。
目次
ActiveHashとは
データをRDBではなくHashやYAMLで定義し、それをActiveRecordライクに利用できるGem。
コード上にデータを持つためmigrationやseedなどの考慮が不要で、テーブルを持つほどでもない・ほとんど変更のないようなちょっとした静的データを保持する際にとても便利です。
詳しい使い方はREADMEを見てほしいですが、基本的には以下のようにActiveHashに用意されているクラスを継承し、データを設定して使います。
class Foo < ActiveHash::Base self.data = [ { id: 1, name: 'a' }, { id: 2, name: 'b' }, { id: 3, name: 'c' }, ] end
上記のようにdata
属性にデータを追加していく方法以外にも、json,yamlなどのファイルにデータを設定してを読み込ませることもできます。
クラシルでの事例
導入経緯
以前もブログにて紹介された「サーバーサイド&SRE改善MTG」での議題として自分が発案し、導入に至りました。
ちなみに、この改善MTGは1月で1周年を迎えたようです 🎉
もともとクラシルのプロジェクトにはactive_hashが使われるようなユースケースが存在しており、すでにレポジトリ内にYAMLなどで定義された静的データが散在してました。
それらがバラバラに管理され、使われ方も統一されていなかったところを一元化したいというところが主な導入意図です。
また、今後何かしらのマスターデータを管理していく際にも便利なので、今のうちに入れておきたいということで入れました。
使われ方
データはyamlで管理し、app/models
配下にActiveRecord継承なモデルと一緒に置くというおそらくはスタンダードな使い方で利用してます。
ActiveYamlRecord
という親クラスをつくり、各クラスで継承していきます。
# app/models/active_yaml_record.rb class ActiveYamlRecord < ActiveYaml::Base set_root_path Rails.root.join('config', 'masters') end # app/models/foo.rb class Foo < ActiveYamlRecord end
- id: 1 name: 'aaa' - id: 2 name: 'bbb' # ...
2020年2月時点ではまだ3モデル程ですが、これから要所要所でガンガン使っていきたいと思っています。
ハマりポイント
ActiveHash導入に関して、自分の過去の経験からハマりやすい(ハマった)ポイントをいくつかご紹介しようと思います。
インスタンス変数がクラスインスタンス変数相当
ActiveRecordでは取得されるごとに生成されるオブジェクトは異なりますが、ActiveHashでは常に同じオブジェクトが返ります。
これはデータが読み込まれる際に各レコードのインスタンスが作成され、クラスインスタンス変数に保持され使い回されるからです。
以下はActiveRecordとの挙動の差です。
class Foo < ActiveHash::Base self.data = [ { id: 1, name: 'a' }, { id: 2, name: 'b' } ] end require "active_record" ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:") ActiveRecord::Schema.define do create_table :bars, force: true do |t| end end class Bar < ActiveRecord::Base end Bar.insert_all([{id: 1}, {id: 2}]) b1 = Bar.first b2 = Bar.first p b1 #=> #<Bar id: 1> p b2 #=> #<Bar id: 1> p b1.equal? b2 #=> false f1 = Foo.first f2 = Foo.first p f1 #=> #<Foo:0x00007ff31791e738 @attributes={:id=>1, :name=>"a"}> p f2 #=> #<Foo:0x00007ff31791e738 @attributes={:id=>1, :name=>"a"}> p f1.equal? f2 #=> true
このような挙動になるため、ActiveRecordと同じ気分でインスタンスの状態を変更するとハマるので注意が必要です。
class Foo < ActiveHash::Base self.data = [ {id: 1, name: 'a'}, {id: 2, name: 'b'}, ] attr_accessor :foo end f1 = Foo.first f1.foo = 'bar' p f1.foo #=> "bar" f2 = Foo.first p f2.foo #=> "bar" # nilではなくf1で代入した値が返る
データがロードされるタイミング(特にActiveYaml, ActiveJsonの場合)
これはActiveYamlやActiveJsonなど、データをファイルで持ってる場合に特に気をつけたいことです。
これらのクラスのデータロードのデフォルトの挙動は、取得メソッドが実行されたタイミングになります。
具体的には以下のメソッドが実行された際にファイルのパースが実行され各クラスにデータが格納されます。
- find
- find_by_id
- all
- where
- method_missing
class Foo < ActiveYaml::Base set_root_path File.expand_path(__dir__, './') end # この時点ではまだYAMLは未ロード Foo.find(params[:id]) # ここで初めてYAMLが読み込まれる
小さいデータであれば問題はないようにも思えますが、大きなデータの場合は初回リクエストだけロードに時間がかかるようになってしまいます。
また、スレッドセーフな作りになってるわけではなさそうなので、複数スレッドで実行される際にも注意が必要です。
以下のように、意図しない挙動になってしまう可能性があります。
class Foo < ActiveYaml::Base set_root_path File.expand_path(__dir__, './') end t1 = Thread.new do p Foo.first p 't1 done' end t2 = Thread.new do p Foo.first p 't2 done' end t1.join t2.join
$ ruby sample.rb nil "t2 done" #<Foo:0x00007fe224c622e0 @attributes={:id=>1, :name=>"name1"}> "t1 done"
これはおそらく以下のような状況になってるものと思われます。
t1
ActiveFile::Base.first
が呼び出される- データ読み込みフラグ(data_loaded)のチェック
.reload
が呼び出されるdata_loaded
フラグがtrueになる- YAMLのロード処理が走る
t2
ActiveFile::Base.first
が呼び出される- データ読み込みフラグ(data_loaded)のチェック
- データ読み込み済み(data_loaded=true)と判定される
ActiveHash::Base.first
が呼び出されるt1
のロードが終わっておらずデータが空なので[].first
が処理として実行される- nilが返る
t1
- YAMLのロード完了
ActiveHash::Base.first
が呼び出される- レコードが返る
アプリケーションサーバーなどでマルチスレッドな環境を利用している場合は、意図しない挙動が引き起こされやすい状況と言えそうです。
対応策
この対応策として思いつくものとしては、クラスロード時にデータを読み込んでしまうことです。
class Foo < ActiveYaml::Base self.reload end
上記のようにクラス定義で.reload
を呼び出せばクラスが読み込まれる起動時などにデータを読み込ませることができるため、メソッドの初回呼び出し時でもロードが行われません。
あるいは、config/initializers
配下で呼び出す方法もあるかと思います。
# https://github.com/zilkey/active_hash#defining-data の応用 # config/initializers/data.rb Rails.application.config.to_prepare do Country.reload end
ただしどちらも起動が遅くなるというデメリットがあるため、各々のアプリケーションに適用できるかどうかは要確認です。
いずれにせよ、利用前にある程度意識しておく必要はあるかと思います。
最後に
delyではRailsエンジニアを絶賛大募集中です。
- 大量のトラフィックをさばきたい
- 食の課題を解決したい
- ユーザーファーストな開発がしたい
といったことに少しでも興味があるかたは、是非お声がけください!