使用gob包来序列化与反序列化海量数据

本文翻译自《Gobs of data》。

Rob Pike

2011/03/24

介绍

要在网络上传输某种数据结构或将它存储在文件中,必须对它进行编码,然后解码。当然,有很多可用的编码格式:JSONXML、Google的protocol buffer等等。现在还有另一种,由Go的gob包提供。

为什么要定义一种新的编码格式?这需要大量的工作,而且多余。为什么不使用现有的格式之一?好吧,有一件事,我们做到了!Go本就支持刚才提到的所有编码的protocol buffer包在一个单独的存储库中,但它是下载频率最高的存储库之一)。对于许多目的,包括与用其他语言编写的工具和系统进行通信,它们都是正确的选择。

但对于Go特定的环境,例如用Go编写的两个服务器之间的通信,有机会构建一种更易于使用且可能更高效的编码格式。

Gob以一种外部定义的、独立于语言的编码所不能做到的方式工作。与此同时,还可以从现有的系统中吸取教训。

目标

gob包的设计考虑到了许多目标。

第一,也是最显然的一点,它必须非常易于使用。首先,因为Go支持反射机制,所以无需单独的接口定义或“协议编译器”。数据结构本身就是该包所需要的全部内容,以了解如何对其进行编码和解码。另一方面,这种方式意味着gob永远不会与其他编程语言通用,但没关系:gob就是以Go语言为中心的。

效率也很重要。以XML和JSON为例的文本形式的编码太慢了,无法在高效通信的网络中使用。二进制编码是必须的。

gob的数据流必须是自我描述的。从一开始读取的每个gob数据流都包含足够的信息,使得整个gob数据流可以由对其内容一无所知的代理程序解析。此特性意味着你将始终能够解码存储在文件中的gob数据流,即使在很久以后你已经忘记了它代表什么数据。

从我们使用Google的protocol buffer的经验中也学到了一些东西。

Protocol buffer的缺点

protocol buffer对gob的设计有很大影响,故意避免了它的三个特性。(protocol buffer不是自我描述的:如果你不知道用来编码成protocol buffer的数据的具体定义,你可能无法解码它。)

首先,protocol buffer只适用于我们在Go中称为结构体的数据类型。不能直接对整数或数组进行编码,只能对包含字段的结构体进行编码。这似乎是一个毫无意义的限制,至少在Go中是这样。如果你只想发送一个整数数组,为什么必须先将它放入一个结构体中?

其次,protocol buffer可以指定,每当对类型T的值进行编码或解码时,字段T.x和T.y都必需存在。尽管这样的必需字段(required field)看起来是个好主意,但它们的实现成本很高,因为编解码器在编码和解码时必须保留一个独立的数据结构,以便能够在所需字段丢失时进行报告。这也是一个维护问题。随着时间的推移,可能需要修改数据结构定义以删除必需字段,但这可能会导致现有客户端运行崩溃。最好不要在编码中包含必需字段。(protocol buffer也可以定义可选字段。如果我们没有定义必需字段,那么所有字段都是可选的。稍后将有更多关于可选字段的内容。)

第三个protocol buffer的错误特性是默认值。如果protocol buffer省略了“默认”字段的值,则解码该结构体的行为就好像这些字段默认被设置为该值一样。当你有getter和setter方法来控制对字段的访问时,这种想法非常有效,但当容器只是一个普通的结构体(译者注:没有getter和setter方法)时,就很难干净地处理了。必需字段的实现也很棘手:默认值在哪里定义,它们有什么类型(文本是UTF-8编码的吗?还是没有解释的字节码?一个浮点数用几个bit位来表示?)尽管看起来很简单,但它们在protocol buffer的设计和实现中有许多复杂之处。我们决定把这一特性排除在外,回到Go语言的琐碎但有效的默认规则:除非你另有设置,否则字段的值为其类型的“零值”,不需要传输。

因此,gob最终看起来像是一种通用的、简化的protocol buffer。那么它是如何工作的呢?

字段的值

编码的gob数据与int8或uint16之类的类型无关。相反,有点类似于Go中的常量,它的整数值是抽象的、无尺寸(sizeless)的数字,有符号或无符号。当你对int8字段进行编码时,它的值将作为一个无尺寸、可变长的整数来传输。当你对int64字段进行编码时,它的值也会作为一个无尺寸、可变长的整数进行传输。(有符号和无符号被区别对待,但无符号值也是无尺寸的。)如果两者的值都为7,则在网络上发送的位将相同。当接收者解码该值时,将其放入接收者的变量中,该变量可以是任意整数类型。因此,编码器发送来自int8字段的值7,接收者可以将其存储在int64字段中。这很好:这个值是一个整数,只要尺寸合适,一切都可行。(如果不合适,就会产生错误。)这种变量与其尺寸的解耦为编码器的实现提供了一些灵活性:随着软件的发展,我们可以扩展整数变量的类型,但仍然能够解码旧数据。

这种灵活性也用于指针。在传输之前,所有指针都被压平。int8、*int8、**int8、***int8等类型的值都作为整数值传输,然后可以将其存储在任何尺寸的int、*int或******int等中。

在解码结构体时,只有编码器发送的字段才会存储在目标字段中。给定值

type T struct{ X, Y, Z int } // 只有导出的(公有的)字段才会被编码然后解码。
var t = T{X: 7, Y: 0, Z: 8}

t的编码后的数据仅发送7和8。因为字段Y的值是零,所以它甚至不会被发送;没有必要发送零值。

相反,接收者可以将该值解码成这样的结构体:

type U struct{ X, Y *int8 } //注意:Y是*int8类型
var u U

并且仅设置X字段的值(int8类型的名为X的变量设置为7);Z字段被忽略了——没有哪个字段是Z?在解码结构体时,字段按名称和兼容的类型进行匹配,并且只有两者中都存在的字段才会受到影响。这种简单的方法巧妙地解决了“可选字段”问题:随着T类型通过添加字段而演变,过时的接收者仍可以使用它能够识别的类型的一部分来解码数据。因此,gob提供了可选字段的重要特性——可扩展性——而不需要任何额外的机制或注释。

使用整型数,我们可以构建所有其他类型:字节、字符串、数组、切片、映射,甚至浮点数。浮点数由IEEE 754标准的浮点位模式表示,存储为整数,只要你知道它的类型,它就可以正常工作。顺便说一句,该整数是以字节反转的顺序发送的,因为浮点数的常见值,如小整数,在低位端有很多零,我们可以避免发送这些零。

gob包的一个很好的特性是,它允许你通过让你的类型满足GobEncoderGobDecoder接口来定义自己的编码,行为类似于JSON包的MarshallerUnmarshaler函数,也类似于fmt包的Stringer接口。通过此功能,可以在传输数据时表示特殊特性、强制执行约束或隐藏秘密数据。有关详细信息,请参阅文档

在网络上传输类型

第一次发送某个指定的类型时,gob包会在数据流中包含该类型的描述信息。事实上,编码器用标准的gob编码格式对内部结构体进行编码,该内部结构体描述类型信息并为其提供一个唯一的编号。(基本类型,加上描述类型的结构体的布局,是由引导程序预定义的。)在描述了类型之后,可以通过其类型编号来引用它。

因此,当我们发送第一个类型T时,gob编码器发送T的描述信息,并用一个类型号码标记它,比如127。然后,所有值(包括第一个值)都以该数字为前缀,因此T值的数据流看起来如下:

("define type id" 127, definition of type T)(127, T value)(127, T value), ...

这些类型号码使描述递归类型和发送这些类型的值成为可能。因此,gob可以对树等类型进行编码:

type Node struct {
    Value       int
    Left, Right *Node
}

(这是给读者的一个练习,可以发现零默认规则(zero-defaulting rule)是如何实现这一点的,尽管gob并不呈现指针。)

有了类型信息,gob数据流是完全自我描述的,除了几个引导类型之外,引导类型是一个明确定义的起点。

构建一个小型解释器

第一次对给定类型的值进行编码时,gob包会构建一个特定于该数据类型的小型解释器。Go通过对该类型的反射来构建解释器,但是一旦构建了解释器,它就不依赖于反射。该解释器使用unsafe包和一些技巧将数据高速转换为编码字节。我们也可以使用反射并避免unsafe包,但速度会慢得多。(Go的protocol buffer实现也采用了类似的高速方法,其设计受到了gob实现的影响。)相同类型的后续值使用已经编译得到的解释器,因此可以立即对它们进行编码。

[更新:从Go 1.4开始,unsafe包不再被gob包使用,性能略有下降。]

解码的过程是相似的,但更难实现。当你解码一个值时,gob包会保存一个字节切片,代表给定编码器要解码的类型的值,再加上一个要解码的Go值。gob包也会构建一个解码器:gob类型与对应的解码器代码一起在网络上发送。然而,一旦解码器构建完成,由于它是一个不使用反射机制的引擎,使用不安全的方法来获得最大速度。

用法

虽然幕后做了很多事情,但gob是一个高效、易于使用的数据传输编码系统。下面是一个完整的示例,显示了对不同类型地编码和解码。注意发送和接收值是多么的容易;你所需要做的就是向gob包呈现值和变量,它就可以完成所有的工作。

package main

import (
    "bytes"
    "encoding/gob"
    "fmt"
    "log"
)

type P struct {
    X, Y, Z int
    Name    string
}

type Q struct {
    X, Y *int32
    Name string
}

func main() {
    // 初始化编码器和解码器。一般来说,enc和dec会被绑定到网络连接,并且可能运行在不同的协程里。
    var network bytes.Buffer        // 代表一个网络连接
    enc := gob.NewEncoder(&network) // 将写入网络
    dec := gob.NewDecoder(&network) // 将从网络读取
    // 编码(发送)值
    err := enc.Encode(P{3, 4, 5, "Pythagoras"})
    if err != nil {
        log.Fatal("encode error:", err)
    }
    // 解码(接收)值
    var q Q
    err = dec.Decode(&q)
    if err != nil {
        log.Fatal("decode error:", err)
    }
    fmt.Printf("%q: {%d,%d}\n", q.Name, *q.X, *q.Y)
}

rpc包构建在gob之上,将这种编码/解码自动化转变为跨网络的方法调用以及数据传输。这将是另一篇文章的主题。

细节

gob包的文档,尤其是文件doc.go,扩展了这里描述的许多细节,并包括一个完整的示例,显示了编码如何表示数据。如果你对gob实现的内部结构感兴趣,那么这是一个很好的起点。

Cgo介绍

本文翻译自《C? Go? Cgo!》。

Andrew Gerrand

2011/03/17

介绍

Cgo可以让Go包调用C代码。给定一个使用一些特殊特性编写的Go源文件,cgo输出Go和C文件,它们可以组合成单个Go包。

举个例子,这里有一个Go包,它提供了两个函数——RandomSeed——用来包装C的random函数和srrandom函数。

package rand

/*
#include <stdlib.h>
*/
import "C"

func Random() int {
    return int(C.random())
}

func Seed(i int) {
    C.srandom(C.uint(i))
}

让我们看看这里发生了什么,从import语句开始。

rand包导入“C”,但你会发现在标准Go库中没有这个包。这是因为C是一个“伪包(pseudo-package)”,一个被cgo解释为引用C命名空间的特殊名称。

rand包包含对C包的四个引用:对C.randomC.srrandom的调用、C.uint(i)import "C"语句。

Random函数调用标准C库的random函数并返回结果。在C语言中,random返回一个long类型的值,cgo将其表示为C.long类型。必须将其转换为Go类型,才能由该包外的Go代码使用,使用普通的Go类型转换语句即可:

func Random() int {
    return int(C.random())
}

下面是一个等效函数,它使用一个临时变量来更明确地说明这种类型转换:

func Random() int {
    var r C.long = C.random()
    return int(r)
}

Seed函数在某种程度上起相反的作用。它获取一个常规的Go的int类型的变量,将其转换为C的unsigned int类型,并将其传递给C函数srrandom

func Seed(i int) {
    C.srandom(C.uint(i))
}

注意,cgo知道C语言的unsigned int类型用C.uint来表示;有关这些数值类型的名称的完整列表,请参阅cgo文档

我们还没有研究这个例子的一个细节是import语句上方的注释。

/*
#include <stdlib.h>
*/
import "C"

Cgo认识这一注释。任何以#cgo开头、后跟一个空格字符的行都将被删除;这些都成为cgo的指令。在编译Go包的C语言部分代码时,剩余的行用作C语言头部。在上例中,这些行只是一个#include语句,但它们几乎可以是任何C代码。在构建Go包的C语言部分时,#cgo指令用于为编译器和链接器提供标志。

有一个限制:如果你的程序使用任何//export指令,那么注释中的C代码可能只包括这种声明(extern int f();),而不是定义(int f() { return 1; })。你可以使用//export指令使Go函数可被C语言代码访问。

#cgo//export指令记录在cgo文档中。

字符串相关

与Go不同,C没有显式的字符串类型。C中的字符串由一个以零结尾的字符数组表示。

Go和C字符串之间的转换是通过C.CStringC.GoStringC.GoStringN函数完成的。这些转换返回字符串数据的一个拷贝。

下一个示例实现了一个Print函数,该函数使用C的stdio库中的fputs函数将字符串写到标准输出:

package print

// #include <stdio.h>
// #include <stdlib.h>
import "C"
import "unsafe"

func Print(s string) {
    cs := C.CString(s)
    C.fputs(cs, (*C.FILE)(C.stdout))
    C.free(unsafe.Pointer(cs))
}

Go的内存管理器看不见由C代码进行的内存分配。因此当你使用C.CString(或任何C内存分配)创建一个C字符串时,你必须记住在完成调用后使用C.free释放内存。

C.CString的调用返回一个指向char数组头部的指针,我们将其转换为一个unsafe.Pointer指针。并用C.free释放它指向的内存分配。cgo程序中的一个常见惯用法是在分配内存后立即defer释放(尤其是当后面的代码比单个函数调用更复杂时),例如以下对Print函数的重写:

func Print(s string) {
    cs := C.CString(s)
    defer C.free(unsafe.Pointer(cs))
    C.fputs(cs, (*C.FILE)(C.stdout))
}

构建cgo包

要构建cgo包,只需像往常一样使用go buildgo install即可。这些go工具能识别特殊的“C”导入,并自动使用cgo来处理。

cgo相关资源

cgo命令文档提供了关于C伪包和构建过程的更多详细信息。Go树中的cgo示例演示了更高级的概念。

最后,如果你想知道在内部这一切是如何工作的,请查看运行时包的cgocall.go的介绍性注释。

Gofix介绍

本文翻译自《Introducing Gofix》。

Russ Cox

2011/04/15

下一个Go版本将在几个基本Go包中进行API的重大更改。实现HTTP服务器处理程序调用net.Dial调用os.Open使用反射包的代码将不会生成成功,除非将其更新为使用新的API。现在我们的版本更加稳定,频率也降低了,这将是一种常见情况。每一个API更改都发生在不同的每周快照中,并且可能由它们的开发者自己单独管理;然而,这些更改加在一起,更新现有代码时就需要大量的手动工作。

Gofix是一种新工具,它减少了更新现有代码所需的工作量。它从源代码文件中读取程序代码,查找使用旧API的地方,将其重写为使用当前API,然后将程序代码写回源代码文件。并非所有API更改都保留了旧API的所有功能,因此gofix无法始终做到完美。当gofix无法重写使用旧API的代码时,它会打印一条警告,给出源代码文件名和行号,以便开发人员检查和重写相关代码。Gofix负责处理简单、重复、乏味的更改,这样开发人员就可以专注于真正值得关注的更改。

每次我们对API进行重大更改时,我们都会在gofix中添加代码,以尽可能机械地处理代码转换。当你更新到新的Go版本并且你的代码不再能构建成功时,只需在源代码目录上运行gofix即可。

你可以扩展gofix以支持对自己的API进行更改。gofix是一个简单的由插件驱动的程序,插件系统叫作fixes,其中每个插件(一个fix)处理一个特定的API更改。现在,编写一个新的修复程序需要对go/ast语法树进行一些扫描和重写,通常与API更改的复杂程度成比例。如果你想探索一下,netdialFixosopenFixhttpserverFixreflectFix都是说明性的例子,它们的复杂度依次增高。

当然,我们自己也编写Go代码,我们的代码和你的代码一样受到这些API更改的影响。通常,我们在更改API的同时编写gofix支持,然后使用gofix重写主(main)源代码树分支中的用法。我们使用gofix来更新其他Go代码库和我们的个人项目。当需要针对新的Go版本进行构建时,我们甚至会使用gofix来更新谷歌内部的源代码树。

例如,gofix可以重写fmt/print.go中的代码片段:

switch f := value.(type) {
case *reflect.BoolValue:
    p.fmtBool(f.Get(), verb, field)
case *reflect.IntValue:
    p.fmtInt64(f.Get(), verb, field)
// ...
case reflect.ArrayOrSliceValue:
    // Byte slices are special.
    if f.Type().(reflect.ArrayOrSliceType).Elem().Kind() == reflect.Uint8 {
        // ...
    }
// ...
}

为使用新的反射API:

switch f := value; f.Kind() {
case reflect.Bool:
    p.fmtBool(f.Bool(), verb, field)
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
    p.fmtInt64(f.Int(), verb, field)
// ...
case reflect.Array, reflect.Slice:
    // Byte slices are special.
    if f.Type().Elem().Kind() == reflect.Uint8 {
        // ...
    }
// ...
}

上面几乎每一行都有细微的变化。要重写的地方虽然有很多,但几乎完全是机械的,这正是计算机擅长做的事情。

Gofix之所以成为可能,是因为Go的标准库支持将Go源文件解析为语法树,也支持将这些语法树打印回Go源代码。重要的是,Go打印库(printer)以官方推荐的格式打印程序源码(通常通过gofmt工具强制执行),允许gofix对Go程序进行机械地更改,而不会导错误的格式更改。事实上,创建gofmt的一个关键动机可能仅次于避免关于括号应该放在何处的争论,那就是让重写Go程序源码的工具变得简单,无论是创建还是使用,例如gofix。

Gofix已经变得不可或缺。特别是,如果没有自动转换,最近对反射API的更改将是不受欢迎的,并且难以修改之前程序源码里的反射API。Gofix使我们能够修复错误或完全重新思考API,而无需担心转换现有代码所需的成本。我们希望你能发现gofix这个工具是有用和方便的。

使用Gofmt工具格式化Go代码

本文翻译自《go fmt your code》。

Andrew Gerrand 2013/01/23

介绍

Gofmt是一个用来自动格式化Go源代码的工具。

Gofmt的代码:

  • 更易于编写:在编写时永远不要担心小的格式问题,
  • 更容易阅读:当所有代码看起来都一样时,你不需要在心里把别人的格式风格转换成你能理解的东西。
  • 更易于维护:对源码的细节更改不会导致对文件格式的不相关更改;diffs(差异)只显示真正的变化。
  • 无争议:永远不会再争论空格或括号位置这些问题了!

格式化你的代码

我们最近对野外(非官方)的Go软件包进行了一项调查,发现大约70%的软件包是根据gofmt规则格式化的。这比预期的要多,感谢所有使用gofmt的人,但如果能继续缩小差距就太好了。

要格式化Go代码,可以直接使用gofmt工具:

gofmt -w yourcode.go

或者你也可以使用“go fmt”命令:

go fmt path/to/your/package

为了帮助你保持代码的风格规范,Go存储库包含编辑器和版本控制系统的钩子,使你可以轻松地在代码上运行gofmt。

对于Vim用户,Go的Vim插件包含了在当前缓冲区上运行gofmt的Fmt命令。

对于emacs用户,go-mode.el提供了一个保存前执行gofmt的钩子,可以通过将此行添加到.emacs文件来安装:

(add-hook 'before-save-hook #'gofmt-before-save)

对于Eclipse或SublimeText用户,GoClipseGoSublime项目为这些编辑器添加了一个gofmt功能。

对于Git爱好者来说,misc/Git/precommit脚本是一个commit之前的钩子,可以防止提交格式错误的Go代码。如果你使用Mercurial,hgstyle插件提供了一个gofmt预提交钩子。

机器修改源代码

使用机器格式化代码最大的优点之一是可以机械地转换代码格式,而不会产生与格式化无关的东西。当你使用的代码库很庞大时,机械转换是无价的,因为它比手工进行大范围的更改更加全面,也更不容易出错。事实上,当工作规模很大时(就像我们在谷歌所做的那样),手动进行代码更改通常是不现实的。

使用机器操作Go代码的最简单方法是使用gofmt命令的-r标志。标志指定代码格式的重写规则:

pattern -> replacement

其中模式(pattern)和替换(replacement)都是有效的Go表达式。在该模式中,单字符小写标识符用作匹配任意子表达式的通配符,并且这些表达式在replacement中被替换为相同的标识符。

例如,最近对Go核心的更改重写了对bytes.Compare方法的一些使用,以改为使用更高效bytes.Equal方法。仅仅使用两个gofmt命令就修改成功:

gofmt -r 'bytes.Compare(a, b) == 0 -> bytes.Equal(a, b)'
gofmt -r 'bytes.Compare(a, b) != 0 -> !bytes.Equal(a, b)'

Gofmt还使用了gofix,它可以进行任意复杂的源代码转换。在早期,当我们经常对语言和库进行破坏性的更改时,Gofix是一个非常宝贵的工具。例如,在Go 1之前,内置的error接口还不存在,约定是使用os.Error类型。当我们引入error接口时,我们提供了一个gofix模块,它重写了对os.Error及其相关助手函数的所有引用,以使用error接口和新的errors。尝试手工更改代码可能会让人望而却步,但有了标准格式的代码,准备、执行和审查这一变化就相对容易,这几乎涉及到了现有的所有Go代码。

有关gofix的更多信息,请参阅本文

教程:泛型入门

本文翻译自《Tutorial: Getting started with generics》。

目录

前提条件

为代码新建一个文件夹

添加两个非泛型函数

添加一个泛型函数来处理多种类型

调用泛型函数时删除类型参数

声明一个类型约束

结论

完整的代码

本教程介绍Go中泛型的基本知识。使用泛型,你可以声明和使用函数或类型,这些函数或类型可以与调用它们的代码提供的任意一组类型一起工作。 在本教程中,你将声明两个简单的非泛型函数,然后在单个泛型函数中实现相同的逻辑。

前提条件

  • 安装Go 1.18或更高版本。有关安装说明,请参阅安装Go
  • 用于编辑代码的工具。你拥有的任何文本编辑器都可以正常工作。
  • 一种命令行终端。Go在Linux和Mac上的任何命令行终端,以及Windows中的PowerShell或cmd上都能很好地工作。

为代码新建一个文件夹

首先,为你要编写的代码创建一个文件夹。

1 打开命令提示符并更改到家目录。 在Linux或Mac上:

$ cd

在Windows上:

C:\> cd %HOMEPATH%

本教程的其余部分将把$作为提示符。你使用的命令也适用于Windows。

2 在命令提示符下,为代码创建一个名为generics的目录。

$ mkdir generics
$ cd generics

3 创建一个模块来保存你的代码。

运行go mod init命令创建一个名为example/generics的模块。

$ go mod init example/generics
go: creating new go.mod: module example/generics

注意:对于生产代码,你应该指定一个更适合自己需求的模块名称。有关详细信息,请参阅管理依赖关系

接下来,你将添加一些简单的代码来处理映射(map)。

添加两个非泛型函数

在这一步中,你将添加两个函数,每个函数将一个map里的值相加并返回总数。

你要声明两个函数而不是只声明一个,因为你使用的是两个不同类型的map:一个存储int64值,另一个存储float64值。

编写代码

1 使用文本编辑器,在generics目录中创建一个名为main.go的文件,在该文件中编写Go代码。

2 在main.go文件的顶部,粘贴以下包声明。

package main

与函数库相对的独立可执行的Go程序,始终位于main包中。

3 在声明main包代码的下面,粘贴以下声明两个函数的代码。

// SumInts函数把映射m的所有int64值加起来。
func SumInts(m map[string]int64) int64 {
    var s int64
    for _, v := range m {
        s += v
    }
    return s
}

// SumFloats函数把映射m的所有float64值加起来。
func SumFloats(m map[string]float64) float64 {
    var s float64
    for _, v := range m {
        s += v
    }
    return s
}

在此代码中,你:

声明两个函数,将映射m的值相加并返回其总和。

  • SumFloats函数使用字符串键到float64值的映射m
  • SumInts函数采用字符串键到int64值的映射m

4 在main.go的顶部,包声明的下面,粘贴以下main函数代码来初始化这两个映射,并作为实参分别调用上一步中声明的函数。

func main() {
    // 初始化字符串键整型数值的一个map
    ints := map[string]int64{
        "first":  34,
        "second": 12,
    }

    // 初始化字符串键浮点数值的一个map
    floats := map[string]float64{
        "first":  35.98,
        "second": 26.99,
    }

    fmt.Printf("Non-Generic Sums: %v and %v\n",
        SumInts(ints),
        SumFloats(floats))
}

在此代码中,你:

  • 初始化一个int64值的映射和一个float64值的映射,每个映射里面都有两个条目。
  • 调用前面声明的两个函数,以分别计算每个映射的值的总和。
  • 打印输出结果。

5 在main.go的顶部附近,就在包声明的下方,导入你需要支持刚刚编写的代码的包。 前几行代码应该如下所示:

package main
import "fmt"

6保存代码文件。

运行代码

在包含main.go的目录中的命令行终端中,运行代码:

$ go run .
Non-Generic Sums: 46 and 62.97

使用泛型,你只需编写一个函数,而不是两个。接下来,你将为包含整数值或浮点值的映射添加一个泛型函数。

添加一个泛型函数来处理多种类型

在本节中,你将添加一个泛型函数,该函数可以接收包含整数值或浮点值的映射,从而用这一个函数有效地替换你刚刚编写的两个函数。

要支持多种类型的值,该函数需要一种方式来声明它支持哪些类型。另一方面,调用该函数的代码需要用一种方式来指定是使用整数值还是浮点值的映射。

为了支持这一点,你编写的这个函数,除了声明普通函数参数之外,还要声明类型参数(type parameter)。这些类型参数使函数具有通用性(成为泛型函数),使其能够处理不同类型的实参。你将使用类型参数和普通函数实参来调用该函数。

每个类型参数都有一个类型约束(type constraint),它充当类型参数的一种元类型(meta type)。每个类型约束都指定了允许的具体类型,调用泛型函数的代码可以将这些具体类型传入相应的类型参数。

虽然一个类型参数可以通过类型约束指定一组类型,但在编译时,一个类型参数代表单个类型——由调用泛型函数的代码提供一个具体的类型。如果一个类型参数的类型约束不允许某个具体类型,代码就会编译报错。

请记住,类型参数所代表的具体类型必须支持泛型代码对其执行的所有操作。例如,如果泛型函数的代码试图对代表数值类型的类型参数执行字符串操作(例如下标操作),代码就会编译报错。

在你即将编写的代码中,你将使用一个允许int64float64类型的类型约束。

编写代码

1 在前面添加的两个函数下面,粘贴以下泛型函数。

// SumIntsOrFloats函数计算映射m的值的总和,同时支持int64或float64类型的值。
func SumIntsOrFloats[K comparable, V int64 | float64](m map[K]V) V {
    var s V
    for _, v := range m {
        s += v
    }
    return s
}

在上述代码中,

在上述代码中,

  • 声明一个SumIntsOrFloats函数,该函数具有两个类型参数(位于方括号内)KV,以及一个使用类型参数的普通函数参数,即类型是map[K]Vm。该函数返回V类型的值。
  • 为类型参数K指定类型约束comparable。专门针对此类情况,在Go中预先声明了comparable类型约束。它允许其值可用作比较运算符==!=的操作数的任何类型。Go要求映射的键是可比较的。因此必须将K声明为可比较的,这样你就可以将K用作映射中的键。它还能确保别人调用上述函数时传入允许的键类型。
  • V类型参数指定一个约束,它是两种类型的联合:int64float64。使用|运算符指定两种类型的联合,这意味着此约束允许其中任何一种类型。编译器将允许其中任何一种类型作为别人调用上述函数时传入的类型参数。
  • 指定函数参数m的类型为map[K]V,其中KV是已经为类型参数指定的类型。请注意,我们知道map[K]V是有效的映射类型,因为K是可比较的类型。如果我们没有声明K是可比较的类型,编译器将拒绝对map[K]V的使用。

在main.go中,在你已有的代码下方,粘贴以下代码。

fmt.Printf("Generic Sums: %v and %v\n",
    SumIntsOrFloats[string, int64](ints),
    SumIntsOrFloats[string, float64](floats))

在此代码中,你:

  • 调用你刚刚声明的泛型函数,传递你创建的每个map。
  • 指定类型参数——方括号中的类型名称——以清楚地指出在你调用的函数中替换类型参数的具体类型。

正如你将在下一节中看到的,通常可以在函数调用中省略类型参数。Go通常可以从你的代码中推断出它们。

  • 打印输出函数返回的总和。

运行代码

在包含main.go的目录的命令行运行代码:

$ go run .
Non-Generic Sums: 46 and 62.97
Generic Sums: 46 and 62.97

为了运行你的代码,在每次调用中,编译器将类型参数替换为指定的具体类型。

在调用你编写的泛型函数时,你给出了类型参数,告诉编译器具体该使用什么类型来代替函数的类型参数。正如你将在下一节中看到的,在许多情况下,你可以省略这些类型参数,因为编译器可以推断出它们。

调用泛型函数时删除类型参数

在本节中,你将增加调用泛型函数的代码的一个修改版本,简化了调用代码。你将删除在本例中不需要的类型参数。

当Go编译器可以推断出要使用的具体类型时,可以在调用代码中省略类型参数。编译器可以从函数实参的类型中推断出类型参数。

请注意,这并不总是可行的。例如,如果需要调用没有普通参数的泛型函数,则需要在函数调用的代码中给出类型实参。

编写代码

在main.go中,在你已经拥有的代码下面,粘贴以下代码。

fmt.Printf("Generic Sums, type parameters inferred: %v and %v\n",
    SumIntsOrFloats(ints),
    SumIntsOrFloats(floats))

运行代码

在包含main.go的目录的命令行运行代码:

$ go run .
Non-Generic Sums: 46 and 62.97
Generic Sums: 46 and 62.97
Generic Sums, type parameters inferred: 46 and 62.97

接下来,你将int64float64类型的并集定义为可以重用的类型约束(例如被其他代码使用),来进一步简化该泛型函数。

声明一个类型约束

在本节中,你将把前面定义的类型约束移动到它自己的接口中,这样你就可以在多个地方重用它。以这种方式声明的类型约束有助于简化代码,例如当类型约束很复杂时。

你可以将类型约束声明为接口。类型约束允许使用实现该接口的任何类型。例如,如果用三个方法声明类型约束的接口,然后将其与泛型函数中的类型参数一起使用,那么用于调用该函数的类型实参必须具有所有这些方法。

类型约束的接口也可以引用特定的类型,正如你将在本节中看到的那样。

编写代码

1 就在main函数之上,在import语句之后立即粘贴以下代码来声明一个类型约束。

type Number interface {
    int64 | float64
}

在此代码中,你:

  • 声明要用作类型约束的Number接口类型。
  • 在接口内声明int64float64的并集。

从本质上讲,你正在将int64float64的并集从函数声明移动到一个新的类型约束中。这样,当你想将类型参数约束为int64float64时,可以使用此Number类型约束,而不是写int64|float64

2 在已有的函数下面,粘贴以下泛型函数SumNumbers的代码。

// SumNumbers计算映射m的值的总和。m的值的类型可以是整数或浮点数。
func SumNumbers[K comparable, V Number](m map[K]V) V {
    var s V
    for _, v := range m {
        s += v
    }
    return s
}

在此代码中,你:

使用新的接口类型而不是类型并集作为类型约束来声明一个泛型函数。和以前一样,你将类型参数用于普通函数参数和返回值的类型。

3 在main.go中,在你已经拥有的代码下面,粘贴以下代码。

fmt.Printf("Generic Sums with Constraint: %v and %v\n",
    SumNumbers(ints),
    SumNumbers(floats))

在此代码中,你:

对每个映射调用SumNumbers函数,打印输出每个映射的值的总和。

如前一节所述,在对该泛型函数的调用中省略了类型参数(方括号中的类型名称)。Go编译器可以从函数实参中推断出类型参数。

运行代码

在包含main.go的目录的命令行运行代码:

$ go run .
Non-Generic Sums: 46 and 62.97
Generic Sums: 46 and 62.97
Generic Sums, type parameters inferred: 46 and 62.97
Generic Sums with Constraint: 46 and 62.97

结论

你刚刚学习了Go中的泛型。

建议学习的下一个主题:

  • Go Tour手把手教你入门Go语言基础知识。
  • 你可以在Effective Go中找到有用的Go语言最佳实践。

完整的代码

package main

import "fmt"

type Number interface {
    int64 | float64
}

func main() {
    // 初始化字符串键整型数值的一个map
    ints := map[string]int64{
        "first": 34,
        "second": 12,
    }

    // 初始化字符串键浮点数值的一个map
    floats := map[string]float64{
        "first": 35.98,
        "second": 26.99,
    }

    fmt.Printf("Non-Generic Sums: %v and %v\n",
        SumInts(ints),
        SumFloats(floats))

    fmt.Printf("Generic Sums: %v and %v\n",
        SumIntsOrFloats[string, int64](ints),
        SumIntsOrFloats[string, float64](floats))

    fmt.Printf("Generic Sums, type parameters inferred: %v and %v\n",
        SumIntsOrFloats(ints),
        SumIntsOrFloats(floats))

    fmt.Printf("Generic Sums with Constraint: %v and %v\n",
        SumNumbers(ints),
        SumNumbers(floats))
}

// SumInts 函数把映射m的所有int64值加起来。
func SumInts(m map[string]int64) int64 {
    var s int64
    for _, v := range m {
        s += v
    }
    return s
}

// SumFloats 函数把映射m的所有float64值加起来。
func SumFloats(m map[string]float64) float64 {
    var s float64
    for _, v := range m {
        s += v
    }
    return s
}

// SumIntsOrFloats 泛型函数SumIntsOrFloats计算映射m的值的总和,同时支持int64或float64类型的值。
func SumIntsOrFloats[K comparable, V int64 | float64](m map[K]V) V {
    var s V
    for _, v := range m {
        s += v
    }
    return s
}

// SumNumbers 泛型函数SumNumbers计算映射m的值的总和。m的值的类型可以是整数或浮点数。使用新的接口类型而不是类型并集作为类型约束
func SumNumbers[K comparable, V Number](m map[K]V) V {
    var s V
    for _, v := range m {
        s += v
    }
    return s
}

Server sent charset (255) unknown to the client. Please, report to the developers的解决方法

MySQL 8将默认字符集更改为utf8mb4。但是有些客户端不知道这个字符集。因此,当服务器向客户端报告其默认字符集,而客户端不知道其含义时,就会抛出此错误。

该错误针对C++实现的MySQL Connector,因此它影响的不仅仅是PHP。

正确的解决方案是升级你的客户端,但与此同时我通过将服务器的字符集更改为utf8来使其正常工作,以与未升级的客户端兼容。我将以下配置添加到/etc/my.cnf并重新启动mysqld:

[mysqld]
collation-server = utf8_unicode_ci
character-set-server = utf8

[client]
default-character-set=utf8

[mysql]
default-character-set=utf8

参考

https://stackoverflow.com/questions/43437490/pdo-construct-server-sent-charset-255-unknown-to-the-client-please-rep

https://bugs.mysql.com/bug.php?id=71606

Go语言database/sql包教程

本文翻译自《Go database/sql tutorial》。

在Go中使用SQL或类SQL的数据库的惯用方法是通过database/sql包。它为面向行的数据库提供了一个轻量级的接口。这个网站是关于如何使用它的最常见的方面的参考。

为什么需要这样做?这个包的文档告诉了你所有的功能,但并没有告诉你如何使用这个它。我们中的许多人发现自己希望获得一份入门手册,即讲故事,而不是列事实。欢迎你来做贡献:请在此处发送拉取请求。

概述

要在Go中访问数据库,你需要使用sql.DB。你可以使用sql.DB类型来创建语句和事务、执行查询和获取结果。

你应该知道的第一件事是sql.DB不是一个数据库连接。它也没有映射到任何特定数据库软件的“数据库”或“模式”这些概念。它是数据库的接口和数据库本身的抽象,抽象的内部可以多种多样:数据库可以是本地文件,可以通过网络连接访问,也可以存在于内存和进程中。

sql.DB在幕后为你执行一些重要任务:

  • 它通过数据库驱动程序打开和关闭与实际底层数据库的连接。
  • 它根据需要管理一个连接池,其中可能包括前面提到的各种内容。

sql.DB抽象旨在避免你担心如何管理对底层数据存储的并发访问。当你使用连接执行任务时,它会被标记为正在使用,然后在不再使用时返回到可用连接池。这样做的一个后果是,如果无法将连接释放回到连接池,可能会导致sql.DB打开大量连接,从而可能耗尽资源(连接太多、打开的文件句柄太多、缺少可用的网络端口等)。我们稍后将对此进行更多讨论。

在创建了一个sql.DB之后,你可以使用它来查询它所代表的数据库,以及创建语句和事务。

导入一个数据库驱动

要使用database/sql,你需要该包本身,和要使用的特定数据库的驱动程序。

你通常不应该直接使用特定数据库的驱动程序包,尽管一些驱动程序鼓励你这样做。(在我们看来,这通常是个坏主意。)相反,如果可能,你的代码应该只引用database/sql中定义的类型。这有助于避免让你的代码依赖于特定的驱动程序,当需要更改底层的数据库驱动程序(以及你正在访问的数据库)时,你就可以通过最少的代码来更改。你应该使用Go语言的惯用法,而不是驱动程序作者可能提供的特定的惯用法。

在本文档中,我们将使用@julienschmidt和@arnehormann开发的优秀的MySQL驱动程序作为示例。 将以下内容添加到Go源代码文件的顶部:

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

请注意,我们正在匿名加载这个驱动程序,将其包的别名设置为_,因此我们的代码看不到任何它的导出名称。在底层,该驱动程序将自己注册为对database/sql包可用,通常除了运行包的init函数之外没有其他任何事情发生。

现在你已准备好了访问数据库。

访问数据库

现在你已经加载了数据库驱动程序包,你已准备好创建一个数据库对象,一个sql.DB

要创建sql.DB,你可以使用sql.Open(),返回一个*sql.DB

func main() {
	db, err := sql.Open("mysql",
		"user:password@tcp(127.0.0.1:3306)/hello")
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()
}

在以上示例中,我们说明了几件事:

1 sql.Open函数的第一个参数是驱动程序的名称。这是驱动程序用来向database/sql注册自己的字符串,通常与包名相同以避免混淆。例如,上例中是github.com/go-sql-driver/mysql包的mysql。一些驱动程序不遵循这一约定使用对应的数据库名称,例如github.com/mattn/go-sqlite3包的sqlite3github.com/lib/pq包的postgres

2 第二个参数是特定于驱动程序的数据源名称字符串(译者注:数据库连接字符串),它告诉驱动程序如何访问底层的数据存储。在此示例中,我们连接到本地的MySQL服务器中的“hello”数据库实例。

3 你应该(几乎)总是检查和处理从所有database/sql操作返回的错误。稍后我们将讨论一些特殊情况,在这些情况下这样做没有意义。

4 如果sql.DB的生命周期不应超出调用它函数的范围,则defer db.Close()是惯用的做法。

也许与直觉相反,sql.Open()并不建立任何与数据库的连接,也不验证驱动程序连接参数。相反,它只是为以后的使用做好准备,在程序确实需要用到与底层数据存储的实际连接时,才真正建立第一个连接。如果你想立即检查数据库是否可用和可访问(例如,检查你是否可以建立网络连接并登录),请使用db.Ping()来执行此操作,并记住检查是否返回了一个错误:

err = db.Ping()
if err != nil {
	// 错误处理
}

尽管在完成数据库时Close()是惯用方法,但sql.DB对象被设计为长寿命的。不要经常Open()Close()数据库。相反,为你需要访问的每个不同的数据存储(数据库)创建一个sql.DB对象,并保留它,直到程序完成对该数据存储的访问。根据需要传递,或者以某种方式在全局范围内提供,保持打开状态。不要从一个短暂的函数中Open()Close()。相反,将sql.DB作为参数传递到那个短暂的函数中。

如果你不将sql.DB视为一个长时间存活的对象,你可能会遇到一些问题,例如连接的重用和共享不足、可用网络资源不足,或者由于许多TCP连接仍处于TIME_WAIT状态而出现偶发故障。这些问题表明你没有按照设计使用database/sql

现在是时候使用sql.DB对象了。

获取结果集

有几个惯用操作可以从数据存储中检索结果:

1 执行返回多行数据的查询。

2 准备一个要重复使用的SQL预处理语句,多次执行,然后销毁它。

3 以一次性的方式执行SQL语句,而无需为重复使用做准备。

4 执行一个返回单行数据的查询。对于这种特殊情况,有一种获取数据的快捷的方式。

Go的database/sql包里的函数名称非常重要。如果函数名中包含Query,那么它被设计为向数据库询问问题,并返回一组行数据或空。不返回行数据的语句不应该使用Query函数;他们应该使用Exec函数。

从数据库中获取数据

让我们看一个如何查询数据库并处理返回结果的示例。我们将在users表中查询id1的用户,并打印出该用户的idname。我们将把结果分配给变量,一次一行,用rows.Scan()函数。

var (
	id int
	name string
)
rows, err := db.Query("select id, name from users where id = ?", 1)
if err != nil {
	log.Fatal(err)
}
defer rows.Close()
for rows.Next() {
	err := rows.Scan(&id, &name)
	if err != nil {
		log.Fatal(err)
	}
	log.Println(id, name)
}
err = rows.Err()
if err != nil {
	log.Fatal(err)
}

以下是上面代码中发生的情况:

1 我们使用db.Query()将查询发送到数据库。我们像往常一样检查错误。

2 我们推迟执行rows.Close()。这非常重要。

3 我们用rows.Next()对行进行迭代。

4 我们使用rows.Scan()将每一行中的列值读取到对于的变量里。

5 在完成对行的迭代之后,我们检查这一过程中是否有错误发生。

这几乎是Go语言中唯一的方式。例如,你不能将一行数据读取到一个map里。这是因为所有东西都是强类型的。你需要创建正确类型的变量,并将指针传递给rows.Scan(),如上所示。

其中有几个部分很容易出错,并可能会产生不良后果:

  • 你应该始终在for rows.Next()循环的末尾检查是否有错误发生。如果在循环过程中出现错误,你需要了解它。不要只假设循环会正常迭代完毕所有行。
  • 其次,只要有一个打开的结果集(由rows表示),底层连接就很忙,不能用于任何其他查询。这意味着它在连接池中不可用。如果你使用rows.Next()遍历所有行,最终你将读取完最后一行,rows.Next()将遇到内部EOF错误并为你调用rows.Close()。但是,如果出于某种原因退出了那个循环——提前返回,等等——那么这些rows就不会关闭,底层数据库连接仍然是打开的。(不过,如果rows.Next()由于错误而返回false,它将会自动关闭)。记得defer rows.Close(),否则很容易耗尽资源。
  • 如果rows已经关闭,那么rows.Close()已是一个无害的空操作(no-op),所以你可以多次调用它。然而,请注意,我们首先检查是否有错误发生,如果没有错误发生才调用rows.Close(),以避免运行时panic
  • 你应该始终defer rows.Close(),即使你也在循环结束时显式调用rows.Close(),这也不是一个坏主意。
  • 不要在循环体中deferdefer语句要等到函数退出后才能执行,所以长时间运行的函数不应该使用它。如果这样做,你会慢慢耗尽内存。如果在循环中重复查询和使用结果集,那么在处理完每个结果后,应该显式调用rows.Close(),而不要使用defer

Scan()是怎么工作的

当你迭代行并将其中的列值扫描到目标变量中时,Go会在后台为你执行数据类型转换工作。它基于目标变量的类型。意识到这一点可以清理代码并避免重复工作。

例如,假设你从包含字符串列值的表中选择一些行,例如包含VARCHAR(45)的列或类似的行。然而,你碰巧知道,这个字符串列总是包含数字。如果将指针传递给字符串,Go将把字节复制到字符串中。现在,你可以使用strconv.ParseInt()或类似的方法将值转换为数字。你必须检查SQL操作中是否有错误发生,以及解析整数时是否有错误发生。这既混乱又乏味。

或者,你可以直接向Scan()传递一个指向整数的指针。Go将检测到这一点,并为你调用strconv.ParseInt()。如果转换中出现错误,调用Scan()会返回错误。你的代码现在更整洁、更小了。这是我们建议使用database/sql包的方式。

准备语句(预处理语句)

通常,你应该始终准备要多次使用的查询语句。准备查询语句的结果是一个准备好的语句(译者注:准备语句,预处理语句),它可以为语句的参数提供占位符(也称为绑定值)。由于所有常见的原因(例如,避免SQL注入攻击),这比连接SQL语句加参数值字符串的做法要好得多。

在MySQL中,参数占位符是?,在PostgreSQL中是$N,其中N是一个数字。SQLite接受其中任何一种。在Oracle中,占位符以冒号开头并命名,如:param1。我们将使用?因为我们使用MySQL作为示例。

stmt, err := db.Prepare("select id, name from users where id = ?")
if err != nil {
	log.Fatal(err)
}
defer stmt.Close()
rows, err := stmt.Query(1)
if err != nil {
	log.Fatal(err)
}
defer rows.Close()
for rows.Next() {
	// ...
}
if err = rows.Err(); err != nil {
	log.Fatal(err)
}

在底层,db.Query()实际上准备、执行和关闭一个准备好的语句。这会对数据库进行三次往返访问。如果你不小心,你的应用程序进行的数据库交互次数可能会增加三倍!某些驱动程序可以在特定情况下避免这种情况,但并非所有驱动程序都这样做。有关详细信息,请参阅准备好的语句

查询单行

如果查询最多返回一行,则可以使用以下快捷的方式绕过一些冗长的样板代码:

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

查询中的错误会延迟到调用Scan(),然后返回。准备好的语句也可以调用QueryRow()

stmt, err := db.Prepare("select name from users where id = ?")
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)

修改数据和使用事务

现在我们已经准备好了解如何修改数据和处理事务。如果你习惯于使用“语句(statement)”对象来获取行和更新数据的编程语言,那么Go语言的做法与它们有点区别,这种区别看起来是人为的,但有一个重要的原因。

修改数据的语句

使用Exec(),最好与准备语句(预处理语句)一起使用,以完成INSERTUPDATEDELETE或其他不返回行数据的语句。以下示例显示如何插入行并检查有关操作的元数据:

stmt, err := db.Prepare("INSERT INTO users(name) VALUES(?)")
if err != nil {
	log.Fatal(err)
}
res, err := stmt.Exec("Dolly")
if err != nil {
	log.Fatal(err)
}
lastId, err := res.LastInsertId()
if err != nil {
	log.Fatal(err)
}
rowCnt, err := res.RowsAffected()
if err != nil {
	log.Fatal(err)
}
log.Printf("ID = %d, affected = %d\n", lastId, rowCnt)

执行Exec()会返回一个sql.Result,它可以访问语句的元数据:最后插入行的ID和受影响的行数。

如果你不在乎执行结果怎么办?如果你只想执行一条语句并检查是否有任何错误发生,但忽略执行的结果怎么办?下面两行代码会做同样的事情吗?

_, err := db.Exec("DELETE FROM users")  // 可行
_, err := db.Query("DELETE FROM users") // 不可行

答案是不一样。它们不做同样的事情,你不应该像这样使用Query()Query()将返回一个sql.Rows,它保留数据库连接直到sql.Rows关闭。由于可能有未读数据(例如多行数据的情况),因此无法重用连接。在上面的示例中,连接将永远不会被再次释放。垃圾收集器最终会为你关闭底层的net.Conn,但这可能需要很长时间之后。此外,database/sql包一直在连接池中跟踪这条连接,希望你在某个时候释放它,以便可以再次使用该连接。因此,这种反模式可能会耗尽资源(例如,太多连接)。

使用事务

在Go中,事务本质上是一个保留到数据库连接的对象。它允许执行到目前为止我们见过的所有操作,并且保证它们将在同一条连接上执行。

通过调用db.begin()开始事务,并对生成的Tx变量使用Commit()Rollback()方法结束事务。在底层,Tx从连接池中获取一条连接,并将其保留为仅用于该事务。sql.DB的方法Tx也拥有对应的方法,例如Query()等等。

在事务中创建的准备语句专门绑定到该事务。有关详细信息,请参阅准备好的语句

你不应该在代码中混合使用事务相关的函数(例如Begin()Commit())和SQL语句(例如BeginCommit)。可能会出现以下不好的结果:

  • Tx对象保持打开的状态,保留连接池中的一条连接而不返回。
  • 数据库的状态可能与用来表示它的Go变量的状态不同步。
  • 你可能会认为你在事务内部的一个连接上执行查询,而实际上Go已经无形地为你创建了几个连接,而有些语句不属于事务的一部分。

在事务内部工作时,应注意不要调用db变量。应该使用db.Begin()创建的Tx变量进行所有调用。db不在事务中,只有Tx对象在事务中。如果你调用db.Exec()或类似的函数,这些调用将发生在该事务范围之外的其他连接上,不在该事务中执行。

如果你需要执行修改连接状态的多个语句,即使你本身不想使用事务,也需要Tx。例如:

  • 创建仅对一个连接可见的临时表。
  • 设置数据库变量,例如MySQL的SET @var := somevalue语法。
  • 更改数据库的连接选项,例如字符集或超时时间。

如果你需要做这些事情中的任何一件,你需要将你的活动绑定到一个连接上,而在Go中做到这一点的唯一方法就是使用Tx

使用准备好的语句

准备好的语句具有Go中所有常见的好处:安全、高效、方便。但它们的实现方式与你习惯的可能有点不同,尤其是在如何与database/sql包的一些内部交互方面。

准备好的语句和数据库连接

在数据库级别,准备好的语句被绑定到单个数据库连接。典型的流程是,客户端向服务器发送一个带有参数占位符的SQL语句进行准备,服务器生成该语句的一个ID进行响应,然后客户端通过发送其ID和参数来执行该语句。

然而,在Go中,数据库连接不会直接暴露给database/sql包的用户。你不是在一条数据库连接上准备SQL语句。你可以在一个DBTx的实例上准备它。database/sql包有一些方便的行为,比如自动重试。由于这些原因,存在于驱动程序级别的准备好的语句和数据库连接之间的潜在关联,对代码来说是透明的。

以下是它的工作原理:

1 准备语句时,它是在连接池中的某条连接上准备的。

2 Stmt实例会记住使用了哪条连接。

3 当你执行Stmt时,它会尝试使用该连接。如果该连接因为关闭或忙于做其他事情而不可用,它将从连接池中获取另一条连接,并在这条连接上重新准备语句

由于语句在原始连接繁忙时,会根据需要重新准备,因此当数据库在高并发使用时,可能会使许多连接繁忙,从而创建大量准备好的语句。这可能会导致明显的语句泄漏,语句的准备和重新准备的频率比你想象的要高,甚至会遇到服务器端对语句数量的限制。

避免准备好的语句

Go在底层为你创建准备好的语句。例如,一个简单的db.Query(sql, param1, param2)的工作原理是准备SQL语句,然后用参数执行它,最后关闭该语句。

然而,某些时候准备好的语句并不是你想要的,可能有如下几个原因:

1 某些数据库不支持准备好的语句。例如,当使用MySQL驱动程序时,你可以连接到MemSQL和Sphinx,因为它们支持MySQL连接协议。但它们不支持包含准备好的语句的“二进制”协议,因此它们可能会以令人困惑的方式执行失败。

2 某些SQL语句的复用程度不足以使用准备好的语句,而且安全问题可以用其他方式处理,因此无需额外性能开销。这方面的一个例子可以在VividCortex的博客上看到。

如果你不想使用准备好的语句,你需要使用fmt.Sprint()或类似的方法来组装SQL,并将其作为唯一的参数传递给db.Query()db.QueryRow()。你的驱动程序需要支持执行纯文本查询,这是通过ExecerQueryer接口在Go 1.1中实现的,文档在此

在事务中使用准备好的语句

Tx实例中创建的准备好的语句专门绑定到Tx实例本身,因此前文关于重新准备的注意事项不再适用。当你对Tx实例进行操作时,你的操作会直接使用Tx实例底层连接的唯一一条连接。

这也意味着在Tx实例中创建的准备好的语句不能与它分开使用。同样,在DB中创建的准备好的语句也没法在事务中使用,因为它们被绑定到不同的数据库连接。

要在Tx中使用在事务外部准备的语句,可以使用Tx.Stmt(),它将从事务外部的准备好的语句创建一个新的绑定到本事务的准备好的语句。它通过获取一个已准备好的现有语句,设置与事务的连接,并在每次执行时重新准备所有语句来实现这一点。这种行为及其实现是不可取的,甚至在database/sql包源代码中有一个TODO来改进它;我们建议不要使用这种方法。

在事务中使用准备好的语句时必须谨慎。考虑以下示例:

tx, err := db.Begin()
if err != nil {
	log.Fatal(err)
}
defer tx.Rollback()
stmt, err := tx.Prepare("INSERT INTO foo VALUES (?)")
if err != nil {
	log.Fatal(err)
}
defer stmt.Close() // 危险!
for i := 0; i < 10; i++ {
	_, err = stmt.Exec(i)
	if err != nil {
		log.Fatal(err)
	}
}
err = tx.Commit()
if err != nil {
	log.Fatal(err)
}
// stmt.Close()在此处return

在Go 1.4关闭一个*sql.Tx之前,它会把与其关联的连接释放回连接池中,但对准备好的语句的延迟Close调用是在这之后执行的,可能会导致对基础连接的并发访问,从而导致连接状态不一致。如果使用Go 1.4或更低版本,则应确保在提交或回滚事务之前始终关闭该准备好的语句。在Go 1.4中修复了此问题,见CR 131650043

参数占位符语法

准备语句中占位符参数的语法是特定于数据库的。例如,比较MySQL、PostgreSQL和Oracle:

MySQL               PostgreSQL            Oracle
=====               ==========            ======
WHERE col = ?       WHERE col = $1        WHERE col = :col
VALUES(?, ?, ?)     VALUES($1, $2, $3)    VALUES(:val1, :val2, :val3)

错误处理

几乎所有使用database/sql包里的类型的操作都会返回一个错误变量作为最后一个值。你应该经常检查这些错误,永远不要忽视它们。

在一些地方,错误行为代表了一种特殊情况,或者你可能需要了解其他信息。

迭代结果集的错误处理

考虑以下代码:

for rows.Next() {
	// ...
}
if err = rows.Err(); err != nil {
	// 在此处处理错误
}

来自rows.Err()的错误可能是rows.Next()循环中,各种可能发生的错误的其中一种。除了正常完成循环之外,循环可能会出于某种原因退出,因此你始终需要检查循环是否正常终止。异常终止会自动调用rows.Close(),尽管多次调用它是无害的。

关闭结果集的错误处理

如前所述,如果提早退出循环,则应始终显式关闭sql.Rows。如果循环正常退出或出现错误,它会自动关闭,但你可能会错误地这样做:

for rows.Next() {
	// ...
	break; // 此时rows没有关闭。
}
// 关闭rows并检查是否有错误发生。多次调用rows.Close()是可以的,即使rows已经关闭了。
if err = rows.Close(); err != nil {
	// 如果此处真的有错误发生,我们又该怎么做呢?
	log.Println(err)
}

常规规则是最好在所有数据库操作中捕获并检查是否有错误发生,rows.Close()返回错误是常规规则的唯一例外。如果rows.Close()返回错误,则不清楚你应该怎么做。记录错误消息到日志或引发panic可能是唯一明智的做法,如果这不明智,那么也许你应该忽略该错误。

QueryRow()的错误处理

考虑以下返回单行数据的代码:

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

如果没有id1的用户怎么办?这样结果中就不会有行,.Scan()也不会将值扫描到name中。然后会发生什么?

Go定义了一个特殊的错误常量,称为sql.ErrNoRows,当结果集为空时,它会从QueryRow()返回。在大多数情况下,这需要作为特殊情况处理。应用程序代码通常不会将空结果集视为一个错误,如果你不检查错误是否等于此特殊常量,则会导致意想不到的应用程序代码错误。

查询中的错误会延迟到调用Scan(),然后返回。上面的代码最好这样写:

var name string
err = db.QueryRow("select name from users where id = ?", 1).Scan(&name)
if err != nil {
	if err == sql.ErrNoRows {
		// 一行数据都没有,也没有其他错误发生
	} else {
		log.Fatal(err)
	}
}
fmt.Println(name)

有人可能会问,为什么空的结果集被认为是一个错误。空结果集并非错误。原因是QueryRow()方法需要使用这种特殊情况,以便让调用者区分QueryRow()是否真的找到了一行;如果没有它,Scan()就什么也不做,而你可能根本没有意识到你的变量没有从数据库中获得任何值。

只有在使用QueryRow()时才会遇到此错误。如果你在其他地方遇到这个错误,那就是你做错了什么。

鉴别特定的数据库错误

编写如下代码可能很诱人:

rows, err := db.Query("SELECT someval FROM sometable")
// err包含如下字符串:
// ERROR 1045 (28000): Access denied for user 'foo'@'::1' (using password: NO)
if strings.Contains(err.Error(), "Access denied") {
	// 处理"Access denied"错误
}

不过,这并不是最好的做法。例如,数据库服务器发送的错误消息所使用的语言不同时,我们会收到不同的字符串。比较错误号码来确定具体的错误是什么要好得多。

然而,实现这一点的机制因驱动程序而异,因为这不是database/sql本身的一部分。在本教程重点介绍的MySQL驱动程序中,你可以编写以下代码:

if driverErr, ok := err.(*mysql.MySQLError); ok { //现在可以直接获取错误号码
	if driverErr.Number == 1045 {
		// 处理拒绝权限错误
	}
}

同样,这里的MySQLError类型是由这个特定的驱动程序提供的,不同的驱动程序的.Number字段可能不同。然而,该数字的值取自MySQL的错误消息,因此是特定于数据库的,而不是特定于驱动程序的。

以上代码仍然很难看。像1045这样的神奇的数字是一种代码臭味。一些驱动程序(虽然不是MySQL驱动程序,但原因与这里的主题无关)提供了错误标识符列表。Postgres数据库的pq驱动程序在error.go中就是这样做的。还有一个由VividCortex维护的MySQL错误号码外部包。使用这样的列表,上面的代码可以更好地这样写:

if driverErr, ok := err.(*mysql.MySQLError); ok {
	if driverErr.Number == mysqlerr.ER_ACCESS_DENIED_ERROR {
		// 处理拒绝权限错误
	}
}

处理连接错误

如果你与数据库的连接被断开、终止或出现错误,该怎么办?

发生这种情况时,你不需要实现任何逻辑来重试失败的语句。作为database/sql中连接池的一部分,处理失败的连接是Go内建的。如果执行查询或其他语句,而底层连接出现故障,Go将重新打开一个新连接(或只是从连接池中获取另一个),然后重试,最多10次。

然而,可能会有一些意想不到的后果。当发生错误情况时,可能会重试某些类型的错误。这也可能是特定于驱动程序的。MySQL驱动程序的一个例子是,使用KILL取消不需要的语句(如长时间运行的查询),会导致该语句被重试多达10次。

处理空列值

可为null的列很烦人,会导致很多难看的代码。如果可以的话,应该避免它们。如果没有,那么你需要使用database/sql包中的特殊类型来处理它们,或者定义自己的类型。

database/sql包定义了可为null的布尔值、字符串、整数和浮点值的类型。以下是如何使用它们的一个示例:

for rows.Next() {
	var s sql.NullString
	err := rows.Scan(&s)
	// 检查err
	if s.Valid {
	   // 使用s.String
	} else {
	   // 是NULL值
	}
}

可为null的类型的限制,以及避免可为null列的原因:

1 没有sql.NullUint64或其他sql.NullYourFavoriteType类型。你需要为此定义自己的。

2 使用可为null的类型是一种奇技淫巧,而且不能经得起未来的考验。如果你认为某个东西不会为空,但你错了,你的程序就会崩溃,可能很少会在发布之前发现错误。

3 Go的一个好处是为每个变量都有一个有用的默认零值。但这不是可为null的类型的工作方式。

如果需要定义自己的类型来处理NULL,可以复制sql.NullString的设计来实现这一点。

如果你无法避免在数据库中使用NULL值,那么大多数数据库系统都支持另一种方法,即使用COALESCE()函数。你可以在不引入无数sql.Null*类型的情况下编写类似于以下的代码:

rows, err := db.Query(`
	SELECT
		name,
		COALESCE(other_field, '') as otherField
	WHERE id = ?
`, 42)

for rows.Next() {
	err := rows.Scan(&name, &otherField)
	// ..
	// 如果`other_field`列的值为NULL, 最终存入`otherField`变量的值就是空字符串。这种方式也以同样的逻辑处理其他数据类型。
}

处理未知列数

函数Scan()要求你传递正确数量的目标变量。如果你不知道查询将返回什么,该怎么办?

如果你不知道查询将返回多少列,可以使用columns()返回一个列名的列表。你可以检查此列表的长度以查看有多少列,还可以将一个具有正确列数的切片传递到Scan()中。例如,MySQL的一些分支执行SHOW PROCESSLIST命令返回不同的列,因此你必须为此做好准备,否则会导致错误。这里有一种方法:

cols, err := rows.Columns()
if err != nil {
	// 处理这个错误
} else {
	dest := []interface{}{ // 标准MySQL的列
		new(uint64), // id
		new(string), // host
		new(string), // user
		new(string), // db
		new(string), // command
		new(uint32), // time
		new(string), // state
		new(string), // info
	}
	if len(cols) == 11 {
		// 如果返回了11列,那么使用的数据库可能是Percona server for MySQL
	} else if len(cols) > 8 {
		// 如果返回的列数大于8,处理这种情况
	}
	err = rows.Scan(dest...)
	// 处理dest里的值
}

如果你不知道列数或它们的类型,那么应该使用sql.RawBytes

cols, err := rows.Columns() // 记得无论如何都要检查err,此处省略,因为与主题无关
vals := make([]interface{}, len(cols))
for i, _ := range cols {
	vals[i] = new(sql.RawBytes)
}
for rows.Next() {
	err = rows.Scan(vals...)
	// Now you can check each element of vals for nil-ness, and you can use type introspection and type assertions to fetch the column into a typed variable.现在,你可以检查vals的每个元素是否为nil,并且可以使用类型内省(type introspection)和类型断言(type assertions)将列获取到特定类型的变量中。
}

连接池

database/sql包中有一个基本的连接池。没有太多的能力来控制或检查它,但以下是一些你可能会觉得有用的事情:

  • 连接池意味着在一个数据库上执行两个连续的语句可能会打开两个连接并分别执行。对于程序员来说,对他们的代码为什么表现不佳感到困惑是很常见的。例如,LOCK TABLES后面跟着INSERT可能会阻塞,因为INSERT位于不包含LOCK TABLES锁表的连接上。
  • 当连接池中没有可用的连接时,database/sql包会在需要时创建新的连接。
  • 默认情况下,对连接的数量没有限制。如果你试图同时做很多事情,你可以创建任意数量的连接。这可能会导致数据库返回诸如“连接过多”之类的错误。
  • 在Go 1.1或更新的版本中,可以使用db.SetMaxIdleConns(N)来限制连接池中的空闲连接数。不过,这并没有限制连接池的大小(size)。
  • 在Go 1.2.1或更新的版本中,可以使用db.SetMaxOpenConn(N)来限制到数据库的打开连接的总数。不幸的是,一个死锁Bug修复)使db.SetMaxOpenConn(N)无法在Go 1.2中安全使用。
  • 连接的回收速度相当快。使用db.SetMaxIdleConns(N)设置更多空闲连接的数目可以减少这种流失,并有助于保持连接以供重用。
  • 长时间保持空闲的连接可能会导致问题(例如Microsoft Azure上的MySQL的问题)。如果由于连接空闲时间过长而导致连接超时,请尝试db.SetMaxIdleConns(0)
  • 你还可以通过设置db.SetConnMaxLifetime(duration)来指定连接可以重复使用的最长时间,因为重复使用寿命过长的连接可能会导致网络问题。这会延迟关闭未使用的连接,即可能会延迟关闭过期的连接。

惊讶、反模式和限制

尽管一旦你习惯了database/sql包,数据库编程就很简单,但你可能会对它所支持的其他一些巧妙用法感到惊讶。这在Go的核心库中很常见。

资源耗尽

正如本网站中所提到的,如果你没有按预期使用database/sql,你肯定会给自己带来麻烦,通常是消耗一些资源或阻止它们被有效地重用:

  • 过于频繁地打开和关闭数据库可能会导致资源耗尽。
  • 未能读取完毕所有行数据或rows.Close()执行失败,将占据连接池中的一条连接。
  • 对不返回行数据的语句使用Query()将占据连接池中的一条连接。
  • 没有意识到准备好的语句是如何工作的,可能会导致大量额外的数据库活动。

uint64

这是一个令人惊讶的错误。如果设置了高位(high bit),则不能将大的无符号整数作为参数传递给语句:

_, err := db.Exec("INSERT INTO users(id) VALUES", math.MaxUint64) // Error

这将引发一个错误。如果使用uint64值,请小心,因为它们可能一开始很小,工作时没有错误,但随着时间的推移而增大,并开始抛出错误。

连接状态不匹配

有些事情可能会改变连接状态,这可能会导致问题,原因有两个:

  • 某些连接状态,例如是否处于事务中,应该通过Go类型来处理。
  • 你可能会假设你的查询在某一个连接上运行,而事实并非如此。

例如,用USE语句设置当前数据库是许多人通常要做的事情。但在Go中,这么做只会影响当前运行它的这条数据库连接。除非在事务中(译者注:或者使用专用连接),否则你认为在该连接上执行的其他语句,实际上可能会在从连接池中获得的其他连接上运行,所以可能无法应用这些语句的更改。

此外,在你更改连接后,它将返回到池中,并可能污染其他代码的状态。这也是为什么永远不应该直接将BEGINCOMMIT语句作为SQL命令发出的原因之一。

数据库的特定语法

database/sql包的API提供了面向行的数据库的抽象,但特定的数据库和驱动程序可能在行为和/或语法上有所不同,例如准备好的语句的占位符语法

多个结果集

Go驱动程序无论如何都不支持来自单个查询返回的多个结果集,而且似乎没有任何计划这样做,尽管有一个支持批量复制等批量操作的功能请求

这意味着,返回多个结果集的存储过程将无法正常工作。

调用存储过程

调用存储过程的语法是特定于驱动程序的,但在MySQL驱动程序中,目前无法做到这一点。你似乎可以通过执行以下操作来调用一个返回单个结果集的简单存储过程:

err := db.QueryRow("CALL mydb.myprocedure").Scan(&result) // Error

实际上它不能按预期执行,反而会报错:”Error 1312: PROCEDURE mydb.myprocedure can’t return a result set in the given context.”。这是因为MySQL希望连接被设置为多语句模式(multi-statement mode),即使只是返回单个结果,而驱动程序目前不会这样做(尽管请参阅此议题)。

支持多语句

database/sql包目前没有明确支持多语句,这意味着它的行为依赖于后端实现:

_, err := db.Exec("DELETE FROM tbl1; DELETE FROM tbl2") // 出错或不能返回预期结果

database/sql包目前没有明确支持多语句,这意味着它的行为依赖于后端实现:

_, err := db.Exec("DELETE FROM tbl1; DELETE FROM tbl2") // 出错或不能返回预期结果

服务器可以按照自己的意愿对此进行解释,包括返回一个错误、只执行第一条语句或同时执行这两条语句。

类似地,没有办法在事务中批处理SQL语句。事务中的每条语句都必须串行执行,并且必须扫描或关闭结果中的资源,例如RowRows,以便底层连接可以供下一条语句使用。这与非事务的普通行为不同。在普通行为下,完全可以执行查询,在Rows上循环遍历,并在循环中对数据库进行另一次查询(这将在一条新连接上发生):

rows, err := db.Query("select * from tbl1") // 使用数据库连接1
for rows.Next() {
	err = rows.Scan(&myvariable)
	// 因为连接1已经在使用,以下代码将不会使用连接1
	db.Query("select * from tbl2 where id = ?", myvariable)
}

但是事务只绑定到一个数据库连接,所以上述代码在事务中是不可能的:

tx, err := db.Begin()
rows, err := tx.Query("select * from tbl1") // 使用绑定到tx的数据库连接
for rows.Next() {
	err = rows.Scan(&myvariable)
	tx.Query("select * from tbl2 where id = ?", myvariable) // ERROR,tx的连接正在忙!
}

不过,Go并不能阻止你去尝试。如果你试图在第一个语句释放其资源并在其自身清理之前执行另一个语句,就可能会导致连接损坏。这也意味着事务中的每条语句与数据库服务器之间的网络通讯,是一组单独的网络往返。

其他相关文章和资源

以下是我们发现有帮助的一些外部信息资源:

我们希望这个网站会有所帮助。如果你有任何改进建议,请到https://github.com/VividCortex/go-database-sql-tutorial发送拉取请求(pull request)或开启一个议题。

打开一个数据库句柄

本文翻译自《Opening a database handle》。

目录

查找和导入一个数据库驱动程序

打开一个数据库句柄

确认一个数据库连接

存储数据库凭据

释放资源

database/sql包减少了你管理连接的需要,从而简化了数据库访问。与许多数据访问API不同,使用database/sql时,你不是显式地打开数据库连接、工作,然后关闭连接。相反,你的代码打开一个表示连接池的数据库句柄,然后使用该句柄执行数据访问操作,仅在需要释放资源时调用Close方法,例如检索到的行或准备好的语句所持有的资源。

换句话说,由sql.DB表示数据库句柄,用于处理连接,代表代码打开和关闭连接。当你的代码使用数据库句柄执行数据库操作时,这些操作可以并发地访问数据库。有关详细信息,请参阅管理数据库连接

注意:你也可以使用一条专用的数据库连接。有关更多信息,请参阅使用专用连接

除了database/sql包中提供的API之外,Go社区还为所有最常见(以及许多不常见)的数据库管理系统(DBMS)开发了驱动程序。

打开数据库句柄时,你执行以下高层级的步骤:

1 找到一个驱动程序。

驱动程序在Go代码和数据库之间转换请求和响应。有关详细信息,请参阅查找和导入一个数据库驱动程序

2 打开一个数据库句柄。

导入驱动程序后,可以打开访问特定数据库的一个句柄。有关详细信息,请参见打开一个数据库句柄

3 确认一个数据库连接。

打开数据库句柄后,代码就可以检查这个数据库连接是否可用。有关详细信息,请参阅确认一个数据库连接

你的代码通常不会显式地打开或关闭数据库连接——这是由数据库句柄完成的。然而,你的代码应该释放在此过程中获得的资源,例如包含查询结果的sql.Rows。有关详细信息,请参见释放资源

查找和导入一个数据库驱动程序

你需要一个支持你正在使用的DBMS的Go语言的数据库驱动程序。要查找数据库的驱动程序,请参阅SQLDrivers

为了使驱动程序可用于你的代码,你可以像导入另一个Go包一样导入它。下面是一个例子:

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

请注意,如果你不直接从驱动程序包调用任何函数,例如当sql包隐式使用它时,你需要使用一个空白导入,该导入在导入路径前加一个下划线:

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

注意:作为最佳实践,避免使用数据库驱动程序自己的API进行数据库操作。相反,应该使用database/sql包中的函数。这将有助于保持代码与DBMS的松散耦合,从而在需要时更容易切换到不同的DBMS。

打开一个数据库句柄

sql.DB数据库句柄提供了单独或在事务中读取和写入数据库的能力。

你可以通过调用sql.Open函数(使用连接字符串)或sql.OpenDB函数(使用driver.Connector)来获取数据库句柄。两者都返回一个指向sql.DB的指针。

注意:请确保将你的数据库凭据放在Go源代码之外。有关更多信息,请参阅存储数据库凭据

使用一个连接字符串

如果要使用连接字符串进行连接,请使用sql.Open函数。字符串的格式会因你使用的驱动程序而异。

以下是MySQL的一个示例:

db, err = sql.Open("mysql", "username:password@tcp(127.0.0.1:3306)/jazzrecords")
if err != nil {
    log.Fatal(err)
}

然而,你可能会发现,以更结构化的方式捕获连接属性可以为你提供更具可读性的代码。具体细节因数据库驱动而异。

例如,你可以将前面的示例替换为以下示例,该示例使用MySQL驱动程序的Config指定连接属性,并使用FormatDSN方法构建连接字符串。

// 指定连接属性。
cfg := mysql.Config{
    User:   username,
    Passwd: password,
    Net:    "tcp",
    Addr:   "127.0.0.1:3306",
    DBName: "jazzrecords",
}

// 获得一个数据库句柄。
db, err = sql.Open("mysql", cfg.FormatDSN())
if err != nil {
    log.Fatal(err)
}

使用一个连接器

当你想利用连接字符串中无法设置的特定于驱动程序的连接特性时,请通过sql.OpenDB函数。每个驱动程序都支持自己的连接特性集,通常用于自定义特定于DBMS的连接请求。

将前面的sql.Open示例改编为使用sql.OpenDB,你可以使用以下代码创建一个数据库句柄:

// 指定连接属性。
cfg := mysql.Config{
    User:   username,
    Passwd: password,
    Net:    "tcp",
    Addr:   "127.0.0.1:3306",
    DBName: "jazzrecords",
}

// 获取一个特定驱动的连接器
connector, err := mysql.NewConnector(&cfg)
if err != nil {
    log.Fatal(err)
}

// 获取一个数据库句柄。
db = sql.OpenDB(connector)

处理错误

你的代码应该检查尝试创建句柄时是否出现了一个错误,例如使用sql.Open函数。这不会是连接错误。相反,如果sql.Open函数无法初始化句柄,则会出现一个错误。例如,如果它无法解析你指定的DSN,就可能会发生这种情况。

确认一个数据库连接

打开一个数据库句柄时,sql包可能不会立即创建一个新的数据库连接。相反,它可能会在你的代码需要时创建这个连接。如果你不想立即使用数据库,并且想确认可以建立连接,请调用PingPingContext函数。

以下示例中的代码ping数据库以确认连接。

db, err = sql.Open("mysql", connString)

// 确认是否能连接数据库成功。
if err := db.Ping(); err != nil {
    log.Fatal(err)
}

存储数据库凭据

避免在Go源代码中存储数据库凭据,因为这可能会将数据库的内容暴露给其他人。相反,找到一种方法将它们存储在代码之外但可供使用的位置。例如,考虑一个秘密保管器应用程序,该应用程序存储凭据并提供一个API,代码可以使用该API来检索凭据,以便与DBMS进行身份验证。

一种流行的方法是在程序启动前将秘密信息存储在你的环境变量中,然后你的Go程序可以使用os.Getenv函数:

username := os.Getenv("DB_USER")
password := os.Getenv("DB_PASS")

这种方法还允许你自己设置用于本地测试的环境变量。

释放资源

尽管你没有使用database/sql包显式地管理或关闭连接,但当不再需要资源时,你的代码应该会释放所获得的资源。这些包括sql.Rows所拥有的资源,表示从查询返回的行数据,或者sql.Stmt表示准备好的语句。

通常,通过延迟对Close函数的调用来释放资源,以便在外部函数退出之前释放资源。

以下示例中的代码延迟调用Close函数以释放sql.Rows所拥有的资源。

rows, err := db.Query("SELECT * FROM album WHERE artist = ?", artist)
if err != nil {
    log.Fatal(err)
}
defer rows.Close()

// 循环遍历返回的行数据。

访问关系型数据库

本文翻译自《Accessing relational databases》。

目录

支持的数据库管理系统

用于执行查询或更改数据库的函数

事务

取消查询

管理连接池

使用Go,你可以将各种各样的数据库和数据访问方法集成到你的应用程序中。本节中的主题描述了如何使用标准库的database/sql包来访问关系数据库。

有关Go访问数据的入门教程,请参阅教程:访问关系型数据库

Go还支持其他数据访问技术,包括用于对关系型数据库进行更高级别访问的ORM库,以及非关系型的NoSQL数据库。

  • 对象关系映射(Object-relational mapping,ORM)库。虽然database/sql包包括用于较低级别的数据访问逻辑的函数,但你也可以使用Go访问更高抽象级别的数据访问。有关Go的两个流行的对象关系映射(ORM)库的更多信息,请参阅GORM引用该包的地址)和ent引用该包的地址)。
  • NoSQL数据存储。Go社区已经为大多数NoSQL数据存储开发了驱动程序,包括MongoDBCouchbase。你可以搜索pkg.go.dev了解更多信息。

支持的数据库管理系统

Go支持所有最常见的关系型数据库管理系统,包括MySQL、Oracle、Postgres、SQL Server、SQLite等。

你可以在SQLDrivers页面上找到驱动程序的完整列表。

用于执行查询或更改数据库的函数

database/sql包包含专门为你正在执行的数据库操作设计的函数。例如,虽然你可以使用QueryQueryRow来执行查询,但QueryRow是为只期望返回一行数据的情况而设计的,省去了返回仅包括一行数据的sql.Rows的开销。你可以使用Exec函数传入SQL语句(例如INSERTUPDATEDELETE)更改数据库。

更多信息请参考:

事务

通过sql.Tx,可以编写代码在事务中执行数据库操作。在事务中,多个操作可以一起执行并以最终提交(commit)结束,以在一个原子步骤中应用所有更改,或者回滚以所有更改。

有关事务的更多信息,请参阅执行事务

取消查询

当你希望能够取消数据库操作时,例如当客户端的连接关闭或操作运行时间超过你的预期时,你可以使用context.Context

对于任何数据库操作,你可以使用database/sql包里的将Context作为参数的函数。使用Context,你可以为操作指定超时时间或截止时间。你还可以使用Context通过应用程序将取消请求传播到执行SQL语句的函数,确保资源在不再需要时得到释放。 有关更多信息,请参阅取消正在进行中的数据库操作。

管理连接池

当你使用sql.DB数据库句柄时,你正在使用一个内置的连接池,该连接池根据你的代码创建和处理数据库连接。通过sql.DB的句柄是使用Go进行数据库访问的最常见方式。有关详细信息,请参阅打开一个数据库句柄

database/sql包为你管理连接池。但是,对于更高级的需求,你可以设置连接池属性,详见设置连接池属性

对于那些需要单个专用数据库连接的操作,database/sql包提供了sql.Conn。当使用sql.Tx的事务不是一个好选择时,Conn尤其有用。

例如,你的代码可能需要:

  • 通过DDL更改数据库模式,包括自身就带事务语义的逻辑。将sql包的事务函数与SQL事务语句混合是一种不良做法,如执行事务中所述。
  • 执行创建临时表等查询锁定(译者注:期间不允许执行查询语句)的操作。

有关更多信息,请参阅使用专用连接

取消正在进行中的数据库操作

本文翻译自《Canceling in-progress operations》。 你可以使用Go的context.Context管理正在进行中的操作。Context是一个标准的Go类型,可用来报告它所代表的整体操作是否已被取消并且不再需要。通过在你的应用程序中跨函数或服务传递context.Context,可以提前停止工作并返回一个错误。有关Context的更多信息,请参阅Go并发模式:Context

例如,你可能希望:

  • 结束长时间运行的操作,包括耗时过长的数据库操作。
  • 传播来自其他地方的“取消继续工作”请求,例如当客户端关闭连接时。

Go的许多API都采用Context参数,使你更容易在整个应用程序中使用Context

超时后取消正在进行中的数据库操作

你可以使用Context来设置超时时间或截止时间,在此之后操作将被取消。要派生具有超时时间或截止时间的Context,请调用Context.WithTimeoutContext.WithDeadline函数。

以下示例中的代码派生一个附带超时时间的Context,并将其传递到sql.DBQueryContext方法中。

func QueryWithTimeout(ctx context.Context) {
    // 创建一个附带超时时间的Context。
    queryCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
    defer cancel()

    // 传入这个附带超时时间的Context和一个查询语句。
    rows, err := db.QueryContext(queryCtx, "SELECT * FROM album")
    if err != nil {
        log.Fatal(err)
    }
    defer rows.Close()

    // 处理返回的行数据。
}

当一个context从外部的context派生而来时,由于queryCtx在本例中是从函数参数ctx派生的,如果外部的context被取消,那么派生的context也会自动取消。例如,在HTTP服务器中,HTTP.Request.Context方法返回与请求相关联的context。如果HTTP客户端断开连接或取消HTTP请求(可能使用HTTP/2),则该context将被取消。如果整个HTTP请求被取消,或者查询耗时超过五秒,那么将HTTP请求的context传递给上面的QueryWithTimeout函数将导致数据库查询操作提前结束。

注意:当你在创建带有超时时间或截止时间的一个新context时,总是应该defer对这个context的cancel方法的调用。当包含这个新context的函数退出时,就能释放这个新context所持有的资源。它还将取消queryCtx,当这个新context的cancel方法返回时,就不应该再使用queryCtx