Skip to content

03-关联关系:一对一、一对多、多对多

现实世界的业务对象之间存在着丰富的关联关系:用户与用户详情一对一、用户与文章一对多、文章与标签多对多。GORM 通过结构体嵌套和标签提供了完整的关联关系支持,并通过 Preload、Association API 等机制简化关联操作。本篇将系统讲解 GORM 的四种关联关系及其使用方法。

一对一关系:Has One

Has One 表示一个模型拥有另一个模型,外键定义在被拥有的模型上。

go
package main

import (
	"fmt"
	"log"

	"gorm.io/driver/sqlite"
	"gorm.io/gorm"
)

// User 用户:拥有一个 Profile
type User struct {
	ID      uint
	Name    string
	Profile Profile // has one
}

// Profile 用户详情:包含 UserID 外键
type Profile struct {
	ID       uint
	UserID   uint // 外键,默认为 <拥有者模型名>+ID
	Bio      string
	Avatar   string
}

func main() {
	db, err := gorm.Open(sqlite.Open("hasone.db"), &gorm.Config{})
	if err != nil {
		log.Fatal(err)
	}
	db.AutoMigrate(&User{}, &Profile{})

	// 创建用户并关联 Profile(级联创建)
	user := User{
		Name: "Tom",
		Profile: Profile{
			Bio:    "Go 开发者",
			Avatar: "tom.png",
		},
	}
	db.Create(&user)

	// 预加载查询
	var found User
	db.Preload("Profile").First(&found, user.ID)
	fmt.Printf("用户: %s, 简介: %s, 头像: %s\n",
		found.Name, found.Profile.Bio, found.Profile.Avatar)
}

一对多关系:Has Many

Has Many 表示一个模型拥有多个其他模型。

go
package main

import (
	"fmt"
	"log"

	"gorm.io/driver/sqlite"
	"gorm.io/gorm"
)

// User 一个用户有多篇文章
type User struct {
	ID    uint
	Name  string
	Posts []Post // has many
}

// Post 文章:包含 UserID 外键
type Post struct {
	ID     uint
	UserID uint // 外键
	Title  string
}

func main() {
	db, err := gorm.Open(sqlite.Open("hasmany.db"), &gorm.Config{})
	if err != nil {
		log.Fatal(err)
	}
	db.AutoMigrate(&User{}, &Post{})

	// 创建用户并关联多篇文章
	user := User{
		Name: "Tom",
		Posts: []Post{
			{Title: "文章1"},
			{Title: "文章2"},
			{Title: "文章3"},
		},
	}
	db.Create(&user)

	// 预加载查询
	var found User
	db.Preload("Posts").First(&found, user.ID)
	fmt.Printf("用户 %s%d 篇文章:\n", found.Name, len(found.Posts))
	for _, p := range found.Posts {
		fmt.Printf("  - %s\n", p.Title)
	}
}

属于关系:Belongs To

Belongs To 与 Has One/Has Many 相反,表示当前模型属于另一个模型。

go
package main

import (
	"fmt"
	"log"

	"gorm.io/driver/sqlite"
	"gorm.io/gorm"
)

// User 用户
type User struct {
	ID   uint
	Name string
}

// Post 文章:属于某个用户
type Post struct {
	ID     uint
	Title  string
	UserID uint // 外键
	User   User // belongs to
}

func main() {
	db, err := gorm.Open(sqlite.Open("belongsto.db"), &gorm.Config{})
	if err != nil {
		log.Fatal(err)
	}
	db.AutoMigrate(&User{}, &Post{})

	// 创建用户
	user := User{Name: "Tom"}
	db.Create(&user)

	// 创建属于该用户的文章
	post := Post{Title: "Hello", UserID: user.ID}
	db.Create(&post)

	// 预加载 User
	var found Post
	db.Preload("User").First(&found, post.ID)
	fmt.Printf("文章: %s, 作者: %s\n", found.Title, found.User.Name)
}

多对多关系:Many2Many

Many2 Many 需要一个中间表来连接两个模型。GORM 自动创建中间表。

go
package main

import (
	"fmt"
	"log"

	"gorm.io/driver/sqlite"
	"gorm.io/gorm"
)

// Post 文章
type Post struct {
	ID    uint
	Title string
	Tags  []Tag `gorm:"many2many:post_tags;"` // 指定中间表名
}

// Tag 标签
type Tag struct {
	ID    uint
	Name  string
	Posts []Post `gorm:"many2many:post_tags;"`
}

func main() {
	db, err := gorm.Open(sqlite.Open("m2m.db"), &gorm.Config{})
	if err != nil {
		log.Fatal(err)
	}
	db.AutoMigrate(&Post{}, &Tag{})

	// 创建标签
	goTag := Tag{Name: "Go"}
	db.Create(&goTag)
	ormTag := Tag{Name: "ORM"}
	db.Create(&ormTag)

	// 创建文章并关联多个标签
	post := Post{
		Title: "GORM 教程",
		Tags:  []Tag{goTag, ormTag},
	}
	db.Create(&post)

	// 预加载查询文章的所有标签
	var foundPost Post
	db.Preload("Tags").First(&foundPost, post.ID)
	fmt.Printf("文章 %s 的标签:\n", foundPost.Title)
	for _, t := range foundPost.Tags {
		fmt.Printf("  - %s\n", t.Name)
	}

	// 反向预加载:查询标签的所有文章
	var foundTag Tag
	db.Preload("Posts").First(&foundTag, goTag.ID)
	fmt.Printf("标签 %s 的文章:\n", foundTag.Name)
	for _, p := range foundTag.Posts {
		fmt.Printf("  - %s\n", p.Title)
	}
}

外键与引用

可以通过 foreignKeyreferences 标签自定义外键和引用字段:

go
package main

import (
	"fmt"
	"log"

	"gorm.io/driver/sqlite"
	"gorm.io/gorm"
)

// Company 公司
type Company struct {
	ID   uint
	Name string
	Code string `gorm:"uniqueIndex"` // 公司代码作为引用
}

// Employee 员工
type Employee struct {
	ID         uint
	Name       string
	CompanyCode string // 自定义外键字段
	Company    Company `gorm:"foreignKey:CompanyCode;references:Code"`
}

func main() {
	db, err := gorm.Open(sqlite.Open("fk.db"), &gorm.Config{})
	if err != nil {
		log.Fatal(err)
	}
	db.AutoMigrate(&Company{}, &Employee{})

	company := Company{Name: "Acme", Code: "ACME001"}
	db.Create(&company)

	emp := Employee{Name: "Tom", CompanyCode: "ACME001"}
	db.Create(&emp)

	var found Employee
	db.Preload("Company").First(&found, emp.ID)
	fmt.Printf("员工 %s 所属公司: %s\n", found.Name, found.Company.Name)
}

预加载 Preload

如果不使用预加载,访问关联字段会是空值。GORM 默认不加载关联数据,需要使用 Preload 主动加载,这是解决 N+1 查询问题的关键。

N+1 问题演示

go
package main

import (
	"fmt"
	"log"

	"gorm.io/driver/sqlite"
	"gorm.io/gorm"
	"gorm.io/gorm/logger"
)

type User struct {
	ID    uint
	Name  string
	Posts []Post
}

type Post struct {
	ID     uint
	UserID uint
	Title  string
}

func main() {
	db, err := gorm.Open(sqlite.Open("n1.db"), &gorm.Config{
		Logger: logger.Default.LogMode(logger.Info), // 打印 SQL
	})
	if err != nil {
		log.Fatal(err)
	}
	db.AutoMigrate(&User{}, &Post{})

	// 准备数据
	for i := 1; i <= 3; i++ {
		user := User{Name: fmt.Sprintf("user_%d", i)}
		db.Create(&user)
		for j := 1; j <= 2; j++ {
			db.Create(&Post{UserID: user.ID, Title: fmt.Sprintf("post_%d_%d", i, j)})
		}
	}

	// 错误做法:N+1 查询
	var users []User
	db.Find(&users) // 1 次查询
	for _, u := range users {
		var posts []Post
		db.Where("user_id = ?", u.ID).Find(&posts) // 每个用户 1 次查询
		u.Posts = posts
	}
	fmt.Println("N+1 完成(执行了 1+N 次查询)")

	// 正确做法:使用 Preload
	var users2 []User
	db.Preload("Posts").Find(&users2) // 只需 2 次查询
	fmt.Printf("Preload 完成,共 %d 用户\n", len(users2))
	for _, u := range users2 {
		fmt.Printf("  %s: %d 篇文章\n", u.Name, len(u.Posts))
	}
}

嵌套预加载

go
package main

import (
	"fmt"
	"log"

	"gorm.io/driver/sqlite"
	"gorm.io/gorm"
)

type User struct {
	ID    uint
	Name  string
	Posts []Post
}

type Post struct {
	ID       uint
	UserID   uint
	Title    string
	Comments []Comment
}

type Comment struct {
	ID     uint
	PostID uint
	Body   string
}

func main() {
	db, err := gorm.Open(sqlite.Open("nested.db"), &gorm.Config{})
	if err != nil {
		log.Fatal(err)
	}
	db.AutoMigrate(&User{}, &Post{}, &Comment{})

	user := User{
		Name: "Tom",
		Posts: []Post{
			{
				Title: "文章1",
				Comments: []Comment{
					{Body: "评论1"},
					{Body: "评论2"},
				},
			},
			{Title: "文章2"},
		},
	}
	db.Create(&user)

	// 嵌套预加载:Posts -> Comments
	var found User
	db.Preload("Posts.Comments").First(&found, user.ID)
	for _, p := range found.Posts {
		fmt.Printf("文章: %s, 评论数: %d\n", p.Title, len(p.Comments))
	}
}

条件预加载

可以为 Preload 提供条件,只加载符合条件的关联:

go
package main

import (
	"fmt"
	"log"

	"gorm.io/driver/sqlite"
	"gorm.io/gorm"
)

type User struct {
	ID    uint
	Name  string
	Posts []Post
}

type Post struct {
	ID       uint
	UserID   uint
	Title    string
	ViewCount int
}

func main() {
	db, err := gorm.Open(sqlite.Open("cond.db"), &gorm.Config{})
	if err != nil {
		log.Fatal(err)
	}
	db.AutoMigrate(&User{}, &Post{})

	user := User{Name: "Tom"}
	db.Create(&user)
	db.Create(&Post{UserID: user.ID, Title: "热门", ViewCount: 1000})
	db.Create(&Post{UserID: user.ID, Title: "冷门", ViewCount: 5})

	var found User
	// 只预加载 ViewCount > 100 的文章
	db.Preload("Posts", "view_count > ?", 100).First(&found, user.ID)
	fmt.Printf("用户 %s 的热门文章:\n", found.Name)
	for _, p := range found.Posts {
		fmt.Printf("  - %s (浏览: %d)\n", p.Title, p.ViewCount)
	}

	// 自定义排序预加载
	var found2 User
	db.Preload("Posts", func(db *gorm.DB) *gorm.DB {
		return db.Order("view_count DESC").Limit(1)
	}).First(&found2, user.ID)
	fmt.Printf("用户 %s 浏览量最高的文章:\n", found2.Name)
	for _, p := range found2.Posts {
		fmt.Printf("  - %s (浏览: %d)\n", p.Title, p.ViewCount)
	}
}

关联模式 Association API

GORM 提供了 Association 方法获取关联模式,可以对关联进行查找、添加、替换、删除、清空、计数等操作。

go
package main

import (
	"fmt"
	"log"

	"gorm.io/driver/sqlite"
	"gorm.io/gorm"
)

type User struct {
	ID    uint
	Name  string
	Roles []Role `gorm:"many2many:user_roles;"`
}

type Role struct {
	ID   uint
	Name string
}

func main() {
	db, err := gorm.Open(sqlite.Open("assoc.db"), &gorm.Config{})
	if err != nil {
		log.Fatal(err)
	}
	db.AutoMigrate(&User{}, &Role{})

	// 创建角色
	admin := Role{Name: "admin"}
	editor := Role{Name: "editor"}
	viewer := Role{Name: "viewer"}
	db.Create(&admin)
	db.Create(&editor)
	db.Create(&viewer)

	// 创建用户并关联角色
	user := User{Name: "Tom", Roles: []Role{admin, editor}}
	db.Create(&user)

	// 查找关联
	var roles []Role
	db.Model(&user).Association("Roles").Find(&roles)
	fmt.Printf("用户 %s 的角色数: %d\n", user.Name,
		db.Model(&user).Association("Roles").Count())
	for _, r := range roles {
		fmt.Printf("  - %s\n", r.Name)
	}

	// 添加关联
	db.Model(&user).Association("Roles").Append(&viewer)
	fmt.Printf("添加后角色数: %d\n",
		db.Model(&user).Association("Roles").Count())

	// 替换关联:清空原有,添加新的
	db.Model(&user).Association("Roles").Replace(&admin)
	fmt.Printf("替换后角色数: %d\n",
		db.Model(&user).Association("Roles").Count())

	// 删除关联(仅移除关联关系,不删除实体)
	db.Model(&user).Association("Roles").Delete(&admin)
	fmt.Printf("删除后角色数: %d\n",
		db.Model(&user).Association("Roles").Count())

	// 清空所有关联
	db.Model(&user).Association("Roles").Clear()
	fmt.Printf("清空后角色数: %d\n",
		db.Model(&user).Association("Roles").Count())
}

添加/替换/删除关联详解

go
package main

import (
	"fmt"
	"log"

	"gorm.io/driver/sqlite"
	"gorm.io/gorm"
)

type User struct {
	ID      uint
	Name    string
	Languages []Language `gorm:"many2many:user_languages;"`
}

type Language struct {
	ID   uint
	Name string
}

func main() {
	db, err := gorm.Open(sqlite.Open("ops.db"), &gorm.Config{})
	if err != nil {
		log.Fatal(err)
	}
	db.AutoMigrate(&User{}, &Language{})

	go_ := Language{Name: "Go"}
	py := Language{Name: "Python"}
	rs := Language{Name: "Rust"}
	js := Language{Name: "JavaScript"}
	db.Create(&go_)
	db.Create(&py)
	db.Create(&rs)
	db.Create(&js)

	user := User{Name: "Tom", Languages: []Language{go_, py}}
	db.Create(&user)

	// Append:添加多个关联(已存在的不会重复)
	db.Model(&user).Association("Languages").Append(&rs, &js)
	fmt.Printf("Append 后语言数: %d\n",
		db.Model(&user).Association("Languages").Count())

	// Replace:完全替换关联
	db.Model(&user).Association("Languages").Replace(&go_, &rs)
	fmt.Printf("Replace 后语言数: %d\n",
		db.Model(&user).Association("Languages").Count())

	// Delete:删除关联(只是从中间表移除,不删除 Language 记录)
	db.Model(&user).Association("Languages").Delete(&py)
	fmt.Printf("Delete 后语言数: %d\n",
		db.Model(&user).Association("Languages").Count())

	// 验证 Language 表记录没被删除
	var langCount int64
	db.Model(&Language{}).Count(&langCount)
	fmt.Printf("Language 表记录数: %d(未被删除)\n", langCount)

	// Clear:清空所有关联
	db.Model(&user).Association("Languages").Clear()
	fmt.Printf("Clear 后语言数: %d\n",
		db.Model(&user).Association("Languages").Count())
}

多态关联

多态关联允许一个模型同时属于多种类型的模型。例如评论可以属于文章、视频、图片等。

go
package main

import (
	"fmt"
	"log"

	"gorm.io/driver/sqlite"
	"gorm.io/gorm"
)

// Comment 评论模型:通过多态关联属于不同模型
type Comment struct {
	ID           uint
	Body         string
	CommentableID   uint   // 关联对象 ID
	CommentableType string // 关联对象类型:post / video
	Post          *Post  `gorm:"polymorphic:Commentable;"`
	Video         *Video `gorm:"polymorphic:Commentable;"`
}

// Post 文章
type Post struct {
	ID      uint
	Title   string
	Comments []Comment `gorm:"polymorphic:Commentable;"`
}

// Video 视频
type Video struct {
	ID    uint
	Title string
	Comments []Comment `gorm:"polymorphic:Commentable;"`
}

func main() {
	db, err := gorm.Open(sqlite.Open("poly.db"), &gorm.Config{})
	if err != nil {
		log.Fatal(err)
	}
	db.AutoMigrate(&Post{}, &Video{}, &Comment{})

	// 创建文章并关联评论
	post := Post{
		Title: "Go 教程",
		Comments: []Comment{
			{Body: "很棒!"},
			{Body: "学到了"},
		},
	}
	db.Create(&post)

	// 创建视频并关联评论
	video := Video{
		Title: "GORM 视频",
		Comments: []Comment{
			{Body: "讲得清楚"},
		},
	}
	db.Create(&video)

	// 预加载查询文章的所有评论
	var foundPost Post
	db.Preload("Comments").First(&foundPost, post.ID)
	fmt.Printf("文章 %s 的评论:\n", foundPost.Title)
	for _, c := range foundPost.Comments {
		fmt.Printf("  - %s (类型=%s, ID=%d)\n",
			c.Body, c.CommentableType, c.CommentableID)
	}

	// 预加载查询视频的评论
	var foundVideo Video
	db.Preload("Comments").First(&foundVideo, video.ID)
	fmt.Printf("视频 %s 的评论:\n", foundVideo.Title)
	for _, c := range foundVideo.Comments {
		fmt.Printf("  - %s (类型=%s, ID=%d)\n",
			c.Body, c.CommentableType, c.CommentableID)
	}
}

完整示例:电商系统模型

下面用一个电商系统模型综合演示各种关联关系,包含分类、商品、标签、图片等。

go
package main

import (
	"fmt"
	"log"
	"time"

	"gorm.io/driver/sqlite"
	"gorm.io/gorm"
)

// Category 商品分类(自关联:一对多)
type Category struct {
	ID        uint `gorm:"primaryKey"`
	Name      string
	ParentID  *uint       // 父分类 ID,可为空
	Parent    *Category   `gorm:"foreignKey:ParentID"` // 父分类
	Children  []Category  `gorm:"foreignKey:ParentID"` // 子分类
	Products  []Product   // 分类下的商品
}

// Product 商品
type Product struct {
	ID         uint `gorm:"primaryKey"`
	Name       string
	Price      float64
	CategoryID uint
	Category   Category  `gorm:"foreignKey:CategoryID"`  // 属于某个分类
	Tags       []Tag     `gorm:"many2many:product_tags;"` // 多对多标签
	Images     []Image   `gorm:"polymorphic:Imageable;"`  // 多态:商品图片
	CreatedAt  time.Time
}

// Tag 标签
type Tag struct {
	ID       uint
	Name     string
	Products []Product `gorm:"many2many:product_tags;"`
}

// Image 图片(多态:可属于 Product 或其他模型)
type Image struct {
	ID            uint
	URL           string
	ImageableID   uint
	ImageableType string
}

func main() {
	db, err := gorm.Open(sqlite.Open("ecommerce.db"), &gorm.Config{})
	if err != nil {
		log.Fatal(err)
	}
	db.AutoMigrate(&Category{}, &Product{}, &Tag{}, &Image{})

	// 创建分类层级
	electronics := Category{Name: "电子产品"}
	db.Create(&electronics)
	phones := Category{Name: "手机", ParentID: &electronics.ID}
	db.Create(&phones)

	// 创建标签
	goTag := Tag{Name: "热销"}
	newTag := Tag{Name: "新品"}
	db.Create(&goTag)
	db.Create(&newTag)

	// 创建商品并关联分类、标签、图片
	product := Product{
		Name:       "iPhone 15",
		Price:      7999,
		CategoryID: phones.ID,
		Tags:       []Tag{goTag, newTag},
		Images: []Image{
			{URL: "iphone_front.jpg"},
			{URL: "iphone_back.jpg"},
		},
	}
	if err := db.Create(&product).Error; err != nil {
		log.Fatal(err)
	}
	fmt.Printf("商品创建成功: %s (id=%d)\n", product.Name, product.ID)

	// 综合查询:商品 + 分类 + 标签 + 图片
	var fullProduct Product
	if err := db.
		Preload("Category").
		Preload("Category.Parent").
		Preload("Tags").
		Preload("Images").
		First(&fullProduct, product.ID).Error; err != nil {
		log.Fatal(err)
	}

	fmt.Println("\n=== 商品详情 ===")
	fmt.Printf("名称: %s\n", fullProduct.Name)
	fmt.Printf("价格: %.2f\n", fullProduct.Price)
	fmt.Printf("分类: %s\n", fullProduct.Category.Name)
	if fullProduct.Category.Parent != nil {
		fmt.Printf("父分类: %s\n", fullProduct.Category.Parent.Name)
	}
	fmt.Printf("标签: ")
	for _, t := range fullProduct.Tags {
		fmt.Printf("%s ", t.Name)
	}
	fmt.Println()
	fmt.Printf("图片: ")
	for _, img := range fullProduct.Images {
		fmt.Printf("%s ", img.URL)
	}
	fmt.Println()

	// 查询分类下的所有商品
	var category Category
	db.Preload("Products").First(&category, phones.ID)
	fmt.Printf("\n分类 %s 下的商品: %d\n", category.Name, len(category.Products))

	// 查询标签关联的所有商品
	var tag Tag
	db.Preload("Products").First(&tag, goTag.ID)
	fmt.Printf("标签 %s 关联商品: %d\n", tag.Name, len(tag.Products))

	// 使用 Association 添加新标签
	discountTag := Tag{Name: "折扣"}
	db.Create(&discountTag)
	db.Model(&fullProduct).Association("Tags").Append(&discountTag)
	fmt.Printf("\n添加折扣标签后商品标签数: %d\n",
		db.Model(&fullProduct).Association("Tags").Count())
}

小结

本篇系统讲解了 GORM 的关联关系:

  1. Has One(一对一):一个模型拥有另一个模型,外键在被拥有方
  2. Has Many(一对多):一个模型拥有多个其他模型
  3. Belongs To(属于):当前模型引用另一个模型作为外键
  4. Many2Many(多对多):通过中间表连接两个模型
  5. 外键与引用foreignKeyreferences 标签自定义映射
  6. Preload 预加载:解决 N+1 查询问题,支持嵌套和条件预加载
  7. Association API:查找、添加、替换、删除、清空关联
  8. 多态关联:一个模型属于多种类型的模型

下一篇我们将学习事务与 Hook 钩子机制,掌握数据一致性与生命周期回调。