结构体是 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 类型。接收者名字通常短一些,比如 u、o、c,不需要写成 this 或 self。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,比裸字符串更有含义;订单项单独建模,避免把所有字段堆在订单里;总价通过方法计算,避免调用处重复写循环;支付规则集中在 CanPay 和 MarkPaid 中。
使用:
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 代码就会从“语法练习”进入“业务建模”。这也是后面理解接口和服务边界的基础。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。