はじめに
Goのインターフェースについて簡単なユースケースを用意し、実装を行いながら整理してみます。
ユースケース
GoでHTTPサーバーを実装しているとします。
この時、指定したパスへリクエストが来た際に、環境(dev or stg)に応じてレスポンスメッセージを変更したいとします。
これを実現するコードを、インターフェースを使用しない場合と使用する場合で実装してみます。
実装
共通処理
以下の処理が、インターフェースの有無によらず共通しているとします。
http.Handle の第一引数はパスであり、第二引数は http.Handler インターフェースを実装した任意の型です。つまり、この型は、ServeHTTP(http.ResponseWriter, *http.Request) を実装していれば良く、今回の例だとそれが Webhook 構造体に該当します。
そして、DevHandler と StgHandler はどちらも DoLogic メソッドを提供しており、ServeHTTP では環境に応じてどちらの DoLogic を呼ぶかを選択する仕組みになっています。
package main
import (
"fmt"
"net/http"
"os"
)
var env string = os.Getenv("APP_ENV")
type Webhook struct {
// どう定義するか
}
type DevHandler struct{}
type StgHandler struct{}
func (*DevHandler) DoLogic(path string) string {
return "Hello from dev" + path
}
func (*StgHandler) DoLogic(path string) string {
return "Hello from stg" + path
}
func (w *Webhook) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
// DoLogicを呼び出す
// 環境に応じて利用するHandlerが変わる
}
func main() {
// Webhookの定義を行う
http.Handle("/webhook", webhook)
fmt.Println("Server started at :8080")
http.ListenAndServe(":8080", nil)
}
この処理を共通処理として、ここからはインターフェースを利用しない場合とする場合において差分を見ていきます。
インターフェースを利用しない場合
特徴
インターフェースを使わない素朴な実装では、Webhook 構造体が例えば DevHandler と StgHandler の2種類の具体的な型をそのまま保持する実装になりがちです。この時、Webhook は各環境ごとの具体的なHandler実装に依存します。
ここで、ProdHandler や LocalHandler といったような形でロジックの種類が増えていくと、Webhook 側のフィールドは膨れていくと同時に ServeHTTP メソッドの中の処理も場合分けが都度必要となり膨れていきます。
また、環境に応じたロジック選択の責務を Webhook.ServeHTTP や main() といった複数の場所で抱えてしまっています。
このように、拡張性や変更容易性に弱い構造になってしまっています。
実装(差分)
type Webhook struct {
DevHandler *DevHandler
StgHandler *StgHandler
}
func (w *Webhook) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
var result string
if env == "dev" {
result = w.DevHandler.DoLogic(req.URL.Path)
} else {
result = w.StgHandler.DoLogic(req.URL.Path)
}
rw.Write([]byte(result))
}
func main() {
webhook := &Webhook{
DevHandler: &DevHandler{},
StgHandler: &StgHandler{},
}
http.Handle("/webhook", webhook)
}
インターフェースを利用する場合
特徴
Webhook が保持するのは LogicHandler というインターフェースだけになり、ここで環境の差異を吸収します。
この構造にすることで、環境ごとのハンドラ切り替えは main() 側で完結します。そのため、新しい環境が増えて新しいロジックが必要になったとしても、Webhook や ServeHTTP メソッドを変更する必要はなく、LogicHandler の新しい実装を 1 つ追加するだけで済みます。
また、以前は Webhook が具体的な DevHandler, StgHandler へ依存しているという関係になっていましたが、インターフェースを導入したことで LogicHandler という抽象への依存という関係に変化します。
これにより、環境切り替えの判断は外部(main 側)に集約でき、WebHook 自身は環境の違いを一切知らずに済むという点で責務が明確に分離されるようになります。
これは、後に確認しますがテストコードの記述にも大きく影響します。
実装(差分)
type Webhook struct{
Handler LogicHandler
}
type LogicHandler interface {
DoLogic(path string) string
}
func (w *Webhook) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
result := w.Handler.DoLogic(req.URL.Path)
rw.Write([]byte(result))
}
func main() {
var handler LogicHandler
if env == "dev" {
handler = &DevHandler{}
} else {
handler = &StgHandler{}
}
// 依存性の注入
webhook := &Webhook{
Handler: handler,
}
http.Handle("/webhook", webhook)
}
テストコードの差について
概要
「Webhook が外部から与えられた DoLogic を正しく呼び出して、その返り値を HTTP レスポンスとして返す」という一連の流れについてテストを書きたいとします。 この時、インターフェースを利用しない実装と利用した実装とでテストコードに生じる差について説明したいと思います。
インターフェースなし
Webhook のテストを行うにあたり、DevHandler.DoLogic の実装や env の具体的な値に依存してしまっています。 そのため、webhook.ServeHTTP のテストを行うにあたり正しいメソッドが正しい引数や戻り値で呼び出されるかだけでなく、環境の切り替えロジックもテストに含まれてしまっています。
webhook := &Webhook{
DevHandler: &DevHandler{},
StgHandler: &StgHandler{},
}
req := httptest.NewRequest("GET", "/path", nil)
rr := httptest.NewRecorder()
webhook.ServeHTTP(rr, req)
if rr.Body.String() != "Hello from dev/path" {
t.Fatalf("unexpected")
}
インターフェースあり
Webhook はインターフェースのみに依存し、具体的な DevHandler 等の実装に依存しないため、環境の変化を意識する必要がありません。
そのため、Webhook.ServeHTTP が Handler.DoLogic を正しい引数で呼び、その戻り値をレスポンスへ書き込んでいるという、Webhook の責務だけ を切り出してテストができています。
そして、環境ごとの切り替えのロジックやそれに応じたレスポンスメッセージの書き込み処理は別のテストが責務を持つことになります。
type MockHandler struct{}
func (MockHandler) DoLogic(p string) string { return "mock:" + p }
webhook := &Webhook{
Handler: MockHandler{},
}
req := httptest.NewRequest("GET", "/path", nil)
rr := httptest.NewRecorder()
webhook.ServeHTTP(rr, req)
if rr.Body.String() != "mock:/path" {
t.Fatalf("unexpected")
}
まとめ
インターフェースを利用することで、以下のようなメリットが挙げられます。
-
拡張性・変更容易性
- 今後ロジックが増え拡張した場合でも変更箇所が少なく済む。
-
責務の分離
- 環境ごとの切り替え判断は呼び出し側に集約され、実際の処理は共通のインターフェースを通じて扱える。
- 処理が明確に切り離され、依存関係が整理される。
-
ユニットテストのしやすさ
- インターフェースを使うと、モックを簡単に差し込めるようになる。