Go 结构体和方法:用组合组织真实业务数据

本文讲解 Go 结构体、字段标签、方法接收者、嵌入和组合建模,帮助初学者用清楚的数据结构表达真实业务。

结构体是 Go 里最重要的建模工具

如果说变量和函数让程序能运行,那么结构体让程序开始表达业务。用户、订单、文章、配置、任务、支付记录,这些概念都需要有名字、有字段、有规则。Go 没有传统面向对象语言那种复杂类体系,它用结构体加方法来组织数据和行为,再通过组合表达复用。

这让 Go 的建模方式很直接。你看到一个 User,就知道它有哪些字段;看到一个 func (u User) DisplayName(),就知道这个方法属于用户值;看到一个结构体里嵌入另一个结构体,就知道它复用了那部分字段或能力。没有继承树,也没有隐藏的父类初始化过程。

但直接不等于粗糙。结构体字段如何命名,哪些字段导出,方法用值接收者还是指针接收者,是否应该嵌入,是否应该拆成多个小结构体,这些都会影响代码长期可维护性。

定义一个结构体

最基本的结构体:

type User struct {
	ID     int64
	Name   string
	Email  string
	Active bool
}

创建值:

u := User{
	ID:     1,
	Name:   "小林",
	Email:  "xiaolin@example.com",
	Active: true,
}

推荐使用字段名初始化。虽然 Go 也允许按顺序初始化:

u := User{1, "小林", "xiaolin@example.com", true}

但这种写法不适合业务结构体。一旦字段顺序变化,代码很容易错。带字段名虽然多写几个字符,却能让读者清楚每个值的含义。

访问字段:

fmt.Println(u.Name)
u.Active = false

结构体字段首字母大写表示导出,可以被其他包访问;小写表示包内私有。不要所有字段都无脑大写。跨包暴露的数据结构是一种 API,后面修改成本更高。

零值也应该尽量可用

Go 很重视零值。一个结构体没有显式初始化时,字段会是各自类型的零值:

var u User
fmt.Println(u.ID)     // 0
fmt.Println(u.Name)   // ""
fmt.Println(u.Active) // false

如果结构体的零值能代表一个合理状态,使用起来会更舒服。比如:

type Counter struct {
	Value int
}

func (c *Counter) Inc() {
	c.Value++
}

var c Counter 就可以直接使用。

但不是所有结构体都适合零值可用。比如数据库连接、外部客户端、带必填配置的服务对象,通常需要构造函数:

type Mailer struct {
	host string
	port int
}

func NewMailer(host string, port int) (*Mailer, error) {
	if host == "" {
		return nil, fmt.Errorf("host is required")
	}
	if port <= 0 {
		return nil, fmt.Errorf("port must be positive")
	}
	return &Mailer{host: host, port: port}, nil
}

构造函数不是语言特性,只是普通函数。Go 社区习惯用 NewTypeName 命名。它适合封装校验和默认值。

方法让数据拥有行为

给结构体定义方法:

func (u User) DisplayName() string {
	if u.Name != "" {
		return u.Name
	}
	return u.Email
}

调用:

fmt.Println(u.DisplayName())

方法接收者写在 func 和方法名之间。(u User) 表示这个方法属于 User 类型。接收者名字通常短一些,比如 uoc,不需要写成 thisself。Go 没有这两个关键字。

如果方法需要修改结构体,使用指针接收者:

func (u *User) Deactivate() {
	u.Active = false
}

调用:

u.Deactivate()

Go 会自动处理取地址,所以调用处仍然很自然。

方法不应该只是字段访问包装。如果一个方法只是返回字段,没有任何业务含义,未必需要。方法最适合表达规则:

func (u User) CanLogin() bool {
	return u.Active && u.Email != ""
}

这比在各处写 u.Active && u.Email != "" 更好,因为规则集中在一个地方。

值接收者还是指针接收者

选择接收者时,可以用几个实用规则。

如果方法要修改结构体,用指针:

func (o *Order) MarkPaid() {
	o.Paid = true
}

如果结构体比较大,用指针,避免复制:

func (o *Order) Total() int64 {
	return o.TotalCents
}

如果类型包含 sync.Mutex 这类不能复制的字段,必须用指针。

如果结构体很小,而且方法只是读取,用值接收者也可以:

type Point struct {
	X int
	Y int
}

func (p Point) IsZero() bool {
	return p.X == 0 && p.Y == 0
}

同一个类型的方法最好保持一致。如果一部分用值,一部分用指针,读者会困惑。业务结构体通常直接统一用指针接收者,简单稳定。

字段标签:结构体和外部世界的边界

结构体经常需要和 JSON、数据库、表单交互。字段标签用来描述外部名称:

type UserResponse struct {
	ID    int64  `json:"id"`
	Name  string `json:"name"`
	Email string `json:"email,omitempty"`
}

编码 JSON:

data, err := json.Marshal(UserResponse{
	ID:   1,
	Name: "小林",
})
if err != nil {
	fmt.Println(err)
	return
}
fmt.Println(string(data))

omitempty 表示字段是零值时可以省略。比如 Email 为空字符串时,输出 JSON 不包含 email

字段标签不是注释,它会被反射读取。写错不会总是编译失败,所以要小心。比如漏了反引号、字段名拼错,运行结果可能和预期不同。

还有一个建议:不要把数据库模型、业务模型和 API 响应模型无脑混在一个结构体里。小项目可以简单处理,但当字段越来越多、权限越来越复杂时,拆开会更清楚。

组合优于继承

Go 没有类继承,但支持结构体嵌入:

type AuditFields struct {
	CreatedAt time.Time
	UpdatedAt time.Time
}

type Article struct {
	ID    int64
	Title string
	AuditFields
}

使用:

article := Article{
	ID:    1,
	Title: "Go 入门",
	AuditFields: AuditFields{
		CreatedAt: time.Now(),
		UpdatedAt: time.Now(),
	},
}

fmt.Println(article.CreatedAt)

Article 嵌入了 AuditFields,所以可以直接访问 article.CreatedAt。这不是继承,只是字段和方法提升。你也可以写 article.AuditFields.CreatedAt,它更明确。

嵌入适合真正稳定、通用的字段组,比如审计字段、分页参数、响应元信息。不要为了少写几个字段就乱嵌入。组合的目的是表达关系,不是隐藏结构。

有时普通命名字段更清楚:

type Article struct {
	ID    int64
	Title string
	Audit AuditFields
}

这样调用处写 article.Audit.CreatedAt,关系更明显。是否嵌入,要看读者读代码时是否更容易理解。

建模一个订单

下面用订单做一个完整一点的例子:

package main

import (
	"fmt"
	"time"
)

type OrderStatus string

const (
	OrderPending OrderStatus = "pending"
	OrderPaid    OrderStatus = "paid"
	OrderClosed  OrderStatus = "closed"
)

type OrderItem struct {
	SKU        string
	Name       string
	PriceCents int64
	Quantity   int
}

type Order struct {
	ID        int64
	UserID    int64
	Status    OrderStatus
	Items     []OrderItem
	CreatedAt time.Time
}

func (o Order) TotalCents() int64 {
	var total int64
	for _, item := range o.Items {
		total += item.PriceCents * int64(item.Quantity)
	}
	return total
}

func (o Order) CanPay() bool {
	return o.Status == OrderPending && len(o.Items) > 0
}

func (o *Order) MarkPaid() error {
	if !o.CanPay() {
		return fmt.Errorf("order cannot be paid")
	}
	o.Status = OrderPaid
	return nil
}

这个模型做了几件事。订单状态用自定义类型 OrderStatus,比裸字符串更有含义;订单项单独建模,避免把所有字段堆在订单里;总价通过方法计算,避免调用处重复写循环;支付规则集中在 CanPayMarkPaid 中。

使用:

func main() {
	order := Order{
		ID:     1001,
		UserID: 8,
		Status: OrderPending,
		Items: []OrderItem{
			{SKU: "book-go", Name: "Go 入门书", PriceCents: 5900, Quantity: 1},
			{SKU: "sticker", Name: "贴纸", PriceCents: 500, Quantity: 2},
		},
		CreatedAt: time.Now(),
	}

	fmt.Println(order.TotalCents())

	if err := order.MarkPaid(); err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println(order.Status)
}

这段代码不像教科书里的抽象例子,它非常接近日常业务。结构体负责表达数据,方法负责表达和数据紧密相关的规则。

不要把结构体设计成万能容器

初学者容易写出这种结构体:

type User struct {
	ID          int64
	Name        string
	Email       string
	Password    string
	Token       string
	Page        int
	PageSize    int
	Permissions []string
	Debug       bool
}

它把数据库字段、登录请求、分页参数、权限结果和调试开关都塞在一起。短期省事,长期会让每个函数都拿到太多不该知道的信息。

更好的方式是按场景拆分:

type User struct {
	ID    int64
	Name  string
	Email string
}

type LoginRequest struct {
	Email    string `json:"email"`
	Password string `json:"password"`
}

type PageQuery struct {
	Page     int
	PageSize int
}

结构体越贴近具体场景,字段越少,函数越容易写。Go 没有强迫你分层,但它鼓励你用简单类型把边界表达清楚。

小结

结构体是 Go 业务建模的核心。定义字段时要考虑含义和边界,初始化时优先使用字段名,方法要表达真实规则,接收者选择要和修改语义一致。字段标签帮助结构体和 JSON 等外部格式对接,但不要让一个结构体承担所有场景。

组合是 Go 的重要思想。它不靠继承树复用代码,而是把小而清楚的类型放在一起。什么时候嵌入,什么时候用命名字段,取决于哪种方式让关系更明显。

当你能用结构体把用户、订单、文章这类概念表达清楚,Go 代码就会从“语法练习”进入“业务建模”。这也是后面理解接口和服务边界的基础。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页