クラウドワークス エンジニアブログ

日本最大級のクラウドソーシング「クラウドワークス」の開発の裏側をお届けするエンジニアブログ

レガシーなRubyコードのリファクタリングを支援するSutureの紹介

f:id:yosu1:20160914154128p:plain

はじめに

こんにちは、先日のRubyKaigi 2016に参加してきた@yosuです。 Ruby 3に向けた話題や幅広いテーマで楽しくとても刺激になりました。

そんな中僕が特に印象に残ったのが2日目のキーノート、Justin Searlsさんの「Fearlessly Refactoring Legacy Ruby」です。直訳すると「レガシーなRubyコードを恐れずリファクタリングする」でしょうか。

トークの内容も素晴らしかったのですが、そのトークをベースにTDD(トーク・ドリブン・デベロップメント)でSutureというGemも 作られていて、感動して触ってみたので今回はこちらのGemを紹介したいと思います。

スライドも公開されており(Surgical Refactors by Justin Searls)、こちらにこのGemを作った背景や目指すゴールについて書かれていますので是非見てみて下さい!

概要

Sutureはレガシーコードを安全にリファクタリングするためのツールです。 ここでいうレガシーコードは

Legacy Code - Code we don't understand well enough to change confidently.

と定義されていて、ざっくり訳すと「自信をもって変更できるほどには十分に理解していないコード」のことです。

こういったコードのリファクタリングは実際に行なう事が難しいこともさることながら、 高いコストに対してそのビジネス的価値を評価したり、それを伝えたり説得して実施するのが難しいという問題があります。

Sutureはこういったコードのリファクタリングコストやリスクを下げ、実施しやすくする枠組みを提供します。

Sutureを利用したリファクタリングの流れ

Sutureはリファクタリングが必要な箇所の起点にコードレイヤーを差し込むことで、 振る舞いの記録や、新旧コードの実行をコントロールできるようにします。

f:id:yosu1:20160914151557p:plain

Sutureはこのレイヤーを利用して開発(テスト)、ステージング、プロダクション(本番)のそれぞれでリファクタリングをサポートします。

開発時のサポート

開発時にSutureを利用したリファクタリングの流れは次のようになります。

  1. 新コードと旧コードを差し替える箇所を見つける
  2. 旧コード実行時の振る舞いをSutureで記録する
  3. 記録した振る舞いから生成されるテストを実施する
  4. テストを利用して新コードを実装する

ではこの流れを見ていきましょう。

新コードと旧コードを差し替える箇所を見つける

まず、次のようなコードの差し替えに適した箇所を見つけます(Sutureのドキュメントではseam=縫い目と呼んでいます)。

  • 分離して実行しやすい(独立して実行できる)
  • 引数を受け取って値を返す
  • 副作用がない(またはできるだけ少ない)

例としてSutureのドキュメントにあるコード例で見ていきます。

class MyWorker
  def do_work(id)
    MyMailer.send(LegacyWorker.new.call(id))
  end
end

class LegacyWorker
  def call(id)
    thing = Thing.find(id)
    # … Still 99 lines. Still terrible …
    thing.result
  end
end

この場合、置き換えたいのは do_work() 中の LegacyWorker#call の実装で、ここにSutureのレイヤーを差し込みます。

class MyWorker
  def do_work(id)
    MyMailer.send(Suture.create(:worker, {
      old: LegacyWorker.new,
      args: [id]
    }))
  end
end

Sutureを差し込んでいますが、変更前と同じようにLegacyWorker.new.call(id) が実行されます。 Suture.create() の第一引数 :worker は後でテストコードから参照するための名前で、区別が付けばなんでもいいです(後述)。 old には call() が呼べるもの(MethodやProc/lambdaなど)を渡し、 args にその call() メソッドの引数を渡します。

例えば先ほどの例でLegacyWorkerのメソッド名が call ではなく run だった場合は次のように書けます。

class LegacyWorker
  def run(id)
    thing = Thing.find(id)
    # … Still 99 lines. Still terrible …
    thing.result
  end
end

class MyWorker
  def do_work(id)
    MyMailer.send(Suture.create(:worker, {
      old: LegacyWorker.new.method(:run),
#     old: -> id { LegacyWorker.new.run(id) },
#     old: Proc.new { |id| LegacyWorker.new.run(id) },
      args: [id]
    }))
  end
end

これで振る舞いを記録する準備ができました。

旧コードの振る舞いを記録する

環境変数SUTURE_RECORD_CALLS=trueを設定して、先ほどのコードを実行すると実行時の引数と結果がsqliteのデータベースに記録されます。Railsであれば SUTURE_RECORD_CALLS=true bundle exec rails s で開発サーバーを立ち上げて触ってみるイメージです。 デフォルトのデータベースファイルは db/suture.sqlite3 に保存されます。

※記録の有効化/無効化やデータベースファイルのパスはconfigで指定することもできます

記録されたDBファイルを見るとobservationsテーブルにデータが記録されていることが分かります。

$ sqlite3 db/suture.sqlite3

sqlite> .schema
CREATE TABLE suture_schema_info (
            version integer unique
          );
CREATE TABLE observations (
            id integer primary key,
            name varchar(255) not null,
            args clob not null,
            result clob,
            error clob,
            unique(name, args)
          );

そして、nameargs にユニーク制約がついていることから、引数によって結果が決まる必要があることが分かります。 実際、複数回同じ引数で呼び出したとしても記録されるのは1回だけです。

もし、同じ引数で呼び出しても結果が変わるような場合(例えば内部に状態を保つカウンターでインクリメント結果を返すなど)、Sutureはエラーを出力して結果を記録しません。同じ引数で違う結果が返るようだと後述のテストで使えないためです。

このような場合は、何らかの方法で引数と結果の対応が一意になるように工夫してあげる必要があります。

結果に無視しても良い値の変化(例えばタイムスタンプ)が含まれる場合は比較クラスをカスタマイズすることで回避できます。 実際Sutureデフォルトの比較クラスではRailsActiveRecordを比較する場合に created_atupdated_at を無視します。

内部状態によって結果が変わるような場合は、その状態を引数として渡して実行できるようにするなどの工夫が必要になります。

例外の記録

予期している例外がある場合は、expected_error_types に指定してあげることでSutureに伝えることができます。 これにより例外も記録してテストに利用できたり、フォールバックするかどうか(後述)の判断に利用されます。

class SomeError < StandardError; end

class MyWorker
  def do_work(id)
    MyMailer.send(Suture.create(:worker, {
      old: LegacyWorker.new,
      args: [id],
      expected_error_types: [SomeError]
    }))
  end
end

現状の振る舞いをテストする

十分に振る舞いを記録したら、記録したデータを使って現状の振る舞いをテストします。

class MyWorkerCharacterizationTest < Minitest::Test
  def setup
    super
    # Load the test data needed to resemble the environment when recording
  end

  def test_that_it_still_works
    Suture.verify(:worker, {
      subject: LegacyWorker.new,
      fail_fast: true
    })
  end
end

Suture.verify は記録したデータを使って :subjectで指定した対象をテストします。 もし記録時と異なる結果が返ってくる場合、 Suture.verify は失敗します。

あらかじめ変更前のコードでテストすることはテスト環境が正しく作れているかや(例えばデータベースを使っている場合、記録時と同じスナップショットが復元できているかなど)、テストカバレッジを見るのに役立ちます。

カバレッジが足りていなければ、通らなかったコードパスを通るように再度レコーディングしましょう。

新しいコードパスを追加する

テストが全てパスしたら新しいコードを書き始める準備はOKです。 新しいコードを実行するようにするにはSutureの呼び出しを変更します。

class MyWorker
  def do_work(id)
    MyMailer.send(Suture.create(:worker, {
      old: LegacyWorker.new,
      new: NewWorker.new,
      args: [id]
    }))
  end
end

class NewWorker
  def call(id)
  end
end

こうすることでoldは呼ばれず NewWoker#call が呼ばれるようになります。ただし、完全に旧コードを削除できるようになるまではoldの方もそのまま残しておきます。 また、新コードのテストも追加します。

class MyWorkerCharacterizationTest < Minitest::Test
  def setup
    super
    # Load the test data needed to resemble the environment when recording
  end

  def test_that_it_still_works
    Suture.verify(:worker, {
      subject: LegacyWorker.new,
      fail_fast: true
    })
  end

  def test_new_thing_also_works
    Suture.verify(:worker, {
      subject: NewWorker.new,
      fail_fast: false
    })
  end
end

あとはこのテストが通るようになるまで新しいコードを実装していきます(ヒント: Refactor or Reimplement the legacy code

ステージング環境でのサポート

ここまでで自信を持って旧コードを削除できるようになっていればいいのですが、 まだ旧コードを消すには心もとないときに、ステージング環境で確認するための機能があります。

Sutureの呼び出しに call_both: true を指定することで新旧両方のコードを実行し、もし結果が異なる場合は例外を投げるようになります。 もちろんこれを安全に実施できるのは問題になるような副作用がないときです。

class MyWorker
  def do_work(id)
    MyMailer.send(Suture.create(:worker, {
      old: LegacyWorker.new,
      new: NewWorker.new,
      args: [id],
      call_both: true
    }))
  end
end

もしエラーが実際に起きればエラー内容を参考に修正することになります。

補足: raise_on_result_mismatch パラメータを false に指定することで例外を投げずにログだけ残すこともできます

プロダクション環境でのサポート

Sutureにはステージングで十分にテストしたとしてもまだ確実に自信が持てない場合のために、本番環境を考慮した機能もあります。

ログのカスタマイズ(ログレベル、出力先など)

アプリケーションのログとは別にSutureが検知したエラーログが残ると便利です。 以下の設定で新しいコードパスでエラーが発生した場合、指定したファイルにログが残るようになります。

Suture.config({
  log_level: "WARN", #<-- defaults to "INFO"
  log_stdout: false, #<-- defaults to true
  log_io: StringIO.new,      #<-- defaults to nil
  log_file: "log/suture.log" #<-- defaults to nil
})

カスタムエラーハンドラ

カスタムのエラーハンドラを指定することで、新コードでエラーが出た場合に外部サービスに通知するなどの処理を記述することができます。

class MyWorker
  def do_work(id)
    MyMailer.send(Suture.create(:worker, {
      old: LegacyWorker.new,
      new: NewWorker.new,
      args: [id],
      on_error: -> (name, args) { ErrorHandler.new.notify(name, args) }
    }))
  end
end

失敗時のリトライ(フォールバック)

fallback_on_errortrue を設定することで、新コードでエラーが出た場合、旧コードを実行しその結果を返すようにできます。

class MyWorker
  def do_work(id)
    MyMailer.send(Suture.create(:worker, {
      old: LegacyWorker.new,
      new: NewWorker.new,
      args: [id],
      fallback_on_error: true
    }))
  end
end

この機能を利用した場合、旧コードを取り除く前にはログを見て問題が起きていなかったかチェックする必要があります。うまくいっているように見えていたが実はフォールバックのおかげだった、なんていうことがないように。

補足: ステージング時の機能で紹介した call_both と組み合わせて、結果が異なる場合はフォールバックする、というようなことはできません。 あくまでステージングの時に利用する機能とプロダクションで利用する機能を分けているようです。

その他

Sutureのリポジトリには今回紹介した内容の他にさらに詳しいAPIのドキュメントや、サンプルのRailsプロジェクトもありますのでそちらも参考になると思います。

まとめ

いかがだったでしょうか。

まだ公開されたばかりで利用経験はありませんが、十分な機能と柔軟性で試してみる価値はあると思います。

また、実際にSutureを利用しないにしても、どういった手順でリファクタリングしていくべきかプロセスについて考えたり、 一歩を踏み出すきっかけを与えてくれると思います。

クラウドワークスでは近々ロジックが複雑な箇所で利用されているミドルウェアのアップデートを予定しているので、 そういったケースでも活用できないかと思っています。

この記事がきっかけでSutureを利用してみたり、レガシーコードをリファクタリングする一助になれば幸いです。

We're hiring!

クラウドソーシングのクラウドワークスではコードとともにサービスを成長させるエンジニアを募集しています!

www.wantedly.com

興味のある方はお寿司ランチを無料で食べながらお話してみませんか?

crowdworks.co.jp

© 2016 CrowdWorks, Inc., All rights reserved.