👻

SOLID原則を説明してみる(OCP: オープン・クローズドの原則)・実装編

に公開

はじめに

前回の記事は「基本編」と題して、OCPは「差し込み可能な設計」と解説しました。
本記事では、実務でよく遭遇する2つのユースケースを通じて、OCPがどのように保守性を向上させるかを具体的に見ていきます!

この原則は「DIP: 依存性逆転の原則」と切っても切り離せない関係なので、コード自体はかなり被ったものになっています。

おさらい:「差し込み可能な設計」とは

モジュールをプラグインのように付け替えやすくすることです。
機能を変えたかったらプラグイン部分のみ交換するだけで良くなります。

「差し込み可能な設計」の例として、USBが挙げられます。
USB端子というインターフェースに合わせれば、各種ケーブル・Bluetooth子機・キーボード等の必要に応じたモジュールが差し込め、それに合った機能が提供されます。

サンプルケース

  • インフラ層の実装切り替え - 環境毎にDBクライアントを交換可能にする

以下の観点から、OCPの保守性向上効果を実演します。

  • 「新機能追加時の変更箇所の局所化」
  • 「テストのしやすさ」

ケース: インフラ層の実装切り替え

DB, ストレージ, APIなどインフラ層のクライアントモジュールは必要に応じて、実装を変えたいことがあります。

例えば、本番 / 検証環境では実際の外部サービスに繋げたい。
しかし、開発環境ではローカルのDBを使用したいなどです。

このような実装の切り替えには、OCPによる「差し込み可能な設計」が有効です。
そして、この設計は以下の2ステップで実現します。

  1. 実装を差し込める構造の設計
  2. 差し込みの設計

それでは、個別に見ていきましょう。

1. 実装を差し込める構造の設計

冒頭の図では USB にモジュールを差し込むことで、機能を変えると説明しました。
これを実現するためには以下の2つが必要です。

  • 機能を提供するモジュールの定義
  • モジュールの機能を使用する箇所の定義

機能を提供するモジュールの定義

まず、これを実現するためには「差し込み口の仕様」となるインターフェースの定義が不可欠です。
そのうえで、「差し込み口」に取り付けられるモジュールを作ります。

機能を提供するモジュールの定義
  • 差し込み口の仕様としてのinterface
// user/domain/user.go
package domain

type User struct { /.../ }

type UseAccessor interface {
    Find(string) (*User, error)
}
  • MySQLクライアントのモジュール
// user/repository/mysql.go
package repository

import (
    "user/domain"
)

// 差し込めるようにインターフェースを実装
type UserRespository struct { /…/ }

func NewMySQLUserRespository(db *sql.DB) (*UserRespository, error) {
    // MySQL独自の初期化実装
}

func (r UserRespository) Find(id string) (*domain.User, error) { /.../}
  • PostgreSQLクライアントのモジュール
// user/repository/postgresql.go
package repository

// 差し込めるようにインターフェースを実装
type UserRespository struct { /…/ }

func NewPostgreSQLUserRespository(db *sql.DB) (*UserRespository, error) {
    // PostgreSQL独自の初期化実装
}

func (r UserRespository) Find(id string) (*domain.User, error) { /.../ }

// SQLiteは割愛

モジュールの機能を使用する箇所の定義

さて、「差し込み口の仕様」と「差し込むモジュール」は用意できました。
あとは「仕様」に則った「差し込み口」が必要です。

モジュールの機能を使用する箇所の定義
// user/usecase/user.go
package usecase

import (
    "user/domain"
)

// 「差し込み口」を用意
type UserUseacse struct {
    repo *domain.UserAccessor
}

// モジュールを差し込むための処理(何が差し込まれるかは呼び出し元次第)
func NewUserUseacse(repo *domain.UserAccessor) UserUseacse {
    return UserUseacse{repo: repo}
}

func (u UserUseacse) Find(id string) (*domain.User, error) {
    // 差し込んだモジュールから提供される機能を使用
    return u.repo.Find(id)
}

2. 差し込みの設計

「差し込み口」と「差し込むモジュール」、そしてその間をつなぐ「差し込み口の仕様」が揃いました。
しかし、まだモジュールは差し込まれてはいません。

それでは差し込む方法を見ていきましょう!

差し込みの設計
  • 依存性を解決する時に差し込む
// server/server.go
package server

func NewServer(db *sql.DB, env string) (*echo.Echo, error) {
    // 環境毎のモジュールを受け取る
    var userRepo  domain.UserAccessor

    // 差し込むモジュールを選択
    switch env {
    case "prod":
        userRepo = &repository.NewMySQLUserRespository{db: db}
    case "stg":
        userRepo = &repository.NewPostgreSQLUserRespository{db: db}
    default: // dev
        userRepo = &repository.NewSQLiteUserRespository{db: db}
    }

    // ここでモジュールを差し込む!
    userUsecase := usecase.NewUserUseacse(userRepo)
    userController := controller.NewUserController(userUsecase)

    // サーバー生成とルーター登録
    s := echo.New()
    s.GET("/users/:id", userController.Find)
    
    return s
}

どこで差し込むのが良いかという疑問が出るとおもいます。おおむね2パターンになるかと思います。

  • 処理の実行時(APIコールなどで呼び出される度)
  • プログラムが起動時(初期化時に1度だけ)

基本的に プログラムが起動時 が良いです。
なぜなら、起動時点で環境に対するモジュールの構成はおおかた決定できるものだからです。

逆に「処理の実行時」に「差し込みによる保守性向上」を阻害します。
なぜなら、初期化関数などはインターフェースによる抽象化ができないので、密結合になるからです。

OCPの保守性向上効果

さて、「サンプルケース」でOCPの利点に以下のように言いました。

以下の観点から、OCPの保守性向上効果を実演します。

-「新機能追加時の変更箇所の局所化」
-「テストのしやすさ」

実装をプラグインのように差し替えれるメリットとして、上記の効果が見込めます。
では実演していきましょう。

  • ユースケーステストのためにMockを差し込みたい

というケースを考えてみましょう。
簡単ですね、「仕様」に則った「差し込み口」に挿せるMockモジュールを追加するだけです。

新機能追加時の変更箇所の局所化

Mockモジュールを追加する際のメリットは、他のモジュールに影響を与えない点です。

  • クライアントモジュール同士は構造体や関数が分かれているので、互いに影響はない
  • Usecaseはインターフェースでしかクライアントを知らないので、新しく実装が追加されようと影響はない
新機能追加時の変更箇所の局所化
  • 差し込むMockを追加するだけ
// user/usecase/mock.go
// 利用するのはusecaseのテストなので、同じレイヤーで定義
package usecase

// インターフェースを実装するだけで差し込み可能(内部の定義は自由)
type UserRepository struct { /...Mocking出来る定義/ }

func NewMockUserRepository() UserRepository { return UserRepository{} }

func (r UserRespository) Find(id string) (*domain.User, error) { /...Mocking出来る定義/ }

テストのしやすさ

用意したMockをテストに組み込むだけです。
テストで「差し込みの設計」と同様のことを行えばよいだけです。

さて、利点としては以下が挙げられます。

  • テストのために、既存コードを変更しなくてよい

これにつきます。
ひどいコードだとプロダクションコードにも拘らず、テスト用の条件分岐が入り込んでいることがあります。
例えば、server/server.goのNewServerにテスト環境の条件があったりなどです。

テストのしやすさ
// user/usecase/user_test.go
package usercase

type TestUserUsecase struct { /...テスト用の設定/ }

func TestFindUser(t *test.Testing) {
    mockRepo := NewMockUserRepository() // mockRepoにテストで使用するデータを注入したり
    // Mockデータを返すモジュールを差し込む!
    usecase := NewUserUsecase(mockRepo)

    user, err := usecase.Find(test_id)
    // Assert
}

結論

  • ソフトウェアで機能を簡単に切り替えたいのであればOCPを検討
  • 切り替えたい機能のモジュール同士を抽象化できる仕様を検討してinterfaceを定義
  • 差し込みを行う処理はサーバーの起動時などにまとめて行う(ロジックの中に混ぜ込まない)

以上OCPの実装に関しての記事でした!

SOLID原則を説明してみるシリーズ

ぜひこちらもお願いします😆

Discussion