第23章:HasMany —— 一对多关系
本章目标
学完本章后,你将能够:
- 定义和使用 HasMany 关联
- 创建带一对多关联的记录
- 查询和过滤 HasMany 关联数据
- 理解 HasMany 的增删改操作
- 在实际项目中使用一对多关系
前置知识
- 需要先学习:第21-22章(BelongsTo、HasOne)
- 需要了解:MySQL 的外键和一对多关系
23.1 HasMany:有一个对多个
HasMany 表示"A 拥有多个 B",外键在 B 表中(和 HasOne 一样,但没有 UNIQUE 约束):
┌──────────┐ ┌──────────┐
│ User │ ──Has──>│ Order │
│ id: 1 │ │ id: 1 │
│ name:张三│ │ amount:100│
│ │ │ user_id:1│ ← 外键
└──────────┘ ├──────────┤
│ Order │
│ id: 2 │
│ amount:200│
│ user_id:1│ ← 同一个 user_id
└──────────┘基础示例:User 有多个 Order
go
type User struct {
ID uint `gorm:"primaryKey"`
Name string
Orders []Order // HasMany:一个 User 有多个 Order
}
type Order struct {
ID uint `gorm:"primaryKey"`
Amount float64
UserID uint // 外键(不设 uniqueIndex,允许多条相同 user_id)
}23.2 HasMany 标签配置
go
type User struct {
ID uint
Name string
Orders []Order `gorm:"foreignKey:UserID;references:ID"`
// ↑ Order 表中的外键字段 ↑ User 表中的引用字段
}完整的双向定义
通常在一对多关系中,两边都定义关联:
go
type User struct {
ID uint
Name string
Orders []Order // HasMany:User 有多个 Order
}
type Order struct {
ID uint
Amount float64
UserID uint
User User // BelongsTo:Order 属于 User(反向关联)
}这样你可以从两端访问:
user.Orders→ 用户的全部订单order.User→ 订单所属的用户
23.3 创建 HasMany 记录
方式一:手动设置外键
go
user := User{Name: "张三"}
db.Create(&user)
// 创建订单时手动设置 UserID
order1 := Order{Amount: 100, UserID: user.ID}
order2 := Order{Amount: 200, UserID: user.ID}
db.Create(&order1)
db.Create(&order2)方式二:通过关联对象创建
go
// 创建用户时同时创建订单
user := User{
Name: "张三",
Orders: []Order{
{Amount: 100},
{Amount: 200},
{Amount: 300},
},
}
db.Select("Orders").Create(&user) // 同时创建 User 和 Orders
// 给已有用户追加订单
var user User
db.First(&user, 1)
db.Model(&user).Association("Orders").Append(
&Order{Amount: 400},
&Order{Amount: 500},
)23.4 查询 HasMany 关联
预加载
go
// 查询所有用户及其订单
var users []User
db.Preload("Orders").Find(&users)
// SQL: SELECT * FROM `users`
// SQL: SELECT * FROM `orders` WHERE user_id IN (1,2,3,...)
for _, u := range users {
fmt.Printf("%s 有 %d 个订单\n", u.Name, len(u.Orders))
}条件预加载
go
// 只预加载金额大于 100 的订单
db.Preload("Orders", "amount > ?", 100).Find(&users)
// 预加载时排序
db.Preload("Orders", func(db *gorm.DB) *gorm.DB {
return db.Order("amount desc").Limit(5)
}).Find(&users)用 Joins 查询有订单的用户
go
// 查有订单的用户(用 DISTINCT 去重)
var users []User
db.Joins("Orders").Distinct().Find(&users)
// 等价于:SELECT DISTINCT users.* FROM users
// JOIN orders ON users.id = orders.user_id23.5 一对多关系的增删改操作
追加关联
go
var user User
db.First(&user, 1)
// 方式一:Association Append(推荐)
db.Model(&user).Association("Orders").Append(
&Order{Amount: 600},
&Order{Amount: 700},
)
// 自动设置 order.user_id = user.id
// 方式二:直接创建(手动设置外键)
db.Create(&Order{Amount: 600, UserID: user.ID})替换关联
go
// 把用户的订单替换为新列表(旧订单的 user_id 被清空)
newOrders := []Order{
{Amount: 800},
{Amount: 900},
}
db.Model(&user).Association("Orders").Replace(&newOrders)删除关联
go
// 删除特定订单的关联(把 user_id 设为 NULL,不删除记录)
db.Model(&user).Association("Orders").Delete(&order1)
// 清空所有关联(把所有订单的 user_id 设为 NULL)
db.Model(&user).Association("Orders").Clear()统计关联数量
go
count := db.Model(&user).Association("Orders").Count()
fmt.Printf("用户 %s 有 %d 个订单\n", user.Name, count)23.6 完整示例:用户与订单
go
package main
import (
"fmt"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
type User struct {
ID uint `gorm:"primaryKey"`
Name string `gorm:"type:varchar(100);not null"`
Orders []Order // HasMany
}
type Order struct {
ID uint `gorm:"primaryKey"`
Amount float64 `gorm:"type:decimal(10,2)"`
Status string `gorm:"type:varchar(20);default:'pending'"`
UserID uint // 外键
User User // BelongsTo(反向关联)
}
func main() {
dsn := "root:123456@tcp(127.0.0.1:3306)/gorm_tutorial?charset=utf8mb4&parseTime=True&loc=Local"
db, _ := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Info),
})
db.AutoMigrate(&User{}, &Order{})
// 1. 创建用户和订单
user := User{
Name: "张三",
Orders: []Order{
{Amount: 150.00, Status: "paid"},
{Amount: 89.50, Status: "pending"},
{Amount: 299.00, Status: "paid"},
},
}
db.Select("Orders").Create(&user)
// 2. 查询所有用户及订单
var users []User
db.Preload("Orders").Find(&users)
for _, u := range users {
var total float64
for _, o := range u.Orders {
total += o.Amount
}
fmt.Printf("%s: %d个订单, 总金额 %.2f\n", u.Name, len(u.Orders), total)
}
// 3. 查询有已支付订单的用户
var paidUsers []User
db.Joins("Orders").Where("orders.status = ?", "paid").Distinct().Find(&paidUsers)
// 4. 追加订单
var foundUser User
db.First(&foundUser, user.ID)
db.Model(&foundUser).Association("Orders").Append(
&Order{Amount: 500, Status: "paid"},
)
}常见错误
错误1:一对多用 Preload,N+1 问题
go
// ❌ 不 Preload 却在循环中访问 Orders
var users []User
db.Find(&users)
for _, u := range users {
fmt.Println(len(u.Orders)) // Orders 为空!没有 Preload
}
// ✅ 加 Preload
db.Preload("Orders").Find(&users)错误2:Association 和 Preload 混淆
go
// Association:操作关联数据(增删改查关联)
db.Model(&user).Association("Orders").Append(&order)
// Preload:加载关联数据到结构体中(只查询)
db.Preload("Orders").Find(&users)错误3:直接用 Find 查 HasMany 导致大量数据
go
// ❌ 查全部订单(可能百万级!)
db.Preload("Orders").Find(&users)
// 如果每个 User 有 1000 个 Order,10 个 User = 10000 条数据到内存
// ✅ 用条件 Preload 限制
db.Preload("Orders", func(db *gorm.DB) *gorm.DB {
return db.Where("created_at > ?", time.Now().Add(-30*24*time.Hour)).Limit(10)
}).Find(&users)本章小结
- HasMany:A 有多个 B,外键在 B(没有 UNIQUE 约束)
- 字段类型:
[]Order(切片,不是单个结构体) - Preload 加载关联,Association 操作关联
- 条件预加载避免加载不必要的数据
- 双向定义(HasMany + BelongsTo)可以从两端访问
练习题
- 定义
Department和Employee模型,Department HasMany Employees。 - 创建一个部门及其多个员工,用 Select 同时创建。
- 用 Preload 查询所有部门及其员工,打印每个部门的人数。
- 使用 Association 追加、删除、替换员工。
- 写查询:找出员工数量大于 5 的部门。
- (思考题)如果 User 有 10000 个 Order,
Preload("Orders")会有什么问题?如何优化?