Go使用database/sql操作數(shù)據(jù)庫的教程指南
在現(xiàn)代軟件開發(fā)中,數(shù)據(jù)庫扮演著至關(guān)重要的角色,用于存儲和管理應(yīng)用程序的數(shù)據(jù)。針對不同的數(shù)據(jù)庫系統(tǒng),開發(fā)人員通常需要使用特定的數(shù)據(jù)庫驅(qū)動來操作數(shù)據(jù)庫,這往往需要開發(fā)人員掌握不同的驅(qū)動編程接口。在 Go 語言中,好在有一個名為 database/sql
的標(biāo)準(zhǔn)庫,提供了統(tǒng)一的編程接口,使開發(fā)人員能夠以一種通用的方式與各種關(guān)系型數(shù)據(jù)庫進(jìn)行交互。
概念
database/sql
包通過提供統(tǒng)一的編程接口,實現(xiàn)了對不同數(shù)據(jù)庫驅(qū)動的抽象。
它的大致原理如下:
Driver
接口定義:database/sql/driver
包中定義了一個Driver
接口,該接口用于表示一個數(shù)據(jù)庫驅(qū)動。驅(qū)動開發(fā)者需要實現(xiàn)該接口來提供與特定數(shù)據(jù)庫的交互能力。Driver
注冊:驅(qū)動開發(fā)者需要在程序初始化階段,通過調(diào)用database/sql
包提供的sql.Register()
方法將自己的驅(qū)動注冊到database/sql
中。這樣,database/sql
就能夠識別和使用該驅(qū)動。- 數(shù)據(jù)庫連接池管理:
database/sql
維護(hù)了一個數(shù)據(jù)庫連接池,用于管理數(shù)據(jù)庫連接。當(dāng)通過sql.Open()
打開一個數(shù)據(jù)庫連接時,database/sql
會在合適的時機(jī)調(diào)用注冊的驅(qū)動來創(chuàng)建一個具體的連接,并將其添加到連接池中。連接池會負(fù)責(zé)連接的復(fù)用、管理和維護(hù)工作,并且這是并發(fā)安全的。 - 統(tǒng)一的編程接口:
database/sql
定義了一組統(tǒng)一的編程接口供用戶使用,如Prepare()
、Exec()
和Query()
等方法,用于準(zhǔn)備 SQL 語句、執(zhí)行 SQL 語句和執(zhí)行查詢等操作。這些方法會接收參數(shù)并調(diào)用底層驅(qū)動的相應(yīng)方法來執(zhí)行實際的數(shù)據(jù)庫操作。 - 接口方法的實現(xiàn):驅(qū)動開發(fā)者需要實現(xiàn)
database/sql/driver
中定義的一些接口方法,以此來支持上層database/sql
包提供的Prepare()
、Exec()
和Query()
等方法,以提供底層數(shù)據(jù)庫的具體實現(xiàn)。當(dāng)database/sql
調(diào)用這些方法時,實際上會調(diào)用注冊的驅(qū)動的相應(yīng)方法來執(zhí)行具體的數(shù)據(jù)庫操作。
通過以上的機(jī)制,database/sql
包能夠?qū)崿F(xiàn)對不同數(shù)據(jù)庫驅(qū)動的統(tǒng)一封裝和調(diào)用。用戶可以使用相同的編程接口來進(jìn)行數(shù)據(jù)庫操作,無需關(guān)心底層驅(qū)動的具體細(xì)節(jié)。這種設(shè)計使得代碼更具可移植性和靈活性,方便切換和適配不同的數(shù)據(jù)庫。
特點
database/sql
具有如下特點:
- 統(tǒng)一的編程接口:
database/sql
庫提供了一組統(tǒng)一的接口,使得開發(fā)人員可以使用相同的方式操作不同的數(shù)據(jù)庫,而不需要學(xué)習(xí)特定數(shù)據(jù)庫的 API。 - 驅(qū)動支持:通過導(dǎo)入第三方數(shù)據(jù)庫驅(qū)動程序,
database/sql
可以與多種常見的關(guān)系型數(shù)據(jù)庫系統(tǒng)進(jìn)行交互,如 MySQL、PostgreSQL、SQLite 等。 - 預(yù)防 SQL 注入:
database/sql
庫通過使用預(yù)編譯語句和參數(shù)化查詢等技術(shù),有效預(yù)防了 SQL 注入攻擊。 - 支持事務(wù):事務(wù)是一個優(yōu)秀的 SQL 包必備功能。
準(zhǔn)備
為了演示 database/sql
用法,我準(zhǔn)備了如下 MySQL 數(shù)據(jù)庫表:
DROP TABLE IF EXISTS `user`; CREATE TABLE `user` ( `id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(50) DEFAULT NULL COMMENT '用戶名', `email` varchar(255) NOT NULL DEFAULT '' COMMENT '郵箱', `age` tinyint(4) NOT NULL DEFAULT '0' COMMENT '年齡', `birthday` datetime DEFAULT NULL COMMENT '生日', `salary` varchar(128) DEFAULT NULL COMMENT '薪水', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `u_email` (`email`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='用戶表';
你可以使用 MySQL 命令行或圖形化工具創(chuàng)建這張表。
連接數(shù)據(jù)庫
要使用 database/sql
操作數(shù)據(jù)庫,首先要建立與數(shù)據(jù)庫的連接:
package main import ( "database/sql" "log" _ "github.com/go-sql-driver/mysql" ) func main() { db, err := sql.Open("mysql", "user:password@tcp(127.0.0.1:3306)/demo?charset=utf8mb4&parseTime=true&loc=Local") if err != nil { log.Fatal(err) } defer db.Close() }
因為我們要連接 MySQL 數(shù)據(jù)庫,所以需要導(dǎo)入 MySQL 數(shù)據(jù)庫驅(qū)動 github.com/go-sql-driver/mysql
。database/sql
由 Go 語言官方團(tuán)隊設(shè)計,而數(shù)據(jù)庫驅(qū)動程序則由社區(qū)維護(hù),其他關(guān)系型數(shù)據(jù)庫驅(qū)動列表可在這里查看。
與數(shù)據(jù)庫建立連接的代碼非常簡單,只需調(diào)用 sql.Open()
函數(shù)即可。它接收兩個參數(shù),驅(qū)動名稱和 DSN。
這里驅(qū)動名稱為 mysql
,database/sql
之所以能夠識別這個驅(qū)動名稱,是因為在匿名導(dǎo)入 github.com/go-sql-driver/mysql
時,這個庫內(nèi)部調(diào)用了 sql.Register
將其注冊給了 database/sql
。
func init() { sql.Register("mysql", &MySQLDriver{}) }
在 Go 語言中,一個包的 init
方法會在導(dǎo)入時會被自動調(diào)用,這里完成了驅(qū)動程序的注冊。這樣在調(diào)用 sql.Open()
時才能找到 mysql
驅(qū)動。
第二個參數(shù) DSN 全稱 Data Source Name
,數(shù)據(jù)庫的源名稱,其格式如下:
username:password@protocol(address)/dbname?param=value
下面是我們提供的 DSN 各部分解釋:
user:password
:數(shù)據(jù)庫的用戶名和密碼。根據(jù)實際情況,你需要使用你自己的用戶名和密碼。tcp(127.0.0.1:3306)
:連接數(shù)據(jù)庫服務(wù)器的協(xié)議、數(shù)據(jù)庫服務(wù)器的地址和端口號。在這個例子中,使用的是本地主機(jī)127.0.0.1
和 MySQL 默認(rèn)端口號3306
。你可以根據(jù)實際情況修改為你自己的數(shù)據(jù)庫服務(wù)器地址和端口號。/demo
:數(shù)據(jù)庫的名稱。在這個例子中,數(shù)據(jù)庫名稱是demo
。你可以根據(jù)實際情況修改為你自己的數(shù)據(jù)庫名稱。charset=utf8mb4
:指定數(shù)據(jù)庫的字符集為UTF-8
。這里使用的是UTF-8
的變體UTF-8mb4
,支持更廣泛的字符范圍。parseTime=true
:啟用時間解析。這個參數(shù)使得數(shù)據(jù)庫驅(qū)動程序能夠?qū)?shù)據(jù)庫中的時間類型字段(datetime
)解析為 Go 語言的time.Time
類型。loc=Local
:設(shè)置時區(qū)為本地時區(qū)。這個參數(shù)指定數(shù)據(jù)庫驅(qū)動程序使用本地的時區(qū)。
sql.Open()
調(diào)用后將返回一個 *sql.DB
類型,可以用來操作數(shù)據(jù)庫。
另外,我們調(diào)用 defer db.Close()
來釋放數(shù)據(jù)庫連接。其實這一步操作也可以不做,database/sql
底層連接池會幫我們處理。一旦關(guān)閉了連接,就不可以再繼續(xù)使用這個 db
對象了。
*sql.DB
的設(shè)計是用來作為長連接使用的,所以不需要頻繁的進(jìn)行 Open
和 Close
操作。如果我們需要連接多個數(shù)據(jù)庫,則可以為每個不同的數(shù)據(jù)庫創(chuàng)建一個 *sql.DB
對象,保持這些對象為 Open
狀態(tài),不必頻繁使用 Close
來切換連接。
值得注意的是,其實 sql.Open()
并沒有真正建立數(shù)據(jù)庫連接,它只是準(zhǔn)備好了一切,以備后續(xù)使用,連接將在第一次被使用時延遲建立。
這樣的設(shè)計雖然合理,可也有些違反直覺,sql.Open()
甚至不會校驗 DSN 參數(shù)的合法性。不過我們可以使用 db.Ping()
方法來主動檢查連接是否能被正確建立。
if err := db.Ping(); err != nil { log.Fatal(err) }
使用 sql.Open()
并不會建立一個唯一的數(shù)據(jù)庫連接,事實上,database/sql
會維護(hù)一個連接池。
我們可以通過如下方法,控制連接池的一些參數(shù):
db.SetMaxOpenConns(25) // 設(shè)置最大的并發(fā)連接數(shù)(in-use + idle) db.SetMaxIdleConns(25) // 設(shè)置最大的空閑連接數(shù)(idle) db.SetConnMaxLifetime(5 * time.Minute) // 設(shè)置連接的最大生命周期
這些參數(shù)設(shè)置可以根據(jù)經(jīng)驗來修改,以上參數(shù)能夠滿足一些中小項目的使用,如果是大型項目,則可以適當(dāng)調(diào)高參數(shù)。
聲明模型
連接建立好后,理論上我們就可以操作數(shù)據(jù)庫進(jìn)行 CRUD 了。不過為了寫出的代碼更具可維護(hù)性,我們往往需要定義模型
來映射數(shù)據(jù)庫表。
user
表映射后的模型如下:
type User struct { ID int Name sql.NullString Email string Age int Birthday *time.Time Salary Salary CreatedAt time.Time UpdatedAt string }
模型在 Go 中使用 struct 表示,結(jié)構(gòu)體字段同數(shù)據(jù)庫表中的字段一一對應(yīng)。
其中 Salary 類型定義如下:
type Salary struct { Month int `json:"month"` Year int `json:"year"` }
關(guān)于 Name、Salary 兩個字段的特殊性,我將分別在 處理 NULL 和 自定義字段類型 部分講解。
創(chuàng)建
*sql.DB 提供了 Exec 方法來執(zhí)行一條 SQL 命令,可以用來創(chuàng)建、更新、刪除表數(shù)據(jù)等。
這里使用 Exec 方法來實現(xiàn)創(chuàng)建一個用戶:
func CreateUser(db *sql.DB) (int64, error) { birthday := time.Date(2000, 1, 1, 0, 0, 0, 0, time.Local) user := User{ Name: sql.NullString{String: "jianghushinian007", Valid: true}, Email: "jianghushinian007@outlook.com", Age: 10, Birthday: &birthday, Salary: Salary{ Month: 100000, Year: 10000000, }, } res, err := db.Exec(`INSERT INTO user(name, email, age, birthday, salary) VALUES(?, ?, ?, ?, ?)`, user.Name, user.Email, user.Age, user.Birthday, user.Salary) if err != nil { return 0, err } return res.LastInsertId() }
首先我們實例化了一個 User 對象 user,并對相應(yīng)字段進(jìn)行賦值。
接著使用 db.Exec 方法來執(zhí)行 SQL 語句:
INSERT INTO user(name, email, age, birthday, salary) VALUES(?, ?, ?, ?, ?)
其中 ? 作為參數(shù)占位符,不同數(shù)據(jù)庫驅(qū)動程序的占位符可能不同,可以參考數(shù)據(jù)庫驅(qū)動的文檔。
我們將這 5 個參數(shù)順序傳遞給 db.Exec 方法,即可完成用戶的創(chuàng)建。
db.Exec 方法調(diào)用后將返回 sql.Result 保存結(jié)果以及一個 error 來標(biāo)記錯誤。
sql.Result 是一個接口,它包含兩個方法:
- LastInsertId() (int64, error):返回新插入的用戶 ID。
- RowsAffected() (int64, error):返回當(dāng)前操作受影響的行數(shù)。
接口具體實現(xiàn)有數(shù)據(jù)庫驅(qū)動程序來完成。
調(diào)用 CreateUser 函數(shù)即可創(chuàng)建一個新的用戶:
if id, err := CreateUser(db); err != nil { log.Fatal(err) } else { log.Println("id:", id) }
此外,database/sql 還提供了預(yù)處理方法 *sql.DB.Prepare 創(chuàng)建一個準(zhǔn)備好的 SQL 語句,在循環(huán)中使用預(yù)處理,則可以減少與數(shù)據(jù)庫的交互次數(shù)。
比如我們需要創(chuàng)建兩個用戶,則可以先使用 db.Prepare 創(chuàng)建一個 *sql.Stmt 對象,然后多次調(diào)用 *sql.Stmt.Exec 方法來插入數(shù)據(jù):
func CreateUsers(db *sql.DB) ([]int64, error) { stmt, err := db.Prepare("INSERT INTO user(name, email, age, birthday, salary) VALUES(?, ?, ?, ?, ?)") if err != nil { panic(err) } // 注意:預(yù)處理對象是需要關(guān)閉的 defer stmt.Close() birthday := time.Date(2000, 2, 2, 0, 0, 0, 0, time.Local) users := []User{ { Name: sql.NullString{String: "", Valid: true}, Email: "jianghushinian007@gmail.com", Age: 20, Birthday: &birthday, Salary: Salary{ Month: 200000, Year: 20000000, }, }, { Name: sql.NullString{String: "", Valid: false}, Email: "jianghushinian007@163.com", Age: 30, }, } var ids []int64 for _, user := range users { res, err := stmt.Exec(user.Name, user.Email, user.Age, user.Birthday, user.Salary) if err != nil { return nil, err } id, err := res.LastInsertId() if err != nil { return nil, err } ids = append(ids, id) } return ids, nil }
db.Prepare 是預(yù)先將一個數(shù)據(jù)庫連接和一個條 SQL 語句綁定并返回 *sql.Stmt 結(jié)構(gòu)體,它代表了這個綁定后的連接對象,是并發(fā)安全的。
通過使用預(yù)處理,可以避免在循環(huán)中執(zhí)行多次完整的 SQL 語句,從而顯著減少了數(shù)據(jù)庫交互次數(shù),這可以提高應(yīng)用程序的性能和效率。
使用預(yù)處理,會在 db.Prepare 時從連接池獲取一個連接,之后循環(huán)執(zhí)行 stmt.Exec,最終釋放連接。
如果使用 db.Exec,則每次循環(huán)時都需要:獲取連接-執(zhí)行 SQL-釋放連接,這幾個步驟,大大增加了與數(shù)據(jù)庫的交互次數(shù)。
不要忘記調(diào)用 stmt.Close() 關(guān)閉連接,這個方法是密等的,可以多次調(diào)用。
查詢
現(xiàn)在數(shù)據(jù)庫里已經(jīng)有了數(shù)據(jù),我們就可以查詢數(shù)據(jù)了。
因為 Exec 方法只會執(zhí)行 SQL,不會返回結(jié)果,所以不適用于查詢數(shù)據(jù)。
*sql.DB 提供了 Query 方法執(zhí)行查詢操作:
func GetUsers(db *sql.DB) ([]User, error) { rows, err := db.Query("SELECT * FROM user;") if err != nil { return nil, err } defer func() { _ = rows.Close() }() var users []User for rows.Next() { var user User if err := rows.Scan(&user.ID, &user.Name, &user.Email, &user.Age, &user.Birthday, &user.Salary, &user.CreatedAt, &user.UpdatedAt); err != nil { log.Println(err.Error()) continue } users = append(users, user) } // 處理錯誤 if err := rows.Err(); err != nil { return nil, err } return users, nil }
db.Query
返回查詢結(jié)果集 *sql.Rows
,這是一個結(jié)構(gòu)體。
rows.Next()
方法用來判斷是否還有下一條結(jié)果,可以用于 for
循環(huán)(題外話:這有點像 Python 的迭代器,只不過下一個值不是直接返回,而是通過 Scan
方法獲取)。
如果存在下一條結(jié)果,rows.Next()
將返回 true
。
rows.Scan()
方法可以將結(jié)果掃描到傳遞進(jìn)來的指針對象。因為我們使用了 SELECT *
來查詢,所以會返回全部字段的數(shù)據(jù),按順序?qū)?user
對象相應(yīng)的字段指針傳遞進(jìn)來即可。
rows.Scan()
會將一行記錄分別填入指定的變量中,并且會自動根據(jù)目標(biāo)變量的類型處理類型轉(zhuǎn)換的問題,比如數(shù)據(jù)庫中是 varchar
類型,會映射成 Go 中的 string
,但如果與之對應(yīng)的目標(biāo)變量是 int
,那么轉(zhuǎn)換失敗就會返回 error
。
CreatedAt
字段是 time.Time
類型,之所以能夠被正確處理,是因為在調(diào)用 sql.Open()
時傳遞的 DSN 包含 parseTime=true
參數(shù)。
當(dāng) rows.Next()
返回為 false
時,即不再有下一條記錄。我們也就將全部查詢出來的用戶都存儲到 users
切片中了。
循環(huán)結(jié)束后,切記一定要調(diào)用 rows.Err()
來處理錯誤。
以上,我們查詢了多條用戶,*sql.DB
還提供了 QueryRow
方法可以查詢單條記錄:
func GetUser(db *sql.DB, id int64) (User, error) { var user User row := db.QueryRow("SELECT * FROM user WHERE id = ?", id) err := row.Scan(&user.ID, &user.Name, &user.Email, &user.Age, &user.Birthday, &user.Salary, &user.CreatedAt, &user.UpdatedAt) switch { case err == sql.ErrNoRows: return user, fmt.Errorf("no user with id %d", id) case err != nil: return user, err } // 處理錯誤 if err := row.Err(); err != nil { return user, err } return user, nil }
查詢單條記錄會返回 *sql.Row
結(jié)構(gòu)體,它實際上是對 *sql.Rows
的一層包裝:
type Row struct { // One of these two will be non-nil: err error // deferred error for easy chaining rows *Rows }
我們不再需要調(diào)用 rows.Next()
判斷是否有下一條結(jié)果,調(diào)用 row.Sca()
時 *sql.Row
會自動幫我們處理好,返回查詢結(jié)果集中的第一條數(shù)據(jù)。
如果 row.Sca()
返回的錯誤類型為 sql.ErrNoRows
說明沒有查詢到符合條件的數(shù)據(jù),這對于判斷錯誤類型特別有用。
但 database/sql
顯然不能枚舉出所有數(shù)據(jù)庫的錯誤類型,有些針對不同數(shù)據(jù)庫的指定錯誤類型,通常由數(shù)據(jù)庫驅(qū)動程序來定義。
可以按照如下方式判斷特定的 MySQL 錯誤類型:
if driverErr, ok := err.(*mysql.MySQLError); ok { if driverErr.Number == 1045 { // 處理被拒絕的錯誤 } }
不過像 1045
這種魔法數(shù)字最好不要出現(xiàn)在代碼中,mysqlerr 包提供了 MySQL 錯誤類型的枚舉。
以上代碼可以改為:
if driverErr, ok := err.(*mysql.MySQLError); ok { if driverErr.Number == mysqlerr.ER_ACCESS_DENIED_ERROR { // 處理被拒絕的錯誤 } }
最后,同樣不要忘記調(diào)用 row.Err()
處理錯誤。
更新
更新操作同創(chuàng)建一樣可以使用 *sql.DB.Exec
方法來實現(xiàn),不過這里我們將使用 *sql.DB.ExecContext
方法來實現(xiàn)。
ExecContext
方法與 Exec
方法在使用上沒什么兩樣,只不過第一個參數(shù)需要接收一個 context.Context
,它允許你控制和取消執(zhí)行 SQL 語句的操作。使用上下文可以在需要的情況下設(shè)置超時時間、處理請求取消等操作。
func UpdateUserName(db *sql.DB, id int64, name string) error { ctx := context.Background() res, err := db.ExecContext(ctx, "UPDATE user SET name = ? WHERE id = ?", name, id) if err != nil { return err } affected, err := res.RowsAffected() if err != nil { return err } if affected == 0 { // 如果新的 name 等于原 name,也會執(zhí)行到這里 return fmt.Errorf("no user with id %d", id) } return nil }
這里使用 res.RowsAffected()
獲取了當(dāng)前操作影響的行數(shù)。
注意,如果更新后的字段結(jié)果沒有變化,res.RowsAffected()
返回 0。
刪除
使用 *sql.DB.ExecContext
方法實現(xiàn)刪除用戶:
func DeleteUser(db *sql.DB, id int64) error { ctx := context.Background() res, err := db.ExecContext(ctx, "DELETE FROM user WHERE id = ?", id) if err != nil { return err } affected, err := res.RowsAffected() if err != nil { return err } if affected == 0 { return fmt.Errorf("no user with id %d", id) } return nil }
事務(wù)
事務(wù)基本是開發(fā) Web 項目時比不可少的數(shù)據(jù)庫功能,database/sql
提供了對事務(wù)的支持。
如下示例使用事務(wù)來更新用戶:
func Transaction(db *sql.DB, id int64, name string) error { ctx := context.Background() tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) if err != nil { return err } _, execErr := tx.ExecContext(ctx, "UPDATE user SET name = ? WHERE id = ?", name, id) if execErr != nil { if rollbackErr := tx.Rollback(); rollbackErr != nil { log.Fatalf("update failed: %v, unable to rollback: %v\n", execErr, rollbackErr) } log.Fatalf("update failed: %v", execErr) } return tx.Commit() }
*sql.DB.BeginTx
用于開啟一個事務(wù),第一個參數(shù)為 context.Context
,第二個參數(shù)為 *sql.TxOptions
對象,用來配置事務(wù)選項,Isolation
字段用來設(shè)置數(shù)據(jù)庫隔離級別。
事務(wù)中執(zhí)行的 SQL 語句需要放在 tx
對象的 ExecContext
方法中執(zhí)行,而不是 db.ExecContext
。
如果執(zhí)行 SQL 過程中出現(xiàn)錯誤,可以使用 tx.Rollback()
進(jìn)行事務(wù)回滾。
如果沒有錯誤,則可以使用 tx.Commit()
提交事務(wù)。
tx
同樣支持 Prepare
方法,可以點擊這里查看使用示例。
處理 NULL
在創(chuàng)建 User
模型時,我們定義 Name
字段類型為 sql.NullString
,而非普通的 string
類型,這是為了支持?jǐn)?shù)據(jù)庫中的 NULL
類型。
數(shù)據(jù)庫中 name
字段定義如下:
`name` varchar(50) DEFAULT NULL COMMENT '用戶名'
那么 name
在數(shù)據(jù)庫中可能的值將有三種情況:NULL
、空字符串 ''
以及有值的字符串 'n1'
。
我們知道,Go 語言中 string
類型的默認(rèn)值即為空字符串 ''
,但是 string
無法表示 NULL
值。
這個時候,我們有兩種方法解決此問題:
- 使用指針類型。
- 使用
sql.NullString
類型。
因為指針類型可能為 nil
,所以可以使用 nil
來對應(yīng) NULL
值。這就是 User
模型中 Birthday
字段類型定義為 *time.Time
的緣故。
sql.NullString
類型定義如下:
type NullString struct { String string Valid bool // Valid is true if String is not NULL }
String
用來記錄值,Valid
用來標(biāo)記是否為 NULL
。
NullString
結(jié)構(gòu)體的值和數(shù)據(jù)庫中實際存儲的值,有如下映射關(guān)系:
value | value for MySQL |
---|---|
{String:n1 Valid:true} | 'n1' |
{String: Valid:true} | '' |
{String: Valid:false} | NULL |
此外,sql.NullString
類型還實現(xiàn)了 sql.Scanner
和 driver.Valuer
兩個接口:
// Scan implements the Scanner interface. func (ns *NullString) Scan(value any) error { if value == nil { ns.String, ns.Valid = "", false return nil } ns.Valid = true return convertAssign(&ns.String, value) } // Value implements the driver Valuer interface. func (ns NullString) Value() (driver.Value, error) { if !ns.Valid { return nil, nil } return ns.String, nil }
這兩個接口分別用在 *sql.Row.Scan
方法和 *sql.DB.Exec
方法。
即在使用 *sql.DB.Exec
方法執(zhí)行 SQL 時,我們可能需要將 Name
字段的值存入 MySQL,此時 database/sql
會調(diào)用 sql.NullString
類型的 Value()
方法,獲取其將要存儲于數(shù)據(jù)庫中的值。
在使用 *sql.Row.Scan
方法時,我們可能需要將從數(shù)據(jù)庫獲取到的 name
字段值映射到 User
結(jié)構(gòu)體字段 Name
上,此時 database/sql
會調(diào)用 sql.NullString
類型的 Scan()
方法,把從數(shù)據(jù)庫中查詢的值賦值給 Name
字段。
如果你使用的字段不是 string
類型,database/sql
還提供了 sql.NullBool
、sql.NullFloat64
等類型供用戶使用。
但是,這并不能枚舉出所有 MySQL 數(shù)據(jù)庫支持的字段類型,所以如果能夠盡量避免,還是不建議數(shù)據(jù)庫字段允許 NULL
值。
自定義字段類型
有些時候,我們保存在數(shù)據(jù)庫中的數(shù)據(jù)有著特定的格式,比如 salary
字段在數(shù)據(jù)庫中存儲的值為 {"month":100000,"year":10000000}
。
數(shù)據(jù)庫中 salary
字段定義如下:
`salary` varchar(128) DEFAULT NULL COMMENT '薪水'
如果只是將其映射為 Go 中的 string
,則操作時要格外小心,如果忘記寫一個 "
或 ,
等,程序?qū)⒖赡軋箦e。
因為 salary
值明顯是一個 JSON 格式,我們可以定義一個 struct
來映射其內(nèi)容:
type Salary struct { Month int `json:"month"` Year int `json:"year"` }
這還不夠,自定義類型無法支持 *sql.Row.Scan
方法和 *sql.DB.Exec
方法。
不過,我想你已經(jīng)猜到了,我們可以參考 sql.NullString
類型讓 Salary
同樣實現(xiàn) sql.Scanner
和 driver.Valuer
兩個接口:
// Scan implements sql.Scanner func (s *Salary) Scan(src any) error { if src == nil { return nil } var buf []byte switch v := src.(type) { case []byte: buf = v case string: buf = []byte(v) default: return fmt.Errorf("invalid type: %T", src) } err := json.Unmarshal(buf, s) return err } // Value implements driver.Valuer func (s Salary) Value() (driver.Value, error) { v, err := json.Marshal(s) return string(v), err }
這樣,存儲和查詢數(shù)據(jù)的操作,Salary
類型都能夠支持。
未知列
極端情況下,我們可能不知道使用 *sql.DB.Query
方法查詢的結(jié)果集中數(shù)據(jù)的列名以及字段個數(shù)。
此時,我們可以使用 *sql.Rows.Columns
方法獲取所有列名,這將返回一個切片,這個切片長度,即為字段個數(shù)。
示例代碼如下:
func HandleUnknownColumns(db *sql.DB, id int64) ([]interface{}, error) { var res []interface{} rows, err := db.Query("SELECT * FROM user WHERE id = ?", id) if err != nil { return res, err } defer func() { _ = rows.Close() }() // 如果不知道列名稱,可以使用 rows.Columns() 查找列名稱列表 cols, err := rows.Columns() if err != nil { return res, err } fmt.Printf("columns: %v\n", cols) // [id name email age birthday salary created_at updated_at] fmt.Printf("columns length: %d\n", len(cols)) // 獲取列類型信息 types, err := rows.ColumnTypes() if err != nil { return nil, err } for _, typ := range types { // id: &{name:id hasNullable:true hasLength:false hasPrecisionScale:false nullable:false length:0 databaseType:INT precision:0 scale:0 scanType:0x1045d68a0} fmt.Printf("%s: %+v\n", typ.Name(), typ) } res = []interface{}{ new(int), // id new(sql.NullString), // name new(string), // email new(int), // age new(time.Time), // birthday new(Salary), // salary new(time.Time), // created_at // 如果不知道列類型,可以使用 sql.RawBytes,它實際上是 []byte 的別名 new(sql.RawBytes), // updated_at } for rows.Next() { if err := rows.Scan(res...); err != nil { return res, err } } return res, rows.Err() }
除了獲取列名和字段個數(shù),我們還可以使用 *sql.Rows.ColumnTypes
方法獲取每個 column
的詳細(xì)信息。
如果我們不知道某個字段在數(shù)據(jù)庫中的類型,則可以將其映射為 sql.RawBytes
類型,它實際上是 []byte
的別名。
總結(jié)
database/sql
包統(tǒng)一了 Go 語言操作數(shù)據(jù)庫的編程接口,避免了操作不同數(shù)據(jù)庫需要學(xué)習(xí)多套 API 的窘境。
使用 sql.Open()
建立數(shù)據(jù)庫連接并不會立刻生效,連接會在合適的時候延遲建立。
我們可以使用 *sql.DB.Exec
/ *sql.DB.ExecContext
來執(zhí)行 SQL 命令。其實除了 Exec
有方法對應(yīng)的 ExecContext
版本,文中提到的 *sql.DB.Ping
、*sql.DB.Query
、*sql.DB.QueryRow
、*sql.DB.Prepare
方法也都有對應(yīng)的 XxxContext
版本,你可以自行測試。
如果被執(zhí)行的 SQL 語句中包含 MySQL 關(guān)鍵字,則需要使用反引號(`)將關(guān)鍵字進(jìn)行包裹,否則你將得到 Error 1064 (42000): You have an error in your SQL syntax;
錯誤。
*sql.DB.BeginTx
可以開啟一個事務(wù),事務(wù)需要顯式的 Commit
或 Rollback
,MySQL 驅(qū)動還支持使用 *sql.TxOptions
設(shè)置事務(wù)隔離級別。
對于 NULL
類型,database/sql
提供了 sql.NullString
等類型的支持。我們也可以為自定義類型實現(xiàn) sql.Scanner
和 driver.Valuer
兩個接口,來實現(xiàn)特定邏輯。
對于未知列和字段類型,我們可以使用 *sql.Rows.Columns
、sql.RawBytes
等來解決,雖然這極大的增加了靈活性,不過不到萬不得已不建議使用,使用更加明確的代碼可以減少 BUG 的數(shù)量和提高可維護(hù)性。
以上就是Go使用database/sql操作數(shù)據(jù)庫的教程指南的詳細(xì)內(nèi)容,更多關(guān)于Go database/sql的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Go json omitempty如何實現(xiàn)可選屬性
在Go語言中,使用`omitempty`可以幫助我們在進(jìn)行JSON序列化和反序列化時,忽略結(jié)構(gòu)體中的零值或空值,本文介紹了如何通過將字段類型改為指針類型,并在結(jié)構(gòu)體的JSON標(biāo)簽中添加`omitempty`來實現(xiàn)這一功能,例如,將float32修改為*float322024-09-09Golang并發(fā)編程中Context包的使用與并發(fā)控制
Golang的context包提供了在并發(fā)編程中傳遞取消信號、超時控制和元數(shù)據(jù)的功能,本文就來介紹一下Golang并發(fā)編程中Context包的使用與并發(fā)控制,感興趣的可以了解一下2024-11-11Golang根據(jù)job數(shù)量動態(tài)控制每秒?yún)f(xié)程的最大創(chuàng)建數(shù)量方法詳解
這篇文章主要介紹了Golang根據(jù)job數(shù)量動態(tài)控制每秒?yún)f(xié)程的最大創(chuàng)建數(shù)量方法2024-01-01Golang中HTTP路由設(shè)計的使用與實現(xiàn)
這篇文章主要介紹了Golang中HTTP路由設(shè)計的使用與實現(xiàn),為什么要設(shè)計路由規(guī)則,因為路由規(guī)則是HTTP的請求按照一定的規(guī)則 ,匹配查找到對應(yīng)的控制器并傳遞執(zhí)行的邏輯,需要的朋友可以參考下2023-05-05