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 中文文檔
代碼倉庫:
🌍代碼框架鏈接