こんにちは。プロダクトエンジニアリング部に所属している安田です。
ほげほげしている間に春になりました。最近は街の装いも淡い桜色に染まり、きれいですね。 ブログを書きます。
今回はRubyのライブラリ scientist
について調べました。
ことの発端
内容が面白くて好きでよく読んでいる週刊Railsウォッチ*1に載っていて「なんだろう」と思い関心があったので調べました。
ごく個人的な話になりますが、去年ユニファのチームに入ってからの1年間、『振り返るとほぼ追加系かロジック修正系のコミットしかしてない自分\(^p^)/ウウッ』という気持ちを先月あたりから持ちはじめていました。
ユニファのコードやコミットログを読んでいると、何年も前にコミットされたコードがまだ残っていて、それ自体は「おお、、このコードはまだその書かれた当時の仕事をしている。凄い」という気持ちで凄いなぁ凄いなぁって感じるのですが、他のプロジェクトでもそうであるように「このコードが今も使われているのか / 今のバージョンならこの書き方ができる 」を辿ってメスを入れていくことが自分自身できてなかったんですね。これからの1年間は少しづつでも追加だけではなく、削除系の作業に粘り強く取り組む!と思っています。もちろんまだまだ既存コードを読んで理解するべきことが多くありますし、手を動かす時間だけでなくそれに向きあうための時間は有意義なものになると思います。
既存コードへの変更のリスクを緩和するには
そうなると心配なのはその変更が原因で何かが動かなくなった、という不具合につながってしまう可能性を捨てきれないことです。(私なら尚更。。。) ユニファでは少し前に自分達の抱えるプロダクトコードのテストカバレッジを一覧にまとめてみましょうという取り組みがあって自分なりに今の結果を受け止めていました。 100%を記録しているリポジトリもあり凄いなぁと思いつつ努力を続けてきている歴史を感じました。テストを書いたら不具合は出ないということだったらいいのですが、残念ながらそうではなく依然として不具合に繋がる何かを完全にキャッチすることはサービスが大きくなってくると難しいテーマです。
scientistは、本番で実行したらどうなるをテストできる??
A Ruby library for carefully refactoring critical paths.
※scientistのGithubのREADME.mdから引用しました。
テストコードを書く以外にこういう方法もあるんだなぁと知らなかったので驚きでした。
このGemでは、本番環境で古いコードと、置き換え後のコードを実行したときに戻り値に差分があるかどうかを基本的には ==
( *2 ) で評価し、一致してるか、一致してないか、はたまた置き換え後にエラーが出たのか結果をデータベースに入れておけばいつどの処理で戻り値が一致していなかったのかデータをとることができるので、本番でも今までと変わらず動いているを、データから辿ることが可能というもののようです。(今までの処理が既に間違っていた場合をケアしようとすると、変更前のロジックと一致していることが「正しい動き」とは必ずしも一致しないことはあります)
検証したい時の書き方はREADME.mdから引用させていただくと以下の様な感じになります。
require "scientist" class MyWidget include Scientist def allows?(user) science "widget-permissions" do |experiment| experiment.use { model.check_user(user).valid? } # old way experiment.try { user.can?(:read, model) } # new way end # returns the control value end end
現行の処理と、変えようとしているテストしたい処理をそれぞれ use
と try
にブロックで渡します。
主な流れで呼ばれるメソッドそれぞれ実際にどういうことをしてるのか実装を読んでみます。
use
https://github.com/github/scientist/blob/main/lib/scientist/experiment.rb#L289-L291
control
と、ラップしている実際に評価される処理を &block
で受け取り2つの引数にセットして、try
を実行。 ( L290 )
もし既に control
という名前で登録されている処理がscience単位であったらエラーをraise
なければ behaviors
にcontrolをkeyに、実行する処理(block)をセットする
一つのscience単位であれば use
を一つにするというのは、意識すれば十分行けそう。
behaviors
実行していく処理の名前を詰めていくためのインスタンス変数を返却する。
try
https://github.com/github/scientist/blob/main/lib/scientist/experiment.rb#L278-L286
引数2つ(name, &block)をとり、その名前でbehaviorsに評価内容をセットする もし重複していたらエラーをraise 引数にnameを指定できるということは、複数のtryをscience単位で書くことができるということ。 と思ったらREADMEにも明記してありました。引用します。
require "scientist" class MyWidget include Scientist def allows?(user) science "widget-permissions" do |e| e.use { model.check_user(user).valid? } # old way e.try("api") { user.can?(:read, model) } # new service API e.try("raw-sql") { user.can_sql?(:read, model) } # raw query end end end
複数呼べますね。
run
https://github.com/github/scientist/blob/main/lib/scientist/experiment.rb#L212-L250
流れとしてはuse -> try -> run の順番で実行されるので、大枠としては最後に実行される部分です。 nameを引数にとって比較処理を実行した後、publishを呼び出し、resultを元にエラーをあげて、 最後に、 control、つまり、既存の式を評価して値を返して終わり。という流れです。( candidate がテストしたい式を表す ) このメソッドは複数回同一science内で呼ばれることを想定していて、1回目は評価( block.call )し、2回目以降は、評価しないというロジックになっています。
publish
https://github.com/github/scientist/blob/main/lib/scientist/default.rb
デフォルトでは何もしない。インターフェイスだけ用意してくれていて、実際の中身は使う側が書くことができる。 データを保存する処理を書くとしたらここに差し込むことになる。
evaluate_candidates
https://github.com/github/scientist/blob/c7c5e6925eb30d865d4597e49b9ee9d4065b0d71/lib/scientist/result.rb#L66-L77 https://github.com/github/scientist/blob/c7c5e6925eb30d865d4597e49b9ee9d4065b0d71/lib/scientist/experiment.rb#L191-L196
ようやっと、比較する処理にたどり着きました。 https://github.com/github/scientist/blob/c7c5e6925eb30d865d4597e49b9ee9d4065b0d71/lib/scientist/observation.rb#L66-L83
==
でチェックしています。
compare
を使えば何と何を比較するのかを明示的に書くことができるので手厚いです。
コントロールしやすい印象
run_if
や ignore
など、どのときに評価させるか、を柔軟にコントロールできるのでかなり良さそうだと思いました。
気になるのは
- 実際に何箇所か書いて動かしてみると、処理速度などは変化しないのだろうか
- テストする期間はどれくらいを設定すれば十分な検証と言えるのか
use
とtry
で書かれた処理にかかるそれぞれの実行時間の計測は可能なのか -> duration *3で取れそう。- 結局比較する処理で値を取得するためテストしたい式も
block.call
*4してるので、例えばActiveRecord
のcreate
,update
,delete
の処理をtry
の中のメソッドの中に書いたら戻り値としてはアプリケーション上では反映されないけど、処理自体は走ってしまうのではないか? - dbに入れる場合、運用の仕方を決めた上で入れていかないとカオスになりそう
- context の使い所はチームで話し合って方針決めないとそれぞれがそれぞれの粒度で書いてしまいカオスになりそう
- テスト終わったらきちんと消すことを忘れずに!
くらいで、 今回コードを読んでみて、使える処理をきちんと識別した上であれば一つの有効な対応案だと思いました。
他のメンバーでも、このブログを読んでる方でも、「もう既に使ったことあるよ。こうだった。」や、「もっといい方法あるよ。」「こういうアプローチはどう?」 など、既存コードをもっとよくするための修正を安全に行い促進できる何かがあればコメントやslackなどで教えていただけると嬉しいです!
ありがとうございました!次回のユニファ開発者ブログもお楽しみに。
最後に
ユニファでは「家族の幸せを生み出す あたらしい社会インフラを 世界中で創り出す」をpurposeに一緒に走って行ける方を募集しています!
*1:週刊Railsウォッチ: Ruby 3.2.0 Preview 1リリース、Rails向けDocker環境ジェネレータ、scientist gemほか(20220404前編) https://techracho.bpsinc.jp/hachi8833/2022_04_04/116805
*2:Ruby3.1リファレンスマニュアル instance method Object#== https://docs.ruby-lang.org/ja/latest/method/Object/i/=3d=3d.html
*3:https://github.com/github/scientist/blob/c7c5e6925eb30d865d4597e49b9ee9d4065b0d71/lib/scientist/observation.rb#L34-L36
*4:https://github.com/github/scientist/blob/c7c5e6925eb30d865d4597e49b9ee9d4065b0d71/lib/scientist/observation.rb#L29