GORM 上手:一个 main.go 跑通 Go 数据库增删改查
这篇文章用一个博客系统的后端做例子,从零开始演示 GORM 的模型定义、数据库连接、CRUD 操作、关联查询和事务处理。所有代码放在一个文件里,复制就能跑。适合有 Go 基础但没用过 ORM 的开发者。
先看对比
用 database/sql 插入一条记录:
go
stmt, err := db.Prepare("INSERT INTO articles(title, content, author, views) VALUES(?, ?, ?, ?)")
if err != nil { return err }
result, err := stmt.Exec("GORM 入门", "正文...", "张三", 0)
if err != nil { return err }
id, _ := result.LastInsertId()
用 GORM 做同样的事:
go
db.Create(&Article{Title: "GORM 入门", Content: "正文...", Author: "张三"})
字段少的时候差别不大。等你的表有 15 个字段、还要处理 NULL 和时间格式的时候,手写 SQL 的维护成本会陡增。GORM 把这些事收敛到 struct tag 里,你只管定义结构体。
工作原理
┌──────────────┐ ┌──────────┐ ┌────────────┐ ┌──────────────┐
│ 你的代码 │ ──▶ │ GORM │ ──▶ │ Driver │ ──▶ │ Database │
│ (struct) │ │ (链式API) │ │ (mysql/pg) │ │ (MySQL等) │
└──────────────┘ └──────────┘ └────────────┘ └──────────────┘
GORM 在你的 Go struct 和数据库之间做翻译。你操作 struct,它生成 SQL 发给数据库驱动。切换数据库只需要换 Driver,业务代码不用动。
安装
Go 1.18+,执行:
bash
go get -u gorm.io/gorm
go get -u gorm.io/driver/sqlite
其他数据库把 sqlite 换成 mysql 或 postgres。
定义模型
我们做一个博客系统,先定义文章表:
go
type Article struct {
gorm.Model
Title string `gorm:"size:200;not null"`
Content string `gorm:"type:text"`
Author string `gorm:"size:100"`
Views int `gorm:"default:0"`
}
嵌入 gorm.Model 会带上四个字段:ID(主键)、CreatedAt、UpdatedAt、DeletedAt(软删除用)。
对应的表结构:
articles
├── id uint 主键自增
├── title varchar(200)
├── content text
├── author varchar(100)
├── views int 默认 0
├── created_at datetime
├── updated_at datetime
└── deleted_at datetime 软删除标记
连接数据库 + 建表
go
package main
import (
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func main() {
db, err := gorm.Open(sqlite.Open("blog.db"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Info), // 开发时打开,能看到生成的 SQL
})
if err != nil {
panic(err)
}
db.AutoMigrate(&Article{})
}
换 MySQL 改一行:
go
import "gorm.io/driver/mysql"
dsn := "user:pass@tcp(127.0.0.1:3306)/blog?charset=utf8mb4&parseTime=True&loc=Local"
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
AutoMigrate 会建表,表已存在则补齐新加的字段。注意它不会删列也不会改类型,生产环境用 golang-migrate 之类的工具管理 schema 版本。
创建
go
article := Article{
Title: "Go 并发模式详解",
Content: "goroutine 和 channel 的实际用法...",
Author: "张三",
}
db.Create(&article)
fmt.Println(article.ID) // 插入后 ID 自动回填
批量插入:
go
articles := []Article{
{Title: "文章一", Author: "张三"},
{Title: "文章二", Author: "李四"},
}
db.Create(&articles)
查询
go
var art Article
// 按主键
db.First(&art, 1)
// 条件查询
var list []Article
db.Where("author = ?", "张三").Find(&list)
// 链式:阅读量 > 100,按时间倒序,取前 10 条
db.Where("views > ?", 100).Order("created_at desc").Limit(10).Find(&list)
First 找不到记录时返回 gorm.ErrRecordNotFound:
go
result := db.First(&art, "title = ?", "不存在")
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
fmt.Println("没找到")
}
更新
go
// 单字段
db.Model(&art).Update("Views", 200)
// 多字段
db.Model(&art).Updates(Article{Title: "新标题", Views: 300})
这里有个坑:用 struct 更新时,Go 的零值(0、""、false)会被 GORM 忽略。比如你想把 Views 设回 0:
go
// 这样不行,Views 是零值会被跳过
db.Model(&art).Updates(Article{Views: 0})
// 用 map 或 Select 解决
db.Model(&art).Updates(map[string]interface{}{"Views": 0})
db.Model(&art).Select("Views").Updates(Article{Views: 0})
删除
go
// 软删除:设置 deleted_at 字段,记录还在数据库里
db.Delete(&art, 1)
// 查询时默认过滤已软删除的记录
// 想查所有记录(包括已删除的):
db.Unscoped().Find(&list)
// 真删除:从数据库移除
db.Unscoped().Delete(&art, 1)
关联查询
博客系统里,一个用户有多篇文章:
go
type User struct {
gorm.Model
Name string
Articles []Article
}
type Article struct {
gorm.Model
Title string
UserID uint
}
关系图:
┌────────────┐ ┌────────────┐
│ User │ 1 ──▶ N │ Article │
├────────────┤ ├────────────┤
│ ID │ │ ID │
│ Name │ │ Title │
│ CreatedAt │ │ UserID(FK) │
└────────────┘ │ CreatedAt │
└────────────┘
查询用户时预加载文章:
go
var user User
db.Preload("Articles").First(&user, 1)
for _, a := range user.Articles {
fmt.Println(a.Title)
}
不用 Preload 的话,访问 user.Articles 不会自动查询。如果你在循环里逐个加载关联,就会产生 N+1 问题,数据量大了性能很差。
Hook
在数据操作前后插入逻辑:
go
func (a *Article) BeforeCreate(tx *gorm.DB) error {
if a.Title == "" {
return errors.New("标题不能为空")
}
return nil
}
返回 error 会中止操作并回滚事务。适合做校验、自动填充时间戳、记日志。
事务
多个操作要么全成功要么全失败:
go
err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&article1).Error; err != nil {
return err
}
if err := tx.Create(&article2).Error; err != nil {
return err
}
return nil
})
回调里返回 error 自动回滚,返回 nil 自动提交。
完整可运行代码
go
package main
import (
"errors"
"fmt"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
type Article struct {
gorm.Model
Title string `gorm:"size:200;not null"`
Content string `gorm:"type:text"`
Author string `gorm:"size:100"`
Views int `gorm:"default:0"`
}
func (a *Article) BeforeCreate(tx *gorm.DB) error {
if a.Title == "" {
return errors.New("标题不能为空")
}
return nil
}
func main() {
db, err := gorm.Open(sqlite.Open("blog.db"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Info),
})
if err != nil {
panic(err)
}
db.AutoMigrate(&Article{})
// 创建
db.Create(&Article{
Title: "Go 错误处理的几种写法",
Content: "从 if err != nil 到 errors.Is...",
Author: "开发者小王",
})
// 查询
var articles []Article
db.Where("author = ?", "开发者小王").Find(&articles)
for _, a := range articles {
fmt.Printf("#%d %s (views: %d)\n", a.ID, a.Title, a.Views)
}
// 更新
if len(articles) > 0 {
db.Model(&articles[0]).Update("Views", 42)
}
// 软删除
if len(articles) > 0 {
db.Delete(&articles[0])
}
// 验证软删除生效
var count int64
db.Model(&Article{}).Count(&count)
fmt.Printf("可见文章数: %d\n", count)
}
运行:
bash
go mod init blog-demo && go mod tidy && go run main.go
预期输出:
#1 Go 错误处理的几种写法 (views: 0)
可见文章数: 0
什么时候不该用 GORM
几种情况下你可能更适合其他方案:
- 查询逻辑很复杂(多层子查询、窗口函数),GORM 的链式 API 表达起来反而比原生 SQL 更绕
- 对性能有极致要求,需要精确控制每条 SQL 的执行计划
- 团队更习惯 SQL 优先的工作方式,可以看看 sqlc(写 SQL 生成 Go 代码)
对于大多数 Web 后端的常规 CRUD,GORM 省事且够用。