· yuta

Go言語で実装して理解するクリーンアーキテクチャ:第2章 前提となる設計原則

#go#architecture#clean-architecture

前回は、変更に弱いコードの特徴と、クリーンアーキテクチャの同心円・依存性のルールを整理しました。今回は、その土台になっている設計原則を確認します。Robert C. MartinがまとめたSOLID原則です。5つの原則を、このシリーズで作るタスク管理のコードを例に見ていきます。最後に、これらの原則をGoで実践するときの「Goらしい」書き方も押さえておきます。

SOLID原則とは

SOLIDは、変更しやすく理解しやすいソフトウェアを作るための5つの原則の頭文字です。

頭文字 原則 ひとことで言うと
S 単一責任の原則(Single Responsibility Principle) モジュールを変更する理由は1つにする
O 開放閉鎖の原則(Open-Closed Principle) 既存のコードを変えずに、機能を追加できるようにする
L リスコフの置換原則(Liskov Substitution Principle) 実装を差し替えても、使う側が壊れないようにする
I インターフェース分離の原則(Interface Segregation Principle) 使わないメソッドへの依存を強制しない
D 依存性逆転の原則(Dependency Inversion Principle) 具体的な実装ではなく、抽象に依存する

もともとはオブジェクト指向の文脈で語られてきた原則ですが、Goにも十分に当てはまります。Goにはクラスや継承はありませんが、構造体・メソッド・インターフェースがあれば、どの原則も実践できます。

単一責任の原則(SRP)

「1つのことだけをする」ではない

単一責任の原則は「1つの関数は1つのことだけをするべき」と説明されることがありますが、Martinは著書『Clean Architecture』で、この原則を次のように言い直しています。

モジュールはたった1つのアクターに対して責務を負うべきである。

アクターとは、そのモジュールに変更を求めてくる人やグループのことです。つまり単一責任の原則とは、変更を求めてくる相手が違うものは、別のモジュールに分けるという原則です。

例

タスクに関する処理を、次のように1つの構造体にまとめたとします。

// TaskService は、タスクに関する処理をすべて担う
type TaskService struct {
    db *sql.DB
}

// Complete はタスクを完了にする           ← タスクを使うユーザーの要望で変わる
// CompletionRate はチームの完了率を計算する ← 進捗を管理するマネージャーの要望で変わる
// ExportCSV は監査用にCSVを出力する         ← 監査担当の要望で変わる

3つのメソッドは、どれも「タスク」を扱っていますが、変更を求めてくる相手がそれぞれ違います。マネージャーの要望で完了率の計算方法を変えたときに、うっかり共通の処理を変えてしまい、ユーザーが使う完了の処理まで壊れる、ということが起こりえます。変更を求めてくる相手ごとに、別の構造体(あるいは別のパッケージ)に分けるのが、単一責任の原則です。

クリーンアーキテクチャの層も、この原則の延長にあります。HTTPの形式を変えたいのはAPIの利用者、SQLを変えたいのはデータベースの担当、業務のルールを変えたいのは業務の担当者です。変わる理由の違うものを別の層に分けています。

開放閉鎖の原則(OCP)

拡張に開いていて、修正に閉じている

開放閉鎖の原則は、「ソフトウェアは、拡張に対して開いていて、修正に対して閉じているべきだ」という原則です。新しい機能を追加するときに、既存のコードを書き換えるのではなく、新しいコードを足すだけで済むようにします。

例

タスクを完了にしたら通知を送りたいとします。通知の方法をインターフェースにしておけば、通知先を増やしても、完了の処理は変更しなくて済みます。

// Notifier はタスクの完了を知らせる
type Notifier interface {
    TaskCompleted(ctx context.Context, taskID string)
}

// CompleteTask はタスクを完了にして、通知する
type CompleteTask struct {
    notifier Notifier
}

func (uc *CompleteTask) Execute(ctx context.Context, taskID string) error {
    // ……タスクを完了にして保存する……
    uc.notifier.TaskCompleted(ctx, taskID)
    return nil
}

// EmailNotifier はメールで知らせる
type EmailNotifier struct{}

func (EmailNotifier) TaskCompleted(ctx context.Context, taskID string) {
    fmt.Println("メールを送信:", taskID)
}

// SlackNotifier はSlackで知らせる(後から追加しても、CompleteTask は変更しなくてよい)
type SlackNotifier struct {
    webhookURL string
}

func (n SlackNotifier) TaskCompleted(ctx context.Context, taskID string) {
    fmt.Println("Slackに投稿:", n.webhookURL, taskID)
}

CompleteTask は Notifier というインターフェースしか知らないので、メールでもSlackでも、新しい実装を足すだけで通知先を増やせます。第13章では、実際にこの形で完了時の通知を追加します。

リスコフの置換原則(LSP)

インターフェースの「約束」を守る

リスコフの置換原則は、Barbara Liskovによる原則で、「ある型の代わりに、その派生型を使っても、プログラムが正しく動き続けなければならない」というものです。Goに言い換えると、インターフェースのどの実装を渡しても、使う側が正しく動かなければならない、ということです。

大事なのは、インターフェースの「約束」はメソッドのシグネチャだけではない、という点です。引数や戻り値の意味、エラーの返し方も約束に含まれます。

例

リポジトリのインターフェースに、「見つからなければ ErrTaskNotFound を返す」という約束があるとします。

type TaskRepository interface {
    // FindByID はIDでタスクを探す。見つからなければ ErrTaskNotFound を返す
    FindByID(ctx context.Context, id string) (*Task, error)
}

// 約束を守らない実装: 見つからないときに (nil, nil) を返してしまう
type sloppyRepository struct {
    tasks map[string]*Task
}

func (r *sloppyRepository) FindByID(ctx context.Context, id string) (*Task, error) {
    return r.tasks[id], nil // 見つからなければ nil, nil になる
}

// 呼び出し側は、約束どおり「エラーがなければタスクがある」と考えて書いている
func isDone(ctx context.Context, repo TaskRepository, id string) (bool, error) {
    t, err := repo.FindByID(ctx, id)
    if err != nil {
        return false, err
    }
    return t.Status == "done", nil // sloppyRepository だと、ここで nil ポインタ参照のパニックになる
}

sloppyRepository はシグネチャの上では TaskRepository を満たしているので、コンパイルは通ります。しかし約束を破っているので、isDone に渡すとパニックします。実際に試すと、runtime error: invalid memory address or nil pointer dereference で落ちます。

インターフェースを定義するときは、このような約束をコメントに書いておくこと、そして実装する側はそれを守ることが大切です。このシリーズでは、PostgreSQLの実装もメモリの実装も、同じ約束を守るように作ります。

インターフェース分離の原則(ISP)

使わないものに依存させない

インターフェース分離の原則は、「クライアントに、使わないメソッドへの依存を強制してはならない」という原則です。大きなインターフェースを1つ作るのではなく、使う側が必要とするメソッドだけを持つ、小さなインターフェースに分けます。

例

// 大きすぎるインターフェース
type TaskStore interface {
    Save(ctx context.Context, t *Task) error
    FindByID(ctx context.Context, id string) (*Task, error)
    ListByOwner(ctx context.Context, ownerID string) ([]*Task, error)
    Delete(ctx context.Context, id string) error
    CountByStatus(ctx context.Context, status string) (int, error)
    ExportCSV(ctx context.Context) ([]byte, error)
}

タスクを完了にする処理が必要とするのは、「探す」と「保存する」だけです。それなのに TaskStore を受け取るようにすると、テストでは使いもしない ExportCSV まで実装した偽物を用意しなければなりません。TaskStore に新しいメソッドが増えるたびに、関係のない処理のテストまで直すことになります。

使う側が必要とするメソッドだけのインターフェースにすれば、こうした問題は起きません。

// 使う側が必要とするメソッドだけを持つ、小さなインターフェース
type taskFinder interface {
    FindByID(ctx context.Context, id string) (*Task, error)
}

type taskSaver interface {
    Save(ctx context.Context, t *Task) error
}

// 完了の処理に必要なのは、探すことと保存することだけ
type taskFinderSaver interface {
    taskFinder
    taskSaver
}

func complete(ctx context.Context, repo taskFinderSaver, id string) error {
    t, err := repo.FindByID(ctx, id)
    if err != nil {
        return err
    }
    t.Status = "done"
    return repo.Save(ctx, t)
}

Goの標準ライブラリは、この原則のお手本です。io.Reader(Read だけ)や io.Writer(Write だけ)のようにメソッドが1つのインターフェースを基本にして、必要なら io.ReadWriter のように組み合わせます。

依存性逆転の原則(DIP)

上位のモジュールが下位のモジュールに依存しない

依存性逆転の原則は、次の2つからなります。

  1. 上位のモジュールは下位のモジュールに依存してはならない。どちらも抽象に依存すべきである
  2. 抽象は詳細に依存してはならない。詳細が抽象に依存すべきである

ここでの「上位」は業務のルールのような方針を決める部分、「下位」はデータベースへの保存のような具体的な手段です。前回見た、クリーンアーキテクチャの「境界を越えるときは依存を逆転させる」は、この原則そのものです。

例:逆転させる前

ユースケースが、PostgreSQLのリポジトリを直接使っているとします。

package usecase

import "github.com/example/app/postgres"

type CompleteTask struct {
    repo *postgres.TaskRepository // 具体的な実装に依存している
}

パッケージの依存は次のようになります。

usecaseパッケージがpostgresパッケージをimportしている図。業務のルールがデータベースの詳細に依存している

業務のルールが、データベースの詳細に依存しています。postgres パッケージを変更すると usecase パッケージも再コンパイルが必要になり、ユースケースのテストにはPostgreSQLが必要になります。

例:逆転させた後

ユースケースの側に、自分が必要とするインターフェースを定義します。

package usecase

// TaskRepository はユースケースが必要とする保存の機能(ユースケースの側で定義する)
type TaskRepository interface {
    FindByID(ctx context.Context, id string) (*Task, error)
    Save(ctx context.Context, t *Task) error
}

type CompleteTask struct {
    repo TaskRepository // 抽象に依存する
}
package postgres

import "github.com/example/app/usecase"

// TaskRepository は usecase.TaskRepository をPostgreSQLで実装する
type TaskRepository struct {
    db *sql.DB
}

// メソッドの中身(SQLを実行する処理)は省略
func (r *TaskRepository) FindByID(ctx context.Context, id string) (*usecase.Task, error)
func (r *TaskRepository) Save(ctx context.Context, t *usecase.Task) error

依存の向きが逆転しました。

postgresパッケージがusecaseパッケージをimportしている図。usecaseがインターフェースを定義し、postgresがそれを実装している

処理の流れは「ユースケース → PostgreSQL」のままですが、ソースコードの依存は「PostgreSQL → ユースケース」になっています。ユースケースはPostgreSQLのことを何も知らないので、テストではメモリ上の偽物のリポジトリを渡せますし、データベースを差し替えてもユースケースのコードは変わりません。

Goらしさとの折り合い

SOLID原則をGoで実践するときには、Goならではの書き方があります。

インターフェースは使う側で定義する

JavaやC#では、インターフェースを実装する側が implements で明示的に宣言するので、インターフェースは実装の近くに置かれがちです。Goのインターフェースは、メソッドがそろっていれば宣言なしで満たせる(暗黙的に実装される)ので、インターフェースを使う側のパッケージで定義するのが一般的です。Goの公式のコードレビューの指針(Go Code Review Comments)にも、「インターフェースは、そのインターフェース型の値を使うパッケージに置くのが一般的で、実装するパッケージに置くものではない」とあります。

先ほどの依存性逆転の例で、TaskRepository を postgres パッケージではなく usecase パッケージに置いたのは、この考え方によるものです。依存性逆転の原則とGoの流儀が、ぴったり一致しています。

実装する側では、インターフェースを満たしているかをコンパイル時に確かめる、次の書き方がよく使われます。

// *TaskRepository が usecase.TaskRepository を満たしていなければ、コンパイルエラーになる
var _ usecase.TaskRepository = (*TaskRepository)(nil)

インターフェースは小さく

Goのことわざ(Go Proverbs)に「インターフェースが大きいほど、抽象は弱くなる(The bigger the interface, the weaker the abstraction)」という言葉があります。使う側が本当に必要とするメソッドだけを持つ、小さなインターフェースにします。インターフェース分離の原則と同じ考え方です。

インターフェースを受け取り、構造体を返す

「Accept interfaces, return structs(インターフェースを受け取り、構造体を返す)」という指針もよく知られています。関数の引数はインターフェースにして、差し替えられるようにします。一方、コンストラクタの戻り値は具体的な構造体にして、使う側が必要に応じて自分で小さなインターフェースを定義できるようにします。

// 引数はインターフェース、戻り値は構造体のポインタ
func NewCompleteTask(repo TaskRepository, clock Clock) *CompleteTask

必要になるまでインターフェースを作らない

逆に、実装が1つしかなく、差し替える予定もないものまで、とりあえずインターフェースにする必要はありません。インターフェースを作るのは、依存を逆転させたい境界(データベースや外部サービスとの境目)や、テストで差し替えたい部分に絞ります。何でもインターフェースにすると、コードを読むときに「この呼び出しの実体はどれか」をたどるのが難しくなります。

このバランスは、第14章「やりすぎを避けるために」で改めて考えます。

まとめ

次回は、いよいよコードを書き始めます。ただし最初は、あえてアーキテクチャを考えずに、1つのファイルにすべてを書いたタスク管理APIを作り、何が困るのかを実際に確かめます。