萬字長文:Go 語言流行 ORM 框架 GORM 使用詳解
GORM 是 Go 語言中最受歡迎的 ORM 庫之一,它提供了強大的功能和簡潔的 API,讓數據庫操作變得更加簡單和易維護。本文將詳細介紹 GORM 的常見用法,包括數據庫連接、模型定義、CRUD、事務管理等方面,幫助大家快速上手使用 GORM 進行 Web 後端開發。
安裝
通過如下命令安裝 GORM:
$ go get -u gorm.io/gorm
你也許見過使用 go get -u github.com/jinzhu/gorm
命令來安裝 GORM,這個是老版本 v1,現已過時,不建議使用。新版本 v2 已經遷移至 github.com/go-gorm/gorm
倉庫下。
快速開始
如下示例代碼帶你快速上手 GORM 的使用:
package main
import (
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// Product 定義結構體用來映射數據庫表
type Product struct {
gorm.Model
Code string
Price uint
}
func main() {
// 建立數據庫連接
db, err := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})
if err != nil {
panic("failed to connect database")
}
// 遷移表結構
db.AutoMigrate(&Product{})
// 增加數據
db.Create(&Product{Code: "D42", Price: 100})
// 查找數據
var product Product
db.First(&product, 1) // find product with integer primary key
db.First(&product, "code = ?", "D42") // find product with code D42
// 更新數據 - update product's price to 200
db.Model(&product).Update("Price", 200)
// 更新數據 - update multiple fields
db.Model(&product).Updates(Product{Price: 200, Code: "F42"}) // non-zero fields
db.Model(&product).Updates(map[string]interface{}{"Price": 200, "Code": "F42"})
// 刪除數據 - delete product
db.Delete(&product, 1)
}
提示:這裏使用了
SQLite
數據庫驅動,需要通過go get -u gorm.io/driver/sqlite
命令安裝。
將以上代碼保存在 main.go
中並執行。
$ go run main.go
執行完成後,我們將在當前目錄下得到 test.db
SQLite 數據庫文件。
① 進入 SQLite 命令行。
② 查看已存在的數據庫表。
③ 設置稍後查詢表數據時的輸出模式爲按列左對齊。
④ 查詢表中存在的數據。
有過使用 ORM 框架經驗的同學,以上代碼即使我不進行講解也能看懂個大概。
這段示例代碼基本能夠概括 GORM 框架使用套路:
-
定義結構體映射表結構:
Product
結構體在 GORM 中稱作「模型」,一個模型對應一張數據庫表,一個結構體實例對象對應一條數據庫表記錄。 -
連接數據庫:GORM 使用
gorm.Open
方法與數據庫建立連接,連接建立好後,才能對數據庫進行 CRUD 操作。 -
自動遷移表結構:調用
db.AutoMigrate
方法能夠自動完成在數據庫中創建Product
結構體所映射的數據庫表,並且,當Product
結構體字段有變更,再次執行遷移代碼,GORM 會自動對錶結構進行調整,非常方便。不過,我不推薦在生產環境項目中使用此功能。因爲數據庫表操作都是高風險操作,一定要經過多人 Review 並審覈通過,才能執行操作。GORM 自動遷移功能雖然理論上不會出現問題,但線上操作謹慎爲妙,個人認爲只有在小項目或數據不那麼重要的項目中使用比較合適。 -
CRUD 操作:遷移好數據庫後,就有了數據庫表,可以進行 CRUD 操作了。
有些同學可能有個疑問,以上示例代碼中並沒有類似 defer db.Close()
主動關閉連接的操作,那麼何時關閉數據庫連接?
其實 GORM 維護了一個數據庫連接池,初始化 db
後所有的連接都由底層庫來管理,無需程序員手動干預,GORM 會在合適的時機自動關閉連接。GORM 框架作者 jinzhu
也有在源碼倉庫 Issue 中回覆過網友的提問,感興趣的同學可以點擊進入查看。
接下來我將對 GORM 的使用進行詳細講解。
聲明模型
GORM 使用模型(Model)來映射一張數據庫表,模型是標準的 Go struct
,由 Go 的基本數據類型、實現了 Scanner
和 Valuer
接口的自定義類型及其指針或別名組成。
例如:
type User struct {
ID uint
Name string
Email *string
Age uint8
Birthday *time.Time
MemberNumber sql.NullString
ActivatedAt sql.NullTime
CreatedAt time.Time
UpdatedAt time.Time
}
我們可以使用 gorm
字段標籤來控制數據庫表字段的類型、列大小、默認值等屬性,比如使用 column
字段標籤來映射數據庫中字段名稱。
type User struct {
gorm.Model
Name string `gorm:"column:name"`
Email *string `gorm:"column:email"`
Age uint8 `gorm:"column:age"`
Birthday *time.Time `gorm:"column:birthday"`
MemberNumber sql.NullString `gorm:"column:member_number"`
ActivatedAt sql.NullTime `gorm:"column:activated_at"`
}
func (u *User) TableName() string {
return "user"
}
在不指定 column
字段標籤情況下,GORM 默認使用字段名的 snake_case
作爲列名。
GORM 默認使用結構體名的 snake_cases
作爲表名,爲結構體實現 TableName
方法可以自定義表名。
我更喜歡「顯式勝於隱式」的做法,所以數據庫名和表名都會顯示寫出來。
因爲我們不使用自動遷移的功能,所以其他字段標籤都用不到,就不在此一一介紹了,感興趣的同學可以查看官方文檔進行學習。
User
結構體中有一個嵌套的結構體 gorm.Model
,它是 GORM 默認提供的一個模型 struct
,用來簡化用戶模型定義。
GORM 傾向於約定優於配置,默認情況下,使用 ID
作爲主鍵,使用 CreatedAt
、UpdatedAt
、DeletedAt
字段追蹤記錄的創建、更新、刪除時間。而這幾個字段就定義在 gorm.Model
中:
type Model struct {
ID uint `gorm:"primarykey"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt DeletedAt `gorm:"index"`
}
由於我們不使用自動遷移功能,所以需要手動編寫 SQL 語句來創建 user
數據庫表結構:
CREATE TABLE `user` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(50) DEFAULT '' COMMENT '用戶名',
`email` varchar(255) NOT NULL DEFAULT '' COMMENT '郵箱',
`age` tinyint(4) NOT NULL DEFAULT '0' COMMENT '年齡',
`birthday` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '生日',
`member_number` varchar(50) COMMENT '成員編號',
`activated_at` datetime COMMENT '激活時間',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`deleted_at` datetime,
PRIMARY KEY (`id`),
UNIQUE KEY `u_email` (`email`),
INDEX `idx_deleted_at`(`deleted_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='用戶表';
數據庫中字段類型要跟 Go 中模型的字段類型相對應,不兼容的類型可能導致錯誤。
連接數據庫
GORM 官方支持的數據庫類型有:MySQL、PostgreSQL、SQLite、SQL Server 和 TiDB。
這裏使用最常見的 MySQL 作爲示例,來講解 GORM 如何連接到數據庫。
在前文快速開始的示例代碼中,我們使用 SQLite 數據庫時,安裝了 sqlite
驅動程序。要連接 MySQL 則需要使用 mysql
驅動。
在 GORM 中定義了 gorm.Dialector
接口來規範數據庫連接操作,實現了此接口的程序我們將其稱爲「驅動」。針對每種數據庫,都有對應的驅動,驅動是獨立於 GORM 庫的,需要單獨引入。
連接 MySQL 數據庫的代碼如下:
package main
import (
"fmt"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
func ConnectMySQL(host, port, user, pass, dbname string) (*gorm.DB, error) {
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
user, pass, host, port, dbname)
return gorm.Open(mysql.Open(dsn), &gorm.Config{})
}
可以發現,這段代碼與連接 SQLite 數據庫的代碼如出一轍,這就是面向接口編程的好處。
首先,mysql.Open
接收一個字符串 dsn
,DSN 全稱 Data Source Name
,翻譯過來叫數據庫源名稱。DSN 定義了一個數據庫的連接信息,包含用戶名、密碼、數據庫 IP、數據庫端口、數據庫字符集、數據庫時區等信息。DSN 遵循特定格式:
username:password@protocol(address)/dbname?param=value
通過 DSN 所包含的信息,mysql
驅動就能夠知道以什麼方式連接到 MySQL 數據庫了。
mysql.Open
返回的正是一個 gorm.Dialector
對象,將其傳遞給 gorm.Open
方法後,我們將得到 *gorm.DB
對象,這個對象可以用來操作數據庫。
GORM 使用 database/sql
來維護數據庫連接池,對於連接池我們可以設置如下幾個參數:
func SetConnect(db *gorm.DB) error {
sqlDB, err := db.DB()
if err != nil {
return err
}
sqlDB.SetMaxOpenConns(100) // 設置數據庫的最大打開連接數
sqlDB.SetMaxIdleConns(100) // 設置最大空閒連接數
sqlDB.SetConnMaxLifetime(10 * time.Second) // 設置空閒連接最大存活時間
return nil
}
現在,數據庫連接已經建立,我們可以對數據庫進行操作了。
創建
可以使用 Create
方法創建一條數據庫記錄:
now := time.Now()
email := "u1@jianghushinian.com"
user := User{Name: "user1", Email: &email, Age: 18, Birthday: &now}
// INSERT INTO `user` (`created_at`,`updated_at`,`deleted_at`,`name`,`email`,`age`,`birthday`,`member_number`,`activated_at`) VALUES ('2023-05-22 22:14:47.814','2023-05-22 22:14:47.814',NULL,'user1','u1@jianghushinian.com',18,'2023-05-22 22:14:47.812',NULL,NULL)
result := db.Create(&user) // 通過數據的指針來創建
fmt.Printf("user: %+v\n", user) // user.ID 自動填充
fmt.Printf("affected rows: %d\n", result.RowsAffected)
fmt.Printf("error: %v\n", result.Error)
要創建記錄,我們需要先實例化 User
對象,然後將其指針傳遞給 db.Create
方法。
db.Create
方法執行完成後,依然返回一個 *gorm.DB
對象。
user.ID
會被自動填充爲創建數據庫記錄後返回的真實值。
result.RowsAffected
可以拿到此次操作影響行數。
result.Error
可以知道執行 SQL 是否出錯。
在這裏,我將 db.Create(&user)
這句 ORM
代碼所生成的原生 SQL 語句放在了註釋中,方便你對比學習。並且,之後的示例中我也會這樣做。
Create
方法不僅支持創建單條記錄,它同樣支持批量操作,一次創建多條記錄:
now = time.Now()
email2 := "u2@jianghushinian.com"
email3 := "u3@jianghushinian.com"
users := []User{
{Name: "user2", Email: &email2, Age: 19, Birthday: &now},
{Name: "user3", Email: &email3, Age: 20, Birthday: &now},
}
// INSERT INTO `user` (`created_at`,`updated_at`,`deleted_at`,`name`,`email`,`age`,`birthday`,`member_number`,`activated_at`) VALUES ('2023-05-22 22:14:47.834','2023-05-22 22:14:47.834',NULL,'user2','u2@jianghushinian.com',19,'2023-05-22 22:14:47.833',NULL,NULL),('2023-05-22 22:14:47.834','2023-05-22 22:14:47.834',NULL,'user3','u3@jianghushinian.com',20,'2023-05-22 22:14:47.833',NULL,NULL)
result = db.Create(&users)
代碼主要邏輯不變,只需要將單個的 User
實例換成 User
切片即可。GORM 會使用一條 SQL 語句完成批量創建記錄。
查詢
查詢記錄是我們在日常開發中使用最多的場景了,GORM 提供了多種方法來支持 SQL 查詢操作。
使用 First
方法可以查詢第一條記錄:
var user User
// SELECT * FROM `user` WHERE `user`.`deleted_at` IS NULL ORDER BY `user`.`id` LIMIT 1
result := db.First(&user)
First
方法接收一個模型指針,通過模型的 TableName
方法則可以拿到數據庫表名,然後使用 SELECT *
語句從數據庫中查詢記錄。
根據生成的 SQL 可以發現 First
方法查詢數據默認根據主鍵 ID
升序排序,並且只會過濾刪除時間爲 NULL
的數據,使用 LIMIT
關鍵字來限制數據條數。
使用 Last
方法可以查詢最後一條數據,排序規則爲主鍵 ID
降序:
var lastUser User
// SELECT * FROM `user` WHERE `user`.`deleted_at` IS NULL ORDER BY `user`.`id` DESC LIMIT 1
result = db.Last(&lastUser)
使用 Where
方法可以增加查詢條件:
var users []User
// SELECT * FROM `user` WHERE name != 'unknown' AND `user`.`deleted_at` IS NULL
result = db.Where("name != ?", "unknown").Find(&users)
這裏不再查詢單條數據,所以改用 Find
方法來查詢所有符合條件的記錄。
以上介紹的幾種查詢方法,都是通過 SELECT *
查詢數據庫表中的全部字段,我們可以使用 Select
方法指定需要查詢的字段:
var user2 User
// SELECT `name`,`age` FROM `user` WHERE `user`.`deleted_at` IS NULL ORDER BY `user`.`id` LIMIT 1
result = db.Select("name", "age").First(&user2)
使用 Order
方法可以自定義排序規則:
var users2 []User
// SELECT * FROM `user` WHERE `user`.`deleted_at` IS NULL ORDER BY id desc
result = db.Order("id desc").Find(&users2)
GORM 也提供了對 Limit & Offset
的支持:
var users3 []User
// SELECT * FROM `user` WHERE `user`.`deleted_at` IS NULL LIMIT 2 OFFSET 1
result = db.Limit(2).Offset(1).Find(&users3)
使用 -1
可以取消 Limit & Offset
的限制條件:
var users4 []User
var users5 []User
// SELECT * FROM `user` WHERE `user`.`deleted_at` IS NULL LIMIT 2 OFFSET 1; (users4)
// SELECT * FROM `user` WHERE `user`.`deleted_at` IS NULL; (users5)
result = db.Limit(2).Offset(1).Find(&users4).Limit(-1).Offset(-1).Find(&users5)
這段代碼會執行兩條查詢語句,之所以能夠採用這種「鏈式調用」的方式執行多條 SQL,是因爲每個方法返回的都是 *gorm.DB
對象,這也是一種編程技巧。
使用 Count
方法可以統計記錄條數:
var count int64
// SELECT count(*) FROM `user` WHERE `user`.`deleted_at` IS NULL
result = db.Model(&User{}).Count(&count)
有時候遇到比較複雜的業務,我們可能需要使用 SQL 子查詢,子查詢可以嵌套在另一個查詢中,GORM 允許將 *gorm.DB
對象作爲參數時生成子查詢:
var avgages []float64
// SELECT AVG(age) as avgage FROM `user` WHERE `user`.`deleted_at` IS NULL GROUP BY `name` HAVING AVG(age) > (SELECT AVG(age) FROM `user` WHERE name LIKE 'user%')
subQuery := db.Select("AVG(age)").Where("name LIKE ?", "user%").Table("user")
result = db.Model(&User{}).Select("AVG(age) as avgage").Group("name").Having("AVG(age) > (?)", subQuery).Find(&avgages)
Having
方法簽名如下:
func (db *DB) Having(query interface{}, args ...interface{}) (tx *DB)
第二個參數是一個範型 interface{}
,所以不僅可以接收字符串,GORM 在判斷其類型爲 *gorm.DB
時,就會構造一個子查詢。
更新
爲了講解更新操作,我們需要先查詢一條記錄,之後的更新操作都是基於這條被查詢出來的 User
對象:
var user User
// SELECT * FROM `user` WHERE `user`.`deleted_at` IS NULL ORDER BY `user`.`id` LIMIT 1
result := db.First(&user)
更新操作只要修改 User
對象的屬性,然後調用 db.Save(&user)
方法即可完成:
user.Name = "John"
user.Age = 20
// UPDATE `user` SET `created_at`='2023-05-22 22:14:47.814',`updated_at`='2023-05-22 22:24:34.201',`deleted_at`=NULL,`name`='John',`email`='u1@jianghushinian.com',`age`=20,`birthday`='2023-05-22 22:14:47.813',`member_number`=NULL,`activated_at`=NULL WHERE `user`.`deleted_at` IS NULL AND `id` = 1
result = db.Save(&user)
在更新操作時,User
對象要保證 ID
屬性存在值,不然就變成了創建操作。
Save
方法會保存所有的字段,即使字段是對應類型的零值。
除了使用 Save
方法更新所有字段,我們還可以使用 Update
方法更新指定字段:
// UPDATE `user` SET `name`='Jianghushinian',`updated_at`='2023-05-22 22:24:34.215' WHERE `user`.`deleted_at` IS NULL AND `id` = 1
result = db.Model(&user).Update("name", "Jianghushinian")
Update
只能支持更新單個字段,要想更新多個字段,可以使用 Updates
方法:
// UPDATE `user` SET `updated_at`='2023-05-22 22:29:35.19',`name`='JiangHu' WHERE `user`.`deleted_at` IS NULL AND `id` = 1
result = db.Model(&user).Updates(User{Name: "JiangHu", Age: 0})
注意,Updates
方法與 Save
方法有一個很大的不同之處,它只會更新非零值字段。Age
字段爲零值,所以不會被更新。
如果一定要更新零值字段,除了可以使用上面的 Save
方法,還可以將 User
結構體換成 map[string]interface{}
類型的 map
對象:
// UPDATE `user` SET `age`=0,`name`='JiangHu',`updated_at`='2023-05-22 22:29:35.623' WHERE `user`.`deleted_at` IS NULL AND `id` = 1
result = db.Model(&user).Updates(map[string]interface{}{"name": "JiangHu", "age": 0})
此外,更新數據時,還可以使用 gorm.Expr
來實現 SQL 表達式:
// UPDATE `user` SET `age`=age + 1,`updated_at`='2023-05-22 22:24:34.219' WHERE `user`.`deleted_at` IS NULL AND `id` = 1
result = db.Model(&user).Update("age", gorm.Expr("age + ?", 1))
gorm.Expr("age + ?", 1)
方法調用會被轉換成 age=age + 1
SQL 表達式。
刪除
可以使用 Delete
方法刪除數記錄:
var user User
// UPDATE `user` SET `deleted_at`='2023-05-22 22:46:45.086' WHERE name = 'JiangHu' AND `user`.`deleted_at` IS NULL
result := db.Where("name = ?", "JiangHu").Delete(&user)
對於刪除操作,GORM 默認使用邏輯刪除策略,不會對記錄進行物理刪除。
所以 Delete
方法在對數據進行刪除時,實際上執行的是 SQL UPDATE
操作,而非 DELETE
操作。
將 deleted_at
字段更新爲當前時間,表示當前數據已刪除。這也是爲什麼前文在講解查詢和更新的時候,生成的 SQL 語句都自動附加了 deleted_at IS NULL
Where 條件的原因。
這樣就實現了邏輯層面的刪除,數據在數據庫中仍然存在,但查詢和更新的時候會將其過濾掉。
記錄被刪除後,我們無法通過如下代碼直接查詢到被邏輯刪除的記錄:
// SELECT * FROM `user` WHERE name = 'JiangHu' AND `user`.`deleted_at` IS NULL ORDER BY `user`.`id` LIMIT 1
result = db.Where("name = ?", "JiangHu").First(&user)
if err := result.Error; err != nil {
fmt.Println(err) // record not found
}
這將得到一個錯誤 record not found
。
不過,GORM 提供了 Unscoped
方法,可以繞過邏輯刪除:
// SELECT * FROM `user` WHERE name = 'JiangHu' ORDER BY `user`.`id` LIMIT 1
result = db.Unscoped().Where("name = ?", "JiangHu").First(&user)
以上代碼能夠查詢出被邏輯刪除的記錄,生成的 SQL 語句中沒有包含 deleted_at IS NULL
Where 條件。
對於比較重要的數據,建議使用邏輯刪除,這樣可以在需要的時候恢復數據,也便於故障追蹤。
不過,如果明確想要物理刪除一條記錄,同理可以使用 Unscoped
方法:
// DELETE FROM `user` WHERE name = 'JiangHu' AND `user`.`id` = 1
result = db.Unscoped().Where("name = ?", "JiangHu").Delete(&user)
關聯
日常開發中,多數情況下不只是對單表進行操作,還要對存在關聯關係的多表進行操作。
這裏以一個博客系統最常見的三張表「文章表、評論表、標籤表」爲例,對 GORM 如何操作關聯表進行講解。
這裏涉及最常見的關聯關係:一對多和多對多。一篇文章可以有多條評論,所以文章和評論是一對多關係;一篇文章可以存在多個標籤,每個標籤也可以包含多篇文章,所以文章和標籤是多對多關係。
模型定義如下:
type Post struct {
gorm.Model
Title string `gorm:"column:title"`
Content string `gorm:"column:content"`
Comments []*Comment `gorm:"foreignKey:PostID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;references:ID"`
Tags []*Tag `gorm:"many2many:post_tags"`
}
func (p *Post) TableName() string {
return "post"
}
type Comment struct {
gorm.Model
Content string `gorm:"column:content"`
PostID uint `gorm:"column:post_id"`
Post *Post
}
func (c *Comment) TableName() string {
return "comment"
}
type Tag struct {
gorm.Model
Name string `gorm:"column:name"`
Post []*Post `gorm:"many2many:post_tags"`
}
func (t *Tag) TableName() string {
return "tag"
}
我準備了對應的建表 SQL,可以點擊鏈接進行查看:GitHub 地址。
在模型定義中,Post
文章模型使用 Comments
和 Tags
分別保存關聯的評論和標籤,這兩個字段不會保存在數據庫表中。
Comments
字段標籤使用 foreignKey
來指明 Comments
表中的外鍵,並使用 constraint
指明瞭約束條件,references
指明 Comments
表外鍵引用 Post
表的 ID
字段。
其實現在生產環境中都不再推薦使用外鍵,各個表之間不再有數據庫層面的外鍵約束,在做 CRUD 操作時全部通過代碼層面來進行業務約束。這裏爲了演示 GORM 的外鍵和級聯操作功能,所以定義了這些結構體標籤。
Tags
字段標籤使用 many2many
來指明多對多關聯表名。
對於 Comment
模型,PostID
字段就是外鍵,用來保存 Post.ID
。Post
字段同樣不會保存在數據庫中,這種做法在 ORM 框架中非常常見。
接下來,我將同樣對關聯表的 CRUD 操作進行一一講解。
創建
創建 Post
時會自動創建與之關聯的 Comments
和 Tags
:
var post Post
post = Post{
Title: "post1",
Content: "content1",
Comments: []*Comment{
{Content: "comment1", Post: &post},
{Content: "comment2", Post: &post},
},
Tags: []*Tag{
{Name: "tag1"},
{Name: "tag2"},
},
}
result := db.Create(&post)
這裏定義了一個文章對象 post
,並且包含兩條評論和兩個標籤。
注意 Comment
的 Post
字段引用了 &post
,並沒有指定 PostID
外鍵字段,GORM 能夠正確處理它。
以上代碼將生成並依次執行如下 SQL 語句:
BEGIN TRANSACTION;
INSERT INTO `tag` (`created_at`,`updated_at`,`deleted_at`,`name`) VALUES ('2023-05-22 22:56:52.923','2023-05-22 22:56:52.923',NULL,'tag1'),('2023-05-22 22:56:52.923','2023-05-22 22:56:52.923',NULL,'tag2') ON DUPLICATE KEY UPDATE `id`=`id`
INSERT INTO `post` (`created_at`,`updated_at`,`deleted_at`,`title`,`content`) VALUES ('2023-05-22 22:56:52.898','2023-05-22 22:56:52.898',NULL,'post1','content1') ON DUPLICATE KEY UPDATE `id`=`id`
INSERT INTO `comment` (`created_at`,`updated_at`,`deleted_at`,`content`,`post_id`) VALUES ('2023-05-22 22:56:52.942','2023-05-22 22:56:52.942',NULL,'comment1',1),('2023-05-22 22:56:52.942','2023-05-22 22:56:52.942',NULL,'comment2',1) ON DUPLICATE KEY UPDATE `post_id`=VALUES(`post_id`)
INSERT INTO `post_tags` (`post_id`,`tag_id`) VALUES (1,1),(1,2) ON DUPLICATE KEY UPDATE `post_id`=`post_id`
COMMIT;
可以發現,與文章形成一對多關係的評論以及與文章形成多對多關係的標籤,都會被創建,並且 GORM 會維護其關聯關係,而且這些操作全部在一個事務下完成。
此外,前文介紹的 Save
方法不僅能夠更新記錄,實際上它還支持創建記錄,當 Post
對象不存在主鍵 ID
時,Save
方法將會創建一條新的記錄:
var post3 Post
post3 = Post{
Title: "post3",
Content: "content3",
Comments: []*Comment{
{Content: "comment33", Post: &post3},
},
Tags: []*Tag{
{Name: "tag3"},
},
}
result = db.Save(&post3)
以上代碼生成的 SQL 如下:
BEGIN TRANSACTION;
INSERT INTO `tag` (`created_at`,`updated_at`,`deleted_at`,`name`) VALUES ('2023-05-22 23:17:53.189','2023-05-22 23:17:53.189',NULL,'tag3') ON DUPLICATE KEY UPDATE `id`=`id`
INSERT INTO `post` (`created_at`,`updated_at`,`deleted_at`,`title`,`content`) VALUES ('2023-05-22 23:17:53.189','2023-05-22 23:17:53.189',NULL,'post3','content3') ON DUPLICATE KEY UPDATE `id`=`id`
INSERT INTO `comment` (`created_at`,`updated_at`,`deleted_at`,`content`,`post_id`) VALUES ('2023-05-22 23:17:53.19','2023-05-22 23:17:53.19',NULL,'comment33',0) ON DUPLICATE KEY UPDATE `post_id`=VALUES(`post_id`)
INSERT INTO `post_tags` (`post_id`,`tag_id`) VALUES (0,0) ON DUPLICATE KEY UPDATE `post_id`=`post_id`
COMMIT;
查詢
可以使用如下方式,根據 Post
的 ID
查詢與之關聯的 Comments
:
var (
post Post
comments []*Comment
)
post.ID = 1
// SELECT * FROM `comment` WHERE `comment`.`post_id` = 1 AND `comment`.`deleted_at` IS NULL
err := db.Model(&post).Association("Comments").Find(&comments)
注意⚠️:傳遞給
Association
方法的參數是Comments
,即在Post
模型中定義的字段,而非評論的模型名Comment
。這點一定不要搞錯了,不然執行 SQL 時會報錯。
Post
是源模型,主鍵 ID
不能爲空。Association
方法指定關聯字段名,在 Post
模型中關聯的評論使用 Comments
表示。最後使用 Find
方法來查詢關聯的評論。
在查詢 Post
時,我們可以預加載與之關聯的 Comments
:
post2 := Post{}
result := db.Preload("Comments").Preload("Tags").First(&post2)
fmt.Println(post2)
for i, comment := range post2.Comments {
fmt.Println(i, comment)
}
for i, tag := range post2.Tags {
fmt.Println(i, tag)
}
我們可以像往常一樣使用 First
方法查詢一條 Post
記錄,同時搭配使用 Preload
方法來指定預加載的關聯字段名,這樣在查詢 Post
記錄時,會將關聯字段表的記錄全部查詢出來,並賦值給關聯字段。
以上代碼將執行如下 SQL:
BEGIN TRANSACTION;
SELECT * FROM `post` WHERE `post`.`deleted_at` IS NULL ORDER BY `post`.`id` LIMIT 1
SELECT * FROM `comment` WHERE `comment`.`post_id` = 1 AND `comment`.`deleted_at` IS NULL
SELECT * FROM `post_tags` WHERE `post_tags`.`post_id` = 1
SELECT * FROM `tag` WHERE `tag`.`id` IN (1,2) AND `tag`.`deleted_at` IS NULL
COMMIT;
GORM 通過多條 SQL 語句查詢出所有關聯記錄,並且將關聯 Comments
和 Tags
分別賦值給 Post
模型對應字段。
當遇到多表查詢時,我們通常還會使用 JOIN
來連接多張表:
type PostComment struct {
Title string
Comment string
}
postComment := PostComment{}
post3 := Post{}
post3.ID = 3
// SELECT post.title, comment.Content AS comment FROM `post` LEFT JOIN comment ON comment.post_id = post.id WHERE `post`.`deleted_at` IS NULL AND `post`.`id` = 3
result := db.Model(&post3).Select("post.title, comment.Content AS comment").Joins("LEFT JOIN comment ON comment.post_id = post.id").Scan(&postComment)
使用 Select
方法來指定需要查詢的字段,使用 Joins
方法來實現 JOIN
功能,最終使用 Scan
方法可以將查詢結果掃描到 postComment
對象中。
針對一對多關聯關係,Joins
方法同樣支持預加載:
var comments2 []*Comment
// SELECT `comment`.`id`,`comment`.`created_at`,`comment`.`updated_at`,`comment`.`deleted_at`,`comment`.`content`,`comment`.`post_id`,`Post`.`id` AS `Post__id`,`Post`.`created_at` AS `Post__created_at`,`Post`.`updated_at` AS `Post__updated_at`,`Post`.`deleted_at` AS `Post__deleted_at`,`Post`.`title` AS `Post__title`,`Post`.`content` AS `Post__content` FROM `comment` LEFT JOIN `post` `Post` ON `comment`.`post_id` = `Post`.`id` AND `Post`.`deleted_at` IS NULL WHERE `comment`.`deleted_at` IS NULL
result = db.Joins("Post").Find(&comments2)
for i, comment := range comments2 {
fmt.Println(i, comment)
fmt.Println(i, comment.Post)
}
JOIN
功能的預加載無需顯式使用 Preload
來指明,只需要在 Joins
方法中指明一對多關係中一這一端模型 Post
即可,使用 Find
查詢 Comment
記錄。
根據生成的 SQL 可以發現查詢主表爲 comment
,副表爲 post
。並且副表的字段都被重命名爲 模型名__字段名
的格式,如 Post__title
(題外話:如果你使用過 Python 的 Django ORM 框架,那麼對這個雙下劃線命名字段的做法應該有種似曾相識的感覺)。
更新
同講解單表更新時一樣,我們需要先查詢出一條記錄,用來演示更新操作:
var post Post
// SELECT * FROM `post` WHERE `post`.`deleted_at` IS NULL ORDER BY `post`.`id` LIMIT 1
result := db.First(&post)
可以使用如下方法替換 Post
關聯的 Comments
:
comment := Comment{
Content: "comment3",
}
err := db.Model(&post).Association("Comments").Replace([]*Comment{&comment})
仍然使用 Association
方法指定 Post
關聯的 Comments
,Replace
方法用來完成替換操作。
這裏要注意,Replace
方法返回結果不再是 *gorm.DB
對象,而是直接返回 error
。
生成 SQL 如下:
BEGIN TRANSACTION;
INSERT INTO `comment` (`created_at`,`updated_at`,`deleted_at`,`content`,`post_id`) VALUES ('2023-05-23 09:07:42.852','2023-05-23 09:07:42.852',NULL,'comment3',1) ON DUPLICATE KEY UPDATE `post_id`=VALUES(`post_id`)
UPDATE `post` SET `updated_at`='2023-05-23 09:07:42.846' WHERE `post`.`deleted_at` IS NULL AND `id` = 1
UPDATE `comment` SET `post_id`=NULL WHERE `comment`.`id` <> 8 AND `comment`.`post_id` = 1 AND `comment`.`deleted_at` IS NULL
COMMIT;
刪除
使用 Delete
刪除文章表時,不會刪除關聯表的數據:
var post Post
// UPDATE `post` SET `deleted_at`='2023-05-23 09:09:58.534' WHERE id = 1 AND `post`.`deleted_at` IS NULL
result := db.Where("id = ?", 1).Delete(&post)
對於存在關聯關係的記錄,刪除時默認同樣採用 UPDATE
操作,且不影響關聯數據。
如果想要在刪除評論時,順便刪除與文章的關聯關係,可以使用 Association
方法:
// UPDATE `comment` SET `post_id`=NULL WHERE `comment`.`post_id` = 6 AND `comment`.`id` IN (NULL) AND `comment`.`deleted_at` IS NULL
err := db.Model(&post2).Association("Comments").Delete(post2.Comments)
事務
GORM 提供了對事務的支持,這在複雜的業務邏輯中是必要的。
要在事務中執行一系列操作,可以使用 Transaction
方法實現:
func TransactionPost(db *gorm.DB) error {
return db.Transaction(func(tx *gorm.DB) error {
post := Post{
Title: "Hello World",
}
if err := tx.Create(&post).Error; err != nil {
return err
}
comment := Comment{
Content: "Hello World",
PostID: post.ID,
}
if err := tx.Create(&comment).Error; err != nil {
return err
}
return nil
})
}
在 Transaction
方法內部的代碼,都將在一個事務中被處理。Transaction
方法接收一個函數,其參數爲 tx *gorm.DB
,事務中所有數據庫的操作,都應該使用這個 tx
而非 db
。
在執行事務的函數中,返回任何錯誤,整個事務都將被回滾,返回 nil
則事務被提交。
除了使用 Transaction
自動管理事務,我們還可以手動管理事務:
func TransactionPostWithManually(db *gorm.DB) error {
tx := db.Begin()
post := Post{
Title: "Hello World Manually",
}
if err := tx.Create(&post).Error; err != nil {
tx.Rollback()
return err
}
comment := Comment{
Content: "Hello World Manually",
PostID: post.ID,
}
if err := tx.Create(&comment).Error; err != nil {
tx.Rollback()
return err
}
return tx.Commit().Error
}
db.Begin()
用於開啓事務,並返回 tx
,稍後的事務操作都應使用這個 tx
對象。如果在處理事務的過程中遇到錯誤,可以使用 tx.Rollback()
回滾事務,如果沒有問題,最終可以使用 tx.Commit()
提交事務。
注意:手動事務,事務一旦開始,你就應該使用
tx
處理數據庫操作。
鉤子
GORM 還支持 Hook 功能,Hook 是在創建、查詢、更新、刪除等操作之前、之後調用的函數,用來管理對象的生命週期。
鉤子方法的函數簽名爲 func(*gorm.DB) error
,比如以下鉤子函數在創建操作之前觸發:
func (u *User) BeforeCreate(tx *gorm.DB) (err error) {
u.UUID = uuid.New()
if u.Name == "admin" {
return errors.New("invalid name")
}
return nil
}
比如我們爲 User
模型定義 BeforeCreate
鉤子,這樣在創建 User
對象前,GORM 會自動調用此函數,完成爲 User
對象創建 UUID
以及用戶名合法性驗證功能。
GORM 支持的鉤子函數以及執行時機如下:
原生 SQL
雖然我們使用 ORM 框架往往是爲了將原生 SQL 的編寫轉爲面向對象編程,不過對原生 SQL 的支持是一款 ORM 框架必備的功能。
可以使用 Raw
方法執行原生查詢 SQL,並將結果 Scan
到模型中:
var userRes UserResult
db.Raw(`SELECT id, name, age FROM user WHERE id = ?`, 3).Scan(&userRes)
fmt.Printf("affected rows: %d\n", db.RowsAffected)
fmt.Println(db.Error)
fmt.Println(userRes)
原生 SQL 同樣支持使用表達式:
var sumage int
db.Raw(`SELECT SUM(age) as sumage FROM user WHERE member_number ?`, gorm.Expr("IS NULL")).Scan(&sumage)
此外,我們還可以使用 Exec
執行任意原生 SQL:
db.Exec("UPDATE user SET age = ? WHERE id IN ?", 18, []int64{1, 2})
// 使用表達式
db.Exec(`UPDATE user SET age = ? WHERE name = ?`, gorm.Expr("age * ? + ?", 1, 2), "Jianghu")
// 刪除表
db.Exec("DROP TABLE user")
使用 Exec
無法拿到執行結果,可以用來對錶進行操作,比如增加、刪除表等。
編寫 SQL 時支持使用 @name
語法命名參數:
var post Post
db.Where("title LIKE @name OR content LiKE @name", sql.Named("name", "%Hello%")).Find(&post)
var user User
// SELECT * FROM user WHERE name1 = "Jianghu" OR name2 = "shinian" OR name3 = "Jianghu"
db.Raw("SELECT * FROM user WHERE name1 = @name OR name2 = @name2 OR name3 = @name",
sql.Named("name", "Jianghu"), sql.Named("name2", "shinian")).Find(&user)
使用 DryRun
模式可以直接拿到由 GORM 生成的原生 SQL,而不執行,方便後續使用:
var user User
stmt := db.Session(&gorm.Session{DryRun: true}).First(&user, 1).Statement
fmt.Println(stmt.SQL.String()) // SQL: SELECT * FROM `user` WHERE `user`.`id` = ? AND `user`.`deleted_at` IS NULL ORDER BY `user`.`id` LIMIT 1
fmt.Println(stmt.Vars) // 參數: [1]
DryRun
模式可以翻譯爲空跑,意思是不執行真正的 SQL,這在調試時非常有用。
調試
GORM 常用功能我們已經基本講解完成了,最後再來介紹下在日常開發中,遇到問題如何進行調試。
GORM 調試方法我總結了如下 5 點:
- 全局開啓日誌
還記得在連接數據庫時 gorm.Open
方法的第二個參數嗎,我們當時傳遞了一個空配置 &gorm.Config{}
,這個可選的參數可以改變 GORM 的一些默認功能配置,比如我們可以設置日誌級別爲 Info
,這樣就能夠在控制檯打印所有執行的 SQL 語句:
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger:logger.Default.LogMode(logger.Info),
})
- 打印慢查詢 SQL
有時候某段 ORM 代碼執行很慢,我們可以通過開啓慢查詢日誌,來檢測 SQL 中的慢查詢語句:
func ConnectMySQL(host, port, user, pass, dbname string) (*gorm.DB, error) {
slowLogger := logger.New(
log.New(os.Stdout, "\r\n", log.LstdFlags),
logger.Config{
// 設定慢查詢時間閾值爲 3ms(默認值:200 * time.Millisecond)
SlowThreshold: 3 * time.Millisecond,
// 設置日誌級別
LogLevel: logger.Warn,
},
)
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
user, pass, host, port, dbname)
return gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: slowLogger,
})
}
- 打印指定 SQL
使用 Debug
能夠打印當前 ORM 語句執行的 SQL:
db.Debug().First(&User{})
- 全局開啓 DryRun 模型
在連接數據庫時,我們可以全局開啓「空跑」模式:
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
DryRun: true,
})
開啓 DryRun 模型後,任何 SQL 語句都不會真正執行,方便測試。
- 局部開啓 DryRun 模型
在當前 Session
中局部開啓「空跑」模型,可以在不執行操作的情況下生成 SQL 及其參數,用於準備或測試生成的 SQL:
var user User
stmt := db.Session(&gorm.Session{DryRun: true}).First(&user, 1).Statement
fmt.Println(stmt.SQL.String()) // => SELECT * FROM `users` WHERE `id` = $1 ORDER BY `id`
fmt.Println(stmt.Vars) // => []interface{}{1}
總結
本文對 Go 語言中最流行的 ORM 框架 GORM 進行了講解,介紹瞭如何編寫模型,如何連接數據庫,以及最常使用的 CRUD 操作。並且還對關聯表中的一對多、多對多兩種關聯關係操作進行了講解。我們還介紹了必不可少的功能「事務」,GORM 還提供了鉤子函數方便我們在 CRUD 操作前後插入一些自定義邏輯。最後對如何使用原生 SQL 以及如何調試也進行了介紹。
只要你原生 SQL 基礎紮實,ORM 框架學習起來並不會太費力,並且我們還有各種調試方式來打印 GORM 所生成的 SQL,方便排查問題。
由於文章篇幅所限,這裏只介紹了 GORM 常用功能,不過也基本能夠覆蓋日常開發中多數場景。更多高級功能如自定義 Logger、讀寫分離、從數據庫表反向生成模型等操作,可以參考官方文檔進行學習。
本文完整代碼示例我放在了 GitHub 上,歡迎點擊查看。
希望此文能對你有所幫助。
參考
-
GORM 源碼:https://github.com/go-gorm/gorm
-
GORM 文檔:https://gorm.io/zh_CN/
-
本文示例代碼:https://github.com/jianghushinian/blog-go-example/tree/main/gorm
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/ZrYYMsuqrzWubh0zpIp17g