数据库操作与ORM:GORM实战 — 让数据落地生根
前面的章节中,我们的数据都存在内存里——程序一关,数据就灰飞烟灭。这就像把笔记写在沙滩上,一个浪打过来就没了。数据库就是那本永不丢失的笔记本,而GORM就是帮你把笔记写得又快又好的钢笔。
📋 开篇自测:你已经知道多少?
- ORM是什么?它相比直接写SQL有什么优缺点?
- 数据库迁移(Migration)是什么意思?为什么需要它?
- GORM中的
Preload和Joins有什么区别?
一、数据库基础:为什么需要ORM
1.1 直接写SQL的痛苦
在Go中,标准库 database/sql 提供了原生的数据库操作能力:
import "database/sql"
import _ "github.com/go-sql-driver/mysql"
db, err := sql.Open("mysql", "user:password@tcp(localhost:3306)/mydb")
// 查询
rows, err := db.Query("SELECT id, name, email FROM users WHERE age > ?", 18)
defer rows.Close()
var users []User
for rows.Next() {
var u User
err := rows.Scan(&u.ID, &u.Name, &u.Email)
if err != nil {
log.Fatal(err)
}
users = append(users, u)
}
这段代码能工作,但有几个明显的痛点:
- 手动映射:每个字段都要手写
Scan,多一个字段就要多一行代码 - SQL字符串:SQL写在字符串里,IDE没法检查语法错误
- 重复代码:每个查询都要写一遍打开、扫描、关闭的流程
- 数据库差异:换数据库就要改SQL语法
1.2 ORM:对象关系映射
ORM(Object-Relational Mapping)就是在Go结构体和数据库表之间搭建一座桥梁:
Go结构体 <---> ORM <---> 数据库表
User{} GORM users表
GORM是Go生态中最流行的ORM框架——它让你用Go代码来操作数据库,而不用直接写SQL。
这就像Google翻译——你说中文(Go代码),它翻译成英文(SQL),数据库听懂后给你回复,GORM再把回复翻译回中文(Go结构体)。
🤔 想一想 ORM不是万能的。在什么场景下,直接写SQL比使用ORM更合适?
二、GORM入门
2.1 安装
# 安装GORM核心
go get gorm.io/gorm
# 安装数据库驱动(以MySQL为例)
go get gorm.io/driver/mysql
# 如果用SQLite(适合学习和测试)
go get gorm.io/driver/sqlite
# 如果用PostgreSQL
go get gorm.io/driver/postgres
2.2 连接数据库
package main
import (
"fmt"
"log"
"time"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func main() {
// MySQL连接字符串格式:用户名:密码@tcp(地址:端口)/数据库名?参数
dsn := "root:password@tcp(127.0.0.1:3306)/myapp?charset=utf8mb4&parseTime=True&loc=Local"
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Info), // 打印SQL日志,开发时很有用
})
if err != nil {
log.Fatalf("数据库连接失败:%v", err)
}
fmt.Println("数据库连接成功!")
// 配置连接池
sqlDB, err := db.DB()
if err != nil {
log.Fatal("获取底层数据库连接失败:", err)
}
sqlDB.SetMaxOpenConns(100) // 最大连接数
sqlDB.SetMaxIdleConns(10) // 最大空闲连接数
sqlDB.SetConnMaxLifetime(time.Hour) // 连接最大存活时间
}
用SQLite的话更简单,特别适合学习阶段:
import "gorm.io/driver/sqlite"
db, err := gorm.Open(sqlite.Open("app.db"), &gorm.Config{})
2.3 定义模型
GORM用Go结构体来映射数据库表:
type User struct {
gorm.Model // 内嵌模型,自带 ID、CreatedAt、UpdatedAt、DeletedAt
Name string `gorm:"size:100;not null"`
Email string `gorm:"size:200;uniqueIndex"`
Age int `gorm:"default:0"`
Bio string `gorm:"type:text"`
IsActive bool `gorm:"default:true"`
}
gorm.Model 内嵌了四个常用字段:
type Model struct {
ID uint `gorm:"primarykey"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"` // 软删除支持
}
常用的GORM标签:
| 标签 | 说明 | 示例 |
|---|---|---|
size | 字段长度 | gorm:"size:100" |
not null | 非空约束 | gorm:"not null" |
uniqueIndex | 唯一索引 | gorm:"uniqueIndex" |
default | 默认值 | gorm:"default:0" |
type | 指定列类型 | gorm:"type:text" |
column | 自定义列名 | gorm:"column:user_name" |
primarykey | 主键 | gorm:"primarykey" |
index | 普通索引 | gorm:"index" |
2.4 自动迁移
GORM可以根据结构体自动创建或更新数据库表结构:
// 自动迁移——表不存在就创建,有新字段就添加
db.AutoMigrate(&User{})
这就像自动布置房间——你只需要描述”需要一张桌子、两把椅子”,GORM会自动帮你摆好。如果你后来说”还需要一个书架”,它就再加一个书架,不会影响已有的家具。
注意:AutoMigrate 只会添加字段,不会删除或修改已有字段的类型。生产环境建议用专业的迁移工具(如 golang-migrate)。
⚠️ 常见误区
- 误区一:在生产环境依赖AutoMigrate。它不能处理字段删除、类型修改等复杂变更。
- 误区二:忘记配置连接池。默认连接池参数对于高并发场景不够用。
- 误区三:不开启SQL日志。开发阶段一定要打开日志,看看GORM到底生成了什么SQL。
三、CRUD操作:增删改查
3.1 创建(Create)
// 创建单条记录
user := User{Name: "张三", Email: "zhangsan@example.com", Age: 28}
result := db.Create(&user)
fmt.Println(user.ID) // 自动填充的ID
fmt.Println(result.RowsAffected) // 影响的行数
fmt.Println(result.Error) // 错误信息
// 批量创建
users := []User{
{Name: "李四", Email: "lisi@example.com", Age: 30},
{Name: "王五", Email: "wangwu@example.com", Age: 25},
{Name: "赵六", Email: "zhaoliu@example.com", Age: 35},
}
db.Create(&users)
// 每个user的ID都会被自动填充
3.2 查询(Read)
// 按主键查找
var user User
db.First(&user, 1) // 查找ID=1的用户
db.First(&user, "id = ?", 1) // 等效写法
// 按条件查找
var user User
db.Where("email = ?", "zhangsan@example.com").First(&user)
// 查找多条记录
var users []User
db.Where("age > ?", 25).Find(&users)
// 获取所有记录
var allUsers []User
db.Find(&allUsers)
// 条件组合
var users []User
db.Where("age > ? AND is_active = ?", 18, true).
Order("created_at DESC").
Limit(10).
Offset(0).
Find(&users)
// 只查询特定字段
var users []User
db.Select("name", "email").Where("age > ?", 20).Find(&users)
// 统计数量
var count int64
db.Model(&User{}).Where("is_active = ?", true).Count(&count)
3.3 更新(Update)
// 更新单个字段
db.Model(&user).Update("name", "张三丰")
// 更新多个字段
db.Model(&user).Updates(User{Name: "张三丰", Age: 30})
// 或者用map(可以更新为零值)
db.Model(&user).Updates(map[string]interface{}{
"name": "张三丰",
"age": 0, // 用struct更新时,零值会被忽略;用map则不会
})
// 条件更新
db.Model(&User{}).Where("age < ?", 18).Update("is_active", false)
重要提示:使用结构体更新时,零值字段(0、""、false)会被忽略。如果你确实需要把字段更新为零值,请使用map或Select指定字段。
// 这不会把age更新为0!
db.Model(&user).Updates(User{Age: 0}) // age不会变
// 正确做法
db.Model(&user).Updates(map[string]interface{}{"age": 0})
// 或
db.Model(&user).Select("Age").Updates(User{Age: 0})
3.4 删除(Delete)
// 软删除(如果模型包含 gorm.Model,默认就是软删除)
db.Delete(&user) // 设置 deleted_at 字段,数据还在
db.Delete(&User{}, 1) // 删除ID=1的用户
db.Where("age < ?", 18).Delete(&User{}) // 条件删除
// 查询时自动过滤软删除的记录
db.Find(&users) // 只返回未删除的
db.Unscoped().Find(&users) // 包含已删除的
// 永久删除
db.Unscoped().Delete(&user) // 真的从数据库删除了
软删除就像把文件移到回收站——数据还在,只是标记了”已删除”。Unscoped() 就是打开回收站。
🤔 想一想 软删除有什么好处?有没有什么情况下不适合用软删除?
四、关联关系
4.1 一对多关系
一个用户有多篇文章:
type User struct {
gorm.Model
Name string
Articles []Article // 一对多:一个用户有多篇文章
}
type Article struct {
gorm.Model
Title string
Content string
UserID uint // 外键:属于哪个用户
User User // 关联的用户
}
// 创建用户和文章
user := User{
Name: "张三",
Articles: []Article{
{Title: "Go入门", Content: "Go是一门很棒的语言..."},
{Title: "Go进阶", Content: "goroutine让并发变简单..."},
},
}
db.Create(&user) // 自动创建用户和关联的文章
// 查询用户及其文章(预加载)
var user User
db.Preload("Articles").First(&user, 1)
for _, article := range user.Articles {
fmt.Println(article.Title)
}
4.2 多对多关系
文章和标签的关系是多对多——一篇文章可以有多个标签,一个标签可以属于多篇文章:
type Article struct {
gorm.Model
Title string
Tags []Tag `gorm:"many2many:article_tags;"` // 多对多
}
type Tag struct {
gorm.Model
Name string
Articles []Article `gorm:"many2many:article_tags;"`
}
// 创建带标签的文章
article := Article{
Title: "Go并发编程",
Tags: []Tag{
{Name: "Go"},
{Name: "并发"},
{Name: "编程"},
},
}
db.Create(&article)
// 查询文章及其标签
var article Article
db.Preload("Tags").First(&article, 1)
4.3 Preload vs Joins
// Preload:单独查询再关联(多次SQL)
db.Preload("Articles").Find(&users)
// 生成的SQL:
// SELECT * FROM users;
// SELECT * FROM articles WHERE user_id IN (1,2,3);
// Joins:联表查询(一次SQL)
db.Joins("JOIN articles ON articles.user_id = users.id").Find(&users)
// 生成的SQL:
// SELECT * FROM users JOIN articles ON articles.user_id = users.id;
Preload像是分两步走:先拿人,再拿文章。Joins像是一步到位:人和文章一起拿。小数据量用Preload更简单,大数据量且需要过滤条件时用Joins更高效。
五、高级查询技巧
5.1 分页查询
type Pagination struct {
Page int `json:"page" form:"page"`
PageSize int `json:"page_size" form:"page_size"`
Total int64 `json:"total"`
}
func Paginate(page, pageSize int) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
if page <= 0 {
page = 1
}
if pageSize <= 0 {
pageSize = 10
}
if pageSize > 100 {
pageSize = 100 // 限制最大页面大小
}
offset := (page - 1) * pageSize
return db.Offset(offset).Limit(pageSize)
}
}
// 使用
var users []User
var total int64
db.Model(&User{}).Count(&total) // 获取总数
db.Scopes(Paginate(1, 10)).Find(&users) // 第1页,每页10条
5.2 事务处理
事务确保一组操作要么全部成功,要么全部回滚:
// 方式一:自动事务(推荐)
err := db.Transaction(func(tx *gorm.DB) error {
// 从账户A扣款
if err := tx.Model(&Account{}).Where("id = ?", 1).
Update("balance", gorm.Expr("balance - ?", 100)).Error; err != nil {
return err // 返回错误会自动回滚
}
// 向账户B充值
if err := tx.Model(&Account{}).Where("id = ?", 2).
Update("balance", gorm.Expr("balance + ?", 100)).Error; err != nil {
return err // 返回错误会自动回滚
}
return nil // 返回nil会自动提交
})
if err != nil {
log.Println("转账失败:", err)
}
事务就像一场交易——双方必须同时完成才算数。如果中间任何一步出了问题,一切恢复原样,不会出现”钱扣了但没到账”的尴尬。
5.3 Scope:可复用的查询条件
// 定义常用的查询条件
func ActiveUsers(db *gorm.DB) *gorm.DB {
return db.Where("is_active = ?", true)
}
func AgeGreaterThan(age int) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
return db.Where("age > ?", age)
}
}
func OrderByCreatedAt(db *gorm.DB) *gorm.DB {
return db.Order("created_at DESC")
}
// 组合使用
var users []User
db.Scopes(ActiveUsers, AgeGreaterThan(18), OrderByCreatedAt).Find(&users)
Scope就像积木块——每个Scope是一块积木,你可以自由组合拼装出复杂的查询。
⚠️ 常见误区
- 误区一:用结构体更新零值字段。
db.Updates(User{Age: 0})不会把age更新为0,必须用map。- 误区二:在循环中执行查询。这会产生N+1查询问题,应该用
Preload或Joins来解决。- 误区三:忽视SQL注入风险。永远使用参数化查询(
?占位符),不要拼接SQL字符串。
六、实战:将Gin API与GORM整合
package main
import (
"log"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
type Book struct {
gorm.Model
Title string `json:"title" gorm:"size:200;not null" binding:"required"`
Author string `json:"author" gorm:"size:100;not null" binding:"required"`
Price float64 `json:"price" gorm:"not null" binding:"required,gt=0"`
ISBN string `json:"isbn" gorm:"size:20;uniqueIndex"`
}
// 更新请求的独立结构体——使用指针字段区分"未传"和"传了零值"
type UpdateBookInput struct {
Title *string `json:"title" binding:"omitempty"`
Author *string `json:"author" binding:"omitempty"`
Price *float64 `json:"price" binding:"omitempty,gt=0"`
ISBN *string `json:"isbn" binding:"omitempty"`
}
var db *gorm.DB
func initDB() {
var err error
db, err = gorm.Open(sqlite.Open("books.db"), &gorm.Config{})
if err != nil {
log.Fatal("数据库连接失败:", err)
}
db.AutoMigrate(&Book{})
}
func main() {
initDB()
r := gin.Default()
books := r.Group("/api/books")
{
books.GET("", listBooks)
books.POST("", createBook)
books.GET("/:id", getBook)
books.PUT("/:id", updateBook)
books.DELETE("/:id", deleteBook)
}
r.Run(":8080")
}
func listBooks(c *gin.Context) {
var books []Book
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "10"))
var total int64
db.Model(&Book{}).Count(&total)
offset := (page - 1) * size
db.Offset(offset).Limit(size).Find(&books)
c.JSON(http.StatusOK, gin.H{
"data": books,
"total": total,
"page": page,
"size": size,
})
}
func createBook(c *gin.Context) {
var book Book
if err := c.ShouldBindJSON(&book); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := db.Create(&book).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建失败"})
return
}
c.JSON(http.StatusCreated, book)
}
func getBook(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的书籍ID"})
return
}
var book Book
if err := db.First(&book, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "书籍不存在"})
return
}
c.JSON(http.StatusOK, book)
}
func updateBook(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的书籍ID"})
return
}
var book Book
if err := db.First(&book, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "书籍不存在"})
return
}
var input UpdateBookInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 使用map收集需要更新的字段,避免零值被GORM忽略
updates := map[string]interface{}{}
if input.Title != nil {
updates["title"] = *input.Title
}
if input.Author != nil {
updates["author"] = *input.Author
}
if input.Price != nil {
updates["price"] = *input.Price
}
if input.ISBN != nil {
updates["isbn"] = *input.ISBN
}
db.Model(&book).Updates(updates)
c.JSON(http.StatusOK, book)
}
func deleteBook(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的书籍ID"})
return
}
if err := db.Delete(&Book{}, id).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
}
📝 掌握度自测
-
ORM的全称是什么?
- A) Object Resource Management
- B) Object-Relational Mapping
- C) Online Resource Manager
- D) Optimized Read Model
-
GORM中使用结构体更新时,以下哪个值会被忽略?
- A)
"hello" - B)
42 - C)
0 - D)
"world"
- A)
-
GORM的软删除机制是通过什么实现的?
- A) 直接从数据库删除记录
- B) 设置
deleted_at字段的时间戳 - C) 将记录移到另一张表
- D) 设置
is_deleted字段为true
-
以下哪种方式可以避免N+1查询问题?
- A) 在循环中逐个查询
- B) 使用
Preload预加载关联数据 - C) 使用更大的连接池
- D) 关闭SQL日志
-
关于GORM事务,以下说法正确的是:
- A) 事务中任何错误都不会影响结果
- B)
Transaction回调返回error会自动回滚 - C) GORM不支持事务
- D) 事务只能包含查询操作
💡 自我评估
- 答对5题:数据库操作已经上手,可以向精通篇进军了!
- 答对3-4题:核心操作理解不错,建议重点练习关联关系和事务。
- 答对0-2题:建议创建一个小项目,把CRUD和关联操作都动手练一遍。
参考答案: 1-B, 2-C, 3-B, 4-B, 5-B
购买课程解锁全部内容
高并发不踩坑:Go 语言从语法到微服务
¥29.90