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.Flusher、io.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.WaitGroup、sync.Mutex、strings.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]) |






