Gin + GORM 实战:把待办事项真正存进数据库
上一篇我们做了待办事项 API,但数据存在内存切片里,程序一重启就全没了。这一篇给它接上真正的数据库,用 Go 最流行的 ORM 框架 GORM,让数据"活"下来。
使用 SQLite(一个文件就是数据库),零配置、不用装数据库服务,Windows 也能直接跑通。
建议先看完系列前四篇(Go 项目结构、Go 常用标准库包、Gin 入门),衔接更顺。
0. 上一篇的问题
回顾一下上一篇的代码,数据是这样存的:
go
var todos = []model.Todo{} // 内存切片
问题很明显:
- 程序一重启,数据全没了(内存一清空就消失)
- 数据多了以后,内存操作也管理不过来
解决办法就是数据库:数据写到磁盘上,程序重启后还在。而操作数据库,我们不用手写 SQL,用 GORM。
1. GORM 是什么?
GORM 是 Go 最流行的 ORM 框架(对象关系映射)。
一句话:ORM 是结构体和数据库表之间的"翻译官"。
Go 结构体(Todo) ←→ 数据库表(todos)
你写:db.Create(&t) 实际执行:INSERT INTO todos ...
你写:db.First(&t, 1) 实际执行:SELECT * FROM todos WHERE id = 1
你只操作结构体,GORM 帮你翻译成 SQL 去数据库执行。这样代码里没有一堆字符串 SQL,类型安全,也更好读。
GORM 当前版本 v1.31(gorm.io/gorm),支持 MySQL、PostgreSQL、SQLite 等几乎所有主流数据库。本篇用 SQLite 入门,换 MySQL 只改一行连接代码。
2. 准备工作:安装 GORM 和 SQLite 驱动
在你上一篇的 mytodo 项目里执行:
bash
go get gorm.io/gorm
go get github.com/glebarez/sqlite
3. 五分钟认识 GORM 核心五件套
先不看 Gin,单独跑一个最简单的 GORM 程序,把五个核心操作过一遍。
新建 main.go:
go
package main
import (
"fmt"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
// User 结构体,对应数据库里的 users 表
type User struct {
ID uint `gorm:"primaryKey"`
Name string
Age int
}
func main() {
// 1. 连接数据库(SQLite 就是一个文件,自动创建)
db, err := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})
if err != nil {
panic("连接数据库失败:" + err.Error())
}
// 2. 自动建表(没有 users 表就创建,有就跳过)
db.AutoMigrate(&User{})
// 3. 增:Create(注意要传地址,u.ID 会被自动填上)
u := User{Name: "小明", Age: 20}
db.Create(&u)
fmt.Println("新记录的 ID:", u.ID)
// 4. 查:First 按主键查一条
var u1 User
db.First(&u1, u.ID)
fmt.Println("查到的:", u1.Name, u1.Age)
// 5. 改:Save 保存修改
u1.Age = 21
db.Save(&u1)
// 6. 删:Delete 按主键删除
db.Delete(&User{}, u.ID)
fmt.Println("搞定,去当前目录看看 test.db 文件吧")
}
运行 go run .,正常的话什么都不报错。此时项目目录下多了一个 test.db 文件------这就是你的数据库。
核心五件套速记:
| 操作 | GORM 写法 | 对应 SQL |
|---|---|---|
| 连接 | gorm.Open(sqlite.Open("test.db"), ...) |
--- |
| 建表 | db.AutoMigrate(&User{}) |
CREATE TABLE |
| 增 | db.Create(&u) |
INSERT |
| 查 | db.First(&u, id) / db.Find(&list) |
SELECT |
| 改 | db.Save(&u) |
UPDATE |
| 删 | db.Delete(&User{}, id) |
DELETE |
注意:增删改查全部要传地址 (
&),原因和上一篇ShouldBindJSON(&t)一样------函数要修改你的变量,就得知道它在内存哪里。
4. 改造 model:给结构体加数据库标签
回到 Gin 项目,先升级数据模型。原来的 internal/model/todo.go:
go
package model
import "time"
// Todo 待办事项
type Todo struct {
ID uint `json:"id" gorm:"primaryKey"`
Title string `json:"title"`
Done bool `json:"done"`
CreatedAt time.Time `json:"created_at"`
}
和上一篇相比只多了两样东西:
gorm:"primaryKey":告诉 GORM 这个字段是主键,会自动增长CreatedAt time.Time:GORM 的约定------叫这个名字的字段,插入时自动填当前时间,不用你管
5. 改造 handler:从"内存切片"换成"数据库操作"
原来的 internal/handler/todo.go 用 var todos = []model.Todo{} 存数据,现在全部换成数据库操作,并且顺便补上"更新"和"删除"两个接口:
go
package handler
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"example.com/mytodo/internal/model"
)
// DB 全局数据库连接(演示用;正式项目一般封装成单例)
var DB *gorm.DB
// ListTodos GET /api/todos 待办列表
func ListTodos(c *gin.Context) {
var todos []model.Todo
DB.Find(&todos) // 查全部
c.JSON(http.StatusOK, gin.H{"data": todos})
}
// CreateTodo POST /api/todos 新增待办
func CreateTodo(c *gin.Context) {
var t model.Todo
if err := c.ShouldBindJSON(&t); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
DB.Create(&t) // 插入数据库,t.ID 和 t.CreatedAt 会被自动填上
c.JSON(http.StatusOK, gin.H{"data": t})
}
// GetTodo GET /api/todos/:id 查单个待办
func GetTodo(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var t model.Todo
if err := DB.First(&t, id).Error; err != nil { // 按主键查
c.JSON(http.StatusNotFound, gin.H{"error": "没有这条待办"})
return
}
c.JSON(http.StatusOK, gin.H{"data": t})
}
// UpdateTodo PUT /api/todos/:id 更新待办(比如标记完成)
func UpdateTodo(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var t model.Todo
if err := DB.First(&t, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "没有这条待办"})
return
}
// 把请求体里的 JSON 合并进已查出的记录(没传的字段保持原值)
if err := c.ShouldBindJSON(&t); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
DB.Save(&t) // 保存修改
c.JSON(http.StatusOK, gin.H{"data": t})
}
// DeleteTodo DELETE /api/todos/:id 删除待办
func DeleteTodo(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
DB.Delete(&model.Todo{}, id) // 按主键删除
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
}
注意
DB.First(&t, id).Error这个写法:GORM 支持链式调用,最后用.Error拿结果。如果没查到记录,Error 不为空,返回 404------和上一篇if err != nil的判断思路完全一样。
6. 改造 main.go:连接数据库 + 自动建表 + 新路由
入口文件 cmd/server/main.go 也要动:
go
package main
import (
"log"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"example.com/mytodo/internal/handler"
"example.com/mytodo/internal/model"
)
func main() {
// 1. 连接数据库(todo.db 是文件,程序重启数据不丢)
db, err := gorm.Open(sqlite.Open("todo.db"), &gorm.Config{})
if err != nil {
log.Fatal("连接数据库失败:", err)
}
handler.DB = db // 交给 handler 层使用
// 2. 自动建表(表不存在就创建,存在就跳过)
db.AutoMigrate(&model.Todo{})
// 3. 注册路由(新增了 PUT 和 DELETE)
r := gin.Default()
api := r.Group("/api")
{
api.GET("/todos", handler.ListTodos)
api.POST("/todos", handler.CreateTodo)
api.GET("/todos/:id", handler.GetTodo)
api.PUT("/todos/:id", handler.UpdateTodo)
api.DELETE("/todos/:id", handler.DeleteTodo)
}
log.Println("服务启动:http://localhost:8080")
r.Run(":8080")
}
7. 运行并测试:关键验证"重启不丢"
启动服务:
bash
go run ./cmd/server
另开一个终端,把 CRUD 全测一遍:
bash
# 1. 新增两条
curl -X POST http://localhost:8080/api/todos \
-H "Content-Type: application/json" -d '{"title":"买牛奶","done":false}'
curl -X POST http://localhost:8080/api/todos \
-H "Content-Type: application/json" -d '{"title":"写 Gin 博客","done":false}'
# 2. 查列表(能看到刚才两条,且自动带了 id 和 created_at)
curl http://localhost:8080/api/todos
# 3. 把第 1 条标记为完成
curl -X PUT http://localhost:8080/api/todos/1 \
-H "Content-Type: application/json" -d '{"done":true}'
# 4. 删除第 2 条
curl -X DELETE http://localhost:8080/api/todos/2
# 5. 再查一次列表确认
curl http://localhost:8080/api/todos
最后一步是重点 :按 Ctrl+C 停掉服务,再执行 go run ./cmd/server 重启,然后重新查列表------
bash
curl http://localhost:8080/api/todos
你会发现数据还在 !因为数据已经写进了磁盘上的 todo.db 文件,这就是数据库的意义。
顺手看一眼项目目录,会多出
todo.db文件,它就是整个数据库。备份它 = 备份所有数据。
8. 总结:一张表 + 三个坑
GORM 速查表:
| 我想做 | GORM 写法 |
|---|---|
| 连接数据库 | gorm.Open(sqlite.Open("todo.db"), &gorm.Config{}) |
| 自动建表 | db.AutoMigrate(&Todo{}) |
| 新增 | db.Create(&t) |
| 查全部 | db.Find(&todos) |
| 按主键查一条 | db.First(&t, id) |
| 保存修改 | db.Save(&t) |
| 按主键删除 | db.Delete(&Todo{}, id) |
数据库接好之后,下一篇可以讲讲 GORM 的进阶:一对多关联(比如"用户拥有多条待办")、条件查询,或者给接口加上登录鉴权(JWT)。想看哪个评论区告诉我~
参考资料
- GORM 官方文档(中文)--- https://gorm.io/zh_CN/docs/
- glebarez/sqlite 纯 Go 驱动 --- https://github.com/glebarez/sqlite