Olivine:以 Go 建構與 Redis 相容的服務 · 章節目錄

第 12 章

Day 12:Dependency Injection

Dependency Injection 是管理 struct 初始化與依賴的設計模式。

回顧 cmd/olivine/app.go 的 NewApp():

func NewApp(cfg *data.Config) (*App, error) {
	var handler server.Handler
	var restorer server.Restorer
	var aof service.AOF
	if cfg.AOFEnabled {
		var err error
		aof, err = service.NewAOF(cfg, AOFPath)
		if err != nil {
			return nil, err
		}

		handler = server.NewHandler(cmd.NewCommands(repo.NewStorage()), server.NewAOFMiddleware(aof))
		restorer = server.NewRestorer(aof, handler)
	} else {
		handler = server.NewHandler(cmd.NewCommands(repo.NewStorage()))
	}

	return &App{
		cfg: cfg,

		aof: aof,
		srv: server.NewServer(handler, restorer),
	}, nil
}

App 需要 server.Server,而 server 又需要 server.Handler 與 server.Restorer;handler 也會依賴 service.AOF 等元件,形成依賴關係。

Dependency Injection Library

可以像目前的 NewApp() 一樣自行管理依賴,也可以交給 library。

常見方式有兩種:

  1. Compile Time:透過 go generate 產生程式碼。
  2. Runtime:透過 reflect 建立依賴。

我偏好 compile time,因為產生的 Go 原始碼可閱讀、可分析。Runtime 方案也有優點,但 reflect 帶來的隱藏行為比較難除錯。

Kessoku:wire 的替代方案

google/wire 已於 2025 年八月封存,因此我使用 mazrean/kessoku 作為替代。

安裝方式:

$ go get -tool github.com/mazrean/kessoku/cmd/kessoku

注意:Go 1.24 起支援 module 工具管理,不必將工具安裝到全域。

將 NewApp() 移到 kessoku

建立 cmd/olivine/kessoku.go:

// file: cmd/olivine/kessoku.go
package main

import (
	"github.com/mazrean/kessoku"
)

//go:generate go tool kessoku $GOFILE
var _ = kessoku.Inject[*App](
	"NewApp",
	kessoku.Provide(func(cfg *data.Config) (*App, error) { return nil, nil }),
)

kessoku.Inject 指示產生名為 NewApp() 的 constructor,接收 cfg *data.Config 並回傳 (*App, error)。

可以先將原本 NewApp() 的內容直接搬入:

var _ = kessoku.Inject[*App](
	"NewApp",
	kessoku.Provide(func(cfg *data.Config) (*App, error) {
		var handler server.Handler
		var restorer server.Restorer
		var aof service.AOF
		if cfg.AOFEnabled {
			var err error
			aof, err = service.NewAOF(cfg, AOFPath)
			if err != nil {
				return nil, err
			}

			handler = server.NewHandler(cmd.NewCommands(repo.NewStorage()), server.NewAOFMiddleware(aof))
			restorer = server.NewRestorer(aof, handler)
		} else {
			handler = server.NewHandler(cmd.NewCommands(repo.NewStorage()))
		}

		return &App{
			cfg: cfg,

			aof: aof,
			srv: server.NewServer(handler, restorer),
		}, nil
	}),
)

別忘了從 app.go 移除 NewApp()。

$ go generate ./...

初始化依賴

目前仍在同一個 provider 裡初始化 server、handler、restorer,可以拆成個別 provider:

var _ = kessoku.Inject[*App](
	"NewApp",
	kessoku.Bind[service.AOF](kessoku.Provide(func(cfg *data.Config) (service.AOF, error) {
		if !cfg.AOFEnabled {
			return nil, nil
		}

		return service.NewAOF(cfg, AOFPath)
	})),
	kessoku.Bind[server.Handler](kessoku.Provide(func(cfg *data.Config, aof service.AOF) server.Handler {
		middlewares := []server.Middleware{}
		if cfg.AOFEnabled {
			middlewares = append(middlewares, server.NewAOFMiddleware(aof))
		}

		return server.NewHandler(cmd.NewCommands(repo.NewStorage()), middlewares...)
	})),
	kessoku.Bind[server.Restorer](kessoku.Provide(func(cfg *data.Config, aof service.AOF, handler server.Handler) server.Restorer {
		if !cfg.AOFEnabled {
			return nil
		}

		return server.NewRestorer(aof, handler)
	})),
	kessoku.Bind[server.Server](kessoku.Provide(server.NewServer)),
	kessoku.Provide(func(cfg *data.Config, aof service.AOF, srv server.Server) *App {
		return &App{
			cfg: cfg,

			aof: aof,
			srv: srv,
		}
	}),
)

Dependency Injection 技巧

假設需要以下 Server interface:

interface Server {
	Handle()
	Restore()
}

而 Handle() 和 Restore() 分別定義在 service.Handler、service.Restorer,就可以將它們嵌入匿名 struct,再交給 kessoku 注入:

kessoku.Provide(service.NewHandler), // create `*service.Handler{}`
kessoku.Provide(service.NewRestorer), // create `*service.Restorer{}`
kessoku.Bind[server.Server](
	kessoku.Provide(func(h *service.Handler, r *service.Restorer) server.Server {
		return &struct{
			*service.Handler,
			*service.Restorer,
		}{ Handler: h, Restorer: r }
	}),
),

使用 errgroup 重構 goroutine

目前以 error channel 管理 goroutines,可以改用 errgroup:

-	errch := make(chan error, 1)
-	if app.cfg.AOFEnabled && app.cfg.AOFFsync == data.AOFFsyncEverySec {
-		go func() {
-			ticker := time.NewTicker(time.Second)
-			defer ticker.Stop()
-			for {
-				select {
-				case <-ctx.Done():
-					return
-				case <-ticker.C:
-					if err := app.aof.Sync(); err != nil {
-						errch <- err
-						return
-					}
-				}
-			}
-		}()
-	}
-	go func() {
-		slog.Info("starting olivine server")
-		if err := app.srv.ListenAndServe(); err != nil && !errors.Is(err, server.ErrServerClosed) {
-			errch <- err
-			return
-		}
-		errch <- nil
-	}()
+	g, ctx := errgroup.WithContext(ctx)
+	if app.cfg.AOFEnabled && app.cfg.AOFFsync == data.AOFFsyncEverySec {
+		g.Go(func() error {
+			ticker := time.NewTicker(time.Second)
+			defer ticker.Stop()
+
+			for {
+				select {
+				case <-ctx.Done():
+					return nil
+				case <-ticker.C:
+					if err := app.aof.Sync(); err != nil {
+						return err
+					}
+				}
+			}
+		})
+	}
+
+	g.Go(func() error {
+		slog.Info("starting olivine server")
+		if err := app.srv.ListenAndServe(); err != nil && !errors.Is(err, server.ErrServerClosed) {
+			return err
+		}
+		return nil
+	})
+
+	errch := make(chan error, 1)
+	go func() { errch <- g.Wait() }()