型制約付きのgoのRouterとHandlerを考えてみる

最小限でテストしたい。nilにアクセスしたくない。diしたい。
しかしこれって必要なんだろうか?
実験してみる。

型パラメーターの制約の部分にLogger() *Loggerのインターフェイスを書いたり、ルーターAやルーターBそれぞれで別の制約を書いたりしてそれをマージする感じのイメージです。
汎用的なMerge関数を作りたいわけではないのです。2つのルーターをマージして新しいルーターを作る関数を書いてmainで呼びたいのです。ただし個々のルーターは別々の型制約をそれぞれ必要な分だけ持ってるイメージです。

承知いたしました。完全に理解いたしました。
「汎用的なマージ関数」ではなく、「ユーザー用ルーターと商品用ルーターという、それぞれが異なる型制約を持つ2つの具体的なルーターをマージして、1つの新しいルーター(ハンドラ)を作る、特定の関数」をmain
から呼び出したい、というご意図ですね。
はい、そのアプローチは可能であり、かつ非常にクリーンな設計です。
先ほどのコードを、その意図がより明確に伝わるようにリファクタリングしました。main
の構造がご希望の形になっているはずです。
このコードのポイント
-
ルーターのセットアップ関数 (
New...Router
):-
NewUserRouter
は、UserRouterDeps
という制約だけを要求します。この関数の中では、ProductService
の存在を一切知りません。 -
NewProductRouter
は、ProductRouterDeps
という制約だけを要求します。 - これらの関数は、設定済みのルーターを
http.Handler
として返します。これにより、内部実装(ジェネリクスなど)がカプセル化されます。
-
-
マージ関数 (
MergeRouters
):- この関数はジェネリクスを使いません。
- 単純に
http.Handler
を2つ受け取り、http.ServeMux
を使ってそれらを合成し、新しいhttp.Handler
を1つ返します。 - この関数は「どのルーターがどんな依存関係を持っているか」を全く気にする必要がありません。関数の責務が明確に分離されています。
-
main
関数での呼び出し:- DIコンテナ(
app
)を1つ作ります。 -
NewUserRouter(app)
とNewProductRouter(app)
を呼び出して、それぞれのルーターを個別に作ります。(ここで型制約のチェックがコンパイル時に行われます) - 最後に
MergeRouters(userRouter, productRouter)
を呼び出して、最終的なルーターを取得します。
- DIコンテナ(
この流れにより、「個々のルーターは必要な分だけの制約を持ち」「それらをマージする専用の関数が存在する」というご希望の設計が実現できています。
Go Playground用サンプルコード(最終版)
package main
import (
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"os"
)
// --- 1. 依存関係のインターフェース定義 ---
type LoggerProvider interface {
Logger() *slog.Logger
}
type UserServiceProvider interface {
UserService() *UserService
}
type ProductServiceProvider interface {
ProductService() *ProductService
}
// --- 2. ルーターごとの制約定義 ---
type UserRouterDeps interface {
LoggerProvider
UserServiceProvider
}
type ProductRouterDeps interface {
LoggerProvider
ProductServiceProvider
}
// (ビジネスロジックのダミー)
type UserService struct{}
func (s *UserService) GetUserName(id string) string { return "User-" + id }
type ProductService struct{}
func (s *ProductService) GetProductName(id string) string { return "Product-" + id }
// --- 3. ルーターのセットアップ関数 ---
// これらはジェネリックな内部実装をカプセル化し、使いやすいインターフェースを提供します。
// NewUserRouter は、UserRouterDeps を満たすDIコンテナを受け取り、
// ユーザー機能のエンドポイントが設定された http.Handler を返します。
func NewUserRouter[T UserRouterDeps](deps T) http.Handler {
// Routerの内部実装はジェネリック
mux := http.NewServeMux()
mux.HandleFunc("GET /{id}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
// インターフェース経由で安全に依存を取得
deps.Logger().Info("Handling get user request", "id", id)
userName := deps.UserService().GetUserName(id)
fmt.Fprintf(w, "Hello, %s\n", userName)
})
return mux
}
// NewProductRouter は、ProductRouterDeps を満たすDIコンテナを受け取り、
// 商品機能のエンドポイントが設定された http.Handler を返します。
func NewProductRouter[T ProductRouterDeps](deps T) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /{id}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
deps.Logger().Info("Handling get product request", "id", id)
productName := deps.ProductService().GetProductName(id)
fmt.Fprintf(w, "This is %s\n", productName)
})
return mux
}
// --- 4. 2つのルーターをマージして新しいルーターを作る関数 ---
// この関数はジェネリックである必要はなく、Goの標準インターフェースのみを扱います。
func MergeRouters(userRouter http.Handler, productRouter http.Handler) http.Handler {
mainMux := http.NewServeMux()
// プレフィックスを付けて各ルーターをマウント(登録)する
mainMux.Handle("/users/", http.StripPrefix("/users", userRouter))
mainMux.Handle("/products/", http.StripPrefix("/products", productRouter))
return mainMux
}
// --- 5. DIコンテナの実装と main での呼び出し ---
// App は、アプリケーション全体の依存関係を実装する具体的な構造体です。
// この構造体が、定義した全てのインターフェースを満たします。
type App struct {
logger *slog.Logger
userService *UserService
productService *ProductService
}
func (a *App) Logger() *slog.Logger { return a.logger }
func (a *App) UserService() *UserService { return a.userService }
func (a *App) ProductService() *ProductService { return a.productService }
func main() {
// 1. DIコンテナを初期化
app := &App{
logger: slog.New(slog.NewTextHandler(os.Stdout, nil)),
userService: &UserService{},
productService: &ProductService{},
}
// 2. 各ルーターを、それぞれが必要な依存(制約)とともに個別に作成
// NewUserRouter は app が持つ ProductService を知らない(必要としない)
userRouter := NewUserRouter(app)
// NewProductRouter は app が持つ UserService を知らない(必要としない)
productRouter := NewProductRouter(app)
// 3. 2つのルーターをマージして、最終的なハンドラを生成
finalRouter := MergeRouters(userRouter, productRouter)
fmt.Println("✅ Router setup complete. Simulating requests...")
simulateRequests(finalRouter)
}
// (リクエストのシミュレーション部分は変更なし)
func simulateRequests(handler http.Handler) {
fmt.Println("\n--- Test Case 1: Request to User Router ---")
reqUser, _ := http.NewRequest("GET", "/users/123", nil)
rrUser := httptest.NewRecorder()
handler.ServeHTTP(rrUser, reqUser)
fmt.Printf("Request: GET /users/123\n")
fmt.Printf("Response: %s", rrUser.Body.String())
fmt.Println("-------------------------------------------")
fmt.Println("\n--- Test Case 2: Request to Product Router ---")
reqProduct, _ := http.NewRequest("GET", "/products/abc-999", nil)
rrProduct := httptest.NewRecorder()
handler.ServeHTTP(rrProduct, reqProduct)
fmt.Printf("Request: GET /products/abc-999\n")
fmt.Printf("Response: %s", rrProduct.Body.String())
fmt.Println("----------------------------------------------")
}

もう少し粒度を細かくしてRouterとhandlerの関係でhandlerを細かく依存を制約として決めてテストコードはnil埋めなどなく最小の依存で行いルーターでそれらを合成したいとかは可能ですか?
今までの例で言うとUserServiceについてread系のメソッドとwrite系のメソッドで依存が大きく異なるとき一部分だけを依存として要求しそれだけでhandlerを作りテストをし最後にUserServiceを作るイメージです。

はい、完璧に理解いたしました。それは、依存性注入(DI)とジェネリクスの利点を最大限に引き出す、非常に高度で理想的な設計思想です。
結論から言うと、はい、その設計はGoのジェネリクスとインターフェースを使えば完全に実現可能です。
このアプローチの核心は以下の通りです。
-
インターフェースを極限まで小さくする(Interface Segregation Principle):
UserService
という大きなインターフェースを要求するのではなく、「ユーザーを読み取る機能(UserReader
)」「ユーザーを書き込む機能(UserWriter
)」のように、ハンドラが必要とする最小限の責務だけをインターフェースとして定義します。 -
ハンドラは最小限のインターフェースだけを要求する:
-
GetUserHandler
はUserReader
とLoggerProvider
だけを型制約として要求します。UserWriter
のことは一切知りません。 -
CreateUserHandler
はUserWriter
とLoggerProvider
だけを要求します。UserReader
のことは知りません。
-
-
テストは最小限のモックで実現する:
-
GetUserHandler
のテストでは、UserReader
とLoggerProvider
のメソッドだけを持つ小さなモック構造体を用意すれば十分です。UserWriter
のメソッドを実装したり、nil
を設定したりする必要は一切ありません。
-
-
ルーターが依存を合成する:
-
NewUserRouter
関数は、UserReader
とUserWriter
の両方を満たす、より大きな制約を要求します。 - この関数の中で、各ハンドラにDIコンテナ(
deps
)を渡します。Goの型システムが、このDIコンテナが各ハンドラの小さな制約を満たしていることをコンパイル時に保証してくれます。
-
-
具象実装は最後に一度だけ作る:
-
main
で、すべてのインターフェース(UserReader
,UserWriter
,LoggerProvider
など)を実装したApp
のようなDIコンテナ(具象型)を一度だけ作ります。
-
この設計を実証するための、完全なGo Playgroundサンプルコードを作成しました。特に ハンドラごとの制約定義 と テストのシンプルさ に注目してください。
Go Playground用サンプルコード(高粒度DI版)
package main
import (
"errors"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
)
// --- 1. 責務を細分化したインターフェース定義 ---
// UserReader は、ユーザーの読み取り操作のみを定義します。
type UserReader interface {
GetUser(id string) (string, error)
}
// UserWriter は、ユーザーの書き込み操作のみを定義します。
type UserWriter interface {
CreateUser(name string) error
}
// LoggerProvider は共通の依存です。
type LoggerProvider interface {
Logger() *slog.Logger
}
// --- 2. ハンドラごとに、最小の依存制約を定義 ---
// GetUserHandler が要求するのは「読み取り」と「ログ」だけです。
type GetUserDeps interface {
UserReader
LoggerProvider
}
func GetUserHandler[T GetUserDeps](w http.ResponseWriter, r *http.Request, deps T) {
id := r.PathValue("id")
deps.Logger().Info("Handling get user", "id", id)
user, err := deps.GetUser(id)
if err != nil {
http.Error(w, "User not found", http.StatusNotFound)
return
}
fmt.Fprintf(w, "Hello, %s", user)
}
// CreateUserHandler が要求するのは「書き込み」と「ログ」だけです。
type CreateUserDeps interface {
UserWriter
LoggerProvider
}
func CreateUserHandler[T CreateUserDeps](w http.ResponseWriter, r *http.Request, deps T) {
name := r.FormValue("name")
deps.Logger().Info("Handling create user", "name", name)
if err := deps.CreateUser(name); err != nil {
http.Error(w, "Failed to create user", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
fmt.Fprintln(w, "User created")
}
// --- 3. ルーターは、配下のハンドラ全ての制約を合成したものを要求する ---
// UserRouter は、読み書き両方の操作とロガーを必要とします。
type UserRouterDeps interface {
UserReader
UserWriter
LoggerProvider
}
// NewUserRouter は、UserRouterDeps を満たすDIコンテナを受け取ります。
func NewUserRouter[T UserRouterDeps](deps T) http.Handler {
mux := http.NewServeMux()
// `deps` は UserRouterDeps を満たすので、
// より小さい制約である GetUserDeps も満たしていることが保証されます。
mux.HandleFunc("GET /{id}", func(w http.ResponseWriter, r *http.Request) {
GetUserHandler(w, r, deps)
})
// 同様に、CreateUserDeps も満たしています。
mux.HandleFunc("POST /", func(w http.ResponseWriter, r *http.Request) {
CreateUserHandler(w, r, deps)
})
return mux
}
// --- 4. DIコンテナと main ---
// データベースのダミー
type FakeDB struct {
users map[string]string
}
func (db *FakeDB) GetUser(id string) (string, error) {
if user, ok := db.users[id]; ok {
return user, nil
}
return "", errors.New("not found")
}
func (db *FakeDB) CreateUser(name string) error {
id := fmt.Sprintf("id-%d", len(db.users)+1)
db.users[id] = name
return nil
}
// App は、全てのインターフェースを実装する具象DIコンテナです。
type App struct {
db *FakeDB
logger *slog.Logger
}
// インターフェース実装
func (a *App) GetUser(id string) (string, error) { return a.db.GetUser(id) }
func (a *App) CreateUser(name string) error { return a.db.CreateUser(name) }
func (a *App) Logger() *slog.Logger { return a.logger }
// コンパイル時チェック: *App が UserRouterDeps を満たしていることを確認
var _ UserRouterDeps = (*App)(nil)
func main() {
// DIコンテナを初期化
app := &App{
db: &FakeDB{
users: map[string]string{"id-1": "Alice"},
},
logger: slog.New(slog.NewTextHandler(os.Stdout, nil)),
}
// ルーターを初期化
userRouter := NewUserRouter(app)
fmt.Println("✅ Router setup complete. Running test simulation...")
// ここではサーバーを起動する代わりに、テスト関数を直接呼び出して動作をデモします。
// Go Playground は testing パッケージを main 関数から直接実行できます。
testing.Main(
func(pat, str string) (bool, error) { return true, nil },
[]testing.InternalTest{
{Name: "TestGetUserHandler_WithMinimalMock", F: TestGetUserHandler_WithMinimalMock},
},
[]testing.InternalBenchmark{},
[]testing.InternalFuzzTarget{},
)
// サーバーとして動かす場合のシミュレーション
fmt.Println("\n--- Simulating HTTP Server ---")
simulateRequest(userRouter, "GET", "/id-1", "")
simulateRequest(userRouter, "POST", "/", "name=Bob")
simulateRequest(userRouter, "GET", "/id-2", "")
}
// --- 5. ハンドラの単体テスト ---
// TestGetUserHandler_WithMinimalMock は、GetUserHandlerの単体テストです。
// これがこの設計パターンの真価を発揮する部分です。
func TestGetUserHandler_WithMinimalMock(t *testing.T) {
// 1. テスト対象が必要とする、最小限の依存だけを持つモックを定義
type mockDeps struct{}
// UserReader を満たす
func (m *mockDeps) GetUser(id string) (string, error) {
if id == "test-id" {
return "Mocked User", nil
}
return "", errors.New("not found")
}
// LoggerProvider を満たす
func (m *mockDeps) Logger() *slog.Logger {
// テスト中は出力を捨てる
return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
}
// 2. モックをインスタンス化
deps := &mockDeps{}
// 3. テスト実行
req := httptest.NewRequest("GET", "/test-id", nil)
req.SetPathValue("id", "test-id") // パスパラメータを設定
rr := httptest.NewRecorder()
// nil埋めなし! UserWriter のことを気にする必要は一切ありません。
GetUserHandler(rr, req, deps)
// 4. アサーション
if rr.Code != http.StatusOK {
t.Errorf("expected status OK; got %d", rr.Code)
}
expectedBody := "Hello, Mocked User"
if rr.Body.String() != expectedBody {
t.Errorf("expected body '%s'; got '%s'", expectedBody, rr.Body.String())
}
t.Log("✅ GetUserHandler test passed with minimal mock.")
}
// (リクエストシミュレーション用のヘルパー)
func simulateRequest(handler http.Handler, method, path, body string) {
req := httptest.NewRequest(method, path, strings.NewReader(body))
if method == "POST" {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
// パスパラメータを擬似的に設定
if strings.Contains(path, "/") && method == "GET" {
parts := strings.Split(strings.Trim(path, "/"), "/")
if len(parts) > 0 {
req.SetPathValue("id", parts[0])
}
}
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
fmt.Printf("\nRequest: %s %s\n", method, path)
fmt.Printf("Response: Status %d, Body: %s", rr.Code, strings.TrimSpace(rr.Body.String()))
}