リモート開発メインのソフトウェア開発企業のエンジニアブログです

Go の interface は構造体の利用側が定義すると言う話

Go を業務で使い始めてそろそろ 1 年が経ちました。Go には、これまで私が使ってきた Scala や PHP とは違う特性がいくつかあるのですが、その中でもユニークだったのが表題の件です。
これは、 Go 本体の Wiki ページ Go Code Review Comments (Go のコードレビュー時に頻出する、ありがちな誤りを集めた物) の一部である、 Interfaces と言う章に書かれています。

その一部を抜粋しますと、

Go interfaces generally belong in the package that uses values of the interface type, not the package that implements those values. The implementing package should return concrete (usually pointer or struct) types: that way, new methods can be added to implementations without requiring extensive refactoring.

つまり、 Go では interface は通常、これを実装している実態である構造体を提供するパッケージではなく、この interface を使用している依存側のパッケージ自身に属するべき、と言った内容になります。これはどう言う事でしょうか?同 Wiki ページには実際のコードの一例が載っているのですが、あえてオリジナルのコードで検討してみたいと思います。

Go の interface について

Go の interface についてまずおさらいしたいと思います。

Scala や PHP 同様、Go でも interface は同様に機能します。(Scala は trait ですが)
ある振る舞いを持つメソッドに依存した機能において、実装そのものではなく、シグネチャだけを公開した interface に依存する事によって、機能の提供側、依存側共にコードそのものを変更する事なく動作の詳細を変える事ができます。

実際に、 user パッケージにある User と言うモデルと、それを保存する Repository と言う構造体があるとします:

package user

// import は省略

type User struct{
	ID int64
	Email string
}

type Repository struct { DB *sql.DB }

func (r *Repository) CreateUser(user *User) error {
	result, err := r.DB.Exec("INSERT INTO users(email) VALUES(?)", user.Email)
	if err != nil {
		return fmt.Errorf("database error %w", err)
	}

	id, err := result.LastInsertId()
	if err != nil {
		return fmt.Errorf("unable to get lastIsertId %w", err)
	}

	user.ID = id

	return nil
}

Repository.CreateUser は、 User モデルの内容を RDB のテーブルの行として INSERT します。

次に、これを使う側、依存側となる別のパッケージを考えます。ここでは適当に app パッケージとしました:

package app

// import は省略

// RegisterUser email で user.User モデルを初期化し、 user.Repository を使って保存します. 成功した場合、作成した user.User モデルを返します.
func RegisterUser(repo *user.Repository, email string) (*user.User, error) {
	u := &user.User{Email: email}
	if err := repo.CreateUser(u); err != nil {
		return nil, fmt.Errorf("unable to create user %w", err)
	}
	return u, nil
}

この関数は user.Repository の実態に依存している為、この関数自身のユニットテストを書く場合も当然 user.Repository の実態を用意する必要があります。
先程の user パッケージを見ると分かりますが、この構造体は実際の DB のコネクションが必要な為、手軽に初期化をする事はできません。これではテストが大変なので、実際にはモックを使う事が多いと思います。

以下の様に、同じシグネチャを持つ構造体をモックとしましょう。パッケージはあえて app 内に作っておきます:

package app

// import は省略

type mockedUserRepository struct {}
func (r *mockedUserRepository) CreateUser(_ *user.User) error {
	return nil
}

これで、本物の DB を必要としない user.Repository のモックができました。
しかし、 RegisterUser の第 1 引数である userRepo は *user.Repository が型指定されているので、このままでは当然使えません:

mockedUserRepo := &mockedUserRepository
u, err := RegisterUser(mockedUserRepo, "foo@example.com") // compile error: cannot use mockedUserRepo (type *mockedUserRepository) as type *user.Repository in argument to RegisterUser

と言う訳で、 RegisterUser の方を少し修正します:

package app

// import は省略

type userCreator interface {
    CreateUser(user *user.User) error
}

// RegisterUser email で user.User モデルを初期化し、 userCreator を使って保存します. 成功した場合、作成した user.User モデルを返します.
func RegisterUser(repo userCreator, email string) (*user.User, error) {
	u := &user.User{Email: email}
	if err := repo.CreateUser(u); err != nil {
		return nil, fmt.Errorf("unable to create u %w", err)
	}
	return u, nil
}

CreateUser メソッドを userCreator interface に切り出し、第 1 引数の型をこれに変えました。こうすると、先程のコードがコンパイルできる様になります。

creator := &mockedUserRepository{}
u, err := RegisterUser(creator, "foo@example.com") // compile succesfull!

注目したいのは、 interface の名前です。今回、 app 側に用意したのは userRepository ではなく、 userCreator と言う名前の interface です。 RegisterUser 関数は、内部では CreateUser(user *user.User) error と言う、ユーザーを作成する振る舞いを持つメソッドにのみ着目しているので、依存する interface もそれだけに関心を持つ様にしています。(インターフェース分離の原則)
Go では、 interface はその振る舞いの動詞型 + er の形で命名する事が多いので、 userCreator と名付けています。

ともかく、これで RegisterUser の第 1 引数は、このメソッドさえ実装していれば、その実態は user.Repository でも mockedUserRepository でも何でも良くなったと言う訳です。現に、 user.Repository に別のメソッドを追加しても問題なく動作します。

package user

// ...

func (r *Repository) DeleteUserByID(id int64) error {
	if _, err := r.DB.Exec("DELETE FROM users WHERE id = ?", id); err != nil {
		return fmt.Errorf("database error %w", err)
	}
	return nil
}

user.Repository に新しく DeleteUserByID を追加しましたが、 app 側のコードにはこのメソッドは見えないし、影響もしません。

interface を定義する場所について

Go の interface についておさらいした所で、冒頭で話した、「Go では interface は通常、これを実装している実態である構造体を提供するパッケージではなく、この interface への依存側のパッケージに属するべき」と言う話に移ります。

結論から言うと、これはケースバイケースだと個人的には思います。(どのプラクティスにも言える事ですが)

但し、1 つだけ言えるのは、 Go のinterface は Scala や PHP 等のそれとは大きく異なると言う点です。
今回、上で示した例では userCreator interface は user ではなく app 側に定義しました。

Scala や PHP ではどうでしょうか?これらの言語を使う場合でも、モックを使ったテストはよく書くと思います。
今回の様なケースで言うと、これらの言語でも RegisterUser の第 1 引数を interface に依存させる事で、ユニットテストをシンプルに実装できるかと思います。

以下は、 PHP での例です:

namespace app;

// use は省略

/**
 * $email で user\User モデルを初期化し、 user\Repository を使って保存します. 成功した場合、作成した user\User モデルを返します.
 * 失敗した場合、 RuntimeException がスローされます.
 */
function registerUser(user\Repository $userRepo, string $email): ?user\User {
    $user = new user\User(email: $email); // PHP 8 で追加された名前付き引数
    $userRepo->createUser($user);

    return $user;
}

先程の Go の例と殆ど同じ形です。しかし、 user\Repository を interface とする場合、どこに定義されるのでしょうか?答えは上記のコードの通り、 user パッケージになると思います。
何故なら、 user\Repository を定義している user パッケージは app の存在を知らないからです。(大抵の設計では知らない事が多いでしょう)

namespace user;

interface Repository {
    public function createUser(User $user): void;
}

// 実際に RDB への接続が必要な `Repository` 実装
class PdoRepository implements Repository {
    // ...
}

namespace test;

// use は省略

// userRepository のモック
$userRepo = new class implements user\Repository {
    public function createUser(user\User $user): void {}
};

$user = app\registerUser($userRepo, 'foo@example.com');

ここが Go とは大きく違います。Go の場合、構造体が、「interface が定義しているシグネチャのメソッドを全て実装している」場合、それはその interface を実装している事になります。 PHP の様に implements によって実装先の interface を明示する必要はありません。つまり、 interface はどこにでも置くことができ、先程のコードの様に user 側のパッケージを一切変更する事なく、かつ具体的な構造体に依存する事もなく app 側ではそれを利用するコードを書く事ができています。

※少し話は逸れますが、PHP 等でも interface を次の様に使う事で、依存の方向を変える事は一応可能です:

namespace user;

// 実際に RDB への接続が必要な具象クラス `Repository` しかないと仮定
class Repository {
    // ...
}

namespace app;

interface UserCreator {
    public function createUser(user\User $user): void;
}

/**
 * 処理を user\Repository に委譲するだけ.
 */
class DelegatingToRepoUserCreator implements UserCreator {
    public function __construct(private user\Repository $userRepo) {} // PHP 8 で追加されたオブジェクト初期化子
    public function createUser(user\User $user): void {
        $this->userRepo->createUser($user);
    }
}

function registerUser(UserCreator $creator, string $email): ?user\User {
    // ...
}

app 側に定義したクラス DelegatingToRepoUserCreator を user\Repository のクッションにして、その実装を抽象化した UserCreator interface に registerUser を依存させる事で、 Go でやった事とほぼ同じ内容を実現しています。(依存性逆転の原則 で使われるテクニックを応用した物)

さて、 Go の interface はどこに定義しても使える事が分かった所で、本題の定義する場所に戻ります。
冒頭でケースバイケースと話しましたが、例えばパッケージがオープンソースのライブラリであるとか、あるいはインハウスな物でもプロジェクト間で共通化しているモジュール (恐らく go.mod の粒度) であれば、 Go 本体の様に基本は interface をそこに含める必要は無いと個人的には思います。

実際に interface は利用側で用意してもらう事で、構造体の提供側は特に利用側を気にせずメソッドの追加等の拡張が行えます。

反対に提供側 (上位レベル) に interface を定義してしまうと、後方互換の破壊に繋がるので容易に変更する事ができなくなります:

package user

// import は省略

type Repository interface {
	CreateUser(user *User) error
}

type DBRepository struct{ DB *sql.DB }

func (r *DBRepository) CreateUser(user *User) error {
    // ...
}

上のコードは、 user 側に Repository interface を定義した例です。 それ以外は元のコードと同様です。 app 側も同じ様に変更します:

package app

// import は省略

func RegisterUser(repo user.Repository, email string) (*user.User, error) {
	// ...
}

RegisterUser の第 1 引数を、先程追加した user.Repository に変えました。勿論これでもコンパイルは通ります。interface の定義場所を変えただけなので、モックとして作った mockedUserRepository も引き続き動作します。

では、 user.Repository にメソッドを追加してみましょう:

package user

// import は省略

type Repository interface {
	CreateUser(user *User) error
	DeleteUserByID(id int64) error
}

新しく DeleteUserByID を interface に定義しました。(このメソッドは元々の Repository 構造体が実装していましたね)

この状態でも引き続き RegisterUser は変更なしで動作します。問題ありません。ところが、 mockedUserRepository はどうでしょうか?

package user

repo := &mockedUserRepository{}
u, err := RegisterUser(repo, "foo@example.com") // compile error: cannot use repo (type *mockedUserRepository) as type user.Repository in argument to RegisterUser: *mockedUserRepository does not implement user.Repository (missing DeleteUserByID method)

コンパイルできませんでした。 mockedUserRepositoryDeleteUserByID を実装していないので、 interface user.Repository の要件を満たさないからですね。

この様に、上位レベルのパッケージ側の interface への変更はメソッドの追加であっても破壊的変更になってしまいます。

繰り返しになりますが、利用側の app としては、必要なのは user.Repository の中でも CreateUser だけなので、ここだけを自身の側に切り出しておけば、このメソッドの仕様自体が変更されない限り、元の構造体への変更を気にする必要が無くなります。

従って、共通のモジュール側に interface を作る場合、設計はある程度慎重にする必要がある事が分かったかと思います。

では、同じ go.mod 定義内のサブパッケージではどうでしょう?これに関しては、好きに実装したら良いと思います。
先程とは真逆の事を言う様ですが、 user.Repository の様な限定的な機能はそう大きく変更される事も無いですし、多数のサブパッケージにいちいち interface を切り出すよりは、 user の中に入れておいた方がメンテが楽そうです。
勿論、その場合 interface に手を加えた場合はこれを実装している前提の構造体は全て変更が必要になりますが、同じパッケージ内であればそこまで手間ではないでしょう。
勿論、 user パッケージが決して外のモジュールからは使われない事が前提となります。(app を始めとする、 user を使うパッケージ間は全て同じ go.mod で管理される)

まとめ

  • モジュールが広く使われる (OSS 等) の場合、基本は interface は実装しない
  • 同じモジュール内 (同じ go.mod を使っている) のサブパッケージ間では、普通に構造体を定義している所に interface を実装しても良いと思う

以上、 Go が宣言する interface に関する慣習と、それに関する個人的な感想の紹介は終わりです。
最後に、今回検証で使ったコードは GitHub に公開していますので興味があればご覧下さい。

← 前の投稿

Vagrant で Linux での最新の Docker 環境を構築

次の投稿 →

フロントのみでPDFファイルのサムネイルを用意する

優秀な技術者と一緒に、好きな場所で働きませんか

株式会社もばらぶでは、優秀で意欲に溢れる方を常に求めています。働く場所は自由、働く時間も柔軟に選択可能です。

現在、以下の職種を募集中です。ご興味のある方は、リンク先をご参照下さい。

コメントを残す