業務でよくやるRailsのリファクタリング: クラスへの分割

こんにちは、開発チームでエンジニアをしている岡部です。

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)を追加する必要がありそうです。indexcreate同様に各サービスによって取得の処理が異なるため、同じように条件分岐を記述しました。

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

いよいよ、ここまでくるとコードの煩雑さが目立ってきます。
また、今後も新しい外部サービスが追加されたり、シェアした情報を削除したいという要件も出てくるかもしれません。ということで、いよいよリファクタリングをしていきましょう。

いざリファクタリング

クラスへの分割

まずは各サービスに対応するクラスをそれぞれ定義します。
今回はTwitterInstagramFacebook・LINEと4つクラスが必要になります。
抜粋して、TwitterInstagramに対応するクラスのコードを記載します。

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 indexdef 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)として紹介されているものです。

個人的にデザインパターンを全て覚える必要はないと考えていますが、知っておくと実装の幅が広がりますので、ぜひ似たような場面に遭遇している方は参考にしてみてください。

www.techscore.com