Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Railsで学ぶSOLID(2)オープン/クローズの原則(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

訳注: 「オープン/クローズド」とされることもありますが、本シリーズでは「オープン/クローズ」で統一しました。

Railsで学ぶSOLID(2)オープン/クローズの原則(翻訳)

「SOLIDの原則シリーズ」へようこそ。このシリーズ記事では、SOLIDの原則をひとつずつ詳しく説明し、分析します。シリーズの最後にはいくつかのヒントや考察を含む総括記事をお送りしますのでどうぞご期待ください。

それでは始めましょう。「SOLIDの原則」とはそもそも何なのでしょうか?SOLIDとは、オブジェクト指向プログラミング設計における一般的な原則であり、ソフトウェアをより理解しやすくし、拡張性やメンテナンス性やテストのしやすさを高めることを目的としています。

  1. 単一責任の原則SRP: Single responsibility principle)
  2. オープン/クローズの原則OCP: Open/closed principle)(本記事)
  3. リスコフの置換原則LSP: Liskov Substitution Principle)
  4. インターフェイス分離の原則ISP: Interface Segregation Principle)
  5. 依存関係逆転の原則DIP: Dependency Inversion Principle)

今回お送りするのは、第2の「オープン/クローズの原則」です。

オープン/クローズの原則(OCP)

クラスは、変更に対しては門戸を閉ざし、拡張に対しては門戸を開くべきである。

言い換えれば、クラスの機能の拡張は、「クラスのコアの振る舞いを変更せずに」行うべきだということです。この原則を満たすために以下の手法が使えます。

  • 継承メカニズムを用いる
  • コンポジションを用いる
  • 依存性の注入パターンを適用する
  • Decoratorパターンを適用する
  • Storategyパターンを適用する

もちろん、解決法はこれだけではありません。

メリットとしては、元のクラスのコードを安心して使えるようになることです。元クラスのコードが改変されていないので振る舞いは変わらないはずだからです。

注意点としては、常識に従うべきであるということです。実際には、クラスを少々変更しても普通なら害がない状況なのに、(訳注: この法則を守ろうとするあまり)派生クラスを山ほど作ったりしないよう注意しなければなりません。そして、コードが望ましくない挙動を示したらそれに気づけるよう、私たちがコードに対して必ずテストを書いている主な理由がこれです。

では次の例で考えてみましょう。ユーザーデータのバリデーションと更新を担当するService Objectを1つ書きたいとしましょう。残念なことに、バリデーションは一部の条件に応じて変わる可能性があります。このクラスはおそらく次のような感じになるでしょう(Gist)。

class UserCreateService
  def initialize(params)
    @params = params
  end

  def call
    return false unless valid?
    process_user_data
  end

  def valid?
    validator = assign_validator
    validator.new(params).validate
  end

  def assign_validator
    if some_condition
      AdvancedUserValidator
    else
      SimpleUserValidator
    end
  end

  def process_user_data
    ...
  end
end

バリデーションロジックを別クラスに追い出したにもかかわらず、このコードにはまだ問題が残っています。

  • バリデーションロジックを新しく追加するのがつらくなる: そのたびにif条件、下手をするとswitch文まで増やさなければならない。
  • バリデーションロジックを変更するのに、バリデーションに責任を持たないクラスを改造しなければならない。つらさが倍増する。
  • テストがやりにくくなる: 処理とバリデーションの両方のロジックをさまざまな場合についてカバーしなければならなくなる。

解決法のひとつを以下に示します(Gist)。

class UserCreateService
  def initialize(params, validator: UserValidator)  # バリデータを外から渡す
    @params = params
    @validator = validator
  end

  def call
    return false unless validator.new(params).validate
    process_user_data
  end

  attr_reader :params, :validator

  def process_user_data
    ...
  end
end

バリデータオブジェクトをコンストラクタ(訳注: #initialize)経由でService Objectに渡しているだけです。ここで使った手法は依存性の注入(DI: dependency injection)と呼ばれるものです。一部のユーザーの属性に応じたバリデータを選びたい場合は、どのバリデータクラスを選ぶべきかを決定するクラスを別に作ることもできます。(どのコントローラにいるかなどの)コンテキストに応じてバリデータを選択するのであれば、Service Objectに渡したいバリデータを選んで渡すだけで済みます。

この方法によって、単一責任の原則も同時に満たされていることにご注目ください(余分な責務を他のクラスに逃してあります)。これによって、データをバリデーションするクラスを増やしたくなったときにも元のクラスを改変する必要がなくなりました。必要に応じてバリデータクラスを作り、更新したいクラスに渡すだけで作業は完了します。

そして前回の単一責任の原則のときと同様に、コードがクリーンになってメンテしやすくなり、テストもずっとやりやすくなるというメリットを得られます。更新されるService Objectと、別の環境に分離されたバリデータクラスをテストすればよいのです(Service Objectのテストは、バリデータオブジェクトのレスポンスをモック化するだけでできます)。

常に実用を優先しよう

驚くほど柔軟性の高いソリューションになりました。今や新しいバリデーションルールのセットを楽々追加できます。時間と手間はちょっぴり余分にかかりますが、そのおかげで鼻高々です。では新しいバリデーションを追加する必要がその後まったく生じなかった場合はどうでしょうか?答えはもう明らかです。そのためにかけた時間は(そしておそらく費用も)無駄になったのです。このソリューションを使うかどうかという決定は、それが必要になったときに、開発者が経験と(おそらく)今後の変更を考慮しながら、その都度行わなければなりません。

「こうやっておきさえすればいい」という銀の弾丸はないのです。個人的には、無条件に良いソリューションも、無条件に悪いソリューションもないと思っています。あるとすれば、(その状況において)よく合うソリューションと、うまく合わないソリューションでしょう。

私の哲学は「常に実用を優先しよう」(訳注: 原則を振りかざさないようにしようということ)「今必要でないことまでやらないようにしよう」です。

このトピックについては、シリーズ最終回でもう一度扱いたいと思います。

関連記事

YAGNIを実践する(翻訳)

Rubyのクラスメソッドがリファクタリングに抵抗する理由(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。