到目前為止,我們的 /search
handler 還是直接回傳假資料。這樣不利於測試與後續演進。
今天我們要把邏輯抽離成 service 層,定義一個 SearchService
介面,handler 只負責「接請求、回應結果」,而不負責「怎麼搜」。
👉 這樣做的好處:
.
├── go.mod
├── main.go
├── middleware.go
├── metrics.go
├── service.go # ← 新增
└── ...
// service.go
package main
import "context"
// SearchResult 是單筆搜尋結果
type SearchResult struct {
ID int `json:"id"`
Title string `json:"title"`
}
// SearchResponse 是搜尋回應
type SearchResponse struct {
Query string `json:"query"`
Hits []SearchResult `json:"hits"`
}
// SearchService 定義搜尋服務介面
type SearchService interface {
Search(ctx context.Context, query string) (SearchResponse, error)
}
// FakeSearchService 是假的實作,先回固定資料
type FakeSearchService struct{}
func (s *FakeSearchService) Search(ctx context.Context, query string) (SearchResponse, error) {
return SearchResponse{
Query: query,
Hits: []SearchResult{
{ID: 1, Title: "Learning Go"},
{ID: 2, Title: "Go Concurrency Patterns"},
},
}, nil
}
func searchHandler(searchService SearchService) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
if query == "" {
http.Error(w, "missing query parameter: q", http.StatusBadRequest)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
resp, err := searchService.Search(ctx, query)
if err != nil {
http.Error(w, fmt.Sprintf("search error: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
http.Error(w, fmt.Sprintf("encode error: %v", err), http.StatusInternalServerError)
return
}
}
}
func main() {
cfg := LoadConfig()
// 初始化搜尋服務
searchService := &FakeSearchService{}
mux := http.NewServeMux()
mux.HandleFunc("/healthz", healthHandler)
mux.HandleFunc("/search", searchHandler(searchService))
// 新增 metrics endpoint
mux.Handle("/metrics", promhttp.Handler())
// 中介層:metrics → logging → recovery
handler := MetricsMiddleware(LoggingMiddleware(RecoveryMiddleware(mux)))
// 啟動 pprof (只有 build tag=debug 才會啟動)
StartPprof()
log.Printf("Server listening on %s", cfg.Port)
if err := http.ListenAndServe(cfg.Port, handler); err != nil {
log.Fatal(err)
}
}
新增 service_test.go
:
package main
import (
"context"
"testing"
"time"
)
func TestFakeSearchService(t *testing.T) {
svc := &FakeSearchService{}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
resp, err := svc.Search(ctx, "golang")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.Query != "golang" {
t.Errorf("got query=%s, want golang", resp.Query)
}
if len(resp.Hits) != 2 {
t.Errorf("expected 2 hits, got %d", len(resp.Hits))
}
}
go test -v ./...
預期結果:
go test -v ./...
=== RUN TestHealthHandler
=== RUN TestHealthHandler/healthz_should_return_ok
=== RUN TestHealthHandler/unknown_path_should_return_404
--- PASS: TestHealthHandler (0.00s)
--- PASS: TestHealthHandler/healthz_should_return_ok (0.00s)
--- PASS: TestHealthHandler/unknown_path_should_return_404 (0.00s)
=== RUN TestRecoveryMiddleware_Returns500OnPanic
2025/09/25 09:27:58 panic: boom
--- PASS: TestRecoveryMiddleware_Returns500OnPanic (0.00s)
=== RUN TestBackoff_NoJitter
--- PASS: TestBackoff_NoJitter (0.00s)
=== RUN TestIsRetryable_HTTP
--- PASS: TestIsRetryable_HTTP (0.00s)
=== RUN TestIsRetryable_netError
--- PASS: TestIsRetryable_netError (0.00s)
=== RUN TestRetry_SucceedsAfterRetries
--- PASS: TestRetry_SucceedsAfterRetries (0.00s)
=== RUN TestRetry_StopsOnNonRetryable
--- PASS: TestRetry_StopsOnNonRetryable (0.00s)
=== RUN TestRetry_CancelContext
--- PASS: TestRetry_CancelContext (0.02s)
=== RUN TestSearchHandler
=== RUN TestSearchHandler/valid_query
=== RUN TestSearchHandler/missing_query
--- PASS: TestSearchHandler (0.00s)
--- PASS: TestSearchHandler/valid_query (0.00s)
--- PASS: TestSearchHandler/missing_query (0.00s)
=== RUN TestFakeSearchService
--- PASS: TestFakeSearchService (0.00s)
=== RUN TestRunWorkerPool_AllSuccess
--- PASS: TestRunWorkerPool_AllSuccess (0.00s)
=== RUN TestRunWorkerPool_WithError
--- PASS: TestRunWorkerPool_WithError (0.00s)
=== RUN TestRunWorkerPool_CancelEarly
--- PASS: TestRunWorkerPool_CancelEarly (0.00s)
PASS
ok github.com/arealclimber/cloud-native-search (cached)
今天完成:
/search
handler 改成呼叫 SearchService
SearchService
interface,先做 FakeSearchService
假實作👉 明天我們會寫 整合測試:模擬一個假的 ES,在 E2E 測試中跑 /search
API,確保 handler + service + middleware 能一起動。