オープン・クローズドの原則の重要性について

こんにちは!eurekaのAPIチームでエンジニアをやっている@rikiiです。

少し前に発売されたclean Architecture 達人に学ぶソフトウェアの構造と設計を買ったのですが、本で紹介されてるSOLID原則について改めて復習しておこうと思い、今回はその1つであるオープン・クローズドの原則についてまとめてみようと思います。

eurekaではgo言語を使っているので、goを使ったコード例とともにオープン・クローズドの原則の重要性について説明していきたいと思います。
ちなみにオープン・クローズドの原則とはSOLID原則と呼ばれるオブジェクト指向設計原則のうちのひとつです。

SOLID原則とは?

下記5つの原則の頭文字を取ってまとめた、オブジェクト指向設計原則のことです。
S : The Single Responsibility Principle(単一責任の原則)
O : The Open Closed Principle(オープン・クローズドの原則)
L : The Liskov Substitution Principle(リスコフの置換原則)
I : The Interface Segregation Principle(インターフェース分離の原則)
D : The Dependency Inversion Principle(依存性逆転の原則)

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

ソフトウェアの構成要素(クラス、モジュール、関数など)は拡張のために開いていて、修正のために閉じていなければならない。

言い換えると、変更が発生した場合に既存のコードには修正を加えずに、新しくコードを追加するだけで対応しましょう!ということです。
今回は、GoでOCP違反の例をStrategyパターンを使って解決することにより、何のメリットが生まれるのか?をみていきたいと思います。

例えば、自分が担当しているアプリが定額課金を始めることになり、決済処理の実装が必要になったとします。
決済方法としては、
カード決済
あとでアプリ決済もやるし、Paypalやら色々増えるかもしれない

という内容だったとします。とりあえずファーストスコープとしてカード決済だけ実装してみるとします。
わかりやすいように、お馴染みのMVCにService層を組み込んだシステムを想定します。

OCPに違反した実装

図1–1 CardModuleの実体に依存

type PaymentService struct {
// PaymentServiceがCardModuleの実体を持っている
cardModule CardModule
}

func (s *PaymentService) Subscribe() {
// 事前処理
err := s.cardModule.Pay()
if err ! = nil {
// エラー処理
}
// 事後処理
}

図1–1をみると、
 PaymentService(Controllerから呼ばれる想定)がCardModuleの実体に依存しています。(->は使用)
OCPは変更が発生した場合に既存のコードには修正を加えずに、新しくコードを追加するだけで対応する原則でした。
しかし、このコードでは、例えばApple決済が増えた(変更が発生した)場合に、Subscribeメソッドを修正する必要があります。

雑に実装しますが多分こんな感じになるかと思います。

type PaymentService struct {
cardModule CardModule
appleModule AppleModule
}

func (s *PaymentService) Subscribe(PaymentMethod string) {
// 事前処理
switch paymentMethod {
case "card":
err := s.cardModule.Pay()
case "apple":
err := s.appleModule.Pay()
}
if err ! = nil {
// エラー処理
}
// 事後処理
}

この修正方法だと決済方法が増えてきた際に分岐が複雑になり、決済方法の追加が既存の決済方法に影響を及ぼす可能性も増えてしまいます。

OCPに準拠した実装

図1.2 OCPに準じた例 : strategyパターン

変更点
PaymentServiceがPaymentInterfaceに依存するようになった
各ModuleがPaymentInterfaceを実装している

実装

payment_service

type PaymentInterface interface {
Pay() error
}

type PaymentService struct {
paymentModule PaymentInterface
}

type NewCardPaymentService() PaymentService {
paymentModule : CardModule{},
}

type NewApplePaymentService() PaymentService {
paymentModule : AppleModule{},
}

func (s *PaymentService) Subscribe() {
// 事前処理
err := s.paymentModule.Pay()
if err ! = nil {
// エラー処理
}
// 事後処理
}

card_module

type CardModule struct {}

func (m CardModule) Pay() {
//Card固有の決済処理
}

apple_module

type AppleModule struct {}

func (m AppleModule) Pay() {
//Apple固有の決済処理
}

OCPに準拠したことによるメリット

CardModuleをInterfaceにしたことにより、CardModuleの処理内容が実体に依存しなくなりました。

これによって各決済方法を個別に実装する事が可能になり(Open)、各決済方法同士の修正が影響をうけることがなくなりました。(Closed)
利用側から実際に決済方法を分けて使うための実装方法としては、外部からfactoryMethodなどをもちいて依存性を注入するなどがよいかなと思います。

factoryMethodの例

func NewPaymentService(PaymentMethod string) PaymentService {
switch PaymentMethod {
case "card":
return NewCardPaymentService()
case "apple":
return NewApplePaymentService()
// 続き
}
}

まとめ

OCPはオブジェクト指向設計の核心で、再利用、保守、柔軟性のメリットが受けられます。
特にWebサービスの運用は変更が生まれやすいので、変更に耐えられる設計にしておくことが重要だと思います。
ただし、闇雲に抽象化してしまうと、ソースコードは増えるし、複雑になったりするので、個人的には必要なときに使えばいいかなと思います。