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)
コンパイルできませんでした。 mockedUserRepository
は DeleteUserByID
を実装していないので、 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 に公開していますので興味があればご覧下さい。
コメントを残す