Go Web 編程之 數據庫

概述

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

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

數據庫操做

準備工做

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

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文件保存到某個目錄,而後在該目錄打開命令行:golang

$ mysql -u root -p

複製代碼

輸入密碼鏈接到數據庫,而後輸入如下命令:web

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>
複製代碼

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

鏈接數據庫

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

$ go get github.com/go-sql-driver/mysql
複製代碼

使用:bash

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 發佈!

相關文章
相關標籤/搜索