ユニファ開発者ブログ

ユニファ株式会社プロダクトデベロップメント本部メンバーによるブログです。

本番でコードの動きをチェックできるらしいRubyライブラリ"scientist"って何

こんにちは。プロダクトエンジニアリング部に所属している安田です。

ほげほげしている間に春になりました。最近は街の装いも淡い桜色に染まり、きれいですね。 ブログを書きます。

今回はRubyのライブラリ scientist について調べました。

github.com

ことの発端

内容が面白くて好きでよく読んでいる週刊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

現行の処理と、変えようとしているテストしたい処理をそれぞれ usetry にブロックで渡します。 主な流れで呼ばれるメソッドそれぞれ実際にどういうことをしてるのか実装を読んでみます。

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

https://github.com/github/scientist/blob/c7c5e6925eb30d865d4597e49b9ee9d4065b0d71/lib/scientist/experiment.rb#L92-L94

実行していく処理の名前を詰めていくためのインスタンス変数を返却する。

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_ifignore など、どのときに評価させるか、を柔軟にコントロールできるのでかなり良さそうだと思いました。

気になるのは

  • 実際に何箇所か書いて動かしてみると、処理速度などは変化しないのだろうか
  • テストする期間はどれくらいを設定すれば十分な検証と言えるのか
  • usetry で書かれた処理にかかるそれぞれの実行時間の計測は可能なのか -> duration *3で取れそう。 
  • 結局比較する処理で値を取得するためテストしたい式も block.call *4してるので、例えば ActiveRecordcreate , update , delete の処理を try の中のメソッドの中に書いたら戻り値としてはアプリケーション上では反映されないけど、処理自体は走ってしまうのではないか?
  • dbに入れる場合、運用の仕方を決めた上で入れていかないとカオスになりそう
  • context の使い所はチームで話し合って方針決めないとそれぞれがそれぞれの粒度で書いてしまいカオスになりそう
  • テスト終わったらきちんと消すことを忘れずに!

くらいで、 今回コードを読んでみて、使える処理をきちんと識別した上であれば一つの有効な対応案だと思いました。

他のメンバーでも、このブログを読んでる方でも、「もう既に使ったことあるよ。こうだった。」や、「もっといい方法あるよ。」「こういうアプローチはどう?」 など、既存コードをもっとよくするための修正を安全に行い促進できる何かがあればコメントやslackなどで教えていただけると嬉しいです!

ありがとうございました!次回のユニファ開発者ブログもお楽しみに。

最後に

ユニファでは「家族の幸せを生み出す あたらしい社会インフラを 世界中で創り出す」をpurposeに一緒に走って行ける方を募集しています!

unifa-e.com