こんにちは、開発チームでエンジニアをしている岡部です。
N2iではバックエンドの開発言語にRuby、フレームワークにRailsを用いた開発を行なっています。
日々、Railsを触る中で何度か同じリファクタリングをしていることに気づいたので、今回、知見として紹介してみたいと思います。このリファクタリングには多少のコストはかかりますが、効果は絶大で変更に強く、テストがしやすいコードになります。
- 同じような条件分岐が何度も登場する
- 値によって実行する処理を変えたい
という場合には今回、紹介するクラスへの分割(ストラテジーパターン)がオススメです。
こちらの記事を読み終える頃には、どのようにコードをクラスへ分割し使用するのか、お分かり頂けると思います。日々のコーディングの参考になれば何よりです。
リファクタリングするコード
Rails製のアプリケーションに実装された外部サービスに情報をシェアするコントローラーを扱います。
外部サービスの対象となるのは、現時点では以下のようなサービスになります。
コードに落とし込むとこんな感じでしょうか。
それぞれの外部サービスで異なる処理をする必要があるため、パラメーターで受け取った値を元にcase
分による条件分岐をしています。処理の詳細は割愛していますが、同ファイルにそれぞれ外部サービスに対応する関数が定義されているとします。
class Api::V1::ExternalServices::ShareController < ApplicationController def create res = case permitted_params[:service_kind] when 'twitter' do_something_for_twitter(...) when 'instagram' do_something_for_instagram(...) when 'facebook' do_something_for_facebook(...) end if res.success? render status: :ok else redner status: :bad_request end end # eg: 「service_kindがtwitter」の場合に実行される関数 def do_something_for_twitter(args) : end def create_params params.permit(:service_kind, :message) end end
この時点でファットなコントローラーになっており、何とも言えないコードです...。
ただ、期待されている動作を十分に満たしており、バグの報告もないので十分な実装とも言えます。しかし、翌日、新しい機能を追加してほしいという依頼がきました。
要件の追加
1. 外部サービスを追加したい
偉い人: おぉ、前回の外部サービスのシェア機能すごく良かったよ
偉い人: それで新しく外部サービスにLINEを追加してほしいんだけどできるかな
実装者: LINEですね。了解しました。実装するのでお待ちください。
先ほどのコントローラーにLINEへシェアできる機能が追加されて、無事にリリースされました。
# コードの一部分を省略しています class Api::V1::ExternalServices::Share < ApplicationController def create res = case permitted_params[:service_kind] when 'twitter' do_something_for_twitter(...) : when 'line' do_something_for_line(...) end : end # eg: 「service_kindがline」の場合に実行される関数 def do_something_for_line(args) : end end
2. シェアした情報の一覧を取得したい
偉い人: そういえば、お客さんからシェアした情報の一覧が見たいという声があがっていてね。
偉い人: 確かに過去にシェアした情報を見返したいという気持ちは分かるから、一覧を出したいんだけどできるかな。
開発者: 確かに一覧が見れると便利ですね。やってみましょう。
ということで、今までcreate(POST: /api/v1/shares)
だけだったコントローラーにindex(GET: /api/v1/shares
)を追加する必要がありそうです。index
もcreate
同様に各サービスによって取得の処理が異なるため、同じように条件分岐を記述しました。
class Api::V1::ExternalServices::Share < ApplicationController def index shares = case permitted_params[:service_kind] when 'twitter' fetch_from_twitter(...) when 'Instagram' fetch_from_instagram(...) when 'facebook' fetch_from_facebook(...) when 'line' fetch_from_line(...) end render status: :ok, json: { shares: shares } end def create res = case permitted_params[:service_kind] when 'twitter' do_something_for_twitter(...) when 'Instagram' do_something_for_instagram(...) when 'facebook' do_something_for_facebook(...) when 'line' do_something_for_line(...) end if res.success? render status: :ok else redner status: :bad_request end end def permitted_params params.permit(:service_kind, :message) end end
いよいよ、ここまでくるとコードの煩雑さが目立ってきます。
また、今後も新しい外部サービスが追加されたり、シェアした情報を削除したいという要件も出てくるかもしれません。ということで、いよいよリファクタリングをしていきましょう。
いざリファクタリング
クラスへの分割
まずは各サービスに対応するクラスをそれぞれ定義します。
今回はTwitter・Instagram・Facebook・LINEと4つクラスが必要になります。
抜粋して、TwitterとInstagramに対応するクラスのコードを記載します。
Twitterに対応するクラス
class ExternalServices::Twitter def index(args) fetch_from_twitter(...) end def create(args) do_something_for_twitter(...) end end
Instagramに対応するクラス
class ExternalServices::Instagram def index(args) fetch_from_instagram(...) end def create(args) do_something_for_instagram(...) end end
この2つのクラスを見て、勘付いた方もいるでしょうが、定義されている関数がdef index
とdef cretae
と全く同じものになっています。
FacebookとLINEに対応するクラスでもおそらく同じ定義をすることになるでしょう。
こういったケースではJavaなどで利用可能なinterfaceが欲しくなります。
基底クラスの作成
Rubyではinterfaceが用意されていませんが、関数の一覧を定義した基底クラスを作成して継承させることでinterfaceに近い働きをさせることが可能です。
継承先で関数が実装されていなければ例外(NotImplementedError
)を発生させるようにします。
class ExternalServices::Base def index raise NotImplementedError, "Error:Not implemented #{self.class}##{__method__}" end def create raise NotImplementedError, "Error:Not implemented #{self.class}##{__method__}" end end
定義したExternalServices::Base
を継承するようにコードを変更します。
class ExternalServices::Twitter < ExternalServices::Base def index(args) fetch_from_twitter(...) end def create(args) do_something_for_twitter(...) end end
この時点で、先ほどコントローラーにまとめて書かれていた各サービスの処理が、それぞれの外部サービスに対応するクラスに分割されました。新しく外部サービスが増えた場合は対応するクラスを定義するだけで、対応が完了するため、元の状態よりも変更に強いコードになりました。
クラスの読み込み
クラスへ分割したのはいいですが、まだ定義した各サービスに対応するクラスを利用していないのでservice_kind
の値によって対応するクラスを返す関数(load)を基底クラスに定義します。
class ExternalServices::Base def self.load(service_kind) case service_kind when 'twitter' ExternalServices::Twitter when 'instagram' ExternalServices::Instagram when 'facebook' ExternalServices::Facebook when 'line' ExternalServices::Line end end def index raise NotImplementedError, "Error:Not implemented #{self.class}##{__method__}" end def create raise NotImplementedError, "Error:Not implemented #{self.class}##{__method__}" end end
あとはShareControllerから、load関数を利用して対応するクラスを取得後、インスタンスを作成し対応する関数を呼び出せば完了です。
class Api::V1::ExternalServices::ShareController < ApplicationController def create klass = ExternalServices::Base.load(permitted_params[:service_kind]).new if load_class.create.success? render status: :ok else redner status: :bad_request end end def index klass = ExternalServices::Base.load(permitted_params[:service_kind]).new render statu: :ok, json: { shares: klass.index } end def create_params params.permit(:service_kind, :message) end end
リファクタリングへの考察
このリファクタリングは冒頭でも書いたように多少のコストがかかりますが、効果は絶大です。
今後、起こりうる要件の変更にリファクタリングしたコードが耐えうるか考察してみます。
新たな外部サービスの追加
先ほども述べたように新しい外部サービスに対応するクラスを定義して、基底クラスのload関数に条件を追加するだけで完了します。コントローラーのコードは全く触る必要がありません。
シェアした情報の削除
基底クラスにdelete関数を定義して、各サービスに対応するクラスに削除時の処理を書いていけば実装が完了します。新しく条件分岐を追加する必要はなく、それぞれのクラスは全く結合していないので、簡単に変更をすることができます。
さらにクラスごとに処理を分割したことで、単体テストが書きやすくなるというおまけ付きです。
最後に
- 同じような条件分岐が何度も登場する
- 値によって実行する処理を変えたい
...このような場合、今回紹介したリファクタリングが効果を発揮します。
実は今回紹介したリファクタリングはデザインパターンでストラテジーパターン(Strategy Pattern)として紹介されているものです。
個人的にデザインパターンを全て覚える必要はないと考えていますが、知っておくと実装の幅が広がりますので、ぜひ似たような場面に遭遇している方は参考にしてみてください。