Go database/sql文檔

No.1 文檔概要

在Golang中使用SQL或類似SQL的數(shù)據(jù)庫(kù)的慣用方法是通過(guò) database/sql 包操作。它為面向行的數(shù)據(jù)庫(kù)提供了輕量級(jí)的接口。這篇文章是關(guān)于如何使用它,最常見(jiàn)的參考。

為什么需要這個(gè)?包文檔告訴你每件事情都做了什么,但它并沒(méi)有告訴你如何使用這個(gè)包。我們很多人都希望自己能快速參考和入門(mén)的方法,而不是講故事。歡迎捐款;請(qǐng)?jiān)谶@里發(fā)送請(qǐng)求。

在Golang中你用sql.DB訪問(wèn)數(shù)據(jù)庫(kù)。你可以使用此類型創(chuàng)建語(yǔ)句和事務(wù),執(zhí)行查詢,并獲取結(jié)果。下面的代碼列出了sql.DB是一個(gè)結(jié)構(gòu)體,點(diǎn)擊 database/sql/sql.go 查看官方源碼。

首先你應(yīng)該知道一個(gè)sql.DB不是一個(gè)數(shù)據(jù)庫(kù)的連接。它也沒(méi)有映射到任何特點(diǎn)數(shù)據(jù)庫(kù)軟件的“數(shù)據(jù)庫(kù)”或“模式”的概念。它是數(shù)據(jù)庫(kù)的接口和數(shù)據(jù)庫(kù)的抽象,它可能與本地文件不同,可以通過(guò)網(wǎng)絡(luò)連接訪問(wèn),也可以在內(nèi)存和進(jìn)程中訪問(wèn)。

sql.DB為你在幕后執(zhí)行一些重要的任務(wù):

? 通過(guò)驅(qū)動(dòng)程序打開(kāi)和關(guān)閉實(shí)際的底層數(shù)據(jù)庫(kù)的連接。
? 它根據(jù)需要管理一個(gè)連接池,這可能是如上所述的各種各樣的事情。

sql.DB抽象旨在讓你不必?fù)?dān)心如何管理對(duì)基礎(chǔ)數(shù)據(jù)存儲(chǔ)的并發(fā)訪問(wèn)。一個(gè)連接在使用它執(zhí)行任務(wù)時(shí)被標(biāo)記為可用,然后當(dāng)它不在使用時(shí)返回到可用的池中。這樣的后果之一是,如果你無(wú)法將連接釋放到池中,則可能導(dǎo)致db.SQL打開(kāi)大量連接,可能會(huì)耗盡資源(連接太多,打開(kāi)的文件句柄太多,缺少可用網(wǎng)絡(luò)端口等)。稍后我們將進(jìn)一步討論這個(gè)問(wèn)題。

在創(chuàng)建sql.DB之后,你可以用它來(lái)查詢它所代表的數(shù)據(jù)庫(kù),以及創(chuàng)建語(yǔ)句和事務(wù)。

No.2 導(dǎo)入數(shù)據(jù)庫(kù)驅(qū)動(dòng)

要使用 database/sql,你需要 database/sql 自身,以及需要使用的特定的數(shù)據(jù)庫(kù)驅(qū)動(dòng)。

你通常不應(yīng)該直接使用驅(qū)動(dòng)包,盡管有些驅(qū)動(dòng)鼓勵(lì)你這樣做。(在我們看來(lái),這通常是個(gè)壞主意。) 相反的,如果可能,你的代碼應(yīng)該僅引用 database/sql 中定義的類型。這有助于避免使你的代碼依賴于驅(qū)動(dòng),從而可以通過(guò)最少的代碼來(lái)更改底層驅(qū)動(dòng)(因此訪問(wèn)的數(shù)據(jù)庫(kù))。它還強(qiáng)制你使用Golang習(xí)慣用法,而不是特定驅(qū)動(dòng)作者可能提供的特定的習(xí)慣用法。

在本文檔中,我們將使用@julienschmidt 和 @arnehormann中優(yōu)秀的MySql驅(qū)動(dòng)。

將以下內(nèi)容添加到Go源文件的頂部(也就是package name下面):
import (
    "database/sql"
    _ "github.com/go-sql-driver/mysql"
)

注意我們正在加載的驅(qū)動(dòng)是匿名的,將其限定符別名為_(kāi),因此我們的代碼中沒(méi)有一個(gè)導(dǎo)出的名稱可見(jiàn)。在引擎下,驅(qū)動(dòng)將自身注冊(cè)為可用于 database/sql 包,但一般來(lái)說(shuō)沒(méi)有其他情況發(fā)生。

現(xiàn)在你已經(jīng)準(zhǔn)備好訪問(wèn)數(shù)據(jù)庫(kù)了。

No.3 訪問(wèn)數(shù)據(jù)庫(kù)

現(xiàn)在你已經(jīng)加載了驅(qū)動(dòng)包,就可以創(chuàng)建一個(gè)數(shù)據(jù)庫(kù)對(duì)象sql.DB。創(chuàng)建一個(gè)sql.DB你可以使用sql.Open()。Open返回一個(gè)*sql.DB。

func main() {
    db, err := sql.Open("mysql",
        "user:password@tcp(127.0.0.1:3306)/hello")
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()
}
在示例中,我們演示了幾件事:
  1. sql.Open的第一個(gè)參數(shù)是驅(qū)動(dòng)名稱。這是驅(qū)動(dòng)用來(lái)注冊(cè)database/sql的字符串,并且通常與包名相同以避免混淆。例如,它是github.com/go-sql-driver/mysql的MySql驅(qū)動(dòng)(作者:jmhodges)。某些驅(qū)動(dòng)不遵守公約的名稱,例如github.com/mattn/go-sqlite3的sqlite3(作者:matte)和github.com/lib/pq的postgres(作者:mjibson)。

  2. 第二個(gè)參數(shù)是一個(gè)驅(qū)動(dòng)特定的語(yǔ)法,它告訴驅(qū)動(dòng)如何訪問(wèn)底層數(shù)據(jù)存儲(chǔ)。在本例中,我們將連接本地的MySql服務(wù)器實(shí)例中的“hello”數(shù)據(jù)庫(kù)。

  3. 你應(yīng)該(幾乎)總是檢查并處理從所有database/sql操作返回的錯(cuò)誤。有一些特殊情況,我們稍后將討論這樣做事沒(méi)有意義的。

  4. 如果sql.DB不應(yīng)該超出該函數(shù)的作用范圍,則延遲函數(shù)defer db.Close()是慣用的。

也許是反直覺(jué)的,sql.Open()不建立與數(shù)據(jù)庫(kù)的任何連接,也不會(huì)驗(yàn)證驅(qū)動(dòng)連接參數(shù)。相反,它只是準(zhǔn)備數(shù)據(jù)庫(kù)抽象以供以后使用。首次真正的連接底層數(shù)據(jù)存儲(chǔ)區(qū)將在第一次需要時(shí)懶惰地建立。如果你想立即檢查數(shù)據(jù)庫(kù)是否可用(例如,檢查是否可以建立網(wǎng)絡(luò)連接并登陸),請(qǐng)使用db.Ping()來(lái)執(zhí)行此操作,記得檢查錯(cuò)誤:

err = db.Ping()
if err != nil {
    // do something here
}

雖然在完成數(shù)據(jù)庫(kù)之后Close()數(shù)據(jù)庫(kù)是慣用的,但是sql.DB對(duì)象被設(shè)計(jì)為長(zhǎng)連接。不要經(jīng)常Open()和Close()數(shù)據(jù)庫(kù)。相反,為你需要訪問(wèn)的每個(gè)不同的數(shù)據(jù)存儲(chǔ)創(chuàng)建一個(gè)sql.DB對(duì)象,并保留它,直到程序訪問(wèn)數(shù)據(jù)存儲(chǔ)完畢。在需要時(shí)傳遞它,或在全局范圍內(nèi)使其可用,但要保持開(kāi)放。并且不要從短暫的函數(shù)中Open()和Close()。相反,通過(guò)sql.DB作為參數(shù)傳遞給該短暫的函數(shù)。

如果你不把sql.DB視為長(zhǎng)期存在的對(duì)象,則可能會(huì)遇到諸如重復(fù)使用和連接共享不足,耗盡可用的網(wǎng)絡(luò)資源以及由于TIME_WAIT中剩余大量TCP連接而導(dǎo)致的零星故障的狀態(tài)。這些問(wèn)題表明你沒(méi)有像設(shè)計(jì)的那樣使用database/sql的跡象。

現(xiàn)在是時(shí)候使用你的sql.DB對(duì)象了。

No.4 檢索結(jié)果集

有幾個(gè)慣用的操作來(lái)從數(shù)據(jù)存儲(chǔ)中檢索結(jié)果。
  1. 執(zhí)行返回行的查詢。

  2. 準(zhǔn)備重復(fù)使用的語(yǔ)句,多次執(zhí)行并銷毀它。

  3. 以一次關(guān)閉的方式執(zhí)行語(yǔ)句,不準(zhǔn)備重復(fù)使用。

  4. 執(zhí)行一個(gè)返回單行的查詢。這種特殊情況有一個(gè)捷徑。

Golang的database/sql函數(shù)名非常重要。如果一個(gè)函數(shù)名包含查詢Query(),它被設(shè)計(jì)為詢問(wèn)數(shù)據(jù)庫(kù)的問(wèn)題,并返回一組行,即使它是空的。不返回行的語(yǔ)句不應(yīng)該使用Query()函數(shù);他們應(yīng)該使用Exec()。

從數(shù)據(jù)庫(kù)獲取數(shù)據(jù)

讓我們來(lái)看一下如何查詢數(shù)據(jù)庫(kù),使用Query的例子。我們將向用戶表查詢id為1的用戶,并打印出用戶的id和name。我們將使用rows.Scan()將結(jié)果分配給變量,一次一行。

var (
    id int
    name string
)
rows, err := db.Query("select id, name from users where id = ?", 1)
if err != nil {
    log.Fatal(err)
}
defer rows.Close()
for rows.Next() {
    err := rows.Scan(&id, &name)
    if err != nil {
        log.Fatal(err)
    }
    log.Println(id, name)
}
err = rows.Err()
if err != nil {
    log.Fatal(err)
}
下面是上面代碼中正在發(fā)生的事情:
  1. 我們使用db.Query()將查詢發(fā)送到數(shù)據(jù)庫(kù)。我們像往常一樣檢查錯(cuò)誤。

  2. 我們用defer內(nèi)置函數(shù)推遲了rows.Close()的執(zhí)行。這個(gè)非常重要。

  3. 我們用rows.Next()遍歷了數(shù)據(jù)行。

  4. 我們用rows.Scan()讀取每行中的列變量。

  5. 我們完成遍歷行之后檢查錯(cuò)誤。

這幾乎是Golang中唯一的辦法。例如,你不能將一行作為映射來(lái)獲取。這是因?yàn)樗袞|西都是強(qiáng)類型的。你需要?jiǎng)?chuàng)建正確類型的變量并將指針傳遞給它們,如圖所示。

其中的幾個(gè)部分很容易出錯(cuò),可能會(huì)產(chǎn)生不良后果。

? 你應(yīng)該總是檢查rows.Next()循環(huán)結(jié)尾處的錯(cuò)誤。如果循環(huán)中出現(xiàn)錯(cuò)誤,則需要了解它。不要僅僅假設(shè)循環(huán)遍歷,直到你已經(jīng)處理了所有的行。

? 第二,只要有一個(gè)打開(kāi)的結(jié)果集(由行代表),底層連接就很忙,不能用于任何其他查詢。這意味著它在連接池中不可用。如果你使用rows.Next()遍歷所有行,最終將讀取最后一行,rows.Next()將遇到內(nèi)部EOF錯(cuò)誤,并為你調(diào)用rows.Close()。但是,如果由于某種原因退出該循環(huán)-提前返回,那么行不會(huì)關(guān)閉,并且連接保持打開(kāi)狀態(tài)。(如果rows.Next()由于錯(cuò)誤而返回false,則會(huì)自動(dòng)關(guān)閉)。這是一種簡(jiǎn)單耗盡資源的方法。

? rows.Close()是一種無(wú)害的操作,如果它已經(jīng)關(guān)閉,所以你可以多次調(diào)用它。但是請(qǐng)注意,我們首先檢查錯(cuò)誤,如果沒(méi)有錯(cuò)誤,則調(diào)用rows.Close(),以避免運(yùn)行時(shí)的panic。

? 你應(yīng)該總是用延遲語(yǔ)句defer推遲rows.Close(),即使你也在循環(huán)結(jié)束時(shí)調(diào)用rows.Close(),這不是一個(gè)壞主意。

? 不要在循環(huán)中用defer推遲。延遲語(yǔ)句在函數(shù)退出之前不會(huì)執(zhí)行,所以長(zhǎng)時(shí)間運(yùn)行的函數(shù)不應(yīng)該使用它。如果你這樣做,你會(huì)慢慢積累記憶。如果你在循環(huán)中反復(fù)查詢和使用結(jié)果集,則在完成每個(gè)結(jié)果后應(yīng)顯示的調(diào)用rows.Close(),而不用延遲語(yǔ)句defer。

Scan()如何工作

當(dāng)你遍歷行并將其掃描到目標(biāo)變量中時(shí),Golang會(huì)在幕后為你執(zhí)行數(shù)據(jù)類型轉(zhuǎn)換。它基于目標(biāo)變量的類型。意識(shí)到這一點(diǎn)可以干凈你的代碼,并幫助避免重復(fù)工作。

例如,假設(shè)你從表中選擇了一些行,這是用字符串列定義的。如varchar(45)或類似的列。然而,你碰巧知道表格總是包含數(shù)字。如果傳遞指向字符串的指針,Golang會(huì)將字節(jié)復(fù)制到字符串中。現(xiàn)在可以使用strconv.ParseInt()或類似的方式將值轉(zhuǎn)換為數(shù)字。你必須檢查SQL操作中的錯(cuò)誤以及解析整數(shù)的錯(cuò)誤。這又亂又糟糕。

或者,你可以通過(guò)Scan()指向一個(gè)整數(shù)即可。Golang會(huì)檢測(cè)到并為你調(diào)用strconv.ParseInt()。如果有轉(zhuǎn)換錯(cuò)誤,則調(diào)用Scan()將返回它。你的代碼現(xiàn)在更小更整潔。這是推薦使用database/sql的方法。

準(zhǔn)備查詢

一般來(lái)說(shuō),你應(yīng)該總是準(zhǔn)備多次使用查詢。準(zhǔn)備查詢的結(jié)果是一個(gè)準(zhǔn)備語(yǔ)句,可以為執(zhí)行語(yǔ)句時(shí)提供的參數(shù),提供占位符(a.k.a bind值)。這比連接字符串更好,出于所有通常的理由(例如避免SQL注入攻擊)。

在MySql中,參數(shù)占位符為?,在PostgreSql中為$N,其中N為數(shù)字。SQLite接受這兩者之一。在Oracle中占位符以冒號(hào)開(kāi)始,并命名為:param1。本文檔中我們使用?占位符,因?yàn)槲覀兪褂肕ySql作為示例。

stmt, err := db.Prepare("select id, name from users where id = ?")
if err != nil {
    log.Fatal(err)
}
defer stmt.Close()
rows, err := stmt.Query(1)
if err != nil {
    log.Fatal(err)
}
defer rows.Close()
for rows.Next() {
    // ...
}
if err = rows.Err(); err != nil {
    log.Fatal(err)
}

在引擎下,db.Query()實(shí)際上準(zhǔn)備,執(zhí)行和關(guān)閉一個(gè)準(zhǔn)備好的語(yǔ)句。這是數(shù)據(jù)庫(kù)的三次往返。如果你不小心,可以使應(yīng)用程序的數(shù)據(jù)庫(kù)交互數(shù)量增加三倍!有些驅(qū)動(dòng)可以在特定情況下避免這種情況,但并非所有驅(qū)動(dòng)都可以這樣做。點(diǎn)擊prepared statements查看更多聲明。

單行查詢

如果一個(gè)查詢返回最多一行,可以使用一些快速的樣板代碼:

var name string
err = db.QueryRow("select name from users where id = ?", 1).Scan(&name)
if err != nil {
    log.Fatal(err)
}
fmt.Println(name)

來(lái)自查詢的錯(cuò)誤將被推遲到Scan(),然后返回。你也可以在準(zhǔn)備的語(yǔ)句中調(diào)用QueryRow():

stmt, err := db.Prepare("select name from users where id = ?")
if err != nil {
    log.Fatal(err)
}
var name string
err = stmt.QueryRow(1).Scan(&name)
if err != nil {
    log.Fatal(err)
}
fmt.Println(name)

No.5 修改數(shù)據(jù)和使用事務(wù)

現(xiàn)在我們已經(jīng)準(zhǔn)備好了如何修改數(shù)據(jù)和處理事務(wù)。如果你習(xí)慣于使用“statement”對(duì)象來(lái)獲取行并更新數(shù)據(jù),那么這種區(qū)別可能視乎是認(rèn)為的,但是在Golang中有一個(gè)重要的原因。

修改數(shù)據(jù)的statements

使用Exec(),最好用一個(gè)準(zhǔn)備好的statement來(lái)完成INSERT,UPDATE,DELETE或者其他不返回行的語(yǔ)句。下面的示例演示如何插入行并檢查有關(guān)操作的元數(shù)據(jù):

stmt, err := db.Prepare("INSERT INTO users(name) VALUES(?)")
if err != nil {
    log.Fatal(err)
}
res, err := stmt.Exec("Dolly")
if err != nil {
    log.Fatal(err)
}
lastId, err := res.LastInsertId()
if err != nil {
    log.Fatal(err)
}
rowCnt, err := res.RowsAffected()
if err != nil {
    log.Fatal(err)
}
log.Printf("ID = %d, affected = %d\n", lastId, rowCnt)

執(zhí)行該語(yǔ)句將生成一個(gè)sql.Result,該語(yǔ)句提供對(duì)statement元數(shù)據(jù)的訪問(wèn):最后插入的ID和行數(shù)受到影響。

如果你不在乎結(jié)果怎么辦?如果你只想執(zhí)行一個(gè)語(yǔ)句并檢查是否有錯(cuò)誤,但忽略結(jié)果該怎么辦?下面兩個(gè)語(yǔ)句不會(huì)做同樣的事情嗎?

_, err := db.Exec("DELETE FROM users")  // OK
_, err := db.Query("DELETE FROM users") // BAD

答案是否定的。他們不做同樣的事情,你不應(yīng)該使用Query()。Query()將返回一個(gè)sql.Rows,它保留數(shù)據(jù)庫(kù)連接,直到sql.Rows關(guān)閉。由于可能有未讀數(shù)據(jù)(例如更多的數(shù)據(jù)行),所以不能使用連接。在上面的示例中,連接將永遠(yuǎn)不會(huì)被釋放。垃圾回收器最終會(huì)關(guān)閉底層的net.Conn,但這可能需要很長(zhǎng)時(shí)間。此外,database/sql包將繼續(xù)跟蹤池中的連接,希望在某個(gè)時(shí)候釋放它,以便可以再次使用連接。因此,這種反模式是耗盡資源的好方法(例如連接數(shù)太多)。

事務(wù)處理

在Golang中,事務(wù)本質(zhì)上是保留與數(shù)據(jù)存儲(chǔ)的連接的對(duì)象。它允許你執(zhí)行我們迄今為止所看到的所有操作,但保證它們將在同一連接上執(zhí)行。

你可以通過(guò)調(diào)用db.Begin()開(kāi)始一個(gè)事務(wù),并在結(jié)果Tx變量上用Commit()或Rollback()方法關(guān)閉它。在封面下,Tx從池中獲取連接,并保留它僅用于該事務(wù)。Tx上的方法一對(duì)一到可以調(diào)用數(shù)據(jù)本本身的方法,例如Query()等等。

在事務(wù)中創(chuàng)建的Prepare語(yǔ)句僅限于該事務(wù)。點(diǎn)擊prepared statements查看更多準(zhǔn)備的聲明。

你不應(yīng)該在SQL代碼中混合BEGIN和COMMIT相關(guān)的函數(shù)(如Begin()和Commit()的SQL語(yǔ)句),可能會(huì)導(dǎo)致悲劇:

? Tx對(duì)象可以保持打開(kāi)狀態(tài),從池中保留連接而不返回。
? 數(shù)據(jù)庫(kù)的狀態(tài)可能與代表它的Golang變量的狀態(tài)不同步。
? 你可能會(huì)認(rèn)為你是在事務(wù)內(nèi)部的單個(gè)連接上執(zhí)行查詢,實(shí)際上Golang已經(jīng)為你創(chuàng)建了幾個(gè)連接,而且一些語(yǔ)句不是事務(wù)的一部分。

當(dāng)你在事務(wù)中工作時(shí),你應(yīng)該注意不要對(duì)Db變量進(jìn)行調(diào)用。應(yīng)當(dāng)使用db.Begin()創(chuàng)建的Tx變量進(jìn)行所有調(diào)用。Db不在一個(gè)事務(wù)中,只有Tx是。如果你進(jìn)一步調(diào)用db.Exec()或類似的函數(shù),那么這些調(diào)用將發(fā)生在事務(wù)范圍之外,是在其他的連接上。

如果你需要處理修改連接狀態(tài)的多個(gè)語(yǔ)句,即使你不希望事務(wù)本身,也需要一個(gè)Tx。例如:

? 創(chuàng)建僅在一個(gè)連接中可見(jiàn)的臨時(shí)表。

? 設(shè)置變量,如MySql's SET @var := somevalue語(yǔ)法。

? 更改連接選項(xiàng),如字符集或超時(shí)。

如果你需要執(zhí)行任何這些操作,則需要把你的作業(yè)(也可以說(shuō)Tx操作語(yǔ)句)綁定到單個(gè)連接,而在Golang中執(zhí)行此操作的唯一方法是使用Tx。

No.6 使用預(yù)處理語(yǔ)句

準(zhǔn)備語(yǔ)句(db.Prepare()或者tx.Prepare())在Golang中具有所有常見(jiàn)的優(yōu)點(diǎn):安全性,效率,方便性。但是他們的實(shí)現(xiàn)方式與你習(xí)慣的方式可能有所不同,特別是關(guān)于它們?nèi)绾闻cdatabase/sql的一些內(nèi)部組件進(jìn)行交互的方式。

準(zhǔn)備語(yǔ)句和連接

在數(shù)據(jù)庫(kù)級(jí)別,將準(zhǔn)備好的語(yǔ)句綁定到單個(gè)數(shù)據(jù)庫(kù)連接。典型的流程是:客戶端向服務(wù)器發(fā)送帶有占位符的SQL語(yǔ)句以進(jìn)行準(zhǔn)備,服務(wù)器使用語(yǔ)句ID進(jìn)行響應(yīng),然后客戶端通過(guò)發(fā)送其ID和參數(shù)來(lái)執(zhí)行該語(yǔ)句。

然而在Golang中,連接不會(huì)直接暴露給database/sql包的用戶。你不準(zhǔn)備連接上語(yǔ)句。你準(zhǔn)備好在一個(gè)db或tx。并且database/sql具有一些便捷的行為,如自動(dòng)重試。由于這些原因,準(zhǔn)備好的語(yǔ)句和連接(存在于驅(qū)動(dòng)級(jí)別)之間的潛在關(guān)聯(lián)被隱藏在代碼中。

下面是它的工作原理:
  1. 準(zhǔn)備一個(gè)語(yǔ)句時(shí),它會(huì)在池中的連接上準(zhǔn)備好。

  2. Stmt對(duì)象記住使用哪個(gè)連接。

  3. 當(dāng)你執(zhí)行Stmt時(shí),它試圖使用Stmt對(duì)象記住的那個(gè)連接(后面我們將這里的連接稱為原始連接)。如果它不可用,因?yàn)樗P(guān)閉或忙于做其他事情,它從池中獲取另一個(gè)連接,并在另一個(gè)連接上重新準(zhǔn)備與數(shù)據(jù)庫(kù)的語(yǔ)句。

因?yàn)樵谠歼B接繁忙時(shí),會(huì)根據(jù)需要重新準(zhǔn)備語(yǔ)句,因此數(shù)據(jù)庫(kù)的高并發(fā)使用可能會(huì)導(dǎo)致大量連接繁忙,從而創(chuàng)建大量的準(zhǔn)備語(yǔ)句。這會(huì)導(dǎo)致語(yǔ)句的明顯泄露,正在準(zhǔn)備和重新準(zhǔn)備的語(yǔ)句比你想象的更多,甚至?xí)绊懙椒?wù)器端對(duì)語(yǔ)句數(shù)量的限制。

避免準(zhǔn)備好的語(yǔ)句

Golang將為你在封面下創(chuàng)建準(zhǔn)備好的聲明。例如,一個(gè)簡(jiǎn)單的db.Query(sql,param1,param2)通過(guò)準(zhǔn)備sql,然后使用參數(shù)執(zhí)行它,最后關(guān)閉語(yǔ)句。

有時(shí),準(zhǔn)備好的語(yǔ)句并不是你想要的。這可能有幾個(gè)原因。
  1. 數(shù)據(jù)庫(kù)不支持準(zhǔn)備好的語(yǔ)句。例如,當(dāng)使用MySql驅(qū)動(dòng)時(shí),你可以連接到MemSql和Sphinx,因?yàn)樗鼈冎С諱ySql線路協(xié)議。但是它們不支持包含準(zhǔn)備語(yǔ)句的“二進(jìn)制”協(xié)議,因此它們會(huì)以混亂的方式失敗。

  2. 這些語(yǔ)句沒(méi)有重用到足以使它們變得有價(jià)值,而安全問(wèn)題則以其他方式處理,因此性能開(kāi)銷是不需要的。這方面點(diǎn)擊VividCortex博客可以看到一個(gè)例子。

如果不想使用預(yù)處理語(yǔ)句,則需要使用fmt.Sprint()或類似的方法來(lái)組合SQL,并將其作為db.Query()或db.QueryRow()的唯一參數(shù)傳遞。你的驅(qū)動(dòng)需要支持明文查詢執(zhí)行,這是通過(guò)執(zhí)行器(Execer是一個(gè)結(jié)構(gòu)體)和查詢器(Queryer是一個(gè)結(jié)構(gòu)體)接口在Golang 1.1中添加的,在此記錄。

事務(wù)中的準(zhǔn)備語(yǔ)句

在Tx中創(chuàng)建的準(zhǔn)備語(yǔ)句僅限于它,因此早期關(guān)于重新準(zhǔn)備的注意事項(xiàng)不適用。當(dāng)你對(duì)Tx對(duì)象進(jìn)行操作時(shí),你的操作直接映射到它下面唯一的一個(gè)連接上。

這也意味著在Tx內(nèi)創(chuàng)建的準(zhǔn)備語(yǔ)句不能與之分開(kāi)使用。同樣,在DB中創(chuàng)建的準(zhǔn)備語(yǔ)句不能再事務(wù)中使用,因?yàn)樗鼈儗⒈唤壎ǖ讲煌倪B接。

要在Tx中使用事務(wù)外的預(yù)處理語(yǔ)句,可以使用Tx.Stmt(),它將從事務(wù)外部準(zhǔn)備一個(gè)新的特定于事務(wù)的語(yǔ)句。它通過(guò)采用現(xiàn)有的預(yù)處理語(yǔ)句,設(shè)置與事務(wù)的連接,并在執(zhí)行時(shí)重新準(zhǔn)備所有語(yǔ)句。這個(gè)行為及其實(shí)現(xiàn)是不可取的,甚至在databse/sql源代碼中有一個(gè)TODO來(lái)改進(jìn)它;我們建議不要使用這個(gè)。

在處理事務(wù)中的預(yù)處理語(yǔ)句時(shí),必須小心謹(jǐn)慎。請(qǐng)考慮下面的示例:
tx, err := db.Begin()
if err != nil {
    log.Fatal(err)
}
defer tx.Rollback()
stmt, err := tx.Prepare("INSERT INTO foo VALUES (?)")
if err != nil {
    log.Fatal(err)
}
defer stmt.Close() // danger!
for i := 0; i < 10; i++ {
    _, err = stmt.Exec(i)
    if err != nil {
        log.Fatal(err)
    }
}
err = tx.Commit()
if err != nil {
    log.Fatal(err)
}
// stmt.Close() runs here!

之前Golang1.4關(guān)閉*sql.Tx將與之關(guān)聯(lián)的連接返還到池中,但是,在預(yù)處理語(yǔ)句結(jié)束后,延遲調(diào)用時(shí)在那之后發(fā)生的,這可能導(dǎo)致并發(fā)訪問(wèn)底層的連接,使連接狀態(tài)不一致。如果使用Golang1.4或更高的版本,則應(yīng)確保在提交事務(wù)或回滾之前聲明始終關(guān)閉。點(diǎn)擊查看這個(gè)issues

參數(shù)占位符語(yǔ)法

預(yù)處理語(yǔ)句中的占位符參數(shù)的語(yǔ)法是特定于數(shù)據(jù)庫(kù)的。例如,比較MySql,PostgreSQL,Oracle:

MySQL               PostgreSQL            Oracle
=====               ==========            ======
WHERE col = ?       WHERE col = $1        WHERE col = :col
VALUES(?, ?, ?)     VALUES($1, $2, $3)    VALUES(:val1, :val2, :val3)

No.7 錯(cuò)誤處理

幾乎所有使用database/sql類型的操作都會(huì)返回一個(gè)錯(cuò)誤作為最后一個(gè)值。你應(yīng)該總是檢查這些錯(cuò)誤,千萬(wàn)不要忽視它們。有幾個(gè)地方錯(cuò)誤行為是特殊情況,還有一些額外的東西可能需要知道。

遍歷結(jié)果集的錯(cuò)誤

請(qǐng)思考下面的代碼:

for rows.Next() {
    // ...
}
if err = rows.Err(); err != nil {
    // handle the error here
}

來(lái)自rows.Err()的錯(cuò)誤可能是rows.Next()循環(huán)中各種錯(cuò)誤的結(jié)果。除了正常完成循環(huán)之外,循環(huán)可能會(huì)退出,因此你總是需要檢查循環(huán)是否正常終止。異常終止自動(dòng)調(diào)用rows.Close(),盡管多次調(diào)用它是無(wú)害的。

關(guān)閉結(jié)果集的錯(cuò)誤

如上所述,如果你過(guò)早的退出循環(huán),則應(yīng)該總是顯式的關(guān)閉sql.Rows。如果循環(huán)正常退出或通過(guò)錯(cuò)誤,它會(huì)自動(dòng)關(guān)閉,但你可能會(huì)錯(cuò)誤的執(zhí)行此操作:

for rows.Next() {
    // ...
    break; // whoops, rows is not closed! memory leak...
}
// do the usual "if err = rows.Err()" [omitted here]...
// it's always safe to [re?]close here:
if err = rows.Close(); err != nil {
    // but what should we do if there's an error?
    log.Println(err)
}

rows.Close()返回的錯(cuò)誤是一般規(guī)則的唯一例外,最好是捕獲并檢查所有數(shù)據(jù)庫(kù)操作中的錯(cuò)誤。如果rows.Close()返回錯(cuò)誤,那么你應(yīng)該怎么做。記錄錯(cuò)誤信息或panic可能是唯一明智的事情,如果這不明智,那么也許你應(yīng)該忽略錯(cuò)誤。

QueryRow()的錯(cuò)誤

思考下面的代碼來(lái)獲取一行數(shù)據(jù):

var name string
err = db.QueryRow("select name from users where id = ?", 1).Scan(&name)
if err != nil {
    log.Fatal(err)
}
fmt.Println(name)

如果沒(méi)有id = 1的用戶怎么辦?那么結(jié)果中不會(huì)有行,而.Scan()不會(huì)將值掃描到name中。那會(huì)怎么樣?

Golang定義了一個(gè)特殊的錯(cuò)誤常量,稱為sql.ErrNoRows,當(dāng)結(jié)果為空時(shí),它將從QueryRow()返回。這在大多數(shù)情況下需要作為特殊情況來(lái)處理。空的結(jié)果通常不被應(yīng)用程序代碼認(rèn)為是錯(cuò)誤的,如果不檢查錯(cuò)誤是不是這個(gè)特殊常量,那么會(huì)導(dǎo)致你意想不到的應(yīng)用程序代碼錯(cuò)誤。

來(lái)自查詢的錯(cuò)誤被推遲到調(diào)用Scan(),然后從中返回。上面的代碼可以更好地寫(xiě)成這樣:

var name string
err = db.QueryRow("select name from users where id = ?", 1).Scan(&name)
if err != nil {
    if err == sql.ErrNoRows {
        // there were no rows, but otherwise no error occurred
    } else {
        log.Fatal(err)
    }
}
fmt.Println(name)

有人可能會(huì)問(wèn)為什么一個(gè)空的結(jié)果集被認(rèn)為是一個(gè)錯(cuò)誤。空集沒(méi)有什么錯(cuò)誤。原因是QueryRow()方法需要使用這種特殊情況才能讓調(diào)用者區(qū)分是否QueryRow()實(shí)際上找到一行;沒(méi)有它,Scan(0不會(huì)做任何事情,你可能不會(huì)意識(shí)到你的變量畢竟沒(méi)有從數(shù)據(jù)庫(kù)中獲取任何值。

當(dāng)你使用QueryRow()時(shí),你應(yīng)該只會(huì)遇到此錯(cuò)誤。如果你在別處遇到這個(gè)錯(cuò)誤,你就做錯(cuò)了什么。

識(shí)別特定的數(shù)據(jù)庫(kù)錯(cuò)誤

像下面這樣編寫(xiě)代碼是很有誘惑力的:

rows, err := db.Query("SELECT someval FROM sometable")
// err contains:
// ERROR 1045 (28000): Access denied for user 'foo'@'::1' (using password: NO)
if strings.Contains(err.Error(), "Access denied") {
    // Handle the permission-denied error
}

這不是最好的方法。例如,字符串值可能會(huì)取決于服務(wù)器使用什么語(yǔ)言發(fā)送錯(cuò)誤消息。比較錯(cuò)誤編號(hào)以確定具體錯(cuò)誤是啥要好得多。

但是,驅(qū)動(dòng)的機(jī)制不同,因?yàn)檫@不是database/sql本身的一部分。在本教程重點(diǎn)介紹的MySql驅(qū)動(dòng)中,你可以編寫(xiě)以下代碼:

if driverErr, ok := err.(*mysql.MySQLError); ok { // Now the error number is accessible directly
    if driverErr.Number == 1045 {
        // Handle the permission-denied error
    }
}

再次,這里的MySQLError類型由此特定驅(qū)動(dòng)程序提供,并且驅(qū)動(dòng)程序之間的.Number字段可能不同。然而,該值是從MySql的錯(cuò)誤消息中提取的,因此是特定于數(shù)據(jù)庫(kù)的,而不是特定于驅(qū)動(dòng)的。

這段代碼還是很丑相對(duì)于1045,一個(gè)魔術(shù)數(shù)字是一種代碼氣味。一些驅(qū)動(dòng)(雖然不是MySql的驅(qū)動(dòng)程序,因?yàn)檫@里的主題的原因)提供錯(cuò)誤標(biāo)識(shí)符的列表。例如Postgres pg驅(qū)動(dòng)程序在error.go中。還有一個(gè)由VividCortex維護(hù)的MySql錯(cuò)誤號(hào)的外部包。使用這樣的列表,上面的代碼寫(xiě)的更漂亮:

if driverErr, ok := err.(*mysql.MySQLError); ok {
    if driverErr.Number == mysqlerr.ER_ACCESS_DENIED_ERROR {
        // Handle the permission-denied error
    }
}
處理連接錯(cuò)誤

如果與數(shù)據(jù)庫(kù)的連接被丟棄,殺死或發(fā)生錯(cuò)誤該怎么辦?

當(dāng)發(fā)生這種情況時(shí),你不需要實(shí)現(xiàn)任何邏輯來(lái)重試失敗的語(yǔ)句。作為database/sql連接池的一部分,處理失敗的連接是內(nèi)置的。如果你執(zhí)行查詢或其他語(yǔ)句,底層連接失敗,則Golang將重新打開(kāi)一個(gè)新的連接(或從連接池中獲取另一個(gè)連接),并重試10次。

然而,可能會(huì)產(chǎn)生一些意想不到的后果。當(dāng)某些類型錯(cuò)誤可能會(huì)發(fā)生其他錯(cuò)誤條件。這也可能是驅(qū)動(dòng)程序特定的。MySql驅(qū)動(dòng)程序發(fā)生的一個(gè)例子是使用KILL取消不需要的語(yǔ)句(例如長(zhǎng)時(shí)間運(yùn)行的查詢)會(huì)導(dǎo)致語(yǔ)句被重試10次。

No.8 使用空值

可以為空的字段是令人煩惱的,并導(dǎo)致很多丑陋的代碼。如果可以,避開(kāi)它們。如果沒(méi)有,那么你需要使用database/sql包中的特殊類型來(lái)處理它們,或者定義你自己的類型。

有可以空的布爾值,字符串,整數(shù)和浮點(diǎn)數(shù)的類型。下面是你使用它們的方法:

for rows.Next() {
    var s sql.NullString
    err := rows.Scan(&s)
    // check err
    if s.Valid {
       // use s.String
    } else {
       // NULL value
    }
}

可以空的類型的限制和避免的理由的情況下你需要更有說(shuō)服力的可以為空的列:

  1. 沒(méi)有sql.NullUint64或sql.NullYourFavoriteType。你需要為這個(gè)定義你自己的。

  2. 可空性可能會(huì)非常棘手,并不是未來(lái)的證明。如果你認(rèn)為某些內(nèi)容不會(huì)為空,但是你錯(cuò)了,你的程序?qū)?huì)崩潰,也許很少會(huì)發(fā)生錯(cuò)誤。

  3. Golang的好處之一是為每個(gè)變量設(shè)置一個(gè)有用的默認(rèn)零值。這不是空的工作方式。

如果你需要定義自己的類型來(lái)處理NULLS,則可以復(fù)制sql.NullString的設(shè)計(jì)來(lái)實(shí)現(xiàn)。

如果你不能避免在你的數(shù)據(jù)庫(kù)中具有空值,周圍有多數(shù)數(shù)據(jù)庫(kù)系統(tǒng)支持的另一項(xiàng)工作是COALESCE()。像下面這樣的東西可能是可以使用的,而不需要引入大量的sql.Null*類型

rows, err := db.Query(`
    SELECT
        name,
        COALESCE(other_field, '') as other_field
    WHERE id = ?
`, 42)

for rows.Next() {
    err := rows.Scan(&name, &otherField)
    // ..
    // If `other_field` was NULL, `otherField` is now an empty string. This works with other data types as well.
}

No.9 使用未知列

Scan()函數(shù)要求你準(zhǔn)確傳遞正確數(shù)目的目標(biāo)變量。如果你不知道查詢將返回什么呢?
如果你不知道查詢將返回多少列,則可以使用Columns()來(lái)查詢列名稱列表。你可以檢查此列表的長(zhǎng)度以查看有多少列,并且可以將切片傳遞給具有正確數(shù)值的Scan()。列如,MySql的某些fork為SHOW PROCESSLIST命令返回不同的列,因此你必須為此準(zhǔn)備好,否則將導(dǎo)致錯(cuò)誤,這是一種方法;還有其他的方法:

cols, err := rows.Columns()
if err != nil {
    // handle the error
} else {
    dest := []interface{}{ // Standard MySQL columns
        new(uint64), // id
        new(string), // host
        new(string), // user
        new(string), // db
        new(string), // command
        new(uint32), // time
        new(string), // state
        new(string), // info
    }
    if len(cols) == 11 {
        // Percona Server
    } else if len(cols) > 8 {
        // Handle this case
    }
    err = rows.Scan(dest...)
    // Work with the values in dest
}

如果你不知道這些列或者它們的類型,你應(yīng)該使用sql.RawBytes。

cols, err := rows.Columns() // Remember to check err afterwards
vals := make([]interface{}, len(cols))
for i, _ := range cols {
    vals[i] = new(sql.RawBytes)
}
for rows.Next() {
    err = rows.Scan(vals...)
    // Now you can check each element of vals for nil-ness,
    // and you can use type introspection and type assertions
    // to fetch the column into a typed variable.
}

No.10 連接池

database/sql包中有一個(gè)基本的連接池。沒(méi)有很多的控制或檢查能力,但這里有一些你可能會(huì)發(fā)現(xiàn)有用的知識(shí):
? 連接池意味著在單個(gè)數(shù)據(jù)庫(kù)上執(zhí)行兩個(gè)連續(xù)的語(yǔ)句可能會(huì)打開(kāi)兩個(gè)鏈接并單獨(dú)執(zhí)行它們。對(duì)于程序員來(lái)說(shuō),為什么它們的代碼行為不當(dāng),這是相當(dāng)普遍的。例如,后面跟著INSERT的LOCK TABLES可能會(huì)被阻塞,因?yàn)镮NSERT位于不具有表鎖定的連接上。

? 連接是在需要時(shí)創(chuàng)建的,池中沒(méi)有空閑連接。

? 默認(rèn)情況下,連接數(shù)量沒(méi)有限制。如果你嘗試同時(shí)執(zhí)行很多操作,可以創(chuàng)建任意數(shù)量的連接。這可能導(dǎo)致數(shù)據(jù)庫(kù)返回錯(cuò)誤,例如“連接太多”。

? 在Golang1.1或更新版本中,你可以使用db.SetMaxIdleConns(N)來(lái)限制池中的空閑連接數(shù)。這并不限制池的大小。

? 在Golang1.2.1或更新版本中,可以使用db.SetMaxOpenConns(N)來(lái)限制于數(shù)據(jù)庫(kù)的總打開(kāi)連接數(shù)。不幸的是,一個(gè)死鎖bug(修復(fù))阻止db.SetMaxOpenConns(N)在1.2中安全使用。

? 連接回收相當(dāng)快。使用db.SetMaxIdleConns(N)設(shè)置大量空閑連接可以減少此流失,并有助于保持連接以重新使用。

? 長(zhǎng)期保持連接空閑可能會(huì)導(dǎo)致問(wèn)題(例如在微軟azure上的這個(gè)問(wèn)題)。嘗試db.SetMaxIdleConns(0)如果你連接超時(shí),因?yàn)檫B接空閑時(shí)間太長(zhǎng)。

No.11 驚喜,反模式和限制

雖然database/sql很簡(jiǎn)單,但一旦你習(xí)慣了它,你可能會(huì)對(duì)它支持的用例的微妙之處感到驚訝。這是Golang的核心庫(kù)通用的。

資源枯竭

如本網(wǎng)站所述,如果你不按預(yù)期使用database/sql,你一定會(huì)為自己造成麻煩,通常是通過(guò)消耗一些資源或阻止它們被有效的重用:
? 打開(kāi)和關(guān)閉數(shù)據(jù)庫(kù)可能會(huì)導(dǎo)致資源耗盡。

? 沒(méi)有讀取所有行或使用rows.Close()保留來(lái)自池的連接。

? 對(duì)于不返回行的語(yǔ)句,使用Query()將從池中預(yù)留一個(gè)連接。

? 沒(méi)有意識(shí)到預(yù)處理語(yǔ)句如何工作會(huì)導(dǎo)致大量額外的數(shù)據(jù)庫(kù)活動(dòng)。

巨大的uint64值

這里有一個(gè)令人吃驚的錯(cuò)誤。如果設(shè)置了高位,就不能將大的無(wú)符號(hào)整數(shù)作為參數(shù)傳遞給語(yǔ)句:

_, err := db.Exec("INSERT INTO users(id) VALUES", math.MaxUint64) // Error

這將拋出一個(gè)錯(cuò)誤。如果你使用uint64值要小心,因?yàn)樗鼈兛赡荛_(kāi)始小而且無(wú)錯(cuò)誤的工作,但會(huì)隨著時(shí)間的推移而增加,并開(kāi)始拋出錯(cuò)誤。

連接狀態(tài)不匹配

有些事情可以改變連接狀態(tài),這可能導(dǎo)致的問(wèn)題有兩個(gè)原因:

  1. 某些連接狀態(tài),比如你是否處于事務(wù)中,應(yīng)該通過(guò)Golang類型來(lái)處理。

  2. 你可能假設(shè)你的查詢?cè)趩蝹€(gè)連接上運(yùn)行。

例如,使用USE語(yǔ)句設(shè)置當(dāng)前數(shù)據(jù)庫(kù)對(duì)于很多人來(lái)說(shuō)是一個(gè)典型的事情。但是在Golang中,它只會(huì)影響你運(yùn)行的連接。除非你處于事務(wù)中,否則你認(rèn)為在該連接上執(zhí)行的其他語(yǔ)句實(shí)際上可能在從池中獲取的不同的連接上運(yùn)行,因此它們不會(huì)看到這些更改的影響。

此外,在更改連接后,它將返回到池,并可能會(huì)污染其他代碼的狀態(tài)。這就是為什么你不應(yīng)該直接將BEGIN或COMMIT語(yǔ)句作為SQL命令發(fā)出的原因之一。

數(shù)據(jù)庫(kù)特定的語(yǔ)法

database/sql API提供了面向行的數(shù)據(jù)庫(kù)抽象,但是具體的數(shù)據(jù)庫(kù)和驅(qū)動(dòng)程序可能會(huì)在行為或語(yǔ)法上有差異,例如預(yù)處理語(yǔ)句占位符。

多個(gè)結(jié)果集

Golang驅(qū)動(dòng)程序不以任何方式支持單個(gè)查詢中的多個(gè)結(jié)果集,盡管有一個(gè)支持大容量操作(如批量復(fù)制)的功能請(qǐng)求似乎沒(méi)有任何計(jì)劃。

這意味著,除了別的以外,返回多個(gè)結(jié)果集的存儲(chǔ)過(guò)程將無(wú)法正常工作。

調(diào)用存儲(chǔ)過(guò)程

調(diào)用存儲(chǔ)過(guò)程是特定于驅(qū)動(dòng)程序的,但在MySql驅(qū)動(dòng)程序中,目前無(wú)法完成。看來(lái)你可以調(diào)用一個(gè)簡(jiǎn)單的過(guò)程來(lái)返回一個(gè)單一的結(jié)果集,通過(guò)執(zhí)行如下的操作:

err := db.QueryRow("CALL mydb.myprocedure").Scan(&result) // Error

事實(shí)上這行不通。你將收到以下錯(cuò)誤1312:PROCEDURE mydb.myprocedure無(wú)法返回給定上下文中的結(jié)果集。這是因?yàn)镸ySql希望將連接設(shè)置為多語(yǔ)句模式,即使單個(gè)結(jié)果,并且驅(qū)動(dòng)程序當(dāng)前沒(méi)有執(zhí)行此操作(盡管看到這個(gè)問(wèn)題)。

多個(gè)聲明支持

database/sql沒(méi)有顯式的擁有多個(gè)語(yǔ)句支持,這意味著這個(gè)行為是后端依賴的:

_, err := db.Exec("DELETE FROM tbl1; DELETE FROM tbl2") // Error/unpredictable result

服務(wù)器可以解釋它想要的,它可以包括返回的錯(cuò)誤,只執(zhí)行第一個(gè)語(yǔ)句,或執(zhí)行兩者。

同樣,在事務(wù)中沒(méi)有辦法批處理語(yǔ)句。事務(wù)中的每個(gè)語(yǔ)句必須連續(xù)執(zhí)行,并且結(jié)果中的資源(如行或行)必須被掃描或關(guān)閉,以便底層連接可供下一個(gè)語(yǔ)句使用。這與通常不在事務(wù)中工作時(shí)的行為不同。在這種情況下,完全可以執(zhí)行查詢,循環(huán)遍歷行,并在循環(huán)中對(duì)數(shù)據(jù)庫(kù)進(jìn)行查詢(這將發(fā)生在一個(gè)新的連接上):

rows, err := db.Query("select * from tbl1") // Uses connection 1
for rows.Next() {
    err = rows.Scan(&myvariable)
    // The following line will NOT use connection 1, which is already in-use
    db.Query("select * from tbl2 where id = ?", myvariable)
}

但是事務(wù)只綁定到一個(gè)連接,所以事務(wù)不可能做到這一點(diǎn):

tx, err := db.Begin()
rows, err := tx.Query("select * from tbl1") // Uses tx's connection
for rows.Next() {
    err = rows.Scan(&myvariable)
    // ERROR! tx's connection is already busy!
    tx.Query("select * from tbl2 where id = ?", myvariable)
}

不過(guò),Golang不會(huì)阻止你去嘗試。因此,如果你試圖在第一個(gè)釋放資源并自行清理之前嘗試執(zhí)行另一個(gè)語(yǔ)句,可能會(huì)導(dǎo)致一個(gè)損壞的連接。這也意味著事務(wù)中的每個(gè)語(yǔ)句都會(huì)產(chǎn)生一組單獨(dú)的網(wǎng)絡(luò)往返數(shù)據(jù)庫(kù)。

No.12 相關(guān)資料

以下是我們發(fā)現(xiàn)有幫助的一些外部信息來(lái)源。

? 官方database/sql源碼可能需要vpn打開(kāi)

? MySql驅(qū)動(dòng)作者jmoiron關(guān)于驅(qū)動(dòng)的說(shuō)明

? jmoiron內(nèi)置接口

? pregresql驅(qū)動(dòng)作者VividCortex博客透明加密

我們希望本教程是有幫助的。如果你有任何改進(jìn)意見(jiàn),請(qǐng)?jiān)?a target="_blank" rel="nofollow">https://github.com/VividCortex/go-database-sql-tutorial.database-sql-tutorial)的ISSUE中提出。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

推薦閱讀更多精彩內(nèi)容