从上一讲开始,我们介绍了 Go 语言中的持久化存储技巧。所谓的“持久化”,其本质就是文件。所以我们一开始就介绍了 os 和 io 包中的某些函数实现了文件的读写。

那么既然已经有了文件,为何还要提及数据库呢?

这是因为数据库是自带结构化属性的。就拿上一讲中的示例来说,简单的追加内容和输出全部内容,直接使用文件存取已经足够满足。但如果要想精准地查找某个人的留言,或者某个人想要修改自己的留言,显然是很棘手的。更重要的是,在现代网络应用中,这类查询、修改和删除的应用场景比比皆是。

本讲我将介绍如何使用 Go 语言进行数据库操作,选择的数据库引擎是较为流行的 MySQL。在讲解的过程中,还会介绍如何在源码中集成第三方支持库(Go SDK 中并未自带操作 MySQL 数据库的包)。

在正式开始之前,需要各位朋友对以下内容有个大致的了解。篇幅所限,本讲对以下内容不再过多展开阐述。

  1. 什么是关系型数据库
  2. 什么是数据库和数据表,二者的关系又是什么;
  3. 表头(主)键分别指什么;
  4. SQL 语句的使用,包括建表以及CRUD操作
  5. 安装和配置 MySQL 8.0 社区版;
  6. 如何使用命令行或类似 MySQL Workbench 软件查看数据库表结构和表数据。

本讲将手把手教大家完成一个在线记事本服务端应用程序,使用 MySQL 8.0 社区版作为数据库引擎。连接数据库的用户名是 root,密码是 123456,数据库名称为 go_learn。

源码地址位于gitee.com/wh1990xiao2… (opens new window)

# 集成包

现在开始动手!

前文已经说到,Go SDK中并未附带连接数据库的包,因此需要我们自己去找合适的包来使用。幸运的是,我们很快就能找到相应的包:go-sql-driver/mysql (opens new window)

💡 提示:还记得去哪里搜索源码包吗?当然是Go语言官方提供的package库首页:https://pkg.go.dev/ ,只需在搜索框中输入 mysql,就会看到很多搜索结果。通过查看包详情,可以得知包的作用、发布时间、使用人数、使用方法等等,由此便可做出选择。

找到合适的包后,使用命令行将该包集成到项目中。创建一个工程,名为 go-juejin-note-book-server。启动 GoLand 中的 Terminal 视图,使用 go get 命令集成库:

shell
复制代码go get -u github.com/go-sql-driver/mysql

在执行这条命令的时候,很有可能会受到网络错误的提示。解决办法很简单,只需将获取包的 GOPROXY 环境变量指向国内镜像源即可,具体如下:

shell
复制代码$ go env -w GO111MODULE=on
$ go env -w GOPROXY=https://goproxy.cn,direct

其实,国内的镜像源网站不止一个,阿里云同样也支持(阿里云 Go Module代理服务 (opens new window))。在学习本讲内容时,若刚好上述镜像源均已失效,您还可以自行查找,完全不用担心。

集成成功后,再次回到 GoLand,打开 Project 视图,然后打开 External Libraries,可以在其中找到github.com/go-sql-driver/mysql,如下图所示:

image-20220412144953560.png

# 实现网络请求的响应

本例将响应用户的5类请求,对应5个接口地址,具体如下:

  • 根路径(/):接受 POST/GET 请求,均返回欢迎信息和接口调用地址的含义;
  • 添加一条记事本数据(/add):仅接受 POST 请求,传入标题、正文和日期时间,添加成功后返回添加的标题;
  • 根据 id 删除一条记事本数据(/delete):接受 POST/GET 请求,传入 id,删除该 id 所属的数据,删除成功后返回成功结果;
  • 根据id更新一条记事本数据(/update):接受 POST/GET 请求,传入 id 和新的标题、正文和日期时间,用新传入的数据覆盖已有的数据,更新成功后返回成功结果;
  • 查询记事本数据,可传入 id 进行精准查找(/query):接受 POST/GET 请求,支持根据 id 进行单条数据的查找或查找全部数据,执行成功后返回查询结果集。

有关这部分的实现不是本讲的重点,如果您对这部分有疑问,请回看学习包 三 | 实现一个服务器软件 (opens new window)。以下是完整的代码:

go
复制代码package main
import (
   "fmt"
   "net/http"
   "time"
)
type notebook struct {
   Id       int
   Title    string `json:"title"`
   Content  string `json:"content"`
   DateTime string `json:"dateTime"`
}
func main() {
   launchServer()
}
// 添加数据到数据库
func add(data notebook) {
}
// 删除一条数据
func del(id string) {
}
// 更新数据到数据库
func update(id string, data notebook) {
}
// 从数据库获取数据
func query(id string) []notebook {
   var notebooks []notebook
   return notebooks
}
// 启动服务器
func launchServer() {
   //响应/
   http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
      fmt.Fprintf(w, "欢迎使用在线记事本\n")
      fmt.Fprintf(w, "▶▶ /add 添加新的数据\n")
      fmt.Fprintf(w, "▶▶ /delete 根据ID删除数据\n")
      fmt.Fprintf(w, "▶▶ /update 根据ID更新数据\n")
      fmt.Fprintf(w, "▶▶ /query 获取全部数据或根据ID获取单条数据\n")
   })
   //响应/add,从传入的参数新增一条记事本
   http.HandleFunc("/add", func(w http.ResponseWriter, r *http.Request) {
      if r.Method == "POST" {
         err := r.ParseForm()
         if err != nil {
            fmt.Fprintln(w, "错误的请求")
         } else {
            title := r.FormValue("title")
            content := r.FormValue("content")
            dateTime := r.FormValue("dateTime")
            add(notebook{Title: title, Content: content, DateTime: dateTime})
            fmt.Fprintln(w, "添加了:"+title)
         }
      }
   })
   //响应/delete,从传入的参数删除一条记事本
   http.HandleFunc("/delete", func(w http.ResponseWriter, r *http.Request) {
      err := r.ParseForm()
      if err != nil {
         fmt.Fprintln(w, "错误的请求")
      } else {
         id := r.FormValue("id")
         del(id)
         fmt.Fprintln(w, "删除成功")
      }
   })
   //响应/update,更新一条数据
   http.HandleFunc("/update", func(w http.ResponseWriter, r *http.Request) {
      err := r.ParseForm()
      if err != nil {
         fmt.Fprintln(w, "错误的请求")
      } else {
         id := r.FormValue("id")
         title := r.FormValue("title")
         content := r.FormValue("content")
         dateTime := r.FormValue("dateTime")
         update(id, notebook{Title: title, Content: content, DateTime: dateTime})
         fmt.Fprintln(w, "更新成功")
      }
   })
   //响应/query,从传入的参数删除一条记事本
   http.HandleFunc("/query", func(w http.ResponseWriter, r *http.Request) {
      err := r.ParseForm()
      if err != nil {
         fmt.Fprintln(w, "错误的请求")
      } else {
         id := r.FormValue("id")
         fmt.Fprintln(w, query(id))
      }
   })
   //启动本地服务器(localhost)
   err := http.ListenAndServe(":80", nil)
   if err != nil {
      fmt.Println("启动服务失败,错误信息:", err)
   }
}

显然,这段代码中的 add()、del()、update() 和 query() 函数就是真正操作数据库的函数了!不过别急,如果要进行数据库的CRUD操作,首先要做的是要成功地连接到数据库,检查并创建相应的数据表,最后才是针对表的数据操作。当然,这一切的操作均要使用 mysql 包中的函数。

# 建立连接

根据 mysql 包的说明 (opens new window),首先需要import。具体如下:

go
复制代码import (
   "database/sql"

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

请大家留意,这里在 import 时,使用了下划线(“”)开头。**“”是一个特殊标识符,它表示仅执行包内的init()函数,不做其它之用。**

接着,创建一个名为 connectToDb() 的函数,用来与 MySQL 数据库建立连接,并返回 *sql.DB 类型值。在后续的建表以及 CRUD 操作时会频繁用到这个值。

看到这,有些朋友或许会感到好奇:“才刚到连接的步骤,你怎么知道后续的步骤该用什么呢?”实际上,在编码之前,如果我们使用了其它的包(在实际开发中,这是非常常见的场景),务必要先了解这个包的用法。而要了解某个包的用法,最靠谱的办法就是看官方文档。

mysql 包的官方文档就给出了有关建表和 CRUD 操作的具体示例:Examples · go-sql-driver/mysql Wiki (github.com) (opens new window)。从中我们便可得知,建立连接后返回的 *sql.DB 类型值将频繁用于后续步骤。

根据官方指导文档中所述的内容,建立连接的函数是 sql.Open(),其中需要传入数据库连接凭据。此外,我们还可以根据实际情况设置最大连接数等参数。具体代码如下:

go
复制代码var db *sql.DB
// 连接到数据库
func connectToDb() *sql.DB {
   db, _ := sql.Open("mysql", "root:123456@/go_learn")
   // 设置可重用链接的最长时间(0为不限制)
   db.SetConnMaxLifetime(time.Hour * 1)
   // 设置连接到数据库的最大数量(默认值为0,即不限制)
   db.SetMaxOpenConns(5)
   // 设置空闲连接的最大数量(默认值为2)
   db.SetMaxIdleConns(5)
   fmt.Println("连接成功!!")
   return db
}

执行 sql.Open() 函数后,它将返回两个参数:一个是 *sql.DB 类型值,我还声明了一个全局变量,以便后续使用;另一个则是包含错误信息的 error 类型值,上述代码忽略了针对连接错误的处理。

# 创建数据表

一旦数据库连接成功,就可以检查所需的数据表是否存在了,如果不存在则创建。

对于本例中的记事本应用,需要创建的数据表头为自增长的 id、标题(title)、内容(content)和时间(dateTime)。除了 id 是数值型外,其它均为字符串类型。

mysql 包提供了 db.Exec() 方法,用来执行 SQL 语句,它的方法定义格式如下:

go
复制代码func (db *DB) Exec(query string, args ...interface{}) (Result, error)

在创建数据表时,需要传入的参数则是建表的 SQL 语句。因此,整个创建数据表的函数实现如下:

go
复制代码// 创建数据表
func createTable() {
   db.Exec("CREATE TABLE IF NOT EXISTS `notebook` (" +
      "`id` bigint(20) NOT NULL AUTO_INCREMENT," +
      "`title` varchar(45) DEFAULT ''," +
      "`content` varchar(45) DEFAULT ''," +
      "`dateTime` varchar(45) DEFAULT ''," +
      "PRIMARY KEY (`id`)" +
      ") ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;")
   fmt.Println("表存在或成功创建")
}

这段 SQL 语句将检查名为 notebook 的数据表是否存在,如果不存在则执行建表操作。表头信息和值类型均描述得非常清晰。

❗️ 注意:考虑到数据量的增长,id 列的数据类型设置为 bigint 是较为妥善的处理方式。int 的取值范围受限于 4 个字节,bigint 则是 8 个字节。更大的范围意味着该数据表能容纳更多的数据,除非十分确定数据量的大小,否则建议大家将id列的类型设置为 bigint。

数据库的连接和表的创建可以看作是完成本例的“准备工作”,完成了准备工作后,用户的请求才有可能得到正确的响应,main() 函数也可以随之得到完善了:

go
复制代码func main() {
   db = connectToDb()
   createTable()
   launchServer()
}

main() 函数的函数体首先执行了数据库的连接,然后进行了数据表的检查,准备工作完成。最后执行 launchServer() 函数,启动 Http 服务。

# 数据的增、删、改操作

对于 mysql 包而言,实现数据的增、删、改都遵循相同的模式:都是先执行 db.Prepare() 方法,将完整的 SQL 语句作为参数传入。再通过该函数的返回值调用 Exec() 方法,将对应的值依次传入 ,即可完成整个过程。

db.Prepare() 和 Stmt.Exet() 的定义格式如下:

go
复制代码func (db *DB) Prepare(query string) (*Stmt, error)
func (s *Stmt) Exec(args ...interface{}) (Result, error)

以增加一条数据(add()函数)为例,具体代码片段如下:

go
复制代码// 添加数据到数据库
func add(data notebook) {
   stmtInsert, _ := db.Prepare("INSERT INTO notebook SET title=?,content=?,dateTime=?")
   res, _ := stmtInsert.Exec(data.Title, data.Content, data.DateTime)
   idValue, _ := res.LastInsertId()
   fmt.Printf("添加了id值为%d的数据\n", idValue)
}

请大家观察 db.Prepare() 方法和 stmtInsert.Exec() 方法中的对应关系,前者中每个问号(“?”)对应后者中的一个参数值。至于具体是如何匹配的,我们便无需关心了。

res 变量是执行 SQL 语句后的结果,它同样是 Result 类型的。该类型提供了两个方法:LastInsertId() 和 RowsAffected(),前者表示最新一条数据新增的 id 值,后者表示执行完 SQL 语句后受影响的行数。 在 add() 函数中,调用了 LastInsertId() 方法,返回被添加数据的 id 值。

接下来,依葫芦画瓢,实现删除一条数据(del())和更新一条数据(update())函数。具体代码如下:

go
复制代码// 删除一条数据
func del(id string) {
   stmtDelete, _ := db.Prepare("DELETE FROM notebook WHERE id=?")
   res, _ := stmtDelete.Exec(id)
   rawsCount, _ := res.RowsAffected()
   fmt.Printf("删除了%d条数据\n", rawsCount)
}

// 更新数据到数据库
func update(id string, data notebook) {
   stmtInsert, _ := db.Prepare("UPDATE notebook SET title=?, content=?, dateTime=? WHERE id=?")
   res, _ := stmtInsert.Exec(data.Title, data.Content, data.DateTime, id)
   rawsCount, _ := res.RowsAffected()
   fmt.Printf("更新了%d条数据\n", rawsCount)
}

如上代码所示,del() 和 update() 函数均向控制台输出了受影响的行数。由于删除和更新是通过 id 来检索的,因此受影响的行数应为 1。

# 数据的查询操作

在 mysql 包中有一个专门用于数据查询的方法,它就是db.Query()。该方法的定义格式如下:

go
复制代码func (db *DB) Query(query string, args ...interface{}) (*Rows, error) 

在执行查询时,需要将 SQL 语句传入其中,通过遍历 *Rows 类型值来获取查询结果。

本例中,要求服务端根据请求参数响应两种结果。当存在id参数时,进行 id 列条件查找;反之则返回全部数据。具体实现如下:

go
复制代码// 从数据库获取数据
func query(id string) []notebook {
   var notebooks []notebook
   var rows *sql.Rows
   if id == "" {
      rows, _ = db.Query("SELECT * FROM notebook")
   } else {
      rows, _ = db.Query("SELECT * FROM notebook WHERE id=" + id)
   }
   for rows.Next() {
      var singleNote notebook
      rows.Scan(&singleNote.Id, &singleNote.Title, &singleNote.Content, &singleNote.DateTime)
      notebooks = append(notebooks, singleNote)
   }
   return notebooks
}

此外,当仅需要检索第一条与查找条件相匹配的数据时,还可调用 db.QueryRow(),该函数返回 *Row 类型,表示单条数据。

# 总结

🎉 恭喜,您完成了本次课程的学习!

📌 以下是本次课程的重点内容总结:

  • 集成包;
  • Go 语言操作 MySQL 数据库。

在实际开发中,集成包是非常常用的技能。使用包可以简化开发成本,在一定程度上减少产品缺陷。另一方面,对于某些能力的支持,只能使用特定的包才能实现。因此,集成包是每个 Go 语言开发者都必须要掌握的能力。

一旦学会了包的集成方法,很多看似毫无思路或很难实现的需求便迎刃而解了,操作 MySQL 数据库便是其中之一。想象一下,如果没有mysql 包,要我们自己实现一套与数据库引擎通信的代码该有多难。

当然,除了 MySQL 数据库外,还有 SQL Server 等传统数据库,还有 Redis 等非关系型数据库。不过无需担心,对于这些数据库的支持包都可以在 Go 语言官方的 Package 网站上找到。

最后,非常建议大家去 Go 官方的 Package 网站上浏览一番。很快便会发现,除了数据库外,还有很多已经打包好的可供使用的包。当我们日后开发时,不妨先搜索一下,或许已有现成的方案,直接集成即可。

好了,本讲就到这里。

➡️ 在下次课程中,我们会介绍Go语言中的并发,具体内容是:

  • Go 语言并发初探