golang-structs-interfaces

golang-structs-interfaces

熱門

Golang 結構體與介面設計模式 — 組合、嵌入、型別斷言、型別切換、介面隔離、透過介面進行依賴注入、結構體欄位標籤,以及指標接收器與值接收器的選擇。當你在設計 Go 型別、定義或實作介面、嵌入結構體或介面、撰寫型別斷言或型別切換、為 JSON/YAML/DB 序列化新增結構體欄位標籤,或是在指標接收器與值接收器之間做選擇時,請使用此技能。當使用者詢問關於「接受介面,回傳結構體」、編譯時期介面檢查,或將小型介面組合成較大介面時,也請使用此技能。

2261星標
150分支
更新於 2026/6/6
SKILL.md
readonlyread-only
name
golang-structs-interfaces
description

Golang 結構體與介面設計模式 — 組合、嵌入、型別斷言、型別切換、介面隔離、透過介面進行依賴注入、結構體欄位標籤,以及指標接收器與值接收器的選擇。當你在設計 Go 型別、定義或實作介面、嵌入結構體或介面、撰寫型別斷言或型別切換、為 JSON/YAML/DB 序列化新增結構體欄位標籤,或是在指標接收器與值接收器之間做選擇時,請使用此技能。當使用者詢問關於「接受介面,回傳結構體」、編譯時期介面檢查,或將小型介面組合成較大介面時,也請使用此技能。

角色設定: 你是一位 Go 型別系統設計師。你偏好小型、可組合的介面以及具體的回傳型別 — 你為可測試性與清晰度而設計,而非為了抽象而抽象。

社群預設。 若某個公司技能明確取代了 samber/cc-skills-golang@golang-structs-interfaces 技能,則以該公司技能為準。

Go 結構體與介面

介面設計原則

保持介面小型

「介面越大,抽象越弱。」— Go 諺語

介面應有 1-3 個方法。小型介面更容易實作、模擬與組合。若你需要更大的合約,請從小型介面組合而成:

→ 請參閱 samber/cc-skills-golang@golang-naming 技能以了解介面命名慣例(方法 + "-er" 後綴、標準名稱)

type Reader interface {
    Read(p []byte) (n int, err error)
}

type Writer interface {
    Write(p []byte) (n int, err error)
}

// 從小型介面組合而成
type ReadWriter interface {
    Reader
    Writer
}

從較小的介面組合成較大的介面:

type ReadWriteCloser interface {
    io.Reader
    io.Writer
    io.Closer
}

在消費端定義介面

介面屬於消費者。

介面必須在消費的地方定義,而非實作的地方。這讓消費者掌控合約,並避免為了匯入介面而匯入整個套件。

// package notification — 只定義它需要的內容
type Sender interface {
    Send(to, body string) error
}

type Service struct {
    sender Sender
}

email 套件匯出一個具體的 Client 結構體 — 它不需要知道 Sender

接受介面,回傳結構體

函式應接受介面參數以獲得彈性,並回傳具體型別以保持清晰。呼叫端可以完整存取回傳型別的欄位與方法;上游的消費者仍可將結果指派給介面變數(如有需要)。

// 好 — 接受介面,回傳具體型別
func NewService(store UserStore) *Service { ... }

// 不好 — 絕對不要從建構函式回傳介面
func NewService(store UserStore) ServiceInterface { ... }

不要過早建立介面

「不要設計介面,而是發現介面。」

絕對不要過早建立介面 — 等到有兩個以上的實作或可測試性需求時再建立。過早的介面只會增加間接性而沒有價值。從具體型別開始;當第二個消費者或測試模擬需要時,再提取出介面。

// 不好 — 只有一個實作的過早介面
type UserRepository interface {
    FindByID(ctx context.Context, id string) (*User, error)
}
type userRepository struct { db *sql.DB }

// 好 — 從具體型別開始,之後需要時再提取介面
type UserRepository struct { db *sql.DB }

讓零值有用

設計結構體使其無需明確初始化即可使用。設計良好的零值能減少建構函式的樣板程式碼,並防止與 nil 相關的錯誤:

// 好 — 零值可直接使用
var buf bytes.Buffer
buf.WriteString("hello")

var mu sync.Mutex
mu.Lock()

// 不好 — 零值有問題,需要建構函式
type Registry struct {
    items map[string]Item // nil map,寫入時會 panic
}

// 好 — 延遲初始化保護零值
func (r *Registry) Register(name string, item Item) {
    if r.items == nil {
        r.items = make(map[string]Item)
    }
    r.items[name] = item
}

避免在具體型別可用的情況下使用 any / interface{}

從 Go 1.18 開始,對於型別安全的操作,應優先使用泛型而非 any。只有在真正不知道型別的邊界(例如 JSON 解碼、反射)才使用 any

// 不好 — 失去型別安全
func Contains(slice []any, target any) bool { ... }

// 好 — 泛型,型別安全
func Contains[T comparable](slice []T, target T) bool { ... }

重要的標準函式庫介面

介面 套件 方法
Reader io Read(p []byte) (n int, err error)
Writer io Write(p []byte) (n int, err error)
Closer io Close() error
Stringer fmt String() string
error builtin Error() string
Handler net/http ServeHTTP(ResponseWriter, *Request)
Marshaler encoding/json MarshalJSON() ([]byte, error)
Unmarshaler encoding/json UnmarshalJSON([]byte) error

標準的方法簽章必須遵守 — 如果你的型別有 String() 方法,它必須符合 fmt.Stringer。不要發明 ToString()ReadData()

編譯時期介面檢查

使用空白識別字賦值,在編譯時期驗證型別是否實作了某個介面。將其放在型別定義附近:

var _ io.ReadWriter = (*MyBuffer)(nil)

這在執行時期沒有任何成本。如果 MyBuffer 不再滿足 io.ReadWriter,建置會立即失敗。

型別斷言與型別切換

安全的型別斷言

型別斷言必須使用逗號-ok 形式以避免 panic:

// 好 — 安全
s, ok := val.(string)
if !ok {
    // 處理
}

// 不好 — 如果 val 不是 string 會 panic
s := val.(string)

型別切換

發現介面值的動態型別:

switch v := val.(type) {
case string:
    fmt.Println(v)
case int:
    fmt.Println(v * 2)
case io.Reader:
    io.Copy(os.Stdout, v)
default:
    fmt.Printf("unexpected type %T\n", v)
}

使用型別斷言實現選擇性行為

檢查值是否支援額外功能,而不要求事先具備:

type Flusher interface {
    Flush() error
}

func writeData(w io.Writer, data []byte) error {
    if _, err := w.Write(data); err != nil {
        return err
    }
    // 只有當 writer 支援時才 Flush
    if f, ok := w.(Flusher); ok {
        return f.Flush()
    }
    return nil
}

這個模式在標準函式庫中被廣泛使用(例如 http.Flusherio.ReaderFrom)。

結構體與介面嵌入

結構體嵌入

嵌入會將內部型別的方法與欄位提升到外部型別 — 這是組合,而非繼承:

type Logger struct {
    *slog.Logger
}

type Server struct {
    Logger
    addr string
}

// s.Info(...) 可以運作 — 從 slog.Logger 透過 Logger 提升
s := Server{Logger: Logger{slog.Default()}, addr: ":8080"}
s.Info("starting", "addr", s.addr)

被提升方法的接收器是內部型別,而非外部型別。外部型別可以透過定義同名方法來覆蓋。

何時嵌入 vs 使用命名欄位

使用時機 說明
嵌入 你想要提升內部型別的完整 API — 外部型別「是一種」增強版本
命名欄位 你只在內部需要該型別 — 外部型別「有一個」依賴
// 嵌入 — Server 暴露所有 http.Handler 方法
type Server struct {
    http.Handler
}

// 命名欄位 — Server 使用 store 但不暴露其方法
type Server struct {
    store *DataStore
}

透過介面進行依賴注入

在建構函式中接受介面作為依賴。這能解耦元件並使測試變得簡單:

type UserStore interface {
    FindByID(ctx context.Context, id string) (*User, error)
}

type UserService struct {
    store UserStore
}

func NewUserService(store UserStore) *UserService {
    return &UserService{store: store}
}

在測試中,傳入一個滿足 UserStore 的模擬或樁 — 不需要真實的資料庫。

結構體欄位標籤

使用欄位標籤來控制序列化。序列化結構體中的匯出欄位必須有欄位標籤:

type Order struct {
    ID        string    `json:"id"         db:"id"`
    UserID    string    `json:"user_id"    db:"user_id"`
    Total     float64   `json:"total"      db:"total"`
    Items     []Item    `json:"items"      db:"-"`
    CreatedAt time.Time `json:"created_at" db:"created_at"`
    DeletedAt time.Time `json:"-"          db:"deleted_at"`
    Internal  string    `json:"-"          db:"-"`
}
指令 意義
json:"name" JSON 輸出中的欄位名稱
json:"name,omitempty" 若為零值則省略該欄位
json:"-" 永遠從 JSON 中排除
json:",string" 將數字/布林值編碼為 JSON 字串
db:"column" 資料庫欄位對應(sqlx 等)
yaml:"name" YAML 欄位名稱
xml:"name,attr" XML 屬性
validate:"required" 結構體驗證(go-playground/validator)

指標接收器 vs 值接收器

使用指標 (s *Server) 使用值 (s Server)
方法會修改接收器 接收器很小且不可變
接收器包含 sync.Mutex 或類似物件 接收器是基本型別(int, string)
接收器是大型結構體 方法是唯讀存取器
一致性:若任何方法使用指標,則全部使用 Map 與函式值(已是參考型別)

接收器型別在一個型別的所有方法中必須一致 — 如果一個方法使用指標接收器,所有方法都應使用。

使用 noCopy 防止結構體複製

某些結構體在首次使用後絕不能複製(例如包含 mutex、channel 或內部指標的結構體)。嵌入一個 noCopy 標記,讓 go vet 捕捉意外的複製:

// noCopy 可以加入那些在首次使用後不得複製的結構體。
// 請參閱 https://pkg.go.dev/sync#noCopy
type noCopy struct{}

func (*noCopy) Lock()   {}
func (*noCopy) Unlock() {}

type ConnPool struct {
    noCopy noCopy
    mu     sync.Mutex
    conns  []*Conn
}

如果 ConnPool 值被複製(以值傳遞、賦值等),go vet 會回報錯誤。這是標準函式庫用於 sync.WaitGroupsync.Mutexstrings.Builder 等的相同技術。

始終以指標傳遞這些結構體:

// 好
func process(pool *ConnPool) { ... }

// 不好 — go vet 會標記此處
func process(pool ConnPool) { ... }

交叉參考

  • → 請參閱 samber/cc-skills-golang@golang-naming 技能以了解介面命名慣例(Reader, Closer, Stringer)
  • → 請參閱 samber/cc-skills-golang@golang-design-patterns 技能以了解函式選項、建構函式與建造者模式
  • → 請參閱 samber/cc-skills-golang@golang-dependency-injection 技能以了解使用介面的 DI 模式
  • → 請參閱 samber/cc-skills-golang@golang-code-style 技能以了解值與指標函式參數(與接收器不同)

常見錯誤

錯誤 修正
大型介面(5 個以上方法) 拆分為專注的 1-3 方法介面,必要時組合
在實作者套件中定義介面 在消費端定義
從建構函式回傳介面 回傳具體型別
裸型別斷言未使用逗號-ok 始終使用 v, ok := x.(T)
只需要少數方法時卻嵌入 使用命名欄位並明確委派
序列化結構體缺少欄位標籤 為所有匯出欄位加上標籤
在一個型別中混用指標與值接收器 選擇一種並保持一致
忘記編譯時期介面檢查 加入 var _ Interface = (*Type)(nil)
使用 ToString() 而非 String() 遵守標準方法名稱
只有一個實作時過早建立介面 從具體型別開始,需要時再提取
零值結構體中的 nil map/slice 在方法中使用延遲初始化
對型別安全的操作使用 any 改用泛型([T comparable]