Open5

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

podhmopodhmo

最小限でテストしたい。nilにアクセスしたくない。diしたい。

しかしこれって必要なんだろうか?
実験してみる。

podhmopodhmo

型パラメーターの制約の部分にLogger() *Loggerのインターフェイスを書いたり、ルーターAやルーターBそれぞれで別の制約を書いたりしてそれをマージする感じのイメージです。

汎用的なMerge関数を作りたいわけではないのです。2つのルーターをマージして新しいルーターを作る関数を書いてmainで呼びたいのです。ただし個々のルーターは別々の型制約をそれぞれ必要な分だけ持ってるイメージです。

podhmopodhmo

承知いたしました。完全に理解いたしました。
「汎用的なマージ関数」ではなく、「ユーザー用ルーターと商品用ルーターという、それぞれが異なる型制約を持つ2つの具体的なルーターをマージして、1つの新しいルーター(ハンドラ)を作る、特定の関数」をmainから呼び出したい、というご意図ですね。

はい、そのアプローチは可能であり、かつ非常にクリーンな設計です。

先ほどのコードを、その意図がより明確に伝わるようにリファクタリングしました。mainの構造がご希望の形になっているはずです。

このコードのポイント

  1. ルーターのセットアップ関数 (New...Router):

    • NewUserRouter は、UserRouterDeps という制約だけを要求します。この関数の中では、ProductService の存在を一切知りません。
    • NewProductRouter は、ProductRouterDeps という制約だけを要求します。
    • これらの関数は、設定済みのルーターを http.Handler として返します。これにより、内部実装(ジェネリクスなど)がカプセル化されます。
  2. マージ関数 (MergeRouters):

    • この関数はジェネリクスを使いません
    • 単純に http.Handler を2つ受け取り、http.ServeMux を使ってそれらを合成し、新しい http.Handler を1つ返します。
    • この関数は「どのルーターがどんな依存関係を持っているか」を全く気にする必要がありません。関数の責務が明確に分離されています。
  3. main関数での呼び出し:

    • DIコンテナ(app)を1つ作ります。
    • NewUserRouter(app)NewProductRouter(app) を呼び出して、それぞれのルーターを個別に作ります。(ここで型制約のチェックがコンパイル時に行われます)
    • 最後に MergeRouters(userRouter, productRouter) を呼び出して、最終的なルーターを取得します。

この流れにより、「個々のルーターは必要な分だけの制約を持ち」「それらをマージする専用の関数が存在する」というご希望の設計が実現できています。


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("----------------------------------------------")
}
podhmopodhmo

もう少し粒度を細かくしてRouterとhandlerの関係でhandlerを細かく依存を制約として決めてテストコードはnil埋めなどなく最小の依存で行いルーターでそれらを合成したいとかは可能ですか?

今までの例で言うとUserServiceについてread系のメソッドとwrite系のメソッドで依存が大きく異なるとき一部分だけを依存として要求しそれだけでhandlerを作りテストをし最後にUserServiceを作るイメージです。

podhmopodhmo

はい、完璧に理解いたしました。それは、依存性注入(DI)とジェネリクスの利点を最大限に引き出す、非常に高度で理想的な設計思想です。

結論から言うと、はい、その設計はGoのジェネリクスとインターフェースを使えば完全に実現可能です。

このアプローチの核心は以下の通りです。

  1. インターフェースを極限まで小さくする(Interface Segregation Principle):
    UserService という大きなインターフェースを要求するのではなく、「ユーザーを読み取る機能(UserReader)」「ユーザーを書き込む機能(UserWriter)」のように、ハンドラが必要とする最小限の責務だけをインターフェースとして定義します。

  2. ハンドラは最小限のインターフェースだけを要求する:

    • GetUserHandlerUserReaderLoggerProvider だけを型制約として要求します。UserWriter のことは一切知りません。
    • CreateUserHandlerUserWriterLoggerProvider だけを要求します。UserReader のことは知りません。
  3. テストは最小限のモックで実現する:

    • GetUserHandler のテストでは、UserReaderLoggerProvider のメソッドだけを持つ小さなモック構造体を用意すれば十分です。UserWriter のメソッドを実装したり、nil を設定したりする必要は一切ありません。
  4. ルーターが依存を合成する:

    • NewUserRouter 関数は、UserReaderUserWriter の両方を満たす、より大きな制約を要求します。
    • この関数の中で、各ハンドラにDIコンテナ(deps)を渡します。Goの型システムが、このDIコンテナが各ハンドラの小さな制約を満たしていることをコンパイル時に保証してくれます。
  5. 具象実装は最後に一度だけ作る:

    • 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()))
}