从上一讲开始,我们介绍了 Go 语言中的持久化存储技巧。所谓的“持久化”,其本质就是文件。所以我们一开始就介绍了 os 和 io 包中的某些函数实现了文件的读写。
那么既然已经有了文件,为何还要提及数据库呢?
这是因为数据库是自带结构化属性的。就拿上一讲中的示例来说,简单的追加内容和输出全部内容,直接使用文件存取已经足够满足。但如果要想精准地查找某个人的留言,或者某个人想要修改自己的留言,显然是很棘手的。更重要的是,在现代网络应用中,这类查询、修改和删除的应用场景比比皆是。
本讲我将介绍如何使用 Go 语言进行数据库操作,选择的数据库引擎是较为流行的 MySQL。在讲解的过程中,还会介绍如何在源码中集成第三方支持库(Go SDK 中并未自带操作 MySQL 数据库的包)。
在正式开始之前,需要各位朋友对以下内容有个大致的了解。篇幅所限,本讲对以下内容不再过多展开阐述。
- 什么是关系型数据库;
- 什么是数据库和数据表,二者的关系又是什么;
- 表头、行、列、(主)键、值分别指什么;
- SQL 语句的使用,包括建表以及CRUD操作;
- 安装和配置 MySQL 8.0 社区版;
- 如何使用命令行或类似 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,如下图所示:
# 实现网络请求的响应
本例将响应用户的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 语言并发初探