概要
原著者の許諾を得て翻訳・公開いたします。
- 英語記事: SOLID Principles #2: Open/Closed Principle | Netguru Blog on Ruby/Ruby on Rails
- 原文公開日: 2018/03/09
- 著者: Marcin Jakubowski
- サイト: netguru
訳注: 「オープン/クローズド」とされることもありますが、本シリーズでは「オープン/クローズ」で統一しました。
Railsで学ぶSOLID(2)オープン/クローズの原則(翻訳)
「SOLIDの原則シリーズ」へようこそ。このシリーズ記事では、SOLIDの原則をひとつずつ詳しく説明し、分析します。シリーズの最後にはいくつかのヒントや考察を含む総括記事をお送りしますのでどうぞご期待ください。
それでは始めましょう。「SOLIDの原則」とはそもそも何なのでしょうか?SOLIDとは、オブジェクト指向プログラミング設計における一般的な原則であり、ソフトウェアをより理解しやすくし、拡張性やメンテナンス性やテストのしやすさを高めることを目的としています。
- 単一責任の原則(SRP: Single responsibility principle)
- オープン/クローズの原則(OCP: Open/closed principle)(本記事)
- リスコフの置換原則(LSP: Liskov Substitution Principle)
- インターフェイス分離の原則(ISP: Interface Segregation Principle)
- 依存関係逆転の原則(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のテストは、バリデータオブジェクトのレスポンスをモック化するだけでできます)。
常に実用を優先しよう
驚くほど柔軟性の高いソリューションになりました。今や新しいバリデーションルールのセットを楽々追加できます。時間と手間はちょっぴり余分にかかりますが、そのおかげで鼻高々です。では新しいバリデーションを追加する必要がその後まったく生じなかった場合はどうでしょうか?答えはもう明らかです。そのためにかけた時間は(そしておそらく費用も)無駄になったのです。このソリューションを使うかどうかという決定は、それが必要になったときに、開発者が経験と(おそらく)今後の変更を考慮しながら、その都度行わなければなりません。
「こうやっておきさえすればいい」という銀の弾丸はないのです。個人的には、無条件に良いソリューションも、無条件に悪いソリューションもないと思っています。あるとすれば、(その状況において)よく合うソリューションと、うまく合わないソリューションでしょう。
私の哲学は「常に実用を優先しよう」(訳注: 原則を振りかざさないようにしようということ)「今必要でないことまでやらないようにしよう」です。
このトピックについては、シリーズ最終回でもう一度扱いたいと思います。