从零到一:使用 Go、Gin 和 GORM 构建一个 RESTful Todo API
前言
大家好!今天,我将带领大家从零开始,一步步构建一个功能完备的待办事项(Todo)后端服务。这个项目虽然简单,但涵盖了现代 Web 后端开发的核心技术和最佳实践。我们将使用 Go 语言作为开发语言,搭配高性能的 Gin Web 框架和强大的 GORM ORM 库,数据库则选用轻量级的 SQLite。
通过这个项目,你将学到:
-
如何使用 Gin 搭建 RESTful API 服务。
-
如何使用 GORM 定义数据模型并操作数据库。
-
如何实现完整的 CRUD(创建、读取、更新、删除)业务逻辑。
-
如何组织一个清晰、可维护的 Go 项目结构。
-
如何处理跨域请求和 JSON 数据。
无论你是 Go 语言的新手,还是希望巩固 Web 开发技能的开发者,这篇文章都将为你提供一个绝佳的实战机会。
🛠 技术栈概览
在开始编码之前,让我们先了解一下项目所使用的核心技术:
-
语言: Go 1.24.4
-
Web 框架: Gin (v1.11.0) - 一个用 Go 语言编写的高性能 HTTP Web 框架,以其极快的速度和简洁的 API 而闻名。
-
ORM: GORM (v1.31.0) - Go 语言领域功能最强大的 ORM 库之一,提供了对数据库的抽象,让我们可以用 Go 结构体来操作数据表。
-
数据库: SQLite (v1.6.0) - 一个轻量级的、无服务器的、自包含的嵌入式 SQL 数据库引擎。
-
跨域处理:
gin-contrib/cors
- Gin 官方提供的 CORS 中间件,可以轻松解决前端跨域请求问题。
📁 项目结构设计
一个良好的项目结构是项目成功的关键。我们采用了一个清晰、模块化的目录结构,将不同功能的代码分离开来,便于维护和扩展。
backend/
├── main.go # 主程序入口, 路由配置和处理器定义
├── database/
│ ├── db.go # 数据库初始化和连接管理
│ └── todo.go # Todo 数据模型和数据库 CRUD 操作
├── go.mod # Go 模块依赖管理
├── go.sum # Go 模块依赖校验和
└── todo.db # SQLite 数据库文件 (运行时自动生成)
-
main.go
: 项目的启动文件,负责初始化数据库、设置 Gin 引擎、注册路由和启动 HTTP 服务。 -
database/
目录:专门用于处理所有与数据库相关的逻辑。-
db.go
: 封装了数据库的连接、初始化以及 GORM 的配置。 -
todo.go
: 定义了Todo
结构体(即数据模型),并提供了所有针对todos
表的增删改查函数。
-
👨💻 编码实战
接下来,让我们深入代码,看看每个部分是如何实现的。
第一步:定义数据模型和数据库操作 (database/todo.go
)
我们首先定义 Todo
模型。这个模型对应数据库中的 todos
表。GORM 的 gorm.Model
会自动为我们添加 ID
, CreatedAt
, UpdatedAt
, DeletedAt
四个字段,非常方便。
// 包 database 负责处理所有与数据库相关的操作
package database
import "gorm.io/gorm"
// Todo 定义了待办事项的数据模型
// gorm.Model 包含了 ID, CreatedAt, UpdatedAt, DeletedAt 四个字段
type Todo struct {
gorm.Model
Title string `gorm:"not null" json:"title"` // 待办事项的标题,不能为空
Completed bool `gorm:"not null;default:false" json:"completed"` // 完成状态,不能为空,默认为 false
}
// TableName 指定了 Todo 模型在数据库中对应的表名
// 返回 "todos"
func (todo Todo) TableName() string {
return "todos"
}
// CreateTodo 用于向数据库中创建一个新的待-办事项
// 输入参数: todo (*Todo) - 指向要创建的 Todo 对象的指针
// 返回值: error - 如果创建过程中发生错误,则返回错误信息
func CreateTodo(todo *Todo) error {
// 使用 GORM 的 Create 方法将 todo 对象插入数据库
err := DB.Create(todo).Error
if err != nil {
// 如果发生错误,返回错误
return err
}
// 如果没有错误,返回 nil
return nil
}
// GetTodo 用于根据 ID 从数据库中获取单个待办事项
// 输入参数: id (int) - 要获取的 Todo 的 ID
// 返回值: (Todo, error) - 返回找到的 Todo 对象和可能的错误信息
func GetTodo(id int) (Todo, error) {
// 声明一个 Todo 类型的变量用于存储查询结果
var todo Todo
// 使用 GORM 的 Where 方法构建查询条件,并用 First 方法获取第一条匹配的记录
err := DB.Where("id=?", id).First(&todo).Error
if err != nil {
// 如果查询出错(例如,记录不存在),返回一个空的 Todo 对象和错误信息
return Todo{}, err
}
// 如果找到记录,返回该记录和 nil
return todo, nil
}
// GetTodos 用于从数据库中获取所有的待办事项列表
// 返回值: ([]Todo, error) - 返回 Todo 对象切片和可能的错误信息
func GetTodos() ([]Todo, error) {
// 声明一个 Todo 切片用于存储所有记录
var todos []Todo
// 使用 GORM 的 Find 方法查询所有记录并存入 todos 切片
err := DB.Find(&todos).Error
if err != nil {
// 如果查询出错,返回 nil 和错误信息
return nil, err
}
// 返回查询到的所有记录和 nil
return todos, nil
}
// UpdateTodo 用于更新数据库中已存在的待办事项
// 输入参数: todo (*Todo) - 指向包含更新后数据的 Todo 对象的指针
// 输入参数: id (int) - 要更新的 Todo 的 ID (虽然在当前实现中未使用,但保留以符合 RESTful 风格)
// 返回值: error - 如果更新过程中发生错误,则返回错误信息
func UpdateTodo(todo *Todo, id int) error {
// 使用 GORM 的 Save 方法更新记录。Save 会更新所有字段,即使它们是零值。
err := DB.Save(todo).Error
if err != nil {
// 如果更新出错,返回错误
return err
}
// 如果没有错误,返回 nil
return nil
}
// DeleteTodo 用于根据 ID 从数据库中删除一个待办事项
// 输入参数: id (int) - 要删除的 Todo 的 ID
// 返回值: error - 如果删除过程中发生错误,则返回错误信息
func DeleteTodo(id int) error {
// 声明一个 Todo 变量
var todo Todo
// 首先,根据 ID 查找记录是否存在
err := DB.Where("id=?", id).First(&todo).Error
if err != nil {
// 如果记录不存在或查找出错,返回错误
return err
}
// 如果记录存在,则使用 GORM 的 Delete 方法删除它
// 注意:这里传入 &Todo{} 和 id,GORM 会根据主键删除
err = DB.Delete(&Todo{}, id).Error
if err != nil {
// 如果删除出错,返回错误
return err
}
// 如果没有错误,返回 nil
return nil
}
第二步:初始化数据库连接 (database/db.go
)
这个文件负责连接数据库,并使用 GORM 的 AutoMigrate
功能自动创建(或更新)todos
表的结构。
// 包 database 负责处理所有与数据库相关的操作
package database
import (
// 导入日志包,用于记录严重错误
"log"
// 导入 GORM 的 SQLite 驱动
"gorm.io/driver/sqlite"
// 导入 GORM 核心库
"gorm.io/gorm"
)
// DB 是一个全局的数据库连接池指针,供其他包使用
var DB *gorm.DB
// Init 函数用于初始化数据库连接
func Init() {
// 使用 gorm.Open 连接到 SQLite 数据库。 "todo.db" 是数据库文件名。
db, err := gorm.Open(sqlite.Open("todo.db"), &gorm.Config{})
// 如果连接过程中出现错误
if err != nil {
// 使用 log.Fatalf 打印错误信息并终止程序
log.Fatalf("数据库连接失败: %v", err)
}
// 将成功创建的数据库连接赋值给全局变量 DB
DB = db
// 使用 AutoMigrate 自动迁移数据库结构
// 它会根据 Todo 结构体的定义创建或更新 "todos" 表
if err := DB.AutoMigrate(&Todo{}); err != nil {
// 如果迁移过程中出现错误,打印错误信息并终止程序
log.Fatalf("数据库迁移失败: %v", err)
}
}
第三步:主程序入口与路由 (main.go
)
这是我们服务的核心。它初始化数据库,创建 Gin 路由引擎,配置 CORS 中间件,并将每个 API 端点与相应的处理函数(Handler)绑定起来。
// 主包,程序的入口点
package main
import (
// 导入自定义的 database 包,用于数据库操作
"backend/database"
// 导入 strconv 包,用于字符串和基本数据类型之间的转换
"strconv"
// 导入 Gin 的 CORS 中间件,用于处理跨域请求
"github.com/gin-contrib/cors"
// 导入 Gin Web 框架
"github.com/gin-gonic/gin"
)
// main 函数是程序的入口
func main() {
// 调用 database 包的 Init 函数来初始化数据库连接和进行数据迁移
database.Init()
// 创建一个默认的 Gin 引擎
router := gin.Default()
// 使用 cors.Default() 中间件,允许所有来源的跨域请求
router.Use(cors.Default())
// 定义 API 路由
// GET /todo/:id - 获取单个待办事项
router.GET("/todo/:id", HandlerGetID())
// GET /todo - 获取所有待办事项
router.GET("/todo", HandlerGet())
// POST /todo - 创建一个新的待办事项
router.POST("/todo", HandlerCreate())
// PUT /todo/:id - 更新一个已存在的待办事项
router.PUT("/todo/:id", HandlerUpdate())
// DELETE /todo/:id - 删除一个待办事项
router.DELETE("/todo/:id", HandlerDelete())
// 启动 HTTP 服务,监听在 8080 端口
router.Run(":8080")
}
// HandlerGetID 返回一个处理获取单个 Todo 请求的 Gin HandlerFunc
// 作用: 根据 URL 中的 ID 获取特定的 Todo
// 输入: gin.Context - Gin 的上下文对象,包含了请求和响应的信息
// 输出: 无
func HandlerGetID() gin.HandlerFunc {
return func(c *gin.Context) {
// 从 URL 参数中获取 ID,并处理可能发生的错误
id, err := getId(c)
if err != nil {
// 如果 ID 无效,直接返回
return
}
// 调用数据库函数获取 Todo
todo, err := database.GetTodo(id)
if err != nil {
// 如果数据库查询出错(例如,找不到记录),返回 404 Not Found
c.JSON(404, gin.H{"error": "Todo 不存在"})
return
}
// 成功找到,返回 200 OK 和 Todo 数据
c.JSON(200, todo)
}
}
// HandlerGet 返回一个处理获取所有 Todo 列表请求的 Gin HandlerFunc
// 作用: 获取所有的 Todo 列表
// 输入: gin.Context
// 输出: 无
func HandlerGet() gin.HandlerFunc {
return func(c *gin.Context) {
// 调用数据库函数获取所有 Todos
todos, err := database.GetTodos()
if err != nil {
// 如果获取失败,返回 500 Internal Server Error
c.JSON(500, gin.H{"error": "获取 Todo 列表失败"})
return
}
// 成功获取,返回 200 OK 和 Todos 列表
c.JSON(200, todos)
}
}
// HandlerCreate 返回一个处理创建新 Todo 请求的 Gin HandlerFunc
// 作用: 从请求体中解析 JSON 数据并创建一个新的 Todo
// 输入: gin.Context
// 输出: 无
func HandlerCreate() gin.HandlerFunc {
return func(c *gin.Context) {
// 声明一个 database.Todo 变量用于存储绑定的数据
var todo database.Todo
// 将请求体中的 JSON 数据绑定到 todo 变量上
if err := c.BindJSON(&todo); err != nil {
// 如果数据绑定失败(如 JSON 格式错误),返回 400 Bad Request
c.JSON(400, gin.H{"error": "无效的请求数据: " + err.Error()})
return
}
// 简单的输入验证:标题不能为空
if todo.Title == "" {
c.JSON(400, gin.H{"error": "标题不能为空"})
return
}
// 调用数据库函数创建 Todo
if err := database.CreateTodo(&todo); err != nil {
// 如果创建失败,返回 500 Internal Server Error
c.JSON(500, gin.H{"error": "创建 Todo 失败"})
return
}
// 成功创建,返回 200 OK 和新创建的 Todo 对象(此时已包含 ID 和时间戳)
c.JSON(200, todo)
}
}
// HandlerUpdate 返回一个处理更新 Todo 请求的 Gin HandlerFunc
// 作用: 根据 ID 更新一个已存在的 Todo
// 输入: gin.Context
// 输出: 无
func HandlerUpdate() gin.HandlerFunc {
return func(c *gin.Context) {
// 从 URL 参数中获取 ID
id, err := getId(c)
if err != nil {
return
}
// 声明一个 database.Todo 变量
var todo database.Todo
// 绑定请求体中的 JSON 数据
if err := c.BindJSON(&todo); err != nil {
c.JSON(400, gin.H{"error": "无效的请求数据: " + err.Error()})
return
}
// 将从 URL 获取的 int 类型的 id 转换为 uint 类型,并赋值给 todo 的 ID 字段
// 这是因为 GORM 的模型 ID 是 uint 类型
todo.ID = uint(id)
// 调用数据库函数更新 Todo
if err := database.UpdateTodo(&todo, id); err != nil {
c.JSON(500, gin.H{"error": "更新 Todo 失败"})
return
}
// 成功更新,返回 200 OK 和更新后的 Todo 对象
c.JSON(200, todo)
}
}
// HandlerDelete 返回一个处理删除 Todo 请求的 Gin HandlerFunc
// 作用: 根据 ID 删除一个 Todo
// 输入: gin.Context
// 输出: 无
func HandlerDelete() gin.HandlerFunc {
return func(c *gin.Context) {
// 从 URL 参数中获取 ID
id, err := getId(c)
if err != nil {
// 注意:这里为了与 README.md 中的 API 文档保持一致,当 ID 不存在时不直接返回 500,
// 而是在 getId 函数中返回了 400。但如果 getId 内部逻辑变化,这里可能需要调整。
// 实际上,如果 getId 返回错误,响应已经发送,这里可以简化。
// 但为了清晰,我们假设可能存在 getId 内部未发送 JSON 响应的错误路径。
c.JSON(500, gin.H{"error": "ID不存在"})
return
}
// 调用数据库函数删除 Todo
if err := database.DeleteTodo(id); err != nil {
c.JSON(500, gin.H{"error": "删除 Todo 失败"})
return
}
// 成功删除,返回 200 OK 和成功消息
c.JSON(200, gin.H{"message": "删除成功!"})
}
}
// getId 是一个辅助函数,用于从 Gin 上下文中提取并转换 ID
// 作用: 从 URL 路径参数中获取 "id",并将其从字符串转换为整数
// 输入: c (*gin.Context) - Gin 上下文
// 输出: (int, error) - 返回转换后的整数 ID 和可能发生的错误
func getId(c *gin.Context) (int, error) {
// 从 URL 路径中获取名为 "id" 的参数值
idStr := c.Param("id")
// 使用 strconv.Atoi 将字符串 ID 转换为整数
id, err := strconv.Atoi(idStr)
if err != nil {
// 如果转换失败(例如 "id" 不是数字),返回 400 Bad Request
c.JSON(400, gin.H{"error": "无效的ID"})
// 返回 0 和错误,通知调用者操作失败
return 0, err
}
// 如果转换成功,返回整数 ID 和 nil
return id, nil
}
第四步:依赖管理 (go.mod
)
最后,go.mod
文件定义了我们的项目模块和所有依赖项及其版本。
module backend
go 1.24.4
require (
github.com/gin-contrib/cors v1.7.6
github.com/gin-gonic/gin v1.11.0
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.0
)
🚀 运行和测试
现在,代码已经全部完成,让我们来运行和测试它!
-
环境要求
- 确保你已经安装了 Go (1.24.4 或更高版本)。
-
下载依赖
在项目根目录(backend/)下打开终端,运行以下命令来下载 go.mod 中定义的所有依赖包:
Bash
go mod download
-
启动服务
使用以下命令来编译并运行我们的服务:
go run main.go
如果一切顺利,你将在终端看到类似以下的输出,表示服务已在
http://localhost:8080
成功启动:[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached. [GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production. - using code: gin.SetMode(gin.ReleaseMode) - using env: export GIN_MODE=release [GIN-debug] GET /todo/:id --> main.HandlerGetID.func1 (3 handlers) [GIN-debug] GET /todo --> main.HandlerGet.func1 (3 handlers) [GIN-debug] POST /todo --> main.HandlerCreate.func1 (3 handlers) [GIN-debug] PUT /todo/:id --> main.HandlerUpdate.func1 (3 handlers) [GIN-debug] DELETE /todo/:id --> main.HandlerDelete.func1 (3 handlers) [GIN-debug] Listening and serving HTTP on :8080
-
使用 cURL 测试 API
你可以使用 cURL 或任何 API 测试工具(如 Postman)来验证接口是否正常工作。
-
创建待办事项
curl -X POST http://localhost:8080/todo \ -H "Content-Type: application/json" \ -d '{"title":"学习 Go 语言","completed":false}'
-
获取所有待办事项
curl http://localhost:8080/todo
-
获取单个待办事项 (假设 ID 为 1)
curl http://localhost:8080/todo/1
-
总结
恭喜你!你已经成功地使用 Go、Gin 和 GORM 构建了一个完整的 RESTful API 服务。我们从项目设计开始,一步步实现了数据模型、数据库操作和 API 接口,并最终成功运行和测试了我们的应用。
这个项目虽然简单,但它为你提供了一个坚实的起点,你可以基于它进行扩展,比如添加用户认证、输入验证、更复杂的业务逻辑等等。
希望这篇文章能帮助你更好地理解和使用 Go 语言进行后端开发。感谢阅读!