Go Web 編程之 數據庫


概述

數據庫用來存儲數據。只要不是玩具項目,每個項目都需要用到數據庫。現在用的最多的還是 MySQL,PostgreSQL的使用也在快速增長中。
在 Web 開發中,數據庫也是必須的。本文將介紹如何在 Go 語言中操作數據庫,基於 MySQL。本文假定大家已經掌握了數據庫和 MySQL 的基礎知識。
關於 MySQL 有一個非常詳細的免費教程我放在參考中了,需要的自取。

Go 語言標准庫database/sql只是提供了一組查詢和操作數據庫的接口,沒有提供任何實現。在 Go 中操作數據庫只能使用第三方庫。
各種類型的數據庫都有對應的第三方庫。Go 中支持 MySQL 的驅動中最常見的是go-sql-driver/mysql
該庫支持database/sql,全部采用 go 實現。

數據庫操作

准備工作

創建一個數據庫department,表示公司中的某個部門。
在該庫中創建兩張表employeesteamsemployees記錄員工信息,teams記錄小組信息。
每個員工都屬於一個小組,每個小組都有若干名員工。

SET NAMES utf8mb4;

CREATE DATABASE IF NOT EXISTS `department`
CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;

USE `department`;

CREATE TABLE IF NOT EXISTS `employees` (
  `id` INT(11) AUTO_INCREMENT PRIMARY KEY,
  `name` VARCHAR(255) NOT NULL DEFAULT '',
  `age` INT(11) NOT NULL DEFAULT 0,
  `salary` INT(11) NOT NULL DEFAULT 0,
  `team_id` INT(11) NOT NULL DEFAULT 0
) ENGINE=InnoDB;

CREATE TABLE IF NOT EXISTS `teams` (
  `id` INT(11) AUTO_INCREMENT PRIMARY KEY,
  `name` VARCHAR(255) NOT NULL DEFAULT ''
) ENGINE=InnoDB;

INSERT INTO `teams`(`name`)
VALUES
  ('策划'),
  ('開發'),
  ('運營'),
  ('運維');

INSERT INTO `employees`(`name`, `age`, `salary`, `team_id`)
VALUES
  ('張三', 28, 1200, 1),
  ('李四', 38, 4000, 1),
  ('王五', 36, 3500, 1),
  ('趙六', 31, 3100, 2),
  ('田七', 29, 2900, 2),
  ('吳八', 27, 1500, 3),
  ('朱九', 26, 1600, 3),
  ('錢十', 27, 1800, 3),
  ('陶十一', 28, 1900, 4),
  ('汪十二', 25, 2000, 4),
  ('劍十三', 24, 30000, 4);

插入一些測試數據。將這個department.sql文件保存到某個目錄,然后在該目錄打開命令行:

$ mysql -u root -p

輸入密碼連接到數據庫,然后輸入以下命令:

mysql> source department.sql
Query OK, 0 rows affected (0.00 sec)

Query OK, 2 rows affected (0.02 sec)

Query OK, 1 row affected (0.00 sec)

Database changed
Query OK, 0 rows affected, 4 warnings (0.02 sec)

Query OK, 0 rows affected, 1 warning (0.02 sec)

Query OK, 4 rows affected (0.01 sec)
Records: 4  Duplicates: 0  Warnings: 0

Query OK, 11 rows affected (0.00 sec)
Records: 11  Duplicates: 0  Warnings: 0

mysql>

這樣數據庫和表就創建好了。

連接數據庫

go-sql-driver/mysql是第三方庫,需要安裝:

$ go get github.com/go-sql-driver/mysql

使用:

package main

import (
  "database/sql"
  "log"

  _ "github.com/go-sql-driver/mysql"
)

func main() {
  db, err := sql.Open("mysql", "root:12345@tcp(127.0.0.1:3306)/department")
  if err != nil {
    log.Fatal("connect database failed: ", err)
  }
  defer db.Close()
}

我們操作數據庫並不是直接使用mysql庫,而是通過database/sql的接口。

import _ "github.com/go-sql-driver/mysql"

上面代碼導入mysql,但並不直接使用,而是利用導入的副作用執行mysql庫的init函數,將mysql驅動注冊到database/sql中:

// go-sql-driver/mysql/driver.go
func init() {
  sql.Register("mysql", &MySQLDriver{})
}

然后在程序中使用sql.Open創建一個sql.DB結構,參數一即為mysql庫注冊的名字,參數二實際上就是指定數據庫連接信息的。
每個數據庫接受的連接信息是不同的。對於 MySQL 來說,連接信息實際上是一個 DSN (Data Source Name)。DSN 的一般格式為:

[username[:password]@][protocol[(address)]]/dbname[?param1=value1&...&paramN=valueN]

示例中使用的就是一個 DSN,指定用戶名為root,密碼為12345
通過 tcp 協議連接到 ip 為127.0.0.1,端口為 3306 的 MySQL 的department數據庫上。

在使用完成后,需要調用db.Close關閉sql.DB

需要特別注意的是,sql.Open並不會建立到數據庫的連接,它也不會檢測驅動的連接參數。它僅僅創建了一個數據庫抽象層給后面使用。
到數據庫的連接實際上會在需要的時候惰性地創建。
所以,我們使用一個非法的用戶名或密碼,連接一個主機上不存在的庫,sql.Open也不會報錯。
將上面的 DSN 改為user:password@tcp(127.0.0.1:6666)/not_exist_department,運行程序,沒有報錯。

如果想要檢測數據庫是否可訪問,可以使用db.Ping()函數:

err = db.Ping()
if err != nil {
  log.Fatal("ping failed: ", err)
}

這時連接not_exist_department會報錯:

2020/01/20 22:16:12 ping failed: Error 1049: Unknown database 'not_exist_department'
exit status 1

sql.DB對象一般作為某種形式的全局變量長期存活。不要頻繁打開、關閉該對象。這對性能會有非常大的影響。

查詢

先看一個簡單示例:

package main

import (
  "database/sql"
  "log"

  _ "github.com/go-sql-driver/mysql"
)

func main() {
  db, err := sql.Open("mysql", "root:12345@tcp(127.0.0.1:3306)/department")
  if err != nil {
    log.Fatal("open database failed: ", err)
  }
  defer db.Close()

  var id int
  var name string
  var age int
  var salary int
  var teamId int

  rows, err := db.Query("select id, name, age, salary, team_id from employees where id = ?", 1)
  if err != nil {
    log.Fatal("query failed: ", err)
  }
  defer rows.Close()

  for rows.Next() {
    err := rows.Scan(&id, &name, &age, &salary, &teamId)
    if err != nil {
      log.Fatal("scan failed: ", err)
    }
    log.Printf("id: %d name:%s age:%d salary:%d teamId:%d\n", id, name, age, salary, teamId)
  }

  err = rows.Err()
  if err != nil {
    log.Fatal(err)
  }
}

運行程序,輸出:

2020/01/20 22:27:21 id: 1 name:張三 age:28 salary:1200 teamId:1

從上面程序中,我們看到一個查詢操作的基本流程:

  • 使用db.Query()查詢數據庫;
  • 在循環中遍歷返回的行,rows.Scan()讀取各列的值,rows.Next()將“指針”移動到下一行;
  • 遍歷完所有行時,rows.Next()將返回 false,循環退出。

數據庫操作可能會遇到各種各樣的錯誤,所以錯誤處理很重要。例如,在循環中調用rows.Scan可能產生錯誤。

遍歷結束后,一定要關閉rows。因為它持有連接的指針,不關閉會造成資源泄露。rows.Next()遇到最后一行時會返回一個 EOF 錯誤,並關閉連接。
另外,如果rows.Next()由於產生錯誤返回 false,rows也會自動關閉。其它情況下,如果提前退出循環,可能會忘記關閉rows
所以一般使用defer rows.Close()確保正常關閉。

Tips:

調用Scan方法時,其內部會根據傳入的參數類型執行相應的數據類型轉換。利用這個特性可以簡化代碼。
例如,MySQL 中某一列是VARCHAR/CHAR或類似的文本類型,但是我們知道它保存的是一個整數。
那么就可以傳入一個int類型的變量,Scan內部會幫助我們將字符串轉為int。免除了我們手動調用strconv相關方法的麻煩。

database/sql中函數的命名特別講究:

  • Query*這種以Query開頭的函數,肯定返回若干行(可能為 0)數據;
  • 不返回行數據的語句,不能使用Query*函數,應該使用Exec

Prepare

當我們需要多次執行同一條語句時,最好的做法是先創建一個PreparedStatement。這個PreparedStatement可以包含參數占位符,后續執行時再提供參數。

每種數據庫都有自己參數占位符,MySQL 使用的是?。使用參數占位符有一個明顯的好處:能避免SQL 注入攻擊

需要執行 SQL 時,傳入參數調用PreparedStatementQuery方法即可:

func main() {
  db, err := sql.Open("mysql", "root:12345@tcp(127.0.0.1:3306)/department")
  if err != nil {
    log.Fatal("open failed: ", err)
  }
  defer db.Close()

  stmt, err := db.Prepare("select id, name, age, salary from employees where id = ?")
  if err != nil {
    log.Fatal("prepare failed: ", err)
  }
  defer stmt.Close()

  rows, err := stmt.Query(2)
  if err != nil {
    log.Fatal("query failed: ", err)
  }
  defer rows.Close()

  var (
    id int
    name string
    age int
    salary int
  )
  for rows.Next() {
    err := rows.Scan(&id, &name, &age, &salary)
    if err != nil {
      log.Fatal("scan failed: ", err)
    }
    log.Printf("id:%d name:%s age:%d salary:%d\n", id, name, age, salary)
  }

  err = rows.Err()
  if err != nil {
    log.Fatal(err)
  }
}

實際上,在db.Query()函數內部,會先創建一個PreparedStatement,執行它,然后關閉。這會與數據庫產生 3 次通信。所以盡量先創建PreparedStatement,再使用。

單行查詢

如果查詢最多只返回一行數據,我們不用寫循環處理,使用QueryRow可以簡化代碼編寫。

直接調用db.QueryRow

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

也可以在PreparedStatement上調用QueryRow

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

注意,QueryRow遇到的錯誤會延遲到調用Scan時才返回。

插入/修改/刪除

INSERT/UPDATE/DELETE這些操作,由於都不返回行,應該使用Exec函數。建議先創建PreparedStatement再執行。

現在“策划組”新加入了一名員工:

func main() {
  db, err := sql.Open("mysql", "root:12345@tcp(127.0.0.1:3306)/department")
  if err != nil {
    log.Fatal("open failed: ", err)
  }
  defer db.Close()

  stmt, err := db.Prepare("INSERT INTO employees(name, age, salary, team_id) VALUES(?,?,?,?)")
  if err != nil {
    log.Fatal("prepare failed: ", err)
  }
  defer stmt.Close()

  res, err := stmt.Exec("柳十四", 32, 5000, 1)
  if err != nil {
    log.Fatal("exec failed: ", err)
  }
  lastId, err := res.LastInsertId()
  if err != nil {
    log.Fatal("fetch last insert id failed: ", err)
  }
  rowCnt, err := res.RowsAffected()
  if err != nil {
    log.Fatal("fetch rows affected failed: ", err)
  }
  log.Printf("ID = %d, affected = %d\n", lastId, rowCnt)
}

Exec方法返回一個sql.Result接口類型的值:

// src/database/sql/sql.go
type Result interface {
  LastInsertId() (int64, error)
  RowsAffected() (int64, error)
}

有些表設置了自增的 id,插入時不需要設置 id,數據庫會自動生成一個返回。LastInsertId()返回插入時生成的 id。
RowsAffected()返回受影響的行數。

運行程序,輸出:

2020/01/21 07:20:26 ID = 12, affected = 1

事務

在 Go 中,事務本質上是一個對象,它持有一個到數據庫的連接。通過該對象執行我們上面介紹的方法時,
都會使用這個相同的連接。調用db.Begin()創建一個事務對象,然后在該對象上執行上面的方法,
最后成功調用Commit(),失敗調用Rollback()關閉事務。

func main() {
  db, err := sql.Open("mysql", "root:12345@tcp(127.0.0.1:3306)/department")
  if err != nil {
    log.Fatal("open failed: ", err)
  }
  defer db.Close()

  tx, err := db.Begin()
  if err != nil {
    log.Fatal("begin failed: ", err)
  }
  defer tx.Rollback()


  stmt, err := tx.Prepare("UPDATE employees SET team_id=? WHERE id=?")
  if err != nil {
    log.Fatal("prepare failed: ", err)
  }
  defer stmt.Close()

  _, err = stmt.Exec(2, 1)
  if err != nil {
    log.Fatal("exec failed: ", err)
  }

  tx.Commit()
}

注意,在事務內部不能再直接調用db的方法了,因為db使用的是與事務不同的連接,可能會導致執行結果的不一致。

錯誤處理

database/sql中幾乎所有的操作最后一個返回值都是一個error類型。數據庫會出現各種各樣的錯誤,我們應該時刻檢查是否出現了錯誤。下面介紹幾種特殊情況產生的錯誤。

遍歷結果集

for rows.Next() {
  // ...
}

if err = rows.Err(); err != nil {
}

``rows.Err()返回的錯誤可能是rows.Next()循環中的多種錯誤。循環可能由於某些原因提前退出了。我們應該檢測循環是否正常退出。 異常退出時,database/sql會自動調用rows.Close()。提前退出時,我們需要手動調用rows.Close()。**可以多次調用rows.Close()`**。

關閉結果集

實際上,rows.Close()也返回一個錯誤。但是,對於這個錯誤,我們能做的事情比較有限。通常就是記錄日志。
如果不需要記錄日志,通常會忽略這個錯誤。

QueryRow

考慮下面的代碼:

var name string
err = db.QueryRow("SELECT name FROM employees WHERE id = ?", 1).Scan(&name)
if err != nil {
  log.Fatal(err)
}
fmt.Println(name)

如果沒有id = 1的員工,Scan()要如何處理?

Go 定義了一個特殊的錯誤常量,sql.ErrNoRows。如果沒有符合要求的行,QueryRow將返回這個錯誤。
這個錯誤在大多數情況下需要特殊處理,因為沒有結果在應用層通常不認為是錯誤。

var name string
err = db.QueryRow("SELECT name FROM employees WHERE id = ?", 1).Scan(&name)
if err != nil {
  if err == sql.ErrNoRows {
  } else {
	log.Fatal(err)
  }
}
fmt.Println(name)

那為什么QueryRow在沒有符合要求的行時返回一個錯誤?

因為要區分是否返回了行,如果返回空結果集,由於Scan()不會做任何時間,我們就不能區分name讀取到了空字符串,還是初始值。

特定的數據庫錯誤

為了辨別發生了何種錯誤,有一種做法是檢查錯誤描述中是否有特定的文本:

rows, err := db.Query("SELECT someval FROM sometable")
if err != nil {
  if strings.Contains(err.Error(), "Access denied") {
  }
}

但是不推薦這種做法,因為不同的數據庫版本,這些描述不一定能保持一致。

比較好的做法是將錯誤轉成特定數據庫驅動的錯誤,然后比較錯誤碼:

if driverErr, ok := err.(*mysql.MySQLError); ok {
  if driverErr.Number == 1045 {
  }
}

不同驅動間判斷方法可能不同。另外,直接寫數字1045也不太好,VividCortex 整理了 MySQL 錯誤碼,GitHub 倉庫為mysqlerr。使用庫后續便於修改:

if driverErr, ok := err.(*mysql.MySQLError); ok {
  if driverErr.Number == mysqlerr.ER_ACCESS_DENIED_ERROR {
  }
}

處理未知列

有時候,可能我們不能確定查詢返回多少列。但是Scan()要求傳入正確數量的參數。為此,我們可以先使用rows.Columns()返回所有列名,然后創建同樣大小的字符串指針切片傳給Scan()函數:

func main() {
  db, err := sql.Open("mysql", "root:12345@tcp(127.0.0.1:3306)/department")
  if err != nil {
    log.Fatal("open failed: ", err)
  }
  defer db.Close()

  stmt, err := db.Prepare("SELECT * FROM employees")
  if err != nil {
    log.Fatal("prepare failed: ", err)
  }
  defer stmt.Close()

  rows, err := stmt.Query()
  if err != nil {
    log.Fatal("exec failed: ", err)
  }
  defer rows.Close()

  cols, err := rows.Columns()
  if err != nil {
    log.Fatal("columns failed: ", err)
  }

  data := make([]interface{}, len(cols), len(cols))
  for i := range data {
    data[i] = new(string)
  }

  for rows.Next() {
    err = rows.Scan(data...)
    if err != nil {
      log.Fatal("scan failed: ", err)
    }

    for i := 0; i < len(cols); i++ {
      fmt.Printf("%s: %s ", cols[i], *(data[i].(*string)))
    }
    fmt.Println()
  }

  if err = rows.Err(); err != nil {
    log.Fatal(err)
  }
}

運行程序:

id: 1 name: 張三 age: 28 salary: 1200 team_id: 2 
id: 2 name: 李四 age: 38 salary: 4000 team_id: 1
id: 3 name: 王五 age: 36 salary: 3500 team_id: 1
id: 4 name: 趙六 age: 31 salary: 3100 team_id: 2
id: 5 name: 田七 age: 29 salary: 2900 team_id: 2 
id: 6 name: 吳八 age: 27 salary: 1500 team_id: 3
id: 7 name: 朱九 age: 26 salary: 1600 team_id: 3
id: 8 name: 錢十 age: 27 salary: 1800 team_id: 3
id: 9 name: 陶十一 age: 28 salary: 1900 team_id: 4
id: 10 name: 汪十二 age: 25 salary: 2000 team_id: 4
id: 11 name: 劍十三 age: 24 salary: 30000 team_id: 4
id: 12 name: 柳十四 age: 32 salary: 5000 team_id: 1

連接池

database/sql實現了一個基本的連接池。連接池有一些有趣的特性,了解一下,避免踩坑:

  • 對同一個數據庫連續執行兩個語句,這兩個語句可能在不同的數據庫連接上進行的。結果可能讓人誤解。例如先LOCK TABLES,然后執行INSERT可能會阻塞;
  • 需要新的連接且池中沒有空閑連接時,創建一個新連接;
  • 默認,連接數沒有限制。如果同時執行很多操作,可能會同時創建很多連接。數據庫可能出現too many connections錯誤;
  • 調用db.SetMaxIdleConns(N)限制池中最大空閑連接數;db.SetMaxOpenConns(N)限制所有打開的連接數;
  • 一個連接很長時間不使用可能會出現問題,如果遇到連接超時,可以試試將最大空閑連接數設置為 0;
  • 重用長時間存活的連接可能會導致網絡問題,可以調用db.SetConnMaxLifeTime(duration)設置連接最大存活時間。

總結

本文介紹了如何在 Go 中查詢和修改數據庫,主要是database/sqlgo-sql-driver/mysql庫的用法。database/sql的接口並不復雜,但是很多細節需要注意。一不留神可能就有資源泄露。

參考

  1. MySQL 教程,非常詳細的教程
  2. Go database/sql 教程
  3. Build Web Application with Golang

我的博客

歡迎關注我的微信公眾號【GoUpUp】,共同學習,一起進步~

本文由博客一文多發平台 OpenWrite 發布!


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM