本章主要内容
本书在第2章引入了数据持久化这一概念,并简单地介绍了如何将数据持久化到PostgreSQL这个关系数据库中。本章将会继续深入讨论数据持久化这一主题,并说明如何才能将数据存储到内存、文件、关系数据库以及NoSQL数据库中。
尽管数据持久化从技术上来说并不属于Web应用编程的范畴,但因为绝大部分Web应用都会以某种形式存储数据,所以数据持久化是除了模板和处理器这两大支柱之外,任何Web应用都必不可少的第三大支柱。
Web应用通常会采取以下手段存储数据:
在本章中,我们将会分别通过以上这3种手段,使用Go对数据进行访问,并对数据执行俗称CRUD的创建、获取、更新和删除这4个操作。
本节所说的内存存储指的是将数据存储在运行中的应用里面,并在应用运行的过程中使用这些数据,而不是说将数据存储到内存数据库里面。将数据存储在数据结构里面是实现内存存储的常见手段,对于Go语言来说,这意味着使用数组、切片、映射和结构来存储数据。
存储数据这一操作本身是非常简单的,用户只需要创建相应的结构、切片和映射就可以了。但如果我们更加深入地思考这个问题就会发现,程序最终操作的将不是一个个单独的结构,而是一系列由容器 (container)包裹的多个结构:这些容器既可以是数组、切片和映射,也可以是栈、树、队列以及其他任意类型的数据结构。
除容器本身之外,如何从容器里面获取所需的数据也是一个非常有趣的问题。比如说,代码清单6-1就展示了一个使用映射作为结构容器的例子。
代码清单6-1 在内存里面存储数据
package main
import (
"fmt"
)
type Post struct {
Id int
Content string
Author string
}
var PostById map[int]*Post
var PostsByAuthor map[string][]*Post
func store(post Post) {
PostById[post.Id] = &post
PostsByAuthor[post.Author] = append(PostsByAuthor[post.Author], &post)
}
func main() {
PostById = make(map[int]*Post)
PostsByAuthor = make(map[string][]*Post)
post1 := Post{Id: 1, Content: "Hello World!", Author: "Sau Sheong"}
post2 := Post{Id: 2, Content: "Bonjour Monde!", Author: "Pierre"}
post3 := Post{Id: 3, Content: "Hola Mundo!", Author: "Pedro"}
post4 := Post{Id: 4, Content: "Greetings Earthlings!", Author:
➥"Sau Sheong"}
store(post1)
store(post2)
store(post3)
store(post4)
fmt.Println(PostById[1])
fmt.Println(PostById[2])
for _, post := range PostsByAuthor["Sau Sheong"] {
fmt.Println(post)
}
for _, post := range PostsByAuthor["Pedro"] {
fmt.Println(post)
}
}
这个程序会使用Post
结构来表示论坛应用中的帖子,并将该结构存储在内存里面:
type Post struct {
Id int
Content string
Author string
}
Post
结构中最主要的数据是帖子的内容,用户也可以通过帖子的唯一ID或者帖子作者的名字来获取帖子。程序会通过将一个代表帖子的键映射至实际的Post
结构来存储多个帖子。为了提供两种不同的方法来访问帖子,程序分别使用了两个map
来创建两种不同的映射:
var PostById map[int]*Post
var PostsByAuthor map[string][]*Post
程序使用了两个变量来存储映射,其中PostById
变量会将帖子的唯一ID映射至指向帖子的指针,而PostsByAuthor
变量则会将作者的名字映射至一个切片,这个切片可以包含多个指向帖子的指针。注意,无论是PostById
还是PostsByAuthor
,它们映射的都是指向帖子的指针而不是帖子本身。这样做可以确保程序无论是通过ID还是通过作者的名字来获取帖子,得到的都是相同的帖子,而不是同一帖子的不同副本。
在此之后,程序定义了用于存储帖子的store
函数:
func store(post Post) {
PostById[post.Id] = &post
PostsByAuthor[post.Author] = append(PostsByAuthor[post.Author], &post)
}
store
函数会将一个指向帖子的指针分别存储到PostById
变量和PostsByAuthor
变量里面。紧接着,在main()
函数里面,程序创建了多个将要被存储的帖子,这个过程唯一要做的就是创建多个Post
结构的实例:
post1 := Post{Id: 1, Content: "Hello World!", Author: "Sau Sheong"}
post2 := Post{Id: 2, Content: "Bonjour Monde!", Author: "Pierre"}
post3 := Post{Id: 3, Content: "Hola Mundo!", Author: "Pedro"}
post4 := Post{Id: 4, Content: "Greetings Earthlings!", Author: "Sau Sheong"}
接着程序会调用前面定义的store
函数,把这些帖子一一存储起来:
store(post1)
store(post2)
store(post3)
store(post4)
如果运行这个程序,我们将会看到以下输出:
&{1 Hello World! Sau Sheong}
&{2 Bonjour Monde! Pierre}
&{1 Hello World! Sau Sheong}
&{4 Greetings Earthlings! Sau Sheong}
&{3 Hola Mundo! Pedro}
注意,无论程序是通过帖子的ID还是帖子的作者获取帖子,最终得到的都是同一个帖子。
这个例子看上去非常简单直接,甚至可以说有点儿简单过头了。我们之所以要学习怎样将数据存储在内存里面,是因为人们在构建Web应用的时候,常常会像第2章展示的那样,从一开始就使用关系数据库,然后在进行性能扩展的时候,才认识到自己需要将数据库返回的结果缓存起来以提高性能。正如本章接下来要介绍的内容所示,对数据进行持久化的绝大部分手段都会以这样或那样的形式使用结构,在学完本节介绍的方法之后,我们就可以在进行性能扩展的时候,通过重构代码来将缓存数据存储在内存里面,而不一定非得要使用类似Redis那样的外部内存数据库。
因为将数据存储到结构里面对数据存储操作是一种非常重要的重现手段,所以本章以及后续章节还会继续提及这一技术。
因为内存存储不需要访问硬盘,所以相关操作通常都会以风驰电掣般的速度完成。但内存存储有一个不容忽视的缺点,那就是,存储在内存中的数据并不是持久化的。如果你的计算机或者程序可以永远也不关闭,又或者你的数据像缓存一样即使丢失了也无所谓,那么这个缺点对你来说是无伤大雅的;但很多时候,即使是对于缓存数据来说,我们还是希望数据可以在计算机关闭或者程序关闭之后继续存在。实现数据持久化有好几种不同的方式可选,其中最常见的莫过于将数据存储到诸如硬盘或者闪存这样的非易失存储器里面。
把数据存储到非易失存储器里面同样也有多种方法可选,而本节要介绍的是把数据存储到文件系统里面的相关技术。说得更具体一点,我们将要学习的是如何通过 Go语言以两种不同的方式将数据存储到文件里面:第一种方式需要用到通用的CSV(comma-separated value,逗号分隔值)文本格式,而第二种方法则需要用到Go语言特有的gob
包。
CSV是一种常见的文件格式,用户可以通过这种格式向系统传递数据。当你需要用户提供大量数据,但是却因为某些原因而无法让用户把数据填入你提供的表单时,CSV格式就可以派上用场了:你只需要让用户使用电子表格程序(spreadsheet)输入所有数据,然后将这些数据导出为CSV文件,并将其上传到你的Web应用中,这样就可以在获得CSV文件之后,根据自己的需要对数据进行解码。同样地,你的Web应用也可以将用户的数据打包成CSV文件,然后通过向用户发送CSV文件来为他们提供数据。
gob是一种能够存储在文件里面的二进制格式,这种格式可以快速且高效地将内存中的数据序列化到一个或多个文件里面。二进制数据文件的用途非常多,比如,在进行数据备份以及有序关机 [1] 的时候,程序就可以使用二进制数据文件来快速地存储程序中的结构。正如缓存机制对应用程序来说非常有用一样,能够将数据暂时存储在文件里面,并在需要的时候读取这些数据,对于实现会话、购物车以及构建临时工作空间(workspace)也是非常有用的。
代码清单6-2展示了打开一个文件并对其进行写入的具体方法,在讨论CSV文件和gob二进制文件的过程中,类似的代码将会反复出现。
代码清单6-2 对文件进行读写
package main
import (
"fmt"
"io/ioutil"
"os"
)
func main() {
data := []byte("Hello World!\n")
err := ioutil.WriteFile("data1", data, 0644) ❶
if err != nil {
panic(err)
}
read1, _ := ioutil.ReadFile("data1")
fmt.Print(string(read1))
file1, _ := os.Create("data2") ❷
defer file1.Close()
bytes, _ := file1.Write(data)
fmt.Printf("Wrote %d bytes to file\n", bytes)
file2, _ := os.Open("data2")
defer file2.Close()
read2 := make([]byte, len(data))
bytes, _ = file2.Read(read2)
fmt.Printf("Read %d bytes from file\n", bytes)
fmt.Println(string(read2))
}
❶ 通过WriteFile 函数和ReadFile 函数对文件进行写入和读取
❷ 通过File 结构对文件进行写入和读取
为了减少需要展示的代码,代码清单6-2中的程序使用了空白标识符来省略各个函数可能会返回的错误。
在这个代码清单里面,程序使用了两种不同的方法来对文件进行写入和读取。第一种方法非常简单直接,它使用的是ioutil
包中的WriteFile
函数和ReadFile
函数:在写入文件时,程序会将文件的名字、需要写入的数据以及一个用于设置文件权限的数字用作参数调用WriteFile
函数;而在读取文件时,程序只需要将文件的名字用作参数,然后调用ReadFile
函数即可。此外,无论是传递给WriteFile
的数据,还是ReadFile
返回的数据,都是一个由字节组成的切片。
比起前一种方法,使用File
结构读写文件会显得更为麻烦一些,但这种做法的灵活性更高。在使用这种方法实现文件写入时,程序需要先调用os
包的Create
函数,并通过向该函数传入文件名来创建文件。使用defer
关闭文件是一种值得提倡的做法,因为它杜绝了用户在使用文件之后忘记关闭文件的问题。defer
语句可以将给定的函数调用推入到一个栈里面,保存在栈中的调用会在包含defer
语句的函数返回之后执行。对我们的例子来说,这意味着file1
和file2
将会在main
函数执行完毕之后关闭。在拥有了File
结构之后,程序就可以通过它的Write
方法对文件进行写入。除了Write
方法之外,File
结构还提供了其他几个用于写入文件的方法。
使用File
结构读取文件的方法跟写入文件的方法类似:程序需要使用os
包的Open
函数打开文件,然后使用File
结构提供的Read
方法或者其他读取方法来读取文件中的数据。因为File
结构提供了一些方法,它们允许用户定位并读取文件中的指定部分,所以使用File
结构来读取文件比起单纯地调用ReadFile
函数拥有更大的灵活性。
执行代码清单6-2所示的程序会创建data1
和data2
两个文件,它们都包含文本“
Hello World!”。
CSV格式是一种文件格式,它可以让文本编辑器非常方便地读写由文本和数字组成的表格数据。CSV的应用非常广泛,包括微软的Excel和苹果的Numbers在内的绝大多数电子表格程序都支持CSV格式,因此包括Go在内的很多编程语言都提供了能够生成和处理CSV文件数据的函数库。
对Go语言来说,CSV文件可以通过encoding/csv
包进行操作,代码清单6-3展示了如何通过这个包来读写CSV文件。
代码清单6-3 读写CSV文件
package main
import (
"encoding/csv"
"fmt"
"os"
"strconv"
)
type Post struct {
Id int
Content string
Author string
}
func main() {
csvFile, err := os.Create("posts.csv") ❶
if err != nil {
panic(err)
}
defer csvFile.Close()
allPosts := []Post{
Post{Id: 1, Content: "Hello World!", Author: "Sau Sheong"},
Post{Id: 2, Content: "Bonjour Monde!", Author: "Pierre"},
Post{Id: 3, Content: "Hola Mundo!", Author: "Pedro"},
Post{Id: 4, Content: "Greetings Earthlings!", Author: "Sau Sheong"},
}
writer := csv.NewWriter(csvFile)
for _, post := range allPosts {
line := []string{strconv.Itoa(post.Id), post.Content, post.Author}
err := writer.Write(line)
if err != nil {
panic(err)
}
}
writer.Flush()
file, err := os.Open("posts.csv") ❷
if err != nil {
panic(err)
}
defer file.Close()
reader := csv.NewReader(file)
reader.FieldsPerRecord = -1
record, err := reader.ReadAll()
if err != nil {
panic(err)
}
var posts []Post
for _, item := range record {
id, _ := strconv.ParseInt(item[0], 0, 0)
post := Post{Id: int(id), Content: item[1], Author: item[2]}
posts = append(posts, post)
}
fmt.Println(posts[0].Id)
fmt.Println(posts[0].Content)
fmt.Println(posts[0].Author)
}
❶ 创建一个CSV 文件
❷ 打开一个CSV 文件
首先让我们来了解一下如何对CSV文件执行写操作。在一开始,程序会创建一个名为posts.csv
的文件以及一个名为csvFile
的变量,而后续代码的目标则是将allPosts
变量中的所有帖子都写入这个文件。为了完成这一目标,程序会使用NewWriter
函数创建一个新的写入器(writer),并把文件用作参数,将其传递给写入器。在此之后,程序会为每个待写入的帖子都创建一个由字符串组成的切片。最后,程序调用写入器的Write
方法,将一系列由字符串组成的切片写入之前创建的CSV文件。
如果程序进行到这一步就结束并退出,那么前面提到的所有数据都会被写入文件,但由于程序在接下来的代码中立即就要对写入的posts.csv
文件进行读取,而刚刚写入的数据有可能还滞留在缓冲区中,所以程序必须调用写入器的Flush
方法来保证缓冲区中的所有数据都已经被正确地写入文件里面了。
读取CSV文件的方法和写入文件的方法类似。首先,程序会打开文件,并通过将文件传递给NewReader
函数来创建出一个读取器(reader)。接着,程序会将读取器的FieldsPerRecord
字段的值设置为负数,这样的话,即使读取器在读取时发现记录(record)里面缺少了某些字段,读取进程也不会被中断。反之,如果FieldsPerRecord
字段的值为正数,那么这个值就是用户要求从每条记录里面读取出的字段数量,当读取器从CSV文件里面读取出的字段数量少于这个值时,Go就会抛出一个错误。最后,如果FieldsPerRecord
字段的值为0
,那么读取器就会将读取到的第一条记录的字段数量用作FieldsPerRecord
的值。
在设置好FieldsPerRecord
字段之后,程序会调用读取器的ReadAll
方法,一次性地读取文件中包含的所有记录;但如果文件的体积较大,用户也可以通过读取器提供的其他方法,以每次一条记录的方式读取文件。ReadAll
方法将返回一个由一系列切片组成的切片作为结果,程序会遍历这个切片,并为每条记录创建对应的Post
结构。如果我们运行代码清单6-3所示的程序,那么程序将创建一个名为posts.csv
的CSV文件,该文件将包含以下多个由逗号分隔的文本行:
1,Hello World!,Sau Sheong
2,Bonjour Monde!,Pierre
3,Hola Mundo!,Pedro
4,Greetings Earthlings!,Sau Sheong
除此之外,这个程序还会读取posts.csv
文件,并打印出该文件第一行的内容:
1
Hello World!
Sau Sheong
encoding/gob
包用于管理由gob组成的流(stream),这是一种在编码器(encoder)和解码器(decoder)之间进行交换的二进制数据,这种数据原本是为序列化以及数据传输而设计的,但它也可以用于对数据进行持久化,并且为了让用户能够方便地对文件进行读写,编码器和解码器一般都会分别包裹起程序的写入器以及读取器。代码清单6-4展示了如何使用gob
包去创建二进制数据文件,以及如何去读取这些文件。
代码清单6-4 使用gob
包读写二进制数据
package main
import (
"bytes"
"encoding/gob"
"fmt"
"io/ioutil"
)
type Post struct {
Id int
Content string
Author string
}
func store(data interface{}, filename string) { ❶
buffer := new(bytes.Buffer)
encoder := gob.NewEncoder(buffer)
err := encoder.Encode(data)
if err != nil {
panic(err)
}
err = ioutil.WriteFile(filename, buffer.Bytes(), 0600)
if err != nil {
panic(err)
}
}
func load(data interface{}, filename string) { ❷
raw, err := ioutil.ReadFile(filename)
if err != nil {
panic(err)
}
buffer := bytes.NewBuffer(raw)
dec := gob.NewDecoder(buffer)
err = dec.Decode(data)
if err != nil {
panic(err)
}
}
func main() {
post := Post{Id: 1, Content: "Hello World!", Author: "Sau Sheong"}
store(post, "post1")
var postRead Post
load(&postRead, "post1")
fmt.Println(postRead)
}
❶ 存储数据
❷ 载入数据
跟前面展示的程序一样,代码清单6-4所示的程序也会用到Post
结构,并且也包含了相应的store
方法和load
方法,但是跟之前不一样的是,这次的store
方法会将帖子存储为二进制数据,而load
方法则会通过读取这些二进制数据来获取帖子。
首先来分析一下store
函数,这个函数的第一个参数是一个空接口,而第二个参数则是被存储的二进制文件的名字。虽然空接口参数能够接受任意类型的数据作为值,但是在这个函数里面,它接受的将是一个Post
结构。在接受了相应的参数之后,store
函数会创建一个bytes.Buffer
结构,这个结构实际上就是一个拥有Read
方法和Write
方法的可变长度(variable-sized)字节缓冲区,换句话说,bytes.Buffer
既是读取器也是写入器。
在此之后,store
函数会把缓冲区传递给NewEncoder
函数,以此来创建出一个gob编码器,接着调用编码器的Encode
方法将数据(也就是Post
结构)编码到缓冲区里面,最后再将缓冲区中已编码的数据写入文件。
程序在调用store
函数时,会将一个Post
结构和一个文件名作为参数,而这个函数则会创建出一个名为post1
的二进制数据文件。
接下来,让我们来研究一下load
函数,这个函数从二进制数据文件中载入数据的步骤跟创建并写入这个文件的步骤正好相反:首先,程序会从文件里面读取出未经处理的原始数据;接着,程序会根据这些原始数据创建一个缓冲区,并藉此为原始数据提供相应的Read
方法和Write
方法;在此之后,程序会调用NewDecoder
函数,为缓冲区创建相应的解码器,然后使用解码器去解码从文件中读取的原始数据,并最终得到之前写入的Post
结构。
在main
函数里面,程序定义了一个名为postRead
的Post
结构,并将这个结构的引用以及二进制数据文件的名字传递给了load
函数,而load
函数则会把读取二进制文件所得的数据载入给定的Post
结构。
当我们运行代码清单6-4所示的程序时,将创建出一个包含二进制数据的post1
文件——因为这个文件包含的是二进制数据,所以如果直接打开这个文件,将会看到一些似乎毫无意义的数据。除创建post1
文件之外,程序还会读取文件中的数据并将其载入Post
结构里面,然后在控制终端打印出这个结构:
{1 Hello World! Sau Sheong}
好了,关于使用文件存储数据的介绍到此就结束了,本章接下来的内容将会讨论如何将数据存储到一种名为数据库服务器的特殊服务器端程序里面。
在内存和文件系统上存储和访问数据虽然非常有用,但如果你希望在一个健壮并且可扩展的环境里面存储数据,就需要转向使用数据库服务器 (database server)。数据库服务器是一种程序,它可以让其他程序通过客户端-服务器模型(client-server model)来访问数据,并且这种访问只能通过数据库服务器实现,而其他形式的访问则会被拒绝。在通常情况下,数据库服务器的客户端既可以是一个函数库,也可以是另一个程序,这个客户端会与数据库服务器进行连接,然后通过结构化查询语言(structured query language,SQL)对数据进行访问。数据库服务器通常会作为系统的一部分,出现在数据库管理系统(database management system)中。
关系数据库管理系统 (relational database management system,RDBMS)也许是最常见也最流行的数据库管理系统了,这种系统使用的是基于数据的关系模型构建的关系数据库 。在绝大多数情况下,关系数据库服务器都是通过SQL来访问关系数据库的。
关系数据库和SQL是人们在实现可扩展并且易于使用的数据存储方法时最为常见的手段。本书曾经在第2章对关系数据库以及SQL做过简单的介绍,而我们接下来要做的是更加深入地了解这两项技术。
在开始学习本节介绍的知识之前,读者首先要做的就是对数据库进行设置。本书在第2章就曾经介绍过安装并设置Postgres的具体方法,因为本节还会继续用到Postgres数据库,所以如果你尚未安装或者设置好这个数据库,那么请根据第2章介绍的方法进行设置。
在启动并设置好数据库之后,我们还需要执行以下3个步骤:
(1)创建数据库用户;
(2)为用户创建数据库;
(3)运行安装脚本,创建执行相关操作所需的表。
首先,我们可以通过在命令行执行以下命令来创建数据库用户:
createuser -P -d gwp
这一命令会创建出一个名为gwp的Postgres数据库用户,其中-P
选项会让createuser
程序在执行时弹出一个提示符,只需要在提示符出现之后输入相应的字符串,就可以将其设置为gwp用户的密码,而-d
选项则会赋予gwp用户创建数据库所需的权限。
接着,我们需要为 gwp 用户创建数据库。通过在命令行执行以下命令,我们就可以创建一个名为gwp的数据库:
createdb gwp
注意,这个数据库的名字跟我们刚刚创建的数据库用户的名字是一样的,都是gwp。虽然数据库用户也可以创建与自己名字不同的数据库,但这样做需要额外的权限设置,所以为了让事情尽可能简单,我们这里将使用默认的数据库命名方式,也就是,为数据库用户创建一个与之同名的数据库。
在拥有了数据库之后,我们还需要创建一个表,这个表也是接下来的内容中我们唯一需要使用的表。首先,我们需要创建一个名为setup.sql
的文件,并将代码清单6-5所示的内容键入该文件中。
代码清单6-5 用于创建表的脚本
create table posts (
id serial primary key,
content text,
author varchar(255)
);
接着,我们还需要在命令行执行以下命令:
psql -U gwp -f setup.sql -d gwp
这样的话,我们就把接下来要用到的数据库设置好了。注意,在每次执行后续展示的代码之前,你可能都需要重复执行一次这条命令,以便清理并设置数据库。
在创建并设置好数据库之后,现在是时候来连接这个数据库了。代码清单6-6展示了一个名为store.go
的文件,文件中的代码对Postgres执行了一系列操作,而接下来的小节将会逐一地分析这些操作的实现原理。
代码清单6-6 使用Go对Postgres执行CRUD操作
package main
import (
"database/sql"
"fmt"
_ "github.com/lib/pq"
)
type Post struct {
Id int
Content string
Author string
}
var Db *sql.DB
func init() {
var err error
Db, err = sql.Open("postgres", "user=gwp dbname=gwp password=gwp
➥sslmode=disable") ❶
if err != nil {
panic(err)
}
}
func Posts(limit int) (posts []Post, err error) {
rows, err := Db.Query("select id, content, author from posts limit $1",
➥limit)
if err != nil {
return
}
for rows.Next() {
post := Post{}
err = rows.Scan(&post.Id, &post.Content, &post.Author)
if err != nil {
return
}
posts = append(posts, post)
}
rows.Close()
return
}
func GetPost(id int) (post Post, err error) { ❷
post = Post{}
err = Db.QueryRow("select id, content, author from posts where id =
➥$1", id).Scan(&post.Id, &post.Content, &post.Author)
return
}
func (post *Post) Create() (err error) { ❸
statement := "insert into posts (content, author) values ($1, $2)
➥returning id"
stmt, err := Db.Prepare(statement)
if err != nil {
return
}
defer stmt.Close()
err = stmt.QueryRow(post.Content, post.Author).Scan(&post.Id)
return
}
func (post *Post) Update() (err error) {
_, err = Db.Exec("update posts set content = $2, author = $3 where id =
➥$1", post.Id, post.Content, post.Author) ❹
return
}
func (post *Post) Delete() (err error) {
_, err = Db.Exec("delete from posts where id = $1", post.Id) ❺
return
}
func main() {
post := Post{Content: "Hello World!", Author: "Sau Sheong"}
❻
fmt.Println(post)
post.Create()
fmt.Println(post) ❼
readPost, _ := GetPost(post.Id)
fmt.Println(readPost) ❽
readPost.Content = "Bonjour Monde!"
readPost.Author = "Pierre"
readPost.Update()
posts, _ := Posts()
fmt.Println(posts) ❾
readPost.Delete()
}
❶ 连接到数据库
❷ 获取单独一篇帖子
❸ 创建一篇新帖子
❹ 更新帖子
❺ 删除一篇帖子
❻ {0 Hello World! Sau Sheong}
❼ {1 Hello World! Sau Sheong}
❽ {1 Hello World! Sau Sheong}
❾ [{1 Bonjour Monde! Pierre}]
程序在对数据库执行任何操作之前,都需要先与数据库进行连接,代码清单6-7展示了实现这一动作的具体过程:程序首先使用Db
变量定义了一个指向sql.DB
结构的指针,然后使用init()
函数来初始化这个变量(Go语言的每个包都会自动调用定义在包内的init()
函数)。
代码清单6-7 用于创建数据库句柄的函数
var Db *sql.DB
func init() {
var err error
Db, err = sql.Open("postgres", "user=gwp dbname=gwp password=gwp
sslmode=disable")
if err != nil {
panic(err)
}
}
sql.DB
结构是一个数据库句柄(handle),它代表的是一个包含了零个或任意多个数据库连接的连接池(pool),这个连接池由sql
包管理。程序可以通过调用Open
函数,并将相应的数据库驱动名字(driver name)以及数据源名字(data source name)传递给该函数来建立与数据库的连接。比如,在上面展示的例子中,程序使用的是postgres驱动。数据源名字是一个特定于数据库驱动的字符串,它会告诉驱动应该如何与数据库进行连接。Open
函数在执行之后会返回一个指向sql.DB
结构的指针作为结果。
需要注意的是,Open
函数在执行时并不会真正地与数据库进行连接,它甚至不会检查用户给定的参数:Open
函数的真正作用是设置好连接数据库所需的各个结构,并以惰性的方式,等到真正需要时才建立相应的数据库连接。
此外,因为sql.DB
只是一个句柄而不是实际的连接,而这个句柄代表的是一个会自动对连接进行管理的连接池,所以尽管用户可以手动关闭sql.DB
,但是在实际中通常并不需要这样做。在上面展示的例子中,程序通过全局定义的Db
变量在各个CRUD方法以及函数中使用sql.DB
结构;但除此之外,我们也可以选择在创建sql.DB
结构之后,通过向方法或者函数传递这个结构的方式来使用它。
到目前为止,我们讨论的都是Open
函数,这个函数接受数据库驱动名字和数据源名字作为参数,然后返回一个sql.DB
结构作为结果。那么程序本身又是如何获取数据库驱动的呢?一般来说,程序都会向Register
函数提供一个数据库驱动名字以及一个实现了driver.Driver
接口的结构,以此来注册将要用到的数据库驱动,就像这样:
sql.Register("postgres", &drv{})
这个例子中的postgres
就是数据库驱动的名字,而drv
则是实现了Driver
接口的结构。你也许已经注意到了,前面展示的数据库程序并没有包含类似的注册代码,这是因为程序使用的第三方Postgres驱动在被导入的时候已经自行实现了注册:
import (
"fmt"
"database/sql"
_ "github.com/lib/pq"
)
上面这段代码中的github.com/lib/pq
包就是程序导入的Postgres驱动,在导入这个包之后,包内定义的init
函数就会被调用,并对其自身进行注册。因为Go语言没有提供任何官方数据库驱动,所以Go语言的所有数据库驱动都是第三方函数库,并且这些库必须遵守sql.driver
包中定义的接口。注意,因为程序在操作数据库的时候只需要用到database/sql
,而不需要直接使用数据库驱动,所以程序在导入Postgres数据库驱动的时候将这个包的名字设置成了下划线(_)
。这种引用数据库驱动的方式可以让用户在不修改代码的情况下升级驱动,或者修改驱动实现。
至于安装驱动这一操作,则可以通过在命令行里执行以下命令来完成:
go get "github.com/lib/pq"
这一命令会从代码库中获取驱动的具体代码,并将这些代码放置到包库(package repository)里面,当需要用到这个驱动时,编译器就会把驱动代码与用户编写的代码一同编译。
在完成了数据库的初步设置之后,现在是时候创建我们的首条数据库记录了。本节还会用到之前几节展示过的Post
结构,跟之前不一样的是,这次展示的程序将不会再把Post
结构包含的信息存储到内存或者文件中,而是把这些信息存储到Postgres数据库中,并在需要的时候从数据库中获取这些信息。
前面的示例程序向我们展示了如何使用不同的函数执行数据的创建、获取、更新和删除操作,而在这一节,我们将会了解到使用Create
函数创建新帖子的更多细节。在仔细研究Create
函数的代码之前,让我们先来了解一下创建帖子的具体步骤。
创建帖子首先要做的是创建一个Post
结构,并为该结构的Content
字段和Author
字段设置值。需要注意的是,因为结构的Id
字段的值通常是由数据库的自增主键自动生成的,所以我们并不需要为这个字段设置值。
post := Post{Content: "Hello World!", Author: "Sau Sheong"}
如果我们现在使用一个fmt.Println
语句打印这个结构,会看到Id
字段的值被初始化成了0
:
fmt.Println(post) ❶
❶ {0 Hello World! Sau Sheong}
现在,我们可以通过执行Post
结构的Create
方法,把结构中包含的数据存储到数据库的记录(record)里面:
post.Create()
Create
方法在发生故障时会返回一个错误,但为了让代码保持简单,我们这里暂且先省略相应的错误处理代码。现在,再次打印这个Post
结构:
fmt.Println(post) ❶
❶ {1 Hello World! Sau Sheong}
从打印的结果可以看到,Id
字段的值现在被设置成了1
。在了解了使用Create
函数创建新帖子的具体步骤之后,现在是时候来看看代码清单6-8,了解一下它的具体实现代码了。
代码清单6-8 创建一篇帖子
func (post *Post) Create() (err error) {
statement := "insert into posts (content, author) values ($1, $2)
➥returning id "
stmt, err := db.Prepare(statement)
if err != nil {
return
}
defer stmt.Close()
err = stmt.QueryRow(post.Content, post.Author).Scan(&post.Id)
if err != nil {
return
}
return
}
Create
函数是Post
结构的一个方法,这一点可以通过Create
函数的定义看出:在func
关键字和函数名Create
之间,有一个指向Post
结构的引用,这个名为post
的引用也被称为方法的接收者(receiver),接收者可以不使用&
符号,直接在方法内部对结构进行引用。
Create
方法做的第一件事是定义一条SQL预处理语句,一条预处理语句
(prepared statement)就是一个SQL语句模板,这种语句通常用于重复执行指定的SQL语句,用户在执行预处理语句时需要为语句中的参数提供实际值。
比如,在创建数据库记录的时候,Create
函数就会使用实际值去替换以下语句中的$1
和$2
:
statement := "insert into posts (content, author) values ($1, $2) returning id"
除了在数据库里面创建记录之外,这个语句还会要求数据库返回id
列的值,本文稍后就会说明这样做的具体原因。
为了创建预处理语句,程序使用了sql.DB
结构的Prepare
方法:
stmt, err := db.Prepare(statement)
这行代码会创建一个指向sql.Stmt
接口的引用,这个引用就是上面提到的预处理语句。sql.Stmt
接口的定义位于sql.Driver
包当中,而具体的结构则由数据库驱动实现。
之后,程序会调用预处理语句的QueryRow
方法,并把来自接收者的数据传递给该方法,以此来执行预处理语句:
err = stmt.QueryRow(post.Content, post.Author).Scan(&post.Id)
我们之所以在这里使用QueryRow
方法,是因为我们只想要获取一个指向sql.Row
结构的引用:如果QueryRow
发现被执行的SQL语句返回了多于一个sql.Row
,那么它只会返回结果中的第一个sql.Row
,并丢弃剩余的所有sql.Row
。因为QueryRow
方法的返回值只有一个sql.Row
结构,它不会返回任何错误,所以QueryRow
方法通常会跟Row
结构的Scan
方法搭配使用,并由Scan
方法把行中的值复制到程序为其提供的参数里面。正如上面的代码所示,Scan
方法会把SQL查询语句返回的id
列的值设置为post
接收者的Id
字段的值,这也是我们前面在编写预处理语句时,要求SQL查询语句返回id
列的值的原因。很明显,因为接收者的Content
字段和Author
字段都已经有值了,所以程序最后要做的就是将接收者的Id
字段的值设置成数据库生成的自增整数。现在,正如你所料,因为post
变量的Id
字段也已经设置了值,所以程序得到的将是一个完整地进行了设置的Post
结构,并且该结构包含的数据与数据库记录的数据完全一致。
在学会如何创建帖子之后,我们很自然地就要学习如何获取帖子了。跟前面一样,在编写获取帖子的函数之前,我们需要先了解一下获取帖子的具体步骤。因为程序在尝试获取帖子的时候是没有现成的Post
结构可用的,所以它自然也无法通过为Post
结构定义方法来获取帖子了。为此,程序需要定义一个GetPost
函数,这个函数接受帖子的Id作为参数,并返回一个包含了完整帖子数据的Post
结构作为结果:
readPost, _ := GetPost(1)
fmt.Println(readPost) ❶
❶ {1 Hello World! Sau Sheong}
这段代码没有像之前展示过的代码清单那样,向GetPost
函数传递post.Id
变量,而是直接向GetPost
函数传递了帖子的ID
值1
,以此来强调函数是通过帖子ID
来获取帖子的。代码清单6-9展示了GetPost
函数的具体实现代码。
代码清单6-9 获取一篇帖子
func GetPost(id int) (post Post, err error) {
post = Post{}
err = Db.QueryRow("select id, content, author from posts where id =
➥$1", id).Scan(&post.Id, &post.Content, &post.Author)
return
}
GetPost
函数首先创建了一个空的Post
结构,然后在对结构进行设置之后,将其用作函数的返回值:
post = Post{}
跟之前一样,程序通过串联QueryRow
方法和Scan
方法,将执行查询所得的数据复制到空的Post
结构里面。需要注意的是,因为获取单个帖子无需重复执行相同的SQL语句,所以程序使用的是sql.DB
结构的QueryRow
方法而不是sql.Stmt
结构的QueryRow
方法。实际上,Create
方法和GetPost
函数既可以使用sql.DB
来实现,也可以使用sql.Stmt
来实现,在这里使用sql.DB
而不是沿用sql.Stmt
只是为了展示另一种可行的做法。
在将数据库包含的数据填充到空的Post
结构之后,GetPost
就会将这个结构返回给调用函数。
在学会如何获取帖子之后,我们接下来要做的就是学习如何对数据库记录中的信息进行更新。假设现在程序已经通过获取操作把帖子保存到了readPost
变量里面,那么它应该可以对帖子进行修改,并通过更新操作将这些修改反映至数据库:
readPost.Content = "Bonjour Monde!"
readPost.Author = "Pierre"
readPost.Update()
更新操作可以通过为Post
结构添加Update
方法来实现,代码清单6-10展示了这个方法的具体实现代码。
代码清单6-10 更新一篇帖子
func (post *Post) Update() (err error) {
_, err = Db.Exec("update posts set content = $2, author = $3 where id =
➥$1", post.Id, post.Content, post.Author)
return
}
跟创建帖子时的做法不同,这次展示的更新操作没有使用预处理语句,而是直接调用sql.DB
结构的Exec
方法。这是因为程序既不需要对接收者进行任何更新,也不需要对方法返回的结果进行扫描(scan),所以它才会选择使用速度更快的Exec
方法来执行查询:
_, err = Db.Exec(post.Id, post.Content, post.Author)
Exec
方法会返回一个sql.Result
和一个可能出现的错误,其中sql.Result
记录的是受查询影响的行的数量以及可能会出现的最后插入id。因为更新操作对sql.Result
记录的这两项信息都不感兴趣,所以程序会通过将sql.Result
赋值给下划线(
_)来忽略它。如果一切顺利,没有出现错误,当Exec
执行完毕时,给定的帖子就会被更新。
到目前为止,我们已经学习了如何创建、获取和更新帖子,那么接下来要考虑的就是如何在不需要这些帖子的时候删除它们了。比如说,假设程序已经通过获取操作将一篇帖子存储到了readPost
变量里面,那么接下来就可以通过调用readPost
变量的Delete
方法来删除帖子:
readPost.Delete()
Delete
方法的用法非常简单,代码清单6-11展示了这个方法的具体定义,里面使用的都是前面已经介绍过的技术。
代码清单6-11 删除一篇帖子
func (post *Post) Delete() (err error) {
_, err = Db.Exec("delete from posts where id = $1", post.Id)
return
}
跟前面更新帖子时一样,Delete
方法直接通过调用sql.DB
结构的Exec
方法来执行SQL查询,并且因为Delete
方法也对Exec
方法返回的结果不感兴趣,所以它也会把Exec
返回的结果赋值给了下划线(_)
。
也许你已经注意到了,与Post
结构有关的各个方法以及函数都是以一种非常随意的方式进行定义的,所以在需要的时候,你也可以根据自己的想法来修改这些方法和函数。举个例子,除了“先修改已有的Post
结构,然后再调用Update
方法将更新反映到数据库里面”这种更新方法之外,你还可以考虑直接将需要修改的内容当作参数传递给Update
方法;又或者说,你也可以考虑创建更多不同的获取函数,然后通过特定的列或者特定的过滤器来获取你想要的帖子。
根据给定的最大帖子数量,一次从数据库里面获取多篇帖子,是一种非常常见的做法。换句话说,程序可以通过执行以下命令,从数据库里面获取前十篇帖子,并将它们放入到一个切片里面:
posts, _ := Posts(10)
代码清单6-12展示了完成这一操作的Posts
函数的具体定义。
代码清单6-12 一次获取多篇帖子
func Posts(limit int) (posts []Post, err error) {
rows, err := Db.Query("select id, content, author from posts limit $1",
➥limit)
if err != nil {
return
}
for rows.Next() {
post := Post{}
err = rows.Scan(&post.Id, &post.Content, &post.Author)
if err != nil {
return
}
posts = append(posts, post)
}
rows.Close()
return
}
Posts
函数使用了sql.DB
结构的Query
方法来执行查询,这个方法会返回一个Rows
接口。Rows
接口是一个迭代器,程序可以通过重复调用它的Next
方法来对其进行迭代并获得相应的sql.Row
;当所有行都被迭代完毕时,Next
方法将返回io.EOF
作为结果。
Posts
函数在每次进行迭代的时候都会创建一个Post
结构,并将行包含的数据扫描到结构里面,然后再将这个结构追加到posts
切片的末尾。当所有行都被迭代完毕之后,Posts
函数就会将这个包含了多个Post
结构的posts
切片返回给调用者。
关系数据库之所以能够成为一种流行的数据存储手段,其中一个原因就是它可以在表与表之间建立关系,从而使不同的数据能够以一种一致且易于理解的方式互相进行关联。基本上,有4种方法可以把一项记录与其他记录关联起来:
在前面的内容中,我们已经学习了如何对单个数据库表执行标准的CRUD操作,但我们还不知道如何才能对两个相关联的表执行相同的操作。因此,在这一节,我们将要学习如何通过一对多关系为一篇论坛帖子构建多篇评论。与此同时,因为一对多关系跟多对一关系实际上就是一体两面的两个东西,所以除了一对多关系之外,我们还会学习如何使用多对一关系。
在正式开始之前,我们需要再次对数据库进行设置,不过跟上次只创建一个表的做法不同,这一次我们将会创建两个表。此外,这次设置需要用到的命令跟上一次设置使用的命令完全一样,只是被执行的setup.sql
脚本跟之前的有所不同,代码清单6-13展示了新脚本的具体定义。
代码清单6-13 创建两个相关联的表
drop table posts cascade if exists;
drop table comments if exists;
create table posts (
id serial primary key,
content text,
author varchar(255)
);
create table comments (
id serial primary key,
content text,
author varchar(255),
post_id integer references posts(id)
);
这次的脚本除了会创建posts
表之外,还会创建comments
表,comments
表的大部分列都跟posts
表一样,主要区别在于comments
表多了一个额外的post_id
列:这个post_id
会作为外键(foreign key),对posts
表的主键id
进行引用。此外,因为posts
表和comments
表现在已经通过主键和外键建立起了关联,所以用户在删除posts
表的同时也需要将comments
表一并删除;否则,由于comments
表对posts
表的依赖关系,删除posts
表这一操作将无法正常执行。
设置好相应的数据库表之后,现在让我们来看看如何使用Go语言实现一对多以及多对一关系。代码清单6-14展示了具体的实现代码,这些代码都存储在一个名为store.go
的文件里面。
代码清单6-14 使用Go语言实现一对多以及多对一关系
package main
import (
"database/sql"
"errors"
"fmt"
_ "github.com/lib/pq"
)
type Post struct {
Id int
Content string
Author string
Comments []Comment
}
type Comment struct {
Id int
Content string
Author string
Post *Post
}
var Db *sql.DB
func init() {
var err error
Db, err = sql.Open("postgres", "user=gwp dbname=gwp password=gwp
➥sslmode=disable")
if err != nil {
panic(err)
}
}
func (comment *Comment) Create() (err error) { ❶
if comment.Post == nil {
err = errors.New("Post not found")
return
}
err = Db.QueryRow("insert into comments (content, author, post_id)
➥values ($1, $2, $3) returning id", comment.Content, comment.Author,
➥comment.Post.Id).Scan(&comment.Id)
return
}
func GetPost(id int) (post Post, err error) {
post = Post{}
post.Comments = []Comment{}
err = Db.QueryRow("select id, content, author from posts where id =
➥$1", id).Scan(&post.Id, &post.Content, &post.Author)
rows, err := Db.Query("select id, content, author from comments")
if err != nil {
return
}
for rows.Next() {
comment := Comment{Post: &post}
err = rows.Scan(&comment.Id, &comment.Content, &comment.Author)
if err != nil {
return
}
post.Comments = append(post.Comments, comment)
}
rows.Close()
return
}
func (post *Post) Create() (err error) {
err = Db.QueryRow("insert into posts (content, author) values ($1, $2)
➥returning id", post.Content, post.Author).Scan(&post.Id)
return
}
func main() {
post := Post{Content: "Hello World!", Author: "Sau Sheong"}
post.Create()
comment := Comment{Content: "Good post!", Author: "Joe", Post: &post}
comment.Create()
readPost, _ := GetPost(post.Id) ❷
fmt.Println(readPost)
fmt.Println(readPost.Comments) ❸
fmt.Println(readPost.Comments[0].Post) ❹
}
❶ 创建一条评论
❷ {1 Hello World! Sau Sheong [{1 Good post! Joe 0xc20802a1c0}]}
❸ [{1 Goodpost! Joe0xc20802a1c0}
❹ &{1 Hello World! Sau Sheong [{1 Good post! Joe 0xc20802a1c0}]}
我们首先需要考虑的是如何使用Post
和Comment
这两个结构来构建一对多关系:
type Post struct {
Id int
Content string
Author string
Comments []Comment
}
type Comment struct {
Id int
Content string
Author string
Post *Post
}
注意,Post
结构新增了一个Comments
字段,这个字段是一个由任意多个Comment
结构组成的切片;与此同时,Comment
结构也新增了一个Post
字段,这个字段是一个指向Post
结构的指针。初看上去,程序似乎会把多个Comment
结构存储到一个Post
结构里面,然后让Comment
结构通过指针引用Post
结构。但是实际上,因为切片也是一个指向数组的指针,所以Post
结构和Comment
结构在构建关系时使用的都是指针:这种做法可以确保程序获取到的都是同一个Post
结构或者Comment
结构,而不是这些结构的副本。
在设定好Post
结构和Comment
结构之间的关系之后,我们接下来要考虑的就是如何实际地构建这些关系。正如前面所说,一对多关系实际上就是多对一关系,所以这两个结构在定义一对多关系的同时,也定义了多对一关系。当程序创建一条新评论的时候,它就会在评论和被评论的帖子之间建立起以上提到的这两种关系:
comment := Comment{Content: "Good post!", Author: "Joe", Post: &post}
comment.Create()
跟之前的做法一样,程序首先会创建一个Comment
结构,然后通过调用该结构的Create
方法来创建评论,并藉此建立起评论与帖子之间的关系。代码清单6-15展示了Comment
结构的Create
方法的具体定义。
代码清单6-15 创建评论,并建立评论与帖子之间的关系
func (comment *Comment) Create() (err error) {
if comment.Post == nil {
err = errors.New("Post not found")
return
}
err = Db.QueryRow("insert into comments (content, author, post_id)
➥values ($1, $2, $3) returning id", comment.Content, comment.Author,
➥comment.Post.Id).Scan(&comment.Id)
return
}
在为评论和帖子建立关系之前,Create
方法会先检查给定的帖子是否存在,并在帖子不存在时返回一个错误。除了“通过post_id
建立关系”这一细节没有提及之外,Create
方法的其余代码的行为跟我们之前描述的一模一样。
在建立起评论和帖子之间的关系之后,我们接下来要考虑的就是如何修改GetPost
函数,让它可以在获取帖子的同时,一并获取与帖子相关联的评论。比如说,程序在执行完以下代码之后,应该可以通过访问readPost
变量的Comments
字段来查看帖子已有的评论:
readPost, _ := GetPost(post.Id)
代码清单6-16展示了修改之后的GetPost
函数的定义。
代码清单6-16 获取帖子及其评论
func GetPost(id int) (post Post, err error) {
post = Post{}
post.Comments = []Comment{}
err = Db.QueryRow("select id, content, author from posts where id =
➥$1", id).Scan(&post.Id, &post.Content, &post.Author)
rows, err := Db.Query("select id, content, author from comments where
➥post_id = $1", id)
if err != nil {
return
}
for rows.Next() {
comment := Comment{Post: &post}
err = rows.Scan(&comment.Id, &comment.Content, &comment.Author)
if err != nil {
return
}
post.Comments = append(post.Comments, comment)
}
rows.Close()
return
}
GetPost
函数首先会初始化Post
结构中的Comments
字段,并从数据库里面获取帖子的具体数据。在此之后,程序会从数据库里面获取与当前帖子关联的所有评论,接着迭代这些评论,为每个评论都创建一个Comment
结构并将其追加到Comments
切片里面。当所有评论都被迭代完毕之后,GetPost
函数就会将包含了评论的Post
结构返回给调用者。正如上述内容所示,在多个表之间建立关系并不困难,但是这一行为在Web应用变得越来越庞大的同时就会变得越来越麻烦。为了解决这个问题,我们将在接下来的一节中学习如何通过关系映射器来简化关系的构建方法。
虽然本节展示了所有数据库应用都会用到的CRUD操作,但这些操作充其量只是使用Go访问SQL数据库的基本知识,如果你有兴趣了解更多相关的知识,那么可以去读一下Go的官方文档。
初看上去,将数据存储到关系数据库里面似乎并不是一件轻松的事情,有非常多的工作要做。对不少语言来说,这一判断是正确的,然而在实际中,SQL与应用之间通常存在着一些第三方库,这些库在面向对象编程语言中一般称为对象-关系映射器(object-relational mapper,ORM)。诸如Java的Hibernate以及Ruby的ActiveRecord之类的ORM都会把关系数据库中的表映射为编程语言中的对象,但为表创建映射并不是面向对象编程语言的特权,很多其他编程语言也拥有类似的映射器,比如,Scala有Activate框架,Haskell有Groundhog库。
Go同样也拥有类似的关系映射器(relational mapper),本节接下来将介绍其中一些映射器(因为ORM这一术语对于Go来说并不是特别准确,所以我们将使用“关系映射器”而不是“ORM”来称呼接下来提到的Go映射器)。
Sqlx是一个第三方库,它为database/sql
包提供了一系列非常有用的扩展功能。因为Sqlx和database/sql
包使用的是相同的接口,所以Sqlx能够很好地兼容使用database/sql
包的程序,除此之外,Sqlx还提供了以下这些额外的功能:
代码清单6-17展示了如何使用Sqlx及其提供的StructScan
方法来对论坛程序进行简化。另外别忘了,在使用Sqlx库之前,需要先通过执行以下命令来获取这个库:
go get "github.com/jmoiron/sqlx"
代码清单6-17 使用 Sqlx 重新实现论坛程序
package main
import (
"fmt"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
)
type Post struct {
Id int
Content string
AuthorName string `db: author`
}
var Db *sqlx.DB
func init() {
var err error
Db, err = sqlx.Open("postgres", "user=gwp dbname=gwp password=gwp
➥sslmode=disable")
if err != nil {
panic(err)
}
}
func GetPost(id int) (post Post, err error) {
post = Post{}
err = Db.QueryRowx("select id, content, author from posts where id =
➥$1", id).StructScan(&post)
if err != nil {
return
}
return
}
func (post *Post) Create() (err error) {
err = Db.QueryRow("insert into posts (content, author) values ($1, $2)
➥returning id", post.Content, post.AuthorName).Scan(&post.Id)
return
}
func main() {
post := Post{Content: "Hello World!", AuthorName: "Sau Sheong"}
post.Create()
fmt.Println(post) ❶
}
❶ {1 Hello World! Sau Sheong}}
代码清单中的加粗代码展示了使用Sqlx与使用database/sql
之间的区别,而其余的则是一些我们之前已经看到过的代码。首先,程序现在不再导入database/sql
包,而是导入github.com/jmoiron/sqlx
包。在默认情况下,StructScan
会根据结构字段名的英文小写体,将结构中的字段映射至表中的列。为了演示如何将指定的表列映射至指定的结构字段,代码清单6-17将原来的Author
字段修改成了AuthorName
字段,然后通过结构标签来指示Sqlx应该从author
列里面获取AuthorName
字段的数据。本书将在第7章对结构标签做进一步的说明。
程序现在也会使用sqlx.DB
结构来代替之前的sql.DB
结构,这两种结构非常相似,只不过sqlx.DB
包含了诸如Queryx
以及QueryRowx
等额外的方法。
修改之后的GetPost
函数也使用QueryRowx
代替了之前的QueryRow
。QueryRowx
在执行之后将返回Rowx
结构,这种结构拥有StructScan
方法,该方法可以将列自动地映射到相应的字段里面。另一方面,对于Create
方法,我们还是跟之前一样,使用QueryRow
方法进行查询。
除了这里提到的特性之外,Sqlx还拥有其他一些有趣的特性,感兴趣的读者可以通过访问Sqlx的GitHub页面来了解:https://github.com/jmoiron/sqlx。
Sqlx是一个有趣并且有用的database/sql
扩展,但它支持的特性并不多。与此相反,我们接下来要学习的Gorm库不仅把database/sql
包隐藏了起来,它还提供了一个完整且强大的ORM机制来代替database/sql
包。
Gorm的开发者声称Gorm是最棒的Go语言ORM,他们的确所言非虚。Gorm是“Go-ORM”一词的缩写,这个项目是一个使用Go实现的ORM,它遵循的是与Ruby的ActiveRecord以及Java的Hibernate一样的道路。更确切地说,Gorm遵循的是数据映射器模式(Data-Mapper pattern),该模式通过提供映射器来将数据库中的数据映射为结构。(在6.3节介绍关系数据库时,使用的就是ActiveRecord模式。)
Gorm的能力非常强大,它允许程序员定义关系、实施数据迁移、串联多个查询以及执行其他很多高级的操作。除此之外,Gorm还能够设置回调函数,这些函数可以在特定的数据事件发生时执行。因为详尽地描述Gorm的各个特性可能会花掉整整一章的篇幅,所以我们在这里只会讨论它的基本特性。代码清单6-18展示了使用Gorm重新实现论坛程序的方法,跟之前一样,这次的代码也是存储在store.go
文件里面。
代码清单6-18 使用Gorm实现论坛程序
package main
import (
"fmt"
"github.com/jinzhu/gorm"
_ "github.com/lib/pq"
"time"
)
type Post struct {
Id int
Content string
Author string `sql:"not null"`
Comments []Comment
CreatedAt time.Time
}
type Comment struct {
Id int
Content string
Author string `sql:"not null"`
PostId int `sql:"index"`
CreatedAt time.Time
}
var Db gorm.DB
func init() {
var err error
Db, err = gorm.Open("postgres", "user=gwp dbname=gwp password=gwp
➥sslmode=disable")
if err != nil {
panic(err)
}
Db.AutoMigrate(&Post{}, &Comment{})
}
func main() {
post := Post{Content: "Hello World!", Author: "Sau Sheong"} ❶
fmt.Println(post)
Db.Create(&post) ❷
fmt.Println(post) ❸
comment := Comment{Content: "Good post!", Author: "Joe"} ❹
Db.Model(&post).Association("Comments").Append(comment)
var readPost Post
Db.Where("author = $1", "Sau Sheong").First(&readPost) ❺
var comments []Comment
Db.Model(&readPost).Related(&comments)
fmt.Println(comments[0]) ❻
}
❶ {0 Hello World! Sau Sheong [] 0001-01-01 00:00:00 +0000 UTC}
❷ 创建一篇帖子
❸ {1 Hello World! Sau Sheong [] 2015-04-12 11:38:50.91815604 +0800 SGT}
❹ 添加一条评论
❺ 通过帖子获取评论
❻ {1 Good post! Joe 1 2015-04-13 11:38:50.920377 +0800 SGT}
这个新程序创建数据库句柄的方法跟我们之前创建数据库句柄的方法基本相同。另外需要注意的一点是,因为Gorm可以通过自动数据迁移特性来创建所需的数据库表,并在用户修改相应的结构时自动对数据库表进行更新,所以这个程序无需使用setup.sql
文件来设置数据库表:当我们运行这个程序时,程序所需的数据库表就会自动生成。为了正确地运行这个程序,并让程序能够正常地创建数据库表,我们在执行这个程序之前必须先将之前创建的数据库表全部删除:
func init() {
var err error
Db, err = gorm.Open("postgres", "user=gwp dbname=gwp password=gwp sslmode=disable")
if err != nil {
panic(err)
}
Db.AutoMigrate(&Post{}, &Comment{})
}
负责执行数据迁移操作的AutoMigrate
方法是一个变长参数方法,这种类型的方法和函数能够接受一个或多个参数作为输入。在上面展示的代码中,AutoMigrate
方法接受的是Post
结构和Comment
结构。得益于自动数据迁移特性的存在,当用户向结构里面添加新字段的时候,Gorm就会自动在数据库表里面添加相应的新列。
上面的Gorm程序使用了下面所示的Comment
结构:
type Comment struct {
Id int
Content string
Author string `sql:"not null"`
PostId int
CreatedAt time.Time
}
Comment
结构里面出现了一个类型为time.Time
的CreatedAt
字段,包含这样一个字段意味着Gorm每次在数据库里创建一条新记录的时候,都会自动对这个字段进行设置。
此外,Comment
结构的其中一些字段还用到了结构标签,以此来指示Gorm应该如何创建和映射相应的字段。比如,Comment
结构的Author
字段就使用了结构标签
以此来告知Gorm,该字段对应列的值不能为'sql: "not null"'
,null
。
跟前面展示过的程序的另一个不同之处在于,这个程序没有在Comment
结构里设置Post
字段,而是设置了一个PostId
字段。Gorm会自动把这种格式的字段看作是外键,并创建所需的关系。
在了解了Post
结构和Comment
结构的新定义之后,现在,让我们来看看程序是如何创建并获取帖子及其评论的。首先,程序会使用以下语句来创建新的帖子:
post := Post{Content: "Hello World!", Author: "Sau Sheong"}
Db.Create(&post)
这段代码没有什么难懂的地方,它跟之前展示过的代码的最主要区别在于——程序这次遵循了数据映射器模式:它在创建帖子时会使用数据库句柄gorm.DB
作为构造器,而不是像之前遵循ActiveRecord模式时那样,通过直接调用Post
结构自有的Create
方法来创建帖子。
如果直接查看数据库内部,应该会看到created_at
这个时间戳列在帖子创建出来的同时已经自动被设置好了。
在创建出帖子之后,程序使用了以下语句来为帖子添加评论:
comment := Comment{Content: "Good post!", Author: "Joe"}
Db.Model(&post).Association("Comments").Append(comment)
这段代码会先创建出一条评论,然后通过串联Model
方法、Association
方法和Append
方法来将评论添加到帖子里面。注意,在创建评论的过程中,我们无需手动对Comment
结构的PostId
字段执行任何操作。
最后,程序使用了以下代码来获取帖子及其评论:
var readPost Post
Db.Where("author = $1", "Sau Sheong").First(&readPost)
var comments []Comment
Db.Model(&readPost).Related(&comments)
这段代码跟之前展示过的代码有些类似,它使用了gorm.DB
的Where
方法来查找第一条作者名为"Sau Sheong"
的记录,并将这条记录存储在了readPost
变量里面,而这条记录就是我们刚刚创建的帖子。之后,程序首先调用Model
方法获取帖子的模型,接着调用Related
方法获取帖子的评论,并在最后将这些评论存储到comments
变量里面。
正如之前所说,本节展示的特性只是Gorm这个ORM库众多特性的一小部分,如果你对这个库感兴趣,可以通过https://github.com/jinzhu/gorm了解更多相关信息。
Gorm并不是Go语言唯一的ORM库。除Gorm之外,Go还拥有不少同样具备众多特性的ORM库,比如,Beego的ORM库以及GORP(GORP并不完全是一个ORM,但它与ORM相去不远)。
在本章中,我们了解了构建Web应用所需的基本组件,而在接下来的一章中,我们将要开始讨论如何构建Web服务。
database/sql
包,可以对关系数据库执行CRUD操作,并在不同的数据之间建立起相应的关系。
[1] 有序关机指的是等到所有任务都执行完毕之后,以有组织的方式关闭计算机系统,这种关机可以确保系统在重启之后不会丢失任何进度或者数据。——译者注