跳到主要内容
预计阅读 36 分钟

数据库操作与ORM:GORM实战 — 让数据落地生根

前面的章节中,我们的数据都存在内存里——程序一关,数据就灰飞烟灭。这就像把笔记写在沙滩上,一个浪打过来就没了。数据库就是那本永不丢失的笔记本,而GORM就是帮你把笔记写得又快又好的钢笔。

📋 开篇自测:你已经知道多少?

  1. ORM是什么?它相比直接写SQL有什么优缺点?
  2. 数据库迁移(Migration)是什么意思?为什么需要它?
  3. GORM中的 PreloadJoins 有什么区别?

一、数据库基础:为什么需要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)
}

这段代码能工作,但有几个明显的痛点:

  1. 手动映射:每个字段都要手写 Scan,多一个字段就要多一行代码
  2. SQL字符串:SQL写在字符串里,IDE没法检查语法错误
  3. 重复代码:每个查询都要写一遍打开、扫描、关闭的流程
  4. 数据库差异:换数据库就要改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查询问题,应该用 PreloadJoins 来解决。
  • 误区三:忽视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": "删除成功"})
}

📝 掌握度自测

  1. ORM的全称是什么?

    • A) Object Resource Management
    • B) Object-Relational Mapping
    • C) Online Resource Manager
    • D) Optimized Read Model
  2. GORM中使用结构体更新时,以下哪个值会被忽略?

    • A) "hello"
    • B) 42
    • C) 0
    • D) "world"
  3. GORM的软删除机制是通过什么实现的?

    • A) 直接从数据库删除记录
    • B) 设置 deleted_at 字段的时间戳
    • C) 将记录移到另一张表
    • D) 设置 is_deleted 字段为true
  4. 以下哪种方式可以避免N+1查询问题?

    • A) 在循环中逐个查询
    • B) 使用 Preload 预加载关联数据
    • C) 使用更大的连接池
    • D) 关闭SQL日志
  5. 关于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