go go go 出发咯 - go web开发入门系列(四) 数据库ORM框架集成与解读
go go go 出发咯 - go web开发入门系列(四) 数据库ORM框架集成与解读
往期回顾
- go go go 出发咯 - go web开发入门系列(一) helloworld
- go go go 出发咯 - go web开发入门系列(二) Gin 框架实战指南
- go go go 出发咯 - go web开发入门系列(三) 项目基础框架搭建与解读
前言
在上一篇文章中,我们从零开始,搭建了一个生产级的 Go Web 应用框架。我们深入探讨了分层架构、依赖注入和面向接口编程,并最终构建了一个结构清晰、职责分明的“手动挡”应用——我们拥有对每一行 SQL 的完全控制权。
这种控制力在需要极致性能优化的场景下非常宝贵。但对于大多数标准的增删改查(CRUD)操作来说,手动编写和映射每一条 SQL 显得有些繁琐。这正是 ORM(对象关系映射)框架大显身手的舞台。
本文将作为上一篇的进阶,向您展示如何将 Go 生态中最流行的 ORM 框架 GORM,无缝地集成到我们现有的分层架构中。我们的目标是:在不改动任何 Service 和 Handler 层代码的前提下,用 GORM 完全替换掉手写 SQL 的 Repository 层,体验开发效率的飞跃。
架构回顾:解耦是替换的基石
让我们再次回顾一下我们的分层架构,正是这个清晰的结构,使得替换数据访问层成为可能。
/awesomeProject
| ├── cmd/ # 入口文件
│ └── server/
│ └── main.go # 主程序入口
├── configs/ # 配置文件
│ └── config.dev.yaml
├── internal/ # 内部模块
│ ├── config/ # 配置加载
│ ├── database/ # 数据库连接
│ ├── models/ # 数据模型
│ ├── repository/ # 数据访问层
│ └── service/ # 业务逻辑层
├── transport/ # 传输层
...
GORM (全功能 ORM 框架)
GORM 介绍
GORM 是 Go 语言中最流行的全功能 ORM (Object-Relational Mapping) 框架。它的设计哲学最接近您熟悉的 Hibernate
或 MyBatis-Plus
,旨在通过“约定优于配置”和链式调用,将开发者从手写 SQL 中解放出来。
核心理念: 将数据库操作完全对象化。你操作的是 Go 的结构体对象,GORM 负责在背后生成并执行对应的 SQL 语句。
特点:
链式 API: 提供非常流畅的链式调用方法 (db.Where(…).First(…))。
自动化: 自动处理创建、查询、更新、删除 (CRUD) 操作。
高级功能: 支持自动迁移(根据结构体创建/修改表)、钩子(在创建/更新前后执行特定函数)、预加载(Eager Loading)、事务等。
优点:
-
开发效率极高,尤其适合快速构建原型和标准的 CRUD 应用。
-
代码量显著减少,可读性强(对于熟悉 ORM 的人而言)。
缺点:“魔法”太多,可能会隐藏底层 SQL 的性能问题。
-
对于复杂的查询,其链式 API 可能变得复杂,或者不得不退回手写 SQL。
-
学习曲线相对较陡,需要理解其内部的约定和工作方式。
GORM 代码集成示例
本节我们将继续在次框架上,进行实现商品(product)相关的CRUD操作,并给与外部调用,对于商品(product)整个链路过程将采用ORM的方式,便于和之前实现的用户(User)相对比学习。
第1步:安装 GORM 及驱动
GORM 的工作需要两个核心组件:GORM 核心库和对应数据库的驱动。
# 安装 GORM 核心库
go get -u gorm.io/gorm# 安装 GORM 的 MySQL 驱动适配器
go get -u gorm.io/driver/mysql
还记得在上一节中我们连接 mysql
数据库时引入的依赖 go get -u github.com/go-sql-driver/mysql
吗?
Q:"
go get -u github.com/go-sql-driver/mysql
;go get -u gorm.io/gorm
;go get -u gorm.io/driver/mysql
" 这三个依赖不冲突吗?A:
gorm.io/gorm
(ORM 框架本身)
- 这是 GORM 的核心库,提供了所有
.Create()
,.First()
,.Where()
等链式调用方法。- 它是一个高层抽象,负责将对象操作转换为 SQL 思想。但它自己并不知道如何与具体的数据库(如 MySQL 或 PostgreSQL)对话。
gorm.io/driver/mysql
(GORM 的 MySQL 适配器)
- 这个库是连接 GORM 核心框架和底层数据库驱动的“桥梁”或“适配器”。
- 它告诉 GORM:“当你需要操作 MySQL 时,应该使用这种方式来配置和传递指令。”
github.com/go-sql-driver/mysql
(底层的数据库驱动)
- 这是真正负责与 MySQL 服务器进行网络通信、执行 SQL 语句的“工人”。
gorm.io/driver/mysql
这个“适配器”在内部会依赖并调用这个底层的驱动来完成实际工作。
gorm.io/driver/mysql
在底层依赖了go-sql-driver/mysql
,Go 的模块工具会自动处理这个依赖关系。
第2步:创建领域模型 (Model)
我们在 internal/models
下创建产品(product)结构体,对比 SpringBoot
作为数据库实体映射
GORM 可以通过嵌入 gorm.Model
来为我们的结构体自动添加 ID
, CreatedAt
, UpdatedAt
, DeletedAt
等常用字段。
// internal/models/product.go
package modelsimport "gorm.io/gorm"type product struct {gorm.Model // 嵌入gorm.Model,自动获得ID和时间戳字段Name string `gorm:"size:255;not null"` // 使用 GORM 标签定义列属性Price float64 `gorm:"type:decimal(10,2)"`Stock int `gorm:"default:0"`
}//上述结构体等价于//type product struct {
// ID uint `gorm:"primaryKey"`
// CreatedAt time.Time
// UpdatedAt time.Time
// DeletedAt gorm.DeletedAt `gorm:"index"`
// Name string `gorm:"size:255;not null"` // 使用 GORM 标签定义列属性
// Price float64 `gorm:"type:decimal(10,2)"`
// Stock int `gorm:"default:0"`
//}
//
第3步:创建 GORM 数据库连接
在 internal/database/
下创建 gorm.go
来初始化 GORM 的数据库连接。
//internal/database/gorm.go
package databaseimport ("awesomeProject/internal/config""gorm.io/driver/mysql""gorm.io/gorm"
)// NewGormConnection 负责根据配置创建GORM数据库连接池
func NewGormConnection(dbConfig config.DatabaseConfig) (*gorm.DB, error) {// dsn 来自于我们的配置文件dsn := dbConfig.DSN// 使用GORM的MySQL驱动来打开数据库连接db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})if err != nil {return nil, err}// 获取底层的 *sql.DB 对象来设置连接池参数sqlDB, err := db.DB()if err != nil {return nil, err}// 设置从配置中读取的连接池参数sqlDB.SetMaxIdleConns(dbConfig.MaxIdleConns)sqlDB.SetMaxOpenConns(dbConfig.MaxOpenConns)// 可以选择在这里 Ping 数据库以验证连接if err = sqlDB.Ping(); err != nil {return nil, err}return db, nil
}
第4步:实现关于"产品"的 GORM Repository
在 internal/repository/
下创建 ProductRepository.go
使用GORM 来操作数据库,生成对于product的CURD 方法
//internal/repository/ProductRepository.go
package repositoryimport ("awesomeProject/internal/models""context""gorm.io/gorm"
)// ProductRepository 定义了产品数据的所有操作,便于解耦
type ProductRepository interface {Create(ctx context.Context, product *models.Product) errorFindByID(ctx context.Context, id int64) (*models.Product, error)FindAll(ctx context.Context) ([]*models.Product, error)Update(ctx context.Context, product *models.Product) errorDelete(ctx context.Context, id int64) error
}// 构造方法
type gormMySqlProductRepository struct {db *gorm.DB // 应用的是grom 框架 这里持有的是 *gorm.DB 而不是 *sql.DB
}func (g gormMySqlProductRepository) Create(ctx context.Context, product *models.Product) error {result := g.db.WithContext(ctx).Create(product)return result.Error
}func (g gormMySqlProductRepository) FindByID(ctx context.Context, id int64) (*models.Product, error) {var product models.Productresult := g.db.WithContext(ctx).First(&product, id)if result.Error != nil {if result.Error == gorm.ErrRecordNotFound {return nil, nil}return nil, result.Error}return &product, nil}func (g gormMySqlProductRepository) FindAll(ctx context.Context) ([]*models.Product, error) {var products []*models.Productresult := g.db.WithContext(ctx).Find(&products)if result.Error != nil {if result.Error == gorm.ErrRecordNotFound {return nil, nil}return nil, result.Error}return products, nil
}func (g gormMySqlProductRepository) Update(ctx context.Context, product *models.Product) error {result := g.db.WithContext(ctx).Save(product)if result.Error != nil {return result.Error}return nil
}func (g gormMySqlProductRepository) Delete(ctx context.Context, id int64) error {result := g.db.WithContext(ctx).Delete(&models.Product{}, id)return result.Error
}// NewProductRepository 创建一个新的 ProductRepository 实例
func NewProductRepository(db *gorm.DB) ProductRepository {return &gormMySqlProductRepository{db: db}
}
Q: "gorm 官方文档中显示可以使用db.create直接操作数据库,比如: 新增数据直接使用db.create(bean),但是上述代码中使用的是 g.db.WithContext(ctx).create 这是为什么 "?
A:虽然直接使用
r.db.Create(product)
在功能上可以成功插入数据,但使用r.db.WithContext(ctx).Create(product)
是一种更专业、更具弹性的最佳实践。详细解释一下
WithContext(ctx)
带来的三大好处:1. 请求取消传播 (Cancellation Propagation)
- 场景: 一个用户向您的服务器发送了创建产品的请求,但这个请求需要执行一个耗时很长的数据库操作。在操作完成前,用户不耐烦地关闭了浏览器,或者网络中断了。
- 不使用
WithContext
的情况: 您的服务器对此一无所知。即使请求的另一端已经没人等待了,数据库操作依然会继续执行,直到完成。这白白浪费了宝贵的数据库连接和服务器资源。- 使用
WithContext
的情况: Gin 会为每个 HTTP 请求创建一个context
(ctx
)。当用户断开连接时,Gin 会“取消”这个ctx
。WithContext(ctx)
会将这个“取消”信号传递给 GORM,GORM 再传递给底层的数据库驱动。驱动程序收到信号后,可以提前终止那个正在执行的、已经没有意义的数据库查询,从而立即释放资源。2. 超时控制 (Timeout Control)
- 场景: 您可以为整个请求或某个特定的操作设置一个超时时间。比如,您规定任何数据库操作都不能超过3秒。
- 不使用
WithContext
的情况: 如果某个数据库查询因为锁或者性能问题卡住了,它可能会永远地挂起,永久性地占用一个数据库连接,直到数据库自己超时。- 使用
WithContext
的情况: 您可以在Service
层或Handler
层创建一个带超时的context
(e.g.,context.WithTimeout(ctx, 3*time.Second)
)。如果数据库操作在3秒内没有完成,ctx
会自动被取消。WithContext
感知到这个取消信号后,会立即终止数据库操作,并返回一个超时错误。这可以有效地防止慢查询拖垮整个系统。3. 传递元数据 (Passing Metadata)
- 场景: 在复杂的微服务架构中,您需要追踪一个请求经过了哪些服务。通常会有一个全局唯一的“追踪ID (Trace ID)”。
context
的作用:context
是在函数调用链中安全地传递这类请求范围内的元数据(如 Trace ID)的标准方式,而无需修改每个函数的参数列表。GORM 和很多其他库都能与 OpenTelemetry 等链路追踪系统集成,从ctx
中提取这些信息用于日志和监控。
第5步:实现关于"产品"的 productService
之前在实现 userservice
时,对于 userservice
我们没有做到抽象成接口的形式,直接将userservice 做结构体进行声明,在此我会将productservice进行抽象,抽象成一个接口的形式。
package serviceimport ("awesomeProject/internal/models" "awesomeProject/internal/repository" "context""errors" // 导入errors包,用于创建自定义错误
)// ProductService 定义了产品相关的业务逻辑接口
type ProductService interface {CreateProduct(ctx context.Context, name string, price float64, stock int) (*models.Product, error)GetProduct(ctx context.Context, id int64) (*models.Product, error)GetAllProducts(ctx context.Context) ([]*models.Product, error)UpdateProduct(ctx context.Context, id int64, name string, price float64, stock int) (*models.Product, error)DeleteProduct(ctx context.Context, id int64) error
}// productService 是 ProductService 的具体实现
type productService struct {productRepo repository.ProductRepository // 它依赖于 ProductRepository 接口,而不是具体实现
}// NewProductService 是 ProductService 的构造函数
func NewProductService(repo repository.ProductRepository) ProductService {return &productService{productRepo: repo}
}// CreateProduct 处理创建新产品的业务逻辑
func (s *productService) CreateProduct(ctx context.Context, name string, price float64, stock int) (*models.Product, error) {// 在这里可以添加业务逻辑,例如:// 1. 验证产品名称是否有效if name == "" {return nil, errors.New("product name cannot be empty")}// 2. 验证价格是否合法if price <= 0 {return nil, errors.New("product price must be positive")}// 3. 产品库存是否合法if stock < 0 {return nil, errors.New("product stock cannot be negative")}// 创建一个新的产品模型实例product := &models.Product{Name: name,Price: price, Stock: stock,}// 调用仓库层来持久化数据err := s.productRepo.Create(ctx, product)if err != nil {return nil, err}return product, nil
}// GetProduct 处理获取单个产品的业务逻辑
func (s *productService) GetProduct(ctx context.Context, id int64) (*models.Product, error) {// 直接调用仓库层。return s.productRepo.FindByID(ctx, id)
}// GetAllProducts 处理获取所有产品的业务逻辑
func (s *productService) GetAllProducts(ctx context.Context) ([]*models.Product, error) {return s.productRepo.FindAll(ctx)
}// UpdateProduct 处理更新产品的业务逻辑
func (s *productService) UpdateProduct(ctx context.Context, id int64, name string, price float64, stock int) (*models.Product, error) {// 1. 首先,获取要更新的产品product, err := s.productRepo.FindByID(ctx, id)if err != nil {return nil, err // 如果在查找过程中发生数据库错误}if product == nil {return nil, errors.New("product not found") // 如果产品不存在}// 2. 更新产品的字段product.Name = nameproduct.Price = priceproduct.Stock = stock// 3. 在这里可以添加更复杂的验证逻辑...// 4. 调用仓库层的更新方法err = s.productRepo.Update(ctx, product)if err != nil {return nil, err}return product, nil
}// DeleteProduct 处理删除产品的业务逻辑
func (s *productService) DeleteProduct(ctx context.Context, id int64) error {// 在删除前,可以添加权限检查等业务逻辑// 例如:检查当前用户是否有权限删除该产品return s.productRepo.Delete(ctx, id)
}
第6步:实现全新的productHandler.go
package httpimport ("awesomeProject/internal/service" "github.com/gin-gonic/gin""net/http""strconv"
)// ProductHandler 负责处理产品相关的HTTP请求
type ProductHandler struct {productService service.ProductService // 它依赖于 ProductService 接口
}// NewProductHandler 是 ProductHandler 的构造函数
func NewProductHandler(svc service.ProductService) *ProductHandler {return &ProductHandler{productService: svc}
}// CreateProduct godoc
// @Summary 创建一个新产品
// @Description 根据传入的JSON数据创建一个新产品
// @Tags Products
// @Accept json
// @Produce json
// @Param product body CreateProductRequest true "创建产品请求"
// @Success 201 {object} models.Product
// @Failure 400 {object} gin.H
// @Failure 500 {object} gin.H
// @Router /products [post]
func (h *ProductHandler) CreateProduct(c *gin.Context) {// 定义一个临时的结构体来绑定请求的JSON bodyvar req struct {Name string `json:"name" binding:"required"`Price float64 `json:"price" binding:"gt=0"`Stock int `json:"stock" binding:"gte=0"`}// 解析并验证JSON请求体if err := c.ShouldBindJSON(&req); err != nil {c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request data: " + err.Error()})return}// 调用Service层来创建产品product, err := h.productService.CreateProduct(c.Request.Context(), req.Name, req.Price, req.Stock)if err != nil {// 根据Service层返回的错误类型,可以返回更具体的HTTP状态码c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})return}// 返回201 Created状态码和创建成功的产品信息c.JSON(http.StatusCreated, product)
}// GetProduct godoc
// @Summary 获取单个产品
// @Description 根据产品ID获取产品详情
// @Tags Products
// @Produce json
// @Param id path int true "产品ID"
// @Success 200 {object} models.Product
// @Failure 400 {object} gin.H
// @Failure 404 {object} gin.H
// @Failure 500 {object} gin.H
// @Router /products/{id} [get]
func (h *ProductHandler) GetProduct(c *gin.Context) {// 从URL路径中获取ID参数id, err := strconv.ParseInt(c.Param("id"), 10, 64)if err != nil {c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid product ID"})return}// 调用Service层获取产品product, err := h.productService.GetProduct(c.Request.Context(), id)if err != nil {c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})return}// 如果Service层返回nil,说明产品不存在if product == nil {c.JSON(http.StatusNotFound, gin.H{"error": "Product not found"})return}c.JSON(http.StatusOK, product)
}// GetAllProducts godoc
// @Summary 获取所有产品列表
// @Description 获取数据库中所有产品的列表
// @Tags Products
// @Produce json
// @Success 200 {array} models.Product
// @Failure 500 {object} gin.H
// @Router /products [get]
func (h *ProductHandler) GetAllProducts(c *gin.Context) {products, err := h.productService.GetAllProducts(c.Request.Context())if err != nil {c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})return}c.JSON(http.StatusOK, products)
}// UpdateProduct godoc
// @Summary 更新一个产品
// @Description 根据ID和传入的JSON数据更新一个已存在的产品
// @Tags Products
// @Accept json
// @Produce json
// @Param id path int true "产品ID"
// @Param product body UpdateProductRequest true "更新产品请求"
// @Success 200 {object} models.Product
// @Failure 400 {object} gin.H
// @Failure 404 {object} gin.H
// @Failure 500 {object} gin.H
// @Router /products/{id} [put]
func (h *ProductHandler) UpdateProduct(c *gin.Context) {id, err := strconv.ParseInt(c.Param("id"), 10, 64)if err != nil {c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid product ID"})return}var req struct {Name string `json:"name" binding:"required"`Price float64 `json:"price" binding:"gt=0"`Stock int `json:"stock" binding:"gte=0"`}if err := c.ShouldBindJSON(&req); err != nil {c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request data: " + err.Error()})return}product, err := h.productService.UpdateProduct(c.Request.Context(), id, req.Name, req.Price, req.Stock)if err != nil {// 这里可以根据service返回的错误类型判断是404还是500c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})return}c.JSON(http.StatusOK, product)
}// DeleteProduct godoc
// @Summary 删除一个产品
// @Description 根据ID删除一个产品
// @Tags Products
// @Produce json
// @Param id path int true "产品ID"
// @Success 204 {object} nil
// @Failure 500 {object} gin.H
// @Router /products/{id} [delete]
func (h *ProductHandler) DeleteProduct(c *gin.Context) {id, err := strconv.ParseInt(c.Param("id"), 10, 64)if err != nil {c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid product ID"})return}err = h.productService.DeleteProduct(c.Request.Context(), id)if err != nil {c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})return}// 对于删除操作,成功后通常返回 204 No Contentc.Status(http.StatusNoContent)
}
第7步:实现全新的main.go
实现全新的main.go
package mainimport ("awesomeProject/internal/config""awesomeProject/internal/database""awesomeProject/internal/repository""awesomeProject/internal/service""awesomeProject/transport/http"_ "gorm.io/gorm""log""github.com/gin-gonic/gin"
)func main() {// 1. 加载配置cfg, err := config.Load("./configs/config.dev.yaml")if err != nil {log.Fatalf("FATAL: Failed to load config: %v", err)}// 2. 初始化GORM数据库连接// 我们将GORM的初始化逻辑也封装到了database包中,使main.go更整洁db, err := database.NewGormConnection(cfg.Database)if err != nil {log.Fatalf("FATAL: Failed to connect to database: %v", err)}log.Println("Database connection established successfully.")// 3. 依赖注入:将所有组件连接起来// 数据流向: Handler -> Service -> Repository -> Database// a. 创建 Repository 实例,它依赖 GORM 的数据库连接(db)productRepo := repository.NewProductRepository(db)// b. 创建 Service 实例,它依赖 Repository 层的接口(productRepo)productService := service.NewProductService(productRepo)// c. 创建 Handler 实例,它依赖 Service 层的接口(productService)productHandler := http.NewProductHandler(productService)// 4. 初始化 Gin 路由引擎router := gin.Default()// 5. 注册产品相关的路由// 创建一个API分组,方便管理版本,例如 /api/v1apiV1 := router.Group("/api/v1"){products := apiV1.Group("/products"){products.POST("", productHandler.CreateProduct) // 创建产品products.GET("", productHandler.GetAllProducts) // 获取所有产品products.GET("/:id", productHandler.GetProduct) // 获取单个产品products.PUT("/:id", productHandler.UpdateProduct) // 更新产品products.DELETE("/:id", productHandler.DeleteProduct) // 删除产品}}// 6. 启动服务器log.Println("Starting server on port :8080")if err := router.Run(":8080"); err != nil {log.Fatalf("FATAL: Failed to start server: %v", err)}
}
接口调用测试
添加商品成功
查询商品
请忽略中间的参数,懒得没删除而已
查询全部商品
更新编号为1的商品
更新前:
更新后:
删除编号为1的商品:
删除后查询
数据库建设
products.sql
-- auto-generated definition
create table products
(id bigint unsigned auto_incrementprimary key,created_at datetime(3) null,updated_at datetime(3) null,deleted_at datetime(3) null,name varchar(255) not null,price decimal(10, 2) null,stock bigint default 0 null
);create index idx_products_deleted_aton products (deleted_at);
数据表说明:
products
: GORM 默认会将结构体名称Product
转换为蛇形复数形式作为表名。
gorm.Model
: 嵌入的gorm.Model
自动为添加了id
,created_at
,updated_at
,deleted_at
四个核心字段。deleted_at
用于实现 GORM 的软删除功能。
总结
通过将 GORM 集成到我们的分层架构中,我们实现了一个完美的平衡:
- 获得了 ORM 带来的高开发效率:告别了繁琐的 SQL 编写和手动映射。
- 保留了清晰的架构和解耦:各层职责分明,易于维护和测试。
有用的网站:
- GORM 指南 | GORM 中文文档
代码仓库:
🌍代码框架链接