GORM 入门实战:用 Gin + GORM 写一个图书管理 API
适合读者
- 已掌握 Go 基础语法
- 想学 GORM 但不知道从哪开始
- 想看到一个能直接运行的完整项目
- 有其他语言(Java/Python/Node 等)Web 开发经验更佳——本文会顺带对比常见框架的写法差异
环境准备
动手前确认三样东西:
- Go 1.22+(本教程用到
errors.Is;泛型只在系列工程化篇出现,1.21+ 均可,推荐 1.24+); - 本地 PostgreSQL(无安装可用 Docker:
docker run --name pg -e POSTGRES_PASSWORD=123456 -p 5432:5432 -d postgres:16); - 手动创建数据库:
CREATE DATABASE library;——GORM 的AutoMigrate只能建表,不能建库(见第二章注意事项)。
想换 MySQL / SQLite 也可以——本篇的代码本身跨库通用,差别只在驱动安装与 DSN(见第一章末尾对照表)。但要提前知道:入门篇之后的篇目会用 PostgreSQL 专属特性(ILIKE 模糊搜索、聚合查询按主键分组的”函数依赖”、软删除的部分唯一索引),正文会在用到处就地标注差异,并把它们集中收在媒体篇末尾的「PostgreSQL 特性速查」表里。
完整项目结构
gin-demo/
├── main.go # 入口文件
├── db/
│ └── db.go # 数据库连接
├── models/
│ └── book.go # 数据模型
├── handlers/
│ └── book.go # 业务逻辑(CRUD)
└── go.mod
学习级结构:
db/models/handlers平铺适合小项目与入门。业务复杂后建议按职责演进——用 Go 的internal/包约束可见性、抽出 Service 层放业务逻辑、Repository 层收拢数据访问。教程保持平铺以聚焦 GORM,先跑通再谈分层。想对 handler 做单元测试、需要 mock 数据库时,再把数据访问收拢为接口(如BookRepository)注入(见第十章进阶方向)。
先建立心智模型:GORM 的核心理念
在动手写代码之前,先用三十秒建立正确的「心智模型」——它回答的是”GORM 和别的路子哪里不一样”。GORM 最不一样的对手不是 Spring/Django 这类框架,而是 JDBC/MyBatis 这种手写 SQL 的路子:后者要你自己拼 SQL、再把结果集一行行映射成对象,GORM 恰好反过来。先理解下面这套思维方式,再动手,比照抄代码重要得多。
一句话总纲
Gin 负责把「HTTP 请求」变成「Go 函数调用」,GORM 负责把「Go 结构体」翻译成「数据库 SQL」。整篇文章的思维主线只有一条:请求进来 → 装进结构体 → 交给 GORM → 结果填回结构体 → 返回 JSON。
不是写 SQL,是操作结构体
Java 的 JDBC / MyBatis、PHP 手写 PDO 这类技术里,你需要自己拼 SQL 字符串,再把结果集一行行手动映射成对象。GORM 反过来:你只描述意图(Create、Find、Updates、Delete),翻译成 SQL 是框架的事——
| GORM 方法(节选) | 对应 SQL 意图 |
|---|---|
db.Create(&book) |
INSERT INTO books ... |
db.Model(&book).Updates(input) |
UPDATE books SET ... |
db.Delete(&models.Book{}, id) |
UPDATE books SET deleted_at = NOW() ...(软删除) |
想确认 GORM 到底生成了什么 SQL?开启 GORM 日志就能看到(见第十章)。带着「意图」写代码,不要试图在脑子里逐条翻译 SQL;完整的方法 ↔ SQL 对照见第十章总结。
四个必须建立的心智
- 结构体一物三用:同一个 struct 同时扮演三个角色——数据库表结构定义(
gorm标签)、请求/响应的数据载体(json标签)、数据库操作的参数(&book)。类型即契约,改一处全联动。这与 Java 中 Entity / DTO 分离、再配一套 XML 映射的写法完全不同。 - 查数据是「填空」,不是「返回值」:Go 是值传递,所以
Find(&books)、First(&book, id)必须传目标变量的指针,GORM 靠反射把结果填进去。漏写&等于填了一个副本,函数外拿不到数据——这是新手最容易犯、也最反直觉的一处,因为 Java/Python 的对象引用天然是共享的。 - 零值即「未提供」:Go 规定每个变量都有零值(数字
0、字符串""、布尔false)。GORM 的Updates(结构体)正是根据零值判断「这个字段要不要更新」——所以把字段更新成0或""会被静默跳过(不报错,也不更新)。第七章的「零值陷阱」根就在这里。对比 Java 的null、Python 的None——它们表示”没有值”;Go 的零值却是一个真实的值,0明明是”想把字段更新成 0”的意图,却被 GORM 当成”未提供”。这种差异正是零值陷阱对新手最反直觉的地方。 - 错误是结果的一部分:Go 没有异常机制。GORM 把每次操作的结果封装成
result,你要自己检查result.Error有没有错、result.RowsAffected影响了几行。整篇文章你会反复看到这个模式——它取代了其他语言里的try/catch。
小注:为什么到处是 & 和 *?
新手最容易卡在这一处——GORM 和 Gin 的代码里满是 & 与 *,却说不上来为什么。其实它们各管一件事:
&x(取地址传参)=「这个变量归你填,改完要带回来」:Go 默认值传递,传下去的是拷贝。GORM 的First(&book, id)、Find(&books)和 Gin 的c.ShouldBindJSON(&book)都要往里填数据,Delete(&models.Book{}, id)、AutoMigrate(&models.Book{})要拿到对象本身——不传&,函数外拿不到结果(这就是心智点 2「填空」的通用版:不止查询,绑定/删除/迁移全在用)。*gorm.DB/*gin.Context(指针类型声明)=「引用同一个实例,不拷贝」:这两个类型本身就被声明成指针。db.DB是数据库连接句柄、*gin.Context是请求上下文——全局只有一份,到处传递的是指向它的地址,省拷贝且保证操作的是同一实例。*string/*int(DTO 指针字段)=「可能没传」:nil表示”这个字段没出现”,与空串/0 区分开——第七章正文用的是结构体与 map,指针 DTO 的正式落地在《数据工程实战》与第十章进阶方向。
一句话:& 是「填这里」,* 是「这就是引用/可能没有」——它们不是 GORM 的魔法,是 Go 传值与引用语义的体现,所有框架都一样。
软删除:框架改写 SQL 的又一个例子
第八章的 Delete 不会真的删除数据——GORM 会把它改写成「软删除」,删掉的记录以后也不会再出现在查询里。具体机制留到第八章展开,先记住:别指望 Delete 一定生成 DELETE 语句(上面对照表中的「(软删除)」就是伏笔)。
和本文各章的关系
- 第二、三、四章(连接、模型、迁移)——「结构体 ↔ 表」的地基;
- 第五~八章(增删改查)——上面四个心智点的实战演练;
- 第九、十章(路由、总结)——把一切串起来并回顾。
现在带着这套心智进入第一章,边敲代码边印证。
第一章:项目初始化
目标:创建项目目录,安装依赖
mkdir gin-demo
cd gin-demo
go mod init gin-demo
安装依赖
# Web 框架
go get github.com/gin-gonic/gin
# ORM 库 + PostgreSQL 驱动
go get gorm.io/gorm
go get gorm.io/driver/postgres
go get会按当前环境解析最新兼容版本(等价于@latest)。教程不用go get -u——-u会连坐升级所有间接依赖,没必要时反而引入不确定性。
数据库驱动说明
本文使用 PostgreSQL 作为示例数据库。如果你使用的是其他数据库,替换对应的驱动即可:
| 数据库 | 安装命令 | DSN 格式 |
|---|---|---|
| PostgreSQL | go get gorm.io/driver/postgres |
host=localhost user=postgres password=123456 dbname=library sslmode=disable |
| MySQL | go get gorm.io/driver/mysql |
user:pass@tcp(localhost:3306)/library?charset=utf8mb4&parseTime=True |
| SQLite | go get gorm.io/driver/sqlite |
./data.db |
第二章:连接数据库
目标:建立数据库连接,在项目启动时初始化
连接流程
main.go启动时调用db.InitDB()db.InitDB()构造 DSN 字符串,通过gorm.Open()建立连接- 连接成功返回
*gorm.DB实例,失败则退出程序
创建 db/db.go:
package db
import (
"fmt"
"log"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
var DB *gorm.DB
func InitDB() {
host := "localhost"
port := 5432
user := "postgres"
password := "123456"
dbname := "library"
dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
host, port, user, password, dbname)
var err error
DB, err = gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
log.Fatal("数据库连接失败:", err)
}
log.Println("数据库连接成功")
}
⚠️ 生产环境请用环境变量(如
os.Getenv或godotenv)管理敏感配置,不要硬编码。
代码说明
| 代码 | 说明 |
|---|---|
var DB *gorm.DB |
声明全局 DB 变量,供其他包使用 |
gorm.Open(postgres.Open(dsn), &gorm.Config{}) |
建立数据库连接 |
log.Fatal |
连接失败时终止程序,避免后续代码执行 |
DSN 参数说明
| 参数 | 示例 | 说明 |
|---|---|---|
host |
localhost |
数据库主机地址 |
port |
5432 |
PostgreSQL 默认端口 |
user |
postgres |
数据库用户名 |
password |
123456 |
数据库密码 |
dbname |
library |
数据库名称 |
sslmode |
disable |
本地开发禁用 SSL |
注意事项:
- PostgreSQL 需要手动创建数据库:
CREATE DATABASE library;- GORM 的
AutoMigrate能自动创建表,但不能自动创建数据库sslmode=disable仅用于本地开发,生产环境应开启 SSL
第三章:定义数据模型
目标:用 Go 结构体定义数据库表结构
创建 models/book.go:
package models
import "gorm.io/gorm"
type Book struct {
gorm.Model
Title string `json:"title" gorm:"not null"`
Author string `json:"author" gorm:"not null"`
Price int `json:"price"` // 单位:分(5990 表示 59.90 元)
}
字段说明
gorm.Model:内置了ID、CreatedAt、UpdatedAt、DeletedAt四个字段gorm:"not null":对应数据库的NOT NULL约束json:"title":指定 JSON 序列化时的字段名,响应体里也用它
三个新手最容易忽略的点:
gorm.Model的字段会以大写的 Go 字段名出现在 JSON 里:gorm.Model没有 json 标签,所以响应里是"ID"、"CreatedAt"、"DeletedAt": null这样的原名(注意大写)。想隐藏或统一命名,就用自定义字段声明替代内嵌gorm.Model(字段上加json:"-"或小写 tag),或定义专门的 DTO(数据传输对象)作为响应结构。- 金额统一用
int(单位:分):浮点数有精度误差(0.1 + 0.2 != 0.3),金额字段直接用整数(单位:分)规避——接口里price: 5990表示 59.90 元。前端需要”元”时自行除以 100(或自定义MarshalJSON);涉及汇率/费率等需要精确小数的业务才引入decimal库(见第十章进阶方向)。binding:"required"自动校验(加在 DTO 上,不是模型上):给请求结构(DTO)的字段加上binding:"required"后,Gin 在ShouldBindJSON阶段就会校验,缺少必填字段直接返回 400——第五章进阶会实际用到。
命名规则(重要)
- 结构体类型名用单数:
Book而不是Books。这是 Go 的惯例(标准库里的http.Server、time.Time都是单数),语义也更清晰——一个结构体实例就是一条记录。写成Books不会让表名变成别的,反而会和CreateBook、GetBook这类单数函数名冲突。 - 表名由 GORM 自动复数化:类型
Book对应的表名是books(由 inflection 库处理);即使把类型写成Books,表名也还是books,所以复数类型名没有任何数据库上的收益。 - 文件名可以用复数,但类型名必须是单数:
book.go和books.go都合法(一个文件放一组相关类型时复数更常见),本教程统一用单数book.go。 - 需要自定义表名时,用
TableName()方法:
func (Book) TableName() string { return "my_books" }
三套命名:Go 字段、数据库列、JSON 各说各话:
同一个逻辑字段在三个层有三个名字,互不冲突——翻译者是 GORM(列名)和 json: 标签(JSON):
| 层 | 名字 | 由谁决定 |
|---|---|---|
| Go 字段 | BookID(PascalCase) |
Go 标识符惯例 |
| 数据库列 | book_id(snake_case) |
GORM 的 NamingStrategy 从字段名自动转换,一般无需 column: |
| JSON 输出 | bookId(camelCase) |
json:"bookId" 标签,只影响序列化 |
列名与 JSON 名互不干扰——以表中假设的 BookID 为例(你项目里换成 Title/Author 等真实字段同理):库里永远是 book_id,前端看到的是 bookId。想改列名用 gorm:"column:...",想让 JSON 叫别的用 json: 标签——两个开关各管各的。
补充:没有
json:标签时呢? 上面表格讲的是”有 json 标签”的字段。没标签的(比如内嵌gorm.Model的ID、CreatedAt)会直接输出 Go 字段原名——所以第一次curl时你会看到"ID"、"DeletedAt"这种大写键(见第三章「三个新手最容易忽略的点」)。
第四章:自动迁移
目标:程序启动时自动创建或更新表结构
在 main.go 中添加:
package main
import (
"log"
"gin-demo/db"
"gin-demo/models"
"github.com/gin-gonic/gin"
)
func main() {
// 1. 连接数据库
db.InitDB()
// 2. 自动迁移(建表)
if err := db.DB.AutoMigrate(&models.Book{}); err != nil {
log.Fatal("迁移失败:", err)
}
// 3. 启动 Gin 服务
r := gin.Default()
// ... 路由
r.Run(":8080")
}
本章的
main.go是骨架版本,第九章会给出注册完所有路由的最终完整版。
注意事项
AutoMigrate会创建缺失的表、列和索引,但不会删除已有字段(保护数据)- 当字段的
size、precision、可空性(nullable)等属性变化时,GORM 会尝试修改已有列的类型 - 字段重命名不会同步改列名:比如把
Title改成Name,GORM 会新增一列而不是改名,此时需要手动迁移或用db.Migrator().RenameColumn()
先看公式:CRUD 的统一流程
第五到第八章的五个 handler,看起来各写各的,实际全是同一套流程的实例化。先看公式,再进代码——和心智模型一样,先拿到地图再进迷宫。
六步骨架:
- 抓参数:
id := c.Param("id")或c.Query("keyword")——Gin 的数据入口之一 - 绑请求:
c.ShouldBindJSON(&xxx),失败直接返回 400 - 查存在:
db.DB.First(&xxx, id),查无记录(errors.Is命中gorm.ErrRecordNotFound)→ 404 - 做操作:
Create/Find/Updates/Delete——每次操作都返回result - 验结果:非「查无记录」的
result.Error→ 500;RowsAffected == 0→ 404 - 回响应:
c.JSON(200/201, ...)
每章都是公式的一个实例:
| 操作 | 抓参数 | 绑请求 | 查存在 | 做操作 | 验结果 | 响应 |
|---|---|---|---|---|---|---|
POST /books |
– | ✓ | – | Create |
Error → 500 |
201 |
GET /books |
– | – | – | Find |
Error → 500 |
200 |
GET /books/:id |
✓ | – | First → 404 |
– | ErrRecordNotFound → 404 |
200 |
PUT /books/:id |
✓ | ✓ | First → 404 |
Updates |
Error → 500 |
200 |
DELETE /books/:id |
✓ | – | – | Delete |
Error → 500;RowsAffected=0 → 404 |
200 |
Gin + GORM 特化说明(与其他框架最不一样的地方):
- Gin 的数据入口只有一个
*gin.Context:路径参数(c.Param)、查询参数(c.Query)、请求体(c.ShouldBindJSON)都从它身上拿——没有控制器类、没有依赖注入,一个函数签名通吃所有请求。 - GORM 每一步都返回同一个
result:*gorm.DB既是链式调用的承接者,也是Error和RowsAffected的载体——「验结果」这一步,就是心智模型里「错误是结果的一部分」落到 API 层的形态。 - 传指针的分野:要往变量里填数据就必须传
&(Create(&book)、First(&book, id));删除操作不需要填数据,传类型即可(Delete(&models.Book{}, id))。 - 状态码即约定:400 参数问题 / 404 不存在 / 500 服务端错误 / 201 创建成功,整套文章都用这套映射。
- 生产惯例:每个 db 调用都链上请求上下文:
db.WithContext(c.Request.Context()).Xxx(...)——机制与超时中间件见第五章进阶。
现在进入第五章,对照公式看 CreateBook 是怎么实例化第 2、4、5、6 步的。
第五章:创建图书
目标:实现 POST /books 接口,接收 JSON 请求并存入数据库
注: 后续第六、七、八章的所有 CRUD 函数均追加至同一个文件
handlers/book.go中。开头统一为:
package handlers
import (
"errors"
"gin-demo/db"
"gin-demo/models"
"net/http"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
创建图书
// CreateBook 创建图书(POST /books)
func CreateBook(c *gin.Context) {
var book models.Book
// 1. 绑定 JSON 请求体
if err := c.ShouldBindJSON(&book); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请发送合法的 JSON"})
return
}
// 2. 插入数据库
result := db.DB.WithContext(c.Request.Context()).Create(&book)
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建失败"})
return
}
// 3. 返回创建的数据(这就是「结构体一物三用」:请求装进来、DB 插进去、原样返回)
c.JSON(http.StatusCreated, book)
}
⚠️ 测试时机: 路由到第九章才注册,当前
main.go只是骨架(// ... 路由)。建议先跳到第九章、把完整版main.go抄下来跑起服务,再回头逐章测试第五~八章接口(handler 代码按各章顺序照写即可)。
测试:
curl -X POST http://localhost:8080/books \
-H "Content-Type: application/json" \
-d '{"title":"Go语言实战","author":"张三","price":5990}'
响应示例(注意键名大小写):
{
"ID": 1,
"CreatedAt": "2026-09-05T13:05:46+08:00",
"UpdatedAt": "2026-09-05T13:05:46+08:00",
"DeletedAt": null,
"title": "Go语言实战",
"author": "张三",
"price": 5990
}
注意键名大小写:
ID/CreatedAt/DeletedAt是大写,因为gorm.Model没有 json 标签,直接输出 Go 字段原名(见第三章「三个新手最容易忽略的点」);title/author/price是我们自己用小写 tag 定义的。想统一成全小写(id、createdAt),有两条路:改用自定义字段声明,或定义 DTO 作为统一响应结构(见第十章进阶方向「响应精简」)。
进阶:为什么必须带 Context
注意上面的写法里有一个容易忽略的生产细节:所有数据库调用都链上了 WithContext(c.Request.Context())。下面拆开讲它是什么、为什么必须带、怎么配超时。
WithContext 是什么?
WithContext(ctx) 是 GORM 链式 API 的一环,把 Go 的 context.Context 挂到本次查询链上;执行 SQL 时,这个 ctx 会贯穿 database/sql → 数据库驱动(Postgres 驱动底层是 pgx),成为取消与超时的信号来源。它和 Where / Order 一样只是链上的一个方法——不带它查询也能跑:
// 不带:查询照跑,但无法感知请求是否已终止
db.DB.First(&book, id)
// 带:ctx 的取消 / 超时信号会传导到数据库驱动层
db.DB.WithContext(c.Request.Context()).First(&book, id)
为什么必须带?
c.Request.Context() 的生命周期绑定在请求上:客户端断开连接时它会被取消。带上了它,数据库查询会随之中断、连接归还连接池而不是挂死;不带 Context 的裸写法(如 db.DB.Create(&book))也能跑,但查询对请求生命周期毫无感知——所以从第五章起,正文代码统一使用带 Context 的写法(Create / Find / Updates / Delete 同理)。
相关配置:请求级超时中间件
客户端断开由框架自动取消,但服务端还要防”慢查询拖垮连接”——用中间件给每个请求挂一个超时兜底(函数放在 main.go,第九章的完整版会注册它):
// 完整函数与注册见第九章 main.go;核心就是四行:
ctx, cancel := context.WithTimeout(c.Request.Context(), d)
defer cancel() // 释放计时器,防止泄漏
c.Request = c.Request.WithContext(ctx) // 不写回则下游 WithContext 感知不到超时
c.Next()
超时后 GORM 返回 context.DeadlineExceeded,会走现有的 500 分支(生产环境可进一步映射 504,见第十章进阶方向)。
进阶:加参数校验
上面的代码不校验请求内容,{"price":5990}(没有 title / author)也能插入成功。校验标签加在哪?——加在”收请求的结构”上,而不是 models.Book 上:
// createBookInput:创建图书专用的请求结构(DTO),校验标签只属于它
type createBookInput struct {
Title string `json:"title" binding:"required"`
Author string `json:"author" binding:"required"`
Price int `json:"price" binding:"gte=0"`
}
为什么不在 models.Book 上加 binding:"required"? 同一个模型还被第七章的更新接口复用——更新是部分更新(只传 {"price":6990},见零值陷阱),模型上有 required 会让它被 400 拦下。所以:模型管数据库结构与 JSON 命名(gorm / json 标签),校验是接口契约,属于请求结构(DTO);系列正篇《GORM 数据工程实战》会正式展开 DTO。
CreateBook 的绑定目标换成这个结构,再映射成模型:
var input createBookInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请发送合法的 JSON"})
return
}
book := models.Book{Title: input.Title, Author: input.Author, Price: input.Price}
加上后,请求体缺少必填字段时 ShouldBindJSON 会直接返回校验错误,自动走现有的 400 分支:
# 缺少必填字段,返回 400
curl -X POST http://localhost:8080/books \
-H "Content-Type: application/json" \
-d '{"price":5990}'
第六章:查询图书
目标:实现查询列表和查询单条两个接口
// GetBooks 查询所有图书(GET /books)
func GetBooks(c *gin.Context) {
books := []models.Book{} // 空切片而非 nil:表为空时 JSON 输出 [],而不是 null
result := db.DB.WithContext(c.Request.Context()).Find(&books)
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询失败"})
return
}
c.JSON(http.StatusOK, books)
}
// GetBook 查询单条图书(GET /books/:id)
func GetBook(c *gin.Context) {
id := c.Param("id")
var book models.Book
result := db.DB.WithContext(c.Request.Context()).First(&book, id)
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "图书不存在"})
return
}
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询失败"})
return
}
c.JSON(http.StatusOK, book)
}
测试:
# 查询所有
curl http://localhost:8080/books
# 查询单条
curl http://localhost:8080/books/1
提示: 为什么
First的检查分两层?GORM 查不到记录时返回的是gorm.ErrRecordNotFound,用errors.Is精确命中它才返回 404(”图书不存在”);其它错误——比如数据库连接断开——会落到 500,不会误报成”图书不存在”。这就是心智模型里「错误是结果的一部分」在 API 层的落点——查无记录与连接断开是两种不同的error,值得用errors.Is区分。(
GetBooks为什么用[]models.Book{}初始化而不是var books []models.Book?Find查不到行时不会给var声明的切片分配内存,它保持为 nil,JSON 会输出null而不是[]——空表场景下前端解析就会踩坑,初始化成空切片是列表接口的标准姿势,后续文章的分页列表会沿用。)
第七章:更新图书
目标:实现 PUT /books/:id 接口
// UpdateBook 更新图书(PUT /books/:id)
func UpdateBook(c *gin.Context) {
id := c.Param("id")
// 1. 检查图书是否存在
var book models.Book
result := db.DB.WithContext(c.Request.Context()).First(&book, id)
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "图书不存在"})
return
}
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询失败"})
return
}
// 2. 绑定 JSON 请求体
var input models.Book
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请发送合法的 JSON"})
return
}
// 3. 更新字段(仅更新 input 中的非零字段,WHERE 条件由上方 First 决定)
result = db.DB.WithContext(c.Request.Context()).Model(&book).Updates(input)
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新失败"})
return
}
// 4. 返回更新后的数据(GORM 会把更新的非零字段回写 book,与落库一致;零值字段保持上方 First 加载的值)
c.JSON(http.StatusOK, book)
}
PUT 还是 PATCH?
严格按照 REST 语义,PUT /books/:id 表示「整体替换」,只更新部分字段应该用 PATCH /books/:id。本章的 Updates 只更新传入的非零字段,本质上是部分更新(PATCH)语义。入门示例用 PUT 没问题,但你应该知道两者的区别——如果希望语义更严谨,把路由改成 r.PATCH("/books/:id", handlers.UpdateBook),测试命令相应改为 curl -X PATCH ... 即可。
零值陷阱与进阶思考
Updates 传入结构体时,GORM 默认会忽略零值字段(0、""、false 等)。这是设计如此,通常能满足 90% 的业务场景。但如果你确实需要将某个字段更新为 0 或 "",有两种方案:
方案一:用 Select 强制指定字段
db.DB.WithContext(c.Request.Context()).Model(&book).Select("Price").Updates(input)
方案二:用 map[string]interface{}(更通用,推荐)
// 前端只传需要更新的字段,零值也能正常更新
var inputMap map[string]interface{}
if err := c.ShouldBindJSON(&inputMap); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请发送合法的 JSON"})
return
}
db.DB.WithContext(c.Request.Context()).Model(&book).Updates(inputMap)
方案二的优势在于:前端传什么就更新什么,不会因为零值问题导致意外行为,且在字段较多的场景下更灵活。
方案二的代价(两个坑):
- JSON 数字会变成
float64:ShouldBindJSON解析到map[string]interface{}时,任何数字键值都是float64({"price":6990}→float64(6990))。实测(pgx + PostgreSQL):float64 能写进整数列,整数没问题;遇到非整数值(如6990.5)不会报错,而是被静默舍入成 6990——数据悄悄变了。要点是:map 里的值失去了 Go 的类型保证,这正是后面要加白名单的第二个原因;- 请求里的任意键都会进 UPDATE SET:客户端误传的键名如果恰好是模型字段(如
ID、DeletedAt),GORM 会把它们解析成id、deleted_at列真的拼进 SET——最危险的是条件匹配时悄悄改写主键或软删除时间戳,比报错更可怕;只有解析不到任何字段的键(如Comments)才会原样进 SET 报”列不存在”。所以 map 方案要配键白名单(只挑允许的键进更新)——既挡住多余键,也顺带挡掉上面的类型与主键风险;更类型安全的做法是用指针字段 DTO(
*string/*int,见第十章进阶方向「请求 DTO 与指针字段」与《数据工程实战》)。
测试:
curl -X PUT http://localhost:8080/books/1 \
-H "Content-Type: application/json" \
-d '{"price":6990}'
第八章:删除图书
目标:实现 DELETE /books/:id 接口
因为 Book 使用了 gorm.Model,GORM 默认执行软删除。这意味着记录不会真正从数据库中移除,只是 deleted_at 字段被设为当前时间,查询时默认被过滤掉。
// DeleteBook 软删除图书(DELETE /books/:id)
func DeleteBook(c *gin.Context) {
id := c.Param("id")
// 执行软删除
result := db.DB.WithContext(c.Request.Context()).Delete(&models.Book{}, id)
if result.Error != nil { // 先验错误,再判行数——顺序不能反
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"})
return
}
if result.RowsAffected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "图书不存在"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
}
软删除行为解析
| 操作 | GORM 行为 |
|---|---|
| 第一次 DELETE | 设置 deleted_at = NOW(),不再出现在查询中 |
| 再次 DELETE 同一条 | 由于 deleted_at IS NOT NULL,GORM 找不到记录,RowsAffected == 0,返回”图书不存在” |
注意:GORM 执行的是 UPDATE ... SET deleted_at=NOW() WHERE id=? AND deleted_at IS NULL,条件不匹配时 RowsAffected 为 0,并不会重复软删除。
注意: 软删除后,默认的
First/Find查询会自动加上deleted_at IS NULL条件,所以被软删除的记录不会出现在列表中。⚠️ 唯一约束的坑: 若某字段带唯一索引(如
ISBN——假设你已经给模型加了该字段与唯一索引;本章Book只有Title/Author/Price),软删除的记录仍占用索引——再次插入相同 ISBN 会违反唯一约束。PostgreSQL 的解法是部分唯一索引:只让未删除行参与唯一性。
CREATE UNIQUE INDEX idx_books_isbn ON books (isbn) WHERE deleted_at IS NULL;
如果需要查询已删除的记录
db.DB.WithContext(c.Request.Context()).Unscoped().First(&book, id)
如果需要物理删除(彻底删除)
该函数属于可选项。第九章的完整版
main.go默认注册了它(带「可选」注释);如果你不想对外开放物理删除,把那一行删掉即可。
// DeleteBookPermanently 物理删除图书(DELETE /books/:id/permanent)
func DeleteBookPermanently(c *gin.Context) {
id := c.Param("id")
// Unscoped() 绕过软删除,执行物理删除
result := db.DB.WithContext(c.Request.Context()).Unscoped().Delete(&models.Book{}, id)
if result.Error != nil { // 先验错误,再判行数(与 DeleteBook 同一套顺序)
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"})
return
}
if result.RowsAffected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "图书不存在"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "物理删除成功"})
}
测试:
curl -X DELETE http://localhost:8080/books/1
第九章:注册路由
目标:把所有路由注册到 Gin 引擎
更新 main.go:
package main
import (
"context"
"log"
"time"
"gin-demo/db"
"gin-demo/handlers"
"gin-demo/models"
"github.com/gin-gonic/gin"
)
// 给每个请求挂一个超时兜底(第五章进阶)
func requestTimeout(d time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), d)
defer cancel()
c.Request = c.Request.WithContext(ctx)
c.Next()
}
}
func main() {
// 连接数据库
db.InitDB()
// 自动迁移
if err := db.DB.AutoMigrate(&models.Book{}); err != nil {
log.Fatal("迁移失败:", err)
}
r := gin.Default()
// 请求级超时:慢查询会被 context 中断,走 500(生产可映射 504)
r.Use(requestTimeout(5 * time.Second))
// RESTful API 路由
r.POST("/books", handlers.CreateBook)
r.GET("/books", handlers.GetBooks)
r.GET("/books/:id", handlers.GetBook)
r.PUT("/books/:id", handlers.UpdateBook) // 严格 REST 语义下部分更新用 PATCH
r.DELETE("/books/:id", handlers.DeleteBook)
// 可选:第八章的物理删除示例(默认软删除即可满足需求)
r.DELETE("/books/:id/permanent", handlers.DeleteBookPermanently)
r.Run(":8080")
}
测试(物理删除示例)
curl -X DELETE http://localhost:8080/books/1/permanent
完整运行后,除
curl手动测试外,第八章的「物理删除」接口也可通过上面的路由调用。
第十章:总结
你学到的核心知识
| 操作 | GORM 方法 | 对应 SQL |
|---|---|---|
| 创建 | db.Create(&book) |
INSERT INTO ... |
| 查询所有 | db.Find(&books) |
SELECT * FROM ... |
| 查询单条 | db.First(&book, id) |
SELECT * FROM ... WHERE id = ? |
| 更新 | db.Model(&book).Updates(input) |
UPDATE ... SET ... |
| 软删除 | db.Delete(&models.Book{}, id) |
UPDATE ... SET deleted_at = NOW() |
| 物理删除 | db.Unscoped().Delete(&models.Book{}, id) |
DELETE FROM ... |
| 查询已删除 | db.Unscoped().First(&models.Book{}, id) |
SELECT * FROM ... WHERE id = ?(不限软删除) |
全文的 5 个 handler 都是「先看公式」节六步骨架的实例化:参数 → 绑定 → 存在 → 操作 → 结果 → 响应;所有 db 操作均链上
WithContext(c.Request.Context()),查询用errors.Is区分「查无记录」(404)与其它错误(500)。
进阶方向
- 事务:
db.Transaction() - 分页与筛选:
Where/Order/Offset/Limit的实战已在《文件与查询增强实战》覆盖 - 钩子函数:
BeforeCreate、AfterUpdate - 请求 DTO 与指针字段:
*string/*int替代map[string]interface{}的实战已在《数据工程实战》落地(Price *int就是它) - 响应精简:响应键名为什么是大写(
ID/CreatedAt),以及想统一风格怎么办——见第三章「三个新手最容易忽略的点」与第五章响应示例的提示(自定义字段声明json:"-"/小写 tag,或 DTO 做统一响应结构) - 连接池:
sqlDB, _ := db.DB.DB()获取底层连接后,用SetMaxOpenConns()/SetConnMaxLifetime()配置连接池 - 工程化沉淀(选读):Repository / Service 分层与测试(
BookRepository+ sqlmock)、泛型GetPaginated[T]封装——落地见《工程化(一)》与《工程化(二)》 - 超时映射:
errors.Is(err, context.DeadlineExceeded)时返回 504,而不是笼统的 500 - 超时一行库:想少写代码可用 gin-contrib/timeout 的
timeout.New(...)接入,但会把 ctx 派生/回写机制变成黑盒,教程保持手写以便看清原理 - SQL 调试:开启 GORM 日志
&gorm.Config{Logger: logger.Default.LogMode(logger.Info)},排查 SQL 问题 - 统一错误处理:用错误中间件 / 统一响应包装(ok/fail 结构)收敛重复的 500 样板——正文保持显式检查以便看清
errors.Is的区分逻辑
进阶内容请移步系列正篇: 关联查询(
Preload)、分页聚合等实战在《GORM 多表关联实战》与《GORM 文件与查询增强实战》;Repository / Service 分层与可测试性落地于《GORM 工程化实战(一)》与《GORM 工程化实战(二)》——正文保持平铺直连,聚焦 GORM 本体。

