エムスリーエンジニアリンググループ/BIRの滝安(@juntaki)です。 BIRはビジネスインテリジェンス&リサーチの略で、そこでは医療従事者の会員向けアンケートをベースに、製薬会社へのマーケティング支援を提供する事業を行っています。BIRではエムスリーではじめてGoを導入し、今ではほとんどの新規システムをGoで立ち上げています。(一部でKotlinもつかっています)
Goでは、interfaceの使い方を知ることで、依存関係、コンポーネントの責務を整理した設計を考えることができます。 この記事ではGo導入当初にメンバに説明した、interfaceの基本的な使い方やTipsを整理していきます(すでに、Goをよく知っている人には当たり前すぎるかもしれません)。
また、この記事の後にBIRのメンバーがGo関連記事をいろいろ書く予定となっています!
interfaceの使い方
interfaceとはそれとして呼び出させる関数のシグネチャを定義したもので、たとえば次のように定義できます。 これは標準のioパッケージに含まれている定義です。Goで迷ったら標準パッケージのお作法を見に行くとだいたい正解が書いてあります。
type Reader interface { Read(p []byte) (n int, err error) }
定義には満たすべき関数のシグネチャを書きます。ReaderはReadが実装されていれば満たせることがわかります。 また、intefaceの命名規則は"-er"のようなsuffixを持つべき、とされていて標準パッケージはそのようなお作法で書かれています。 命名規則やinterfaceの名からもわかるように、この機能はinterfaceを満たすものが何ができるのかを規定するものです。
さて、実際にこのinterfaceを満たすstructをつくってみます。処理は一切ないので実行するとpanicしますが、コンパイルは通ります。 これで、*Targetは、Readerを満たしていることになります。ここまでの実装は、エディタの機能か、implを使うと生成できます。
type Target struct{ Value string } func (t *Target) Read(p []byte) (n int, err error) { panic("implement me") }
もう1つの方法はinterfaceを埋め込んでしまうことです。この方法は、他の同じinterfaceを実装したstructに処理を移譲するケース、もしくは絶対にReadを呼び出さないようなケース(たとえばテストコード)で使います。 この書き方は、実装無しでコンパイルが通るようになり、実行時に落ちて実装忘れに気づくパターンになりやすいので注意が必要です。
type Target2 struct { io.Reader }
次に、2つのinterfaceを同時に満たす場合を考えてみます。こちらの例もioパッケージに含まれるReadCloserです。 ReaderとCloserを埋め込んだinterfaceとすることで、ReadCloserを満たしているstructは自動的にReaderも、Closerも満たしている事になります。もし、何度もinterfaceを作って同じシグニチャを書いている場合は、何か整理を間違えている可能性が高いです。組み合わせられる小さな機能ごとにinterfaceを切り、それぞれに適切な名前を付けるように整理すると、良い設計へ自然に1歩近づきます。
type Closer interface { Close() error } type ReadCloser interface { Reader Closer }
コンストラクター、戻り値と引数
Goはコンストラクターという具体的な構文が存在しているわけではなく、そういった機能、つまり、structを初期化して返すような関数は、"New-"というprefixがついた関数であるべきとされています。 戻り値としてそのまま初期化したstructを返しても良いのですが、interfaceを返すようにするとより高度なカプセル化が実現できます。
func NewTarget() io.Reader { return &Target{} }
つまり、このコンストラクターを使う側のコードを書く開発者は、戻り値をTargetとして扱うことは期待されていないというメッセージを受け取ることができるのです。 次の例のように、戻り値をTargetと思ってPublicなフィールドにアクセスしようとしても、コンパイルエラーになります。 また、このお作法で書いておくと、wireがシュッと導入できます。規模が大きくなってDIを手で書きたくなくなりそうな見込みがあれば、則っておくのが無難です。
func sample1() { ... r := NewTarget() r.Read(buf) r.Value // Unresolved reference 'Value' ... }
また、関数の引数としてinterfaceを受け取るようにしておけば、Targetに依存しない汎用的な関数ができます。 ただ、Targetに依存しない代わりにio.Readerに依存していることに注意が必要です。 ioパッケージであれば問題ではないですが、それが自作のほかのパッケージである場合など、依存関係が不必要に複雑になっていないかよく考えるべきです。
func sample2(r io.Reader) { ... r.Read(buf) ... }
このように、interfaceによって実装を隠蔽したり、依存先を実際の実装とは別のパッケージに向けることができます。 当然、むやみに使うのは混乱と手間を生みますが、適切な設計によってコントロールすることで、依存方向と各パッケージの責務の分割ができます。
モック
interfaceで扱っておくと、モックで実装を差し替えることができます。 たとえば、データソースに値を保存するRepositoryインタフェースを考えます。(さっそく"-er"の命名規則をぶっちぎっていますが・・)
type Repository interface { Get() (string, error) Save(val string) error }
上位のモジュールのテストでは、Saveが成功するケースや、失敗のパターンごとのテストが書きたいわけですが、プロダクション用に書いたコードはそんなにいろんなバリエーションで失敗してくれるわけではありません。
type MockRepository struct{ Repository } func (m MockRepository) Save(val string) error { return errors.New("not found") }
簡単なものであれば、単純にダミー実装をしてしまえばよいです。テストに必要ない部分については、先に説明した埋め込みで対処してしまう方法があります。 より高度なものを考えるとすると、テストごとに処理を差し替え可能なものにしておく実装や、gomockなどのもモック用ライブラリの利用も検討の価値があります。
type MockRepository2 struct{ MockGet func() (string, error) MockSave func(val string) error } func (m MockRepository2) Get() (string, error) { return m.MockGet() } func (m MockRepository2) Save(val string) error { return m.MockSave(val) }
まとめ
とても基本的なinterfaceとはなんぞや?というところから、Goのinterfaceの使い方を紹介しました。 私のチームでは設計まわりで議論になることが多いので、整理も兼ねてまとめてみました。
We are hiring!
エムスリー、とくにBIRではGo/Reactエンジニアや、データ基盤開発に興味があるエンジニアを募集しています。社員とカジュアルにお話することもできますので、興味を持たれた方は下記よりお問い合わせください。