执行不返回行数据的SQL语句

本文翻译自《Executing SQL statements that don’t return data》。

执行不返回数据的数据库操作时,请使用database/sql包中的ExecExecContext方法。以这种方式执行的SQL语句包括INSERTDELETEUPDATE

当查询可能返回行数据时,请改QueryQueryContext方法。有关详细信息,请参阅查询一个数据库

ExecContext方法的工作原理与Exec方法相同,但有一个额外的context.Context参数,具体作用参见“取消正在进行中的数据库操作”。

以下示例中的代码使用DB.Exec执行一条语句,将一条唱片的信息添加到album表中。

func AddAlbum(alb Album) (int64, error) {
    result, err := db.Exec("INSERT INTO album (title, artist) VALUES (?, ?)", alb.Title, alb.Artist)
    if err != nil {
        return 0, fmt.Errorf("AddAlbum: %v", err)
    }

    // 获取新插入的这条记录的ID
    id, err := result.LastInsertId()
    if err != nil {
        return 0, fmt.Errorf("AddAlbum: %v", err)
    }
    // 返回这条新记录的ID
    return id, nil
}

DB.Exec返回值:一个sql.Result和一个指示是否有错误发生的变量。当错误为nil时,你可以使用Result来获取最后插入项的ID(如示例中所示)或获取数据表中受操作影响的行数。

注意:预处理语句中的参数占位符因所使用的DBMS和驱动程序而异。例如,Postgres的pq驱动程序需要像$1这样的占位符,而不是?

如果你的代码将重复执行相同的SQL语句,请考虑使用SQL.Stmt从SQL语句中创建一个可重复使用的准备语句。有关详细信息,请参阅使用准备好的语句

注意:不要使用fmt.Sprintf等字符串格式函数来组装SQL语句!因为可能会有SQL注入风险。有关更多信息,请参阅避免SQL注入风险

以下列出用于执行不返回行数据的SQL语句的函数

DB.Exec

DB.ExecContext

孤立地执行单个SQL语句。

Tx.Exec

Tx.ExecContext

在较大的事务中执行SQL语句。有关详细信息,请参阅执行事务

Stmt.Exec

Stmt.ExecContext

执行已准备好的SQL语句。有关详细信息,请参阅使用准备好的语句

Conn.ExecContext

用于专用的连接。有关详细信息,请参阅管理连接

Go语言MySQL驱动程序GitHub README.md中文翻译

本文翻译自https://github.com/go-sql-driver/mysql/

Go-MySQL-Driver 用于Go语言database/sql包的MySQL驱动。

特性

要求

安装

用法

       DSN(Data Source Name,数据源名称)

              密码

              协议

              地址

              参数

              实例

       连接池和超时

       支持context.Context

       支持ColumnType

       支持LOAD DATA LOCAL INFILE

       支持time.Time

       支持Unicode

测试/开发

License

特性

  • 轻量又快速
  • 纯Go语言实现,并非是对C语言的封装
  • 通过TCP/IPv4、TCP/IPv6、Unix域套接字或自定义协议进行连接
  • 自动处理断开的连接
  • 自动连接池(通过database/sql包)
  • 支持大于16MB的查询字符串
  • 完整的sql.RawBytes支持
  • 智能地处理预处理语句中的LONG DATA
  • 通过文件白名单安全地支持LOAD DATA LOCAL INFILE,也支持io.Reader
  • 可选的time.Time解析
  • 可选的占位符插值

要求

  • Go 1.13或更高版本。我们的目标是支持Go的3个最新版本。
  • MySQL (4.1+)、MariaDB、Percona Server、Google CloudSQL或Sphinx (2.2.3+)

安装

使用shell中的go工具将包简单地安装到你的$GOPATH中:

$ go get -u github.com/go-sql-driver/mysql

确保Git安装在你的机器上和系统的PATH中。

用法

Go语言MySQL驱动是Go的database/sql/driver包的接口的实现。你只需要导入这个驱动,然后就可以使用完整的database/sql包的API。

使用mysql作为driverName,使用有效的DSN作为dataSourceName

import (
	"database/sql"
	"time"

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

// ...

db, err := sql.Open("mysql", "user:password@/dbname")
if err != nil {
	panic(err)
}
// 参见“重要设置”小节。
db.SetConnMaxLifetime(time.Minute * 3)
db.SetMaxOpenConns(10)
db.SetMaxIdleConns(10)

案例见我们的wiki

重要设置

db.SetConnMaxLifetime()确保连接在MySQL服务器、操作系统或其他中间件关闭之前由驱动程序安全地关闭。由于某些中间件会在5分钟后关闭空闲连接,因此我们建议把超时时间设置为短于5分钟的时间。此设置也有助于负载平衡和更改系统变量。

db.SetMaxOpenConns()强烈建议使用该函数来限制应用程序使用的连接数。没有建议的限制数,因为它取决于具体应用程序和MySQL服务器。

db.SetMaxIdleConns()建议设置为与db.SetMaxOpenConns()的相同。当它小于SetMaxOpenConns()的设置时,连接打开和关闭的频率可能比你预期的要高得多。空闲连接可以通过db.SetConnMaxLifetime()设置超时时间。如果你想更快地关闭空闲连接,从Go 1.15开始,你可以使用db.SetConnMaxIdleTime()函数来设置。

DSN(Data Source Name,数据源名称)

数据源名称具有一个通用格式,例如PEAR DB所使用的,但没有类型前缀(用方括号标记可选部分):

[username[:password]@][protocol[(address)]]/dbname[?param1=value1&...&paramN=valueN]

完整形式的DSN:

username:password@protocol(address)/dbname?param=value

除了数据库名称之外,所有值都是可选的。因此,最小DSN为:

/dbname

如果不想预先选择一个数据库实例,请将dbname保留为空:

/

这与空DSN字符串的效果相同。

或者,我们也可以使用Config.FormatDSN通过填充一个结构体来创建DSN字符串。

密码

密码可以由任何字符组成,无需转义。

协议

有关可用网络的更多信息,请参阅net.Dial。一般来说,如果可以,你应该使用Unix域套接字,否则应该使用TCP,以获得最佳性能。

地址

对于TCP和UDP网络,地址的格式为host[:port]。如果省略port,将使用默认端口号。如果主机使用IPv6地址,则必须将其括在方括号中。函数net.JoinHostPortnet.SplitHostPort以这种形式处理地址。

对于Unix域套接字,地址是MySQL服务的套接字的绝对路径,例如:

/var/run/mysqld/mysqld.sock

/tmp/mysql.sock

参数

参数区分大小写!

请注意,trueTRUETrue1中的任何一个表示布尔值“真”。毫不奇怪,布尔值“假”可以指定为以下任意值:falseFALSEFalse0

allowAllFiles
Type:           bool
Valid Values:   true, false
Default:        false

allowAllFiles=true禁用LOAD DATA LOCAL INFILE的文件白名单,并允许所有文件。可能不安全

allowCleartextPasswords
Type:           bool
Valid Values:   true, false
Default:        false

allowCleartextPasswords=true在帐户需要时允许使用明文(cleartext)客户端插件,例如使用PAM身份验证插件定义的插件。以明文形式发送密码在某些配置中可能是一个安全问题。如果密码有可能被拦截,为避免出现问题,客户端应使用保护密码的方法连接到MySQL服务器,包括使用TLS/SSL、IPsec或专用网络。

allowFallbackToPlaintext
Type:           bool
Valid Values:   true, false
Default:        false

allowFallbackToPlaintext=true的作用类似于使用--ssl-mode=PREFERRED选项的MySQL客户端,如用于连接到服务器的命令选项中所述

allowNativePasswords
Type:           bool
Valid Values:   true, false
Default:        true

allowNativePasswords=false禁止使用MySQL本机密码(native password)方式。

allowOldPasswords
Type:           bool
Valid Values:   true, false
Default:        false

allowOldPasswords=true允许使用不安全的旧密码(old password)方式。应该避免使用这种方式,但在某些情况下是必要的。另请参阅old_passwords维基页面

charset
Type:           string
Valid Values:   <name>
Default:        none

设置用于客户端-服务器交互的字符集(“SET NAMES <value>“)。如果设置了多个字符集(用逗号分隔),则在设置当前字符集失败时继续尝试设置后续的字符集。例如, (charset=utf8mb4,utf8)设置utf8mb4(在MySQL 5.5.3中引入),如果是旧服务器就回退到utf8

不鼓励使用charset参数,因为它会向服务器发出额外的查询。除非你需要回退行为,否则请改用collation参数。

checkConnLiveness
Type:           bool
Valid Values:   true, false
Default:        true

在支持的平台上,Go从连接池中检索连接,在使用该连接之前会检查其是否活动。如果不活动,则将相应的连接标记为坏连接,并尝试使用另一个连接。checkConnLiveness=false不检查连接是否活动。

collation
Type:           string
Valid Values:   <name>
Default:        utf8mb4_general_ci

设置连接时用于客户端-服务器交互的字符排序规则。与charset不同,collation不会发出额外的查询。如果指定的排序规则在目标服务器上不可用,则连接将失败。

可以使用SHOW COLLATION检索服务器的有效字符集列表。

MySQL 5.5支持默认排序规则(utf8mb4_general_ci)。对于较旧的MySQL,应该使用较旧的排序规则(例如utf8_general_ci)。 不能使用字符集”ucs2″、”utf16″、”utf16le”和”utf32″的排序规则(参考)。

clientFoundRows
Type:           bool
Valid Values:   true, false
Default:        false

clientFoundRows=true会导致UPDATE语句返回匹配的行数,而不是更改的行数。

columnsWithAlias
Type:           bool
Valid Values:   true, false
Default:        false

columnsWithAlias=true时,调用sql.Rows.Columns()将返回表别名和用点分隔的列名。例如:

SELECT u.id FROM users as u

如果columnsWithAlias=true,将返回u.id而不仅仅是id

interpolateParams
Type:           bool
Valid Values:   true, false
Default:        false

如果interpolateParams=true,则调用db.Query()db.Exec()时的占位符(?)将插入到具有给定参数的单个查询字符串中。这减少了往返次数,因为当interpolateParams=false时,驱动程序必须准备好一条语句,使用给定的参数执行它,然后再次关闭该语句。

这不能与多字节编码BIG5CP932GB2312GBKSJIS一起使用。这些都被拒绝,因为它们可能会引入SQL注入漏洞

loc
Type:           string
Valid Values:   <escaped name>
Default:        UTC

设置time.Time值的时区(当使用parseTime=true时)。如果时区设置为Local,那么设置为系统的时区。有关详细信息,请参阅time.LoadLocation

注意,这将设置time.time值的时区,但不会更改MySQL的time_zone设置。为此,请参见time_zone系统变量,该变量也可以设置为DSN的参数。

请记住,参数值必须是url.QueryEscape编码的。或者,你可以手动将/替换为%2F。例如,US/Pacific(美国/太平洋时区)应该被设置为loc=US%2FPacific

maxAllowedPacket
Type:          decimal number
Default:       4194304

允许的最大数据包的大小(以字节为单位)。默认值为4MiB,应进行调整以匹配服务器设置。maxAllowedPacket=0可用于在每次连接时从服务器自动获取max_allowed_packet变量。

multiStatements
Type:           bool
Valid Values:   true, false
Default:        false

是否允许在一个查询中使用多条语句。虽然这允许批量查询,但也大大增加了被SQL注入的风险。只返回第一个查询语句的结果,所有其他结果都被默默丢弃。当使用multiStatements时,?参数只能在第一条语句中使用。

parseTime
Type:           bool
Valid Values:   true, false
Default:        false

parseTime=trueDATEDATETIME值的输出类型更改为time.Time,而不是[]byte/string类型的日期或日期时间,例如0000-00-00 00:00:00将转换为time.Time的零值。

readTimeout
Type:           duration
Default:        0

I/O读取的超时时间。该值必须是带有单位后缀的十进制数字(”ms”、”s”、”m”、”h”),例如”30s”、”0.5m”或”1m30s”。

rejectReadOnly
Type:           bool
Valid Values:   true, false
Default:        false

rejectReadOnly=true会导致数据库驱动程序拒绝只读连接。这是针对自动故障切换期间可能出现的争用(race condition)情况,即故障切换后mysql客户端将连接到某个只读副本。

请注意,这应该是一种相当罕见的情况,因为自动故障转移通常发生在主服务器关闭时,并且竞态条件(race condition)不会发生,除非它在故障转移开始后立即恢复在线。另一方面,发生这种情况时,MySQL应用程序可能会卡在只读连接上,直到重新启动。然而,这种故障相当容易复现,例如在AWS Aurora的MySQL兼容集群上手动造成故障转移。

如果你不依赖只读事务来拒绝不应该发生的写入操作,那么在某些MySQL服务提供商(例如AWS Aurora)上设置此选项,对于故障切换来说更安全。

请注意,read-only服务器可能会返回1290号错误,此选项将导致重试该错误。但是,存在其他一些情况,也使用相同的1290错误号。因此启用此选项时,应确保应用程序在read-only模式之外,都不会导致1290号错误。

serverPubKey
Type:           string
Valid Values:   <name>
Default:        none

可以使用mysql.RegisterServerPubKey注册服务器的公钥,然后可以在DSN中通过参数使用该公钥。公钥用于传输加密数据,例如用于身份验证。如果服务器的公钥是已知的,则应手动设置它,以避免每次需要它时都从服务器向客户端传输,代价昂贵且可能不安全。

timeout
Type:           duration
Default:        OS default

建立连接时的超时时间,又称拨号超时。该值必须是带单位后缀(”ms”、”s”、”m”、”h”)的十进制数,例如”30s”、”0.5m”或”1m30s”。

tls
Type:           bool / string
Valid Values:   true, false, skip-verify, preferred, <name>
Default:        false

tls=true启用与服务器的TLS/SSL加密连接。如果要使用自签名或无效证书(服务器端),那么使用skip-verify参数;或通过preferred参数使用TLS(仅当得到服务器端建议时),这类似于skip-verify,但还允许回退到未加密的连接。skip-verifypreferred都不添加任何可靠的安全性。你可以在向mysql.RegisterTLSConfig注册后使用自定义的TLS配置。

writeTimeout
Type:           duration
Default:        0

I/O写入超时。该值必须是带单位后缀(”ms”、”s”、”m”、”h”)的十进制数,例如”30s”、”0.5m”或”1m30s”。

系统变量

任何其他参数都被解释为系统变量:

  • <boolean_var>=<value>: SET <boolean_var>=<value>
  • <enum_var>=<value>: SET <enum_var>=<value>
  • <string_var>=%27<value>%27: SET <string_var>='<value>'

规则:

  • 字符串变量的值必须用’引起来。
  • 值也必须使用url.QueryEscape编码过(这意味着字符串变量的值必须用%27包裹)。

例如:

  • autocommit=1: SET autocommit=1
  • time_zone=%27Europe%2FParis%27: SET time_zone='Europe/Paris'
  • transaction_isolation=%27REPEATABLE-READ%27: SET transaction_isolation='REPEATABLE-READ'

实例

user@unix(/path/to/socket)/dbname

root:pw@unix(/tmp/mysql.sock)/myDatabase?loc=Local

user:password@tcp(localhost:5555)/dbname?tls=skip-verify&autocommit=true

通过设置系统变量sql_mode将警告视为错误:

user:password@/dbname?sql_mode=TRADITIONAL

通过IPv6的TCP:

user:password@tcp([de:ad:be:ef::ca:fe]:80)/dbname?timeout=90s&collation=utf8mb4_unicode_ci

远程主机上的TCP,例如Amazon RDS:

id:password@tcp(your-amazonaws-uri.com:3306)/dbname

应用引擎上的谷歌云SQL:

user:password@unix(/cloudsql/project-id:region-name:instance-name)/dbname

使用本地主机上默认端口(3306)的TCP:

user:password@tcp/dbname?charset=utf8mb4,utf8&sys_var=esc%40ped

使用默认协议(tcp)和主机(localhost:3306):

user:password@/dbname

未预先选择数据库:

user:password@/

连接池和超时

连接池由Go的database/sql包管理。有关如何配置池的大小以及连接在池中停留的时间的详细信息,请参阅database/sql文档中的*DB.SetMaxOpenConns*DB.Set MaxIdleConns*DB.Set-ConnMaxLifetime。每个单独连接的读取、写入和拨号超时分别使用DSN参数readTimeoutwriteTimeouttimeout进行配置。

支持context.Context

此驱动程序支持Go 1.8中引入的ColumnType接口,但ColumnType.Length()除外,该接口目前不受支持。所有无符号的数据库类型都将返回带有UnsignedINTTINYINTSMALLINTBIGINT名称。

支持ColumnType

此驱动程序支持Go 1.8中引入的ColumnType接口,但ColumnType.Length()除外,该接口目前不受支持。所有无符号的数据库类型都将返回带有UnsignedINTTINYINTSMALLINTBIGINT名称。

支持context.Context

在Go 1.8,database/sql包添加了对context.Context的支持。此驱动程序支持查询超时和通过context取消语句的执行。有关详细信息,请参阅database/sql包中的context支持

支持LOAD DATA LOCAL INFILE

对于此功能,你需要直接访问软件包。因此,你必须更改导入路径(无_):

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

文件必须通过mysql.RegisterLocalFile(filepath)注册来明确允许加载里面的数据(推荐使用这种做法),或者必须通过使用DSN参数allowAllFiles=true来禁用allowlist检查(这种做法可能不安全!)。

要使用io.Reader,必须使用mysql.RegisterReaderHandler(name, handler)注册处理函数,该函数返回io.Readorio.ReadCloser。然后,Reader可以使用文件路径Reader::<name>。可以为不同的处理程序 handler注册不同的名字name,,当你不再需要它时,使用DeregisterReaderHandler反注册。

有关详细信息,请参阅Go MySQL驱动程序的godoc

支持time.Time

MySQL DATEDATETIME值的默认内部输出类型为[]byte,这允许你将值扫描到程序中的[]bytestringsql.RawBytes变量中。

然而,许多人希望将MySQL的DATEDATETIME值扫描到Go语言的time.Time变量中,这在逻辑上等同于MySQL中的DATEDATETIME值。你可以通过使用DSN参数parseTime=true将内部输出类型从[]byte更改为time.Time。你可以使用DSN参数loc设置默认time.Time的时区位置

注意:截至Go 1.1,这使得time.Time成为唯一可以扫描DATEDATETIME值的变量类型。这会破坏例如sql.RawBytes的支持

支持Unicode

由于1.5版本的Go MySQL驱动程序默认情况下自动使用排序规则utf8mb4_general_ci

可以使用DSN参数collation设置其他排序规则/字符集。

1.0版的驱动程序建议将&charset=utf8(即MySQL命令SET NAMES utf8的别名)添加到DSN中,以启用正确的UTF-8支持。但现在已经没有必要这么做了,如果要设置其他排序规则/字符集的话,应该首选collation参数。

有关MySQL的Unicode支持的更多详细信息参见http://dev.mysql.com/doc/refman/8.0/en/charset-unicode.html。

测试/开发

要运行驱动程序测试,你可能需要调整配置。有关详细信息,请参阅测试的Wiki 页面

Go语言的MySQL驱动程序的功能还不完善。如果你想做出贡献,我们将非常感谢你的帮助,你可以处理未解决的问题或查看拉取请求

有关详细信息,请参阅贡献指南

License

Go语言的MySQL驱动程序在Mozilla公共许可证2.0版(Mozilla Public License Version 2.0)下获得许可。

Mozilla将许可范围总结如下:

MPL:许可证适用于任何包含MPL代码的文件

这意味着:

  • 你可以在私人和商业用途中使用未更改的源代码。
  • 分发时,你必须根据a) MPL 2.0本身或b)兼容的许可证(例如GPL 3.0或Apache License 2.0)发布MPL 2.0许可的任何更改了源代码的文件
  • 只要根据MPL 2.0许可的文件未更改,你就无需发布你的库的源代码。

如果你对许可证有更多疑问,请阅读MPL 2.0的常见问题解答

你可以在此处阅读完整条款:LICENSE

Go语言MySQL驱动程序GitHub wiki中文翻译

本文翻译自https://github.com/go-sql-driver/mysql/wiki

2023/03/05

例子

旧密码(old_passwords)

测试(Testing)

开发思路

例子

database/sql包的详细介绍可以在这里找到:http://go-database-sql.org/

浅谈sql.Open函数

首先,你应该了解sql.DB不是一个连接。当你使用sql.Open()时,你将获得数据库的句柄。database/sql包在后台管理一个连接池,并且在你需要它们之前不会打开任何连接。因此sql.Open()不直接打开连接。因此,如果服务器不可用或连接数据的(用户名,密码)不正确,sql.Open()不会返回一个错误。如果你想在进行查询之前检查是否成功连接到数据库(例如在应用程序启动时),你可以使用db.Ping()

db, err := sql.Open("mysql", "user:password@/dbname")
if err != nil {
    panic(err.Error()) // 此处只是示例,在实际开发中你应该使用合适的错误处理函数,而不是使用panic
}
defer db.Close()

// 上面的Open函数不真的打开一个连接。可以使用以下函数来验证连接字符串是否能成功连接到数据库
err = db.Ping()
if err != nil {
    panic(err.Error()) // 在实际开发中不要使用painc,而是使用合适的错误处理函数
}

// 以下省略执行查询语句等代码
[...]

预处理语句

假设一个具有以下结构的空表,表名是squareNum:

+--------------+---------+------+-----+---------+-------+
| Field        | Type    | Null | Key | Default | Extra |
+--------------+---------+------+-----+---------+-------+
| number       | int(11) | NO   | PRI | NULL    |       |
| squareNumber | int(11) | NO   |     | NULL    |       |
+--------------+---------+------+-----+---------+-------+

在此示例中,我们准备了两条语句,一条用于插入元组(行),一条用于查询。

package main

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

func main() {
	db, err := sql.Open("mysql", "user:password@/database")
	if err != nil {
		panic(err.Error())
	}
	defer db.Close()

// 用于插入数据的预处理语句
	stmtIns, err := db.Prepare("INSERT INTO squareNum VALUES( ?, ? )") // ?号是占位符
	if err != nil {
		panic(err.Error())
	}
	defer stmtIns.Close() // 当main函数快要运行完毕(程序终止)时,关闭预处理语句,释放占用的资源

	// 用于查询数据的预处理语句
	stmtOut, err := db.Prepare("SELECT squareNumber FROM squarenum WHERE number = ?")
	if err != nil {
		panic(err.Error())
	}
	defer stmtOut.Close()

	// 插入0到24数字到数据库
	for i := 0; i < 25; i++ {
		_, err = stmtIns.Exec(i, (i * i)) // 插入元组(i, i^2)
		if err != nil {
			panic(err.Error())
		}
	}

	var squareNum int // 我们在此处浏览结果

// 查询13的平方数
	err = stmtOut.QueryRow(13).Scan(&squareNum) // WHERE number = 13
	if err != nil {
		panic(err.Error())
	}
	fmt.Printf("The square number of 13 is: %d", squareNum)

	// 查询1的平方数试试
	err = stmtOut.QueryRow(1).Scan(&squareNum) // WHERE number = 1
	if err != nil {
		panic(err.Error())
	}
	fmt.Printf("The square number of 1 is: %d", squareNum)
}

忽略NULL值

也许你已经遇到了这个错误:sql: Scan error on column index 1: unsupported driver -> Scan pair: <nil> -> *string

通常在这种情况下你会使用sql.NullString。但有时你并不关心该值是否为NULL,你只想将其视为一个空字符串。

你可以使用一个变通方法来做到这一点,这利用了一个事实,即nil []byte被转换为空字符串。你可以简单地使用*[]byte(或*sql.RawBytes),而不是使用*string作为rows.Scan(...)的目标,因为它们可以接收nil值:

[...]
var col1, col2 []byte

for rows.Next() {
	// 用[]byte类型的变量作为Scan函数的出参
	err = rows.Scan(&col1, &col2)

	if err != nil {
		panic(err.Error())
	}

	// 再转换为字符串,用于输出
	fmt.Println(string(col1), string(col2))
}

RawBytes

package main

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

func main() {
	// 打开数据库连接
	db, err := sql.Open("mysql", "user:password@/dbname")
	if err != nil {
		panic(err.Error())
	}
	defer db.Close()

	// 执行查询
	rows, err := db.Query("SELECT * FROM table")
	if err != nil {
		panic(err.Error())
	}

	// 获取列名
	columns, err := rows.Columns()
	if err != nil {
		panic(err.Error())
	}

	// 创建一个切片用于存储一行数据
	values := make([]sql.RawBytes, len(columns))

	// rows.Scan想要'[]interface{}'作为一个参数, 所以我们必须把值复制到这样一个切片里。更多细节参考http://code.google.com/p/go-wiki/wiki/InterfaceSlice
	scanArgs := make([]interface{}, len(values))
	for i := range values {
		scanArgs[i] = &values[i]
	}

	// 获取行数据
	for rows.Next() {
		// 从数据中获取RawBytes
		err = rows.Scan(scanArgs...)
		if err != nil {
			panic(err.Error())
		}

		// 现在使用获取到的数据做点事情,此处我们仅仅输出每列的值
		var value string
		for i, col := range values {
			// 这里我们可以检查值是否是nil(在数据库里就是NULL值)
			if col == nil {
				value = "NULL"
			} else {
				value = string(col) // 把列值转换为字符串用来输出
			}
			fmt.Println(columns[i], ": ", value)
		}
		fmt.Println("-----------------------------------")
	}
	// 检查在获取行数据的过程中,是否遇到了一个错误
	if err = rows.Err(); err != nil {
		panic(err.Error())
	}
}

旧密码(old_passwords)

什么是old_passwords?

MySQL 4.1版(2004年发布!)更改了协议,引入了更安全的密码认证。添加了变量old_password,它启用旧密码认证这种传统的认证方式,但禁用新的更安全的密码认证方式。旧密码认证方式使用非常弱的哈希,这就是为什么它被认为是不安全的。如果你不需要传统的旧密码认证方式,就不应该使用它!

由于旧密码不安全且已被弃用,因此Go语言的MySQL驱动程序在默认情况下不启用此认证方式。如果你依赖它,可以通过在DSN中添加allowOldPasswords=true来显式启用它。

如何禁用它?

在MySQL的配置文件my.cnf(Windows上为my.ini)中将old_passwords设置为false。在Linux上,你可以在/etc/my.cnf找到此文件。

变量old_passwords属于mysqld小节,如果在配置文件里找不到它,就添加它:

[mysqld]
old_passwords = 0

你可能还需要重新生成密码。有关如何升级,参考http://code.openark.org/blog/mysql/upgrading-passwords-from-old_passwords-to-new-passwords

测试(Testing)

也许你需要编辑已经打开的数据库连接的参数。以下是如何设置不同密码的示例:

Linux/Unix

$ export MYSQL_TEST_PASS=root

Windows

$ SET MYSQL_TEST_PASS=root

参数列表

MYSQL_TEST_USER ( User用户名 )
MYSQL_TEST_PASS ( Password 密码)
MYSQL_TEST_PROT ( Network Protocol 网络协议)
MYSQL_TEST_ADDR (网络地址,IPv4或IPv6)
MYSQL_TEST_DBNAME (数据库名称)
MYSQL_TEST_CONCURRENT ( 值为1启用并发测试)

开发思路

在Auth响应中发送连接参数[MySQL 5.6.6+]

http://dev.mysql.com/doc/internals/en/connection-phase-packets.html#packet-Protocol::HandshakeResponse41

http://dev.mysql.com/doc/internals/en/capability-flags.html#flag-CLIENT_CONNECT_ATTRS

支持更多认证插件

  • .mylogin.cnf http://dev.mysql.com/doc/refman/5.6/en/mysql-nutshell.html
  • SHA-256 http://dev.mysql.com/doc/refman/5.6/en/sha256-authentication-plugin.html
  • auth_socket http://dev.mysql.com/doc/refman/5.6/en/socket-authentication-plugin.html

网络压缩模式

http://dev.mysql.com/doc/internals/en/compression.html

使用分离的goroutine从连接中读取

目前,我们发送请求数据包,然后接收响应数据包。

由于Go没有提供有效的非阻塞EOF检查方法,即使服务器已经关闭连接,我们也可能会发送请求数据包。在已关闭的连接上发送请求会使ErrBadConn不安全。 如果我们可以在发送数据包之前检测到EOF,我们就可以安全地使用ErrBadConn

使用分离的goroutine从连接中读取数据是Go的常规编程方式。读数据的goroutine 也可能需要实现对Context支持。

可能会有显着的性能衰退。但我认为我们应该这样做。

教程:访问关系型数据库

本文翻译自《Tutorial: Accessing a relational database》。

目录

先决条件

为你的代码创建一个文件夹

建立一个数据库

查找并导入数据库的驱动程序

获取数据库句柄并连接

查询多行

查询单行

添加数据

结论

完成全部代码

本教程介绍了使用Go及其标准库中的database/sql包访问关系数据库的基础知识。

如果你对Go及其工具有基本的了解,你将充分利用本教程。如果这是你第一次接触Go,请先参阅教程:Go入门以获得快速介绍。

你将使用的database/sql包包括用于连接数据库、执行事务、取消正在进行的操作等的类型和函数。有关使用该包的更多详细信息,请参阅访问关系型数据库

在本教程中,你将创建一个数据库,然后编写代码来访问该数据库。你的示例项目将是有关古典爵士乐唱片的数据存储库。

在本教程中,你将逐步完成以下部分:

1 为你的代码创建一个文件夹。

2 建立一个数据库。

3 导入数据库驱动。

4 获取数据库句柄并连接。

5 查询多行。

6 查询单行。

7 添加数据。

注意:有关其他教程,请参阅教程

先决条件

  • 安装MySQL关系数据库管理系统 (DBMS)。
  • 安装Go语言开发环境。有关安装说明,请参阅安装Go
  • 一个编辑代码的工具。你拥有的任何文本编辑器都可以正常工作。
  • 一个命令行终端。Go在Linux和Mac中的任何终端,以及Windows中的PowerShell或cmd上都能很好地工作。

为你的代码创建一个文件夹

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

1 打开命令提示符并切换到你的家目录。 在Linux或Mac上:

$ cd

在Windows上:

C:\> cd %HOMEPATH%

对于本教程的其余部分,我们将显示$作为提示符。我们使用的命令也适用于 Windows。

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

$ mkdir data-access
$ cd data-access

3 创建一个模块,你可以在其中管理将在本教程中添加的依赖项。

运行go mod init命令,为它提供新代码的模块路径。

$ go mod init example/data-access
go: creating new go.mod: module example/data-access

此命令创建一个go.mod文件,其中将列出你添加的依赖项以供追踪。有关更多信息,请参阅管理依赖项

注意:在实际开发中,你应该指定一个更符合你自己需求的模块路径。有关更多信息,请参阅管理依赖项

接下来,你将创建一个数据库。

建立一个数据库

在此步骤中,你将创建要使用的数据库。你将使用DBMS本身的CLI来创建数据库和表,以及添加数据。

你将创建一个数据库,其中包含有关黑胶唱片上的老式爵士乐唱片的数据。

此处的代码使用MySQL CLI,大多数DBMS都有自己的具有类似功能的CLI。

1 打开新的命令提示符。

2 在命令行中,登录DBMS,如下面的MySQL示例所示。

$ mysql -u root -p
Enter password:

mysql>

3 在mysql命令提示符下,创建一个数据库。

mysql> create database recordings;

4 切换到你刚刚创建的数据库,以便你可以添加表。

mysql> use recordings;
Database changed

5 在文本编辑器的数据访问文件夹中,创建一个名为create-tables.sql的文件来保存用于添加表的SQL脚本。

6 在文件中,粘贴以下SQL代码,然后保存文件。

DROP TABLE IF EXISTS album;
CREATE TABLE album (
  id         INT AUTO_INCREMENT NOT NULL,
  title      VARCHAR(128) NOT NULL,
  artist     VARCHAR(255) NOT NULL,
  price      DECIMAL(5,2) NOT NULL,
  PRIMARY KEY (`id`)
);

INSERT INTO album
  (title, artist, price)
VALUES
  ('Blue Train', 'John Coltrane', 56.99),
  ('Giant Steps', 'John Coltrane', 63.99),
  ('Jeru', 'Gerry Mulligan', 17.99),
  ('Sarah Vaughan', 'Sarah Vaughan', 34.98);

在此SQL代码中,你:

  • 删除一个名为album的表。如果你想重新开始使用该表,执行此命令即可。
  • 创建一个包含四列的album表:titleartistprice。每行的id值由DBMS自动创建。
  • 添加四行值。

7 在mysql命令提示符下,运行你刚刚创建的脚本。

使用以下形式的source命令:

mysql> source /path/to/create-tables.sql

8 使用SELECT语句来验证你是否成功创建包含数据的表。

mysql> select * from album;
+----+---------------+----------------+-------+
| id | title         | artist         | price |
+----+---------------+----------------+-------+
|  1 | Blue Train    | John Coltrane  | 56.99 |
|  2 | Giant Steps   | John Coltrane  | 63.99 |
|  3 | Jeru          | Gerry Mulligan | 17.99 |
|  4 | Sarah Vaughan | Sarah Vaughan  | 34.98 |
+----+---------------+----------------+-------+
4 rows in set (0.00 sec)

接下来,你将编写一些Go代码来连接数据库以便你可以查询数据。

查找并导入数据库的驱动程序

现在你已经有了一个包含一些数据的数据库,开始写你的Go代码。

找到并导入一个数据库驱动程序,该驱动程序会将你通过database/sql包中的函数发出的请求转换为数据库可以理解的请求。

1 在你的浏览器中,访问SQLDrivers wiki页面以确定你可以使用的驱动程序。

使用页面上的列表来确定你将使用的驱动程序。为了在本教程中访问MySQL,你将使用Go-MySQL-Driver

2 请注意驱动程序的包名称,此处为github.com/go-sql-driver/mysql

3 使用你的文本编辑器,创建一个用于编写你的Go代码的文件,命名为main.go保存在你之前创建的数据访问目录中。

4 进入main.go,粘贴以下代码导入驱动包。

package main

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

在此代码中,你:

  • 将你的代码添加到main包中,以便可以独立执行它。
  • 导入MySQL驱动程序github.com/go-sql-driver/mysql

导入驱动程序后,你将开始编写代码来访问数据库。

获取数据库句柄并连接

现在编写一些Go代码,使用数据库句柄(handler)访问数据库。

你将使用指向sql.DB结构体的指针,该结构表示对特定数据库的访问。

编写代码

1 在main.go中,在你刚刚添加的import代码下面,粘贴以下go代码以创建数据库句柄。

var db *sql.DB

func main() {
    // 设置连接参数。
    cfg := mysql.Config{
        User:   os.Getenv("DBUSER"),
        Passwd: os.Getenv("DBPASS"),
        Net:    "tcp",
        Addr:   "127.0.0.1:3306",
        DBName: "recordings",
    }
    // 获取一个数据库句柄。
    var err error
    db, err = sql.Open("mysql", cfg.FormatDSN())
    if err != nil {
        log.Fatal(err)
    }

    pingErr := db.Ping()
    if pingErr != nil {
        log.Fatal(pingErr)
    }
    fmt.Println("Connected!")
}

在这段代码中:

  • 声明一个类型为*sql.DB的数据库变量db。这就是你的数据库句柄。

db作为全局变量简化了这个例子。在生产环境中,你应该避免使用全局变量,应该将变量传递给需要它的函数,或者将其封装在结构体中。

  • 使用MySQL驱动程序的Config(配置)类型和这个类型的FormatDSN函数来收集连接选项,并将其格式化为连接字符串的DSN。

Config结构体使得代码比直接使用连接字符串DSN更具可读性。

  • 调用sql.Open初始化db变量,并传入FormatDSN函数的返回值。
  • 检查sql.Open返回的错误变量。例如,如果数据库连接格式不正确,它可能会连接失败。

为了简化代码,我们调用log.Fatal函数来结束执行并将错误打印到控制台。在生产环境中,你应该以更优雅的方式来处理错误。

  • 调用DB.Ping函数以确认是否正常连接到数据库。在运行时,sql.Open可能不会立即连接到数据库,具体取决于驱动程序。你在这里使用DB.Ping函数来确认database/sql包可以在需要时连接到数据库。
  • 检查Ping函数是否返回错误,以防连接失败。
  • 如果Ping函数连接成功,则打印消息。

2. 在main.go文件的顶部附近,就在包声明的下方,导入支持你刚刚编写的代码的包。

main.go文件的顶部现在应该是这样的:

package main

import (
    "database/sql"
    "fmt"
    "log"
    "os"

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

3. 保存main.go文件

运行这段代码

1. 开始将MySQL驱动程序模块作为一个依赖项进行跟踪。

使用go get添加github.com/go-sql-driver/mysql模块作为你自己模块的一个依赖项。使用句点参数表示“获取当前目录中的代码的所有依赖项”。

$ go get .
go get: added github.com/go-sql-driver/mysql v1.6.0

Go下载了这个依赖项,因为你在上一步中将它添加到了import声明中。有关跟踪依赖项的详细信息,请参阅添加一个依赖项

在命令提示符下,设置DBUSERDBPASS环境变量以供这个Go程序使用。

在Linux或Mac上:

$ export DBUSER=username
$ export DBPASS=password

在Windows上:

C:\Users\you\data-access> set DBUSER=username
C:\Users\you\data-access> set DBPASS=password

3. 在包含main.go的目录中的命令行中,通过键入go run和一个点参数来运行代码,意思是“运行当前目录中的main包”:

$ go run .
Connected!

你可以连接成功!接下来,你将查询一些数据。

查询多行

在本节中,你将使用Go执行一个SQL查询,该查询旨在返回多行。

对于可能返回多行的SQL语句,可以使用database/sql包中的Query方法,然后循环获取它返回的行。(稍后将在查询单行一节中学习如何查询单行。)

编写代码

1. 进入main.go,在func main的正上方,粘贴Album结构体的以下定义。你将使用它来保存从查询返回的行数据。

type Album struct {
    ID     int64
    Title  string
    Artist string
    Price  float32
}

2. 在func main下,粘贴以下albumsByArtist函数以查询数据库:

// albumsByArtist函数查询具有指定艺术家(Artist)姓名的专辑。
func albumsByArtist(name string) ([]Album, error) {
    // 一个切片用于保存返回行数据的相册(Album)信息。
    var albums []Album

    rows, err := db.Query("SELECT * FROM album WHERE artist = ?", name)
    if err != nil {
        return nil, fmt.Errorf("albumsByArtist %q: %v", name, err)
    }
    defer rows.Close()
    // 遍历每一行,使用Scan函数将列数据分配给结构体的对应字段。
    for rows.Next() {
        var alb Album
        if err := rows.Scan(&alb.ID, &alb.Title, &alb.Artist, &alb.Price); err != nil {
            return nil, fmt.Errorf("albumsByArtist %q: %v", name, err)
        }
        albums = append(albums, alb)
    }
    if err := rows.Err(); err != nil {
        return nil, fmt.Errorf("albumsByArtist %q: %v", name, err)
    }
    return albums, nil
}

在此代码中,你:

  • 声明Album类型的一个album切片,用来存储来自返回行的数据。结构体字段名称和类型对应于数据库列的名称和类型。
  • 使用DB.Query执行SELECT语句以查询具有指定艺术家(Artist)姓名的专辑。

查询的第一个参数是SQL语句。在该参数之后,可以传递零个或多个任意类型的参数。这些参数的值将依次填入你在SQL语句中使用?号占据的位置。通过将SQL语句与参数值分开(而不是将它们用fmt.Sprintf函数连接成一个字符串),你可以让database/sql包将参数值与SQL文本分开发送,从而消除任何SQL注入风险。

  • defer rows.Close()延迟关闭rows,以便在函数退出时释放它持有的任何资源。
  • 遍历返回的行,使用Rows.Scan将每行的列值分别分配给Album结构体字段。

Scan函数获取指向Go变量的指针列表,对应的列值将写入其中。在这里,你传入&运算符加alb结构体变量中的字段。通过这些指针Scan函数写入以更新结构体的字段。

  • 在循环体内,也检查Scan函数是否有错误返回。
  • 在循环体内,将新的alb变量附加到albums切片。
  • 在循环体之后,使用rows.Err函数检查整个查询是否有错误发生。请注意,如果查询本身失败了,则检查此处的错误是发现结果不完整的唯一方法。

3. 更新你的main函数以调用albumsByArtist函数。

func main的末尾,添加以下代码:

albums, err := albumsByArtist("John Coltrane")
if err != nil {
    log.Fatal(err)
}
fmt.Printf("Albums found: %v\n", albums)

在新代码中,你现在:

  • 调用albumsByArtist函数,将其返回值分配给新的albums变量。
  • 打印结果。

运行代码

从包含main.go文件的目录的命令行运行代码:

$ go run .
Connected!
Albums found: [{1 Blue Train John Coltrane 56.99} {2 Giant Steps John Coltrane 63.99}]

接下来你将查询单行。

查询单行

在本节中,你将使用Go查询数据库中的一行。

对于你知道的最多返回一行数据的SQL语句,你可以使用QueryRow函数,它比使用Query函数更简单。

写出代码

1. 在albumsByArtist函数下,粘贴以下albumByID函数的代码:

// albumByID函数使用指定ID来查询相册(album)信息。
func albumByID(id int64) (Album, error) {
    // 声明一个用来存储返回行数据的Album结构体变量。
    var alb Album

    row := db.QueryRow("SELECT * FROM album WHERE id = ?", id)
    if err := row.Scan(&alb.ID, &alb.Title, &alb.Artist, &alb.Price); err != nil {
        if err == sql.ErrNoRows {
            return alb, fmt.Errorf("albumsById %d: no such album", id)
        }
        return alb, fmt.Errorf("albumsById %d: %v", id, err)
    }
    return alb, nil
}

在这段代码中,你:

  • 使用DB.QueryRow执行SELECT语句来查询具有指定ID的相册的信息。

它返回一个sql.Row。为了简化你的代码,QueryRow函数不会返回错误。相反,它安排稍后的Rows.Scan函数返回任何可能发生的查询错误(例如sql.ErrNoRows)。

  • 使用Row.Scan函数将列值复制到结构体字段中。
  • 检查Scan函数返回错误。

特殊错误sql.ErrNoRows表示查询未返回任何行。通常,该错误值得用更具体的文本来说明,例如此处的“没有这样的专辑(no such album)”。

2. 更新main函数以调用albumByID函数。

func main的末尾,添加以下代码:

// 在此处硬编码ID为2以测试查询。
alb, err := albumByID(2)
if err != nil {
    log.Fatal(err)
}
fmt.Printf("Album found: %v\n", alb)

在这段代码中,你现在:

  • 调用你添加的albumByID函数。
  • 打印返回的相册的ID。

运行代码

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

$ go run .
Connected!
Albums found: [{1 Blue Train John Coltrane 56.99} {2 Giant Steps John Coltrane 63.99}]
Album found: {2 Giant Steps John Coltrane 63.99}

接下来,你将向数据库添加一条相册信息数据。

添加数据

在本节中,你将使用Go语言执行SQL的INSERT语句以向数据库添加新行。

你已经了解了如何将QueryQueryRow函数与返回数据的SQL语句一起使用。如果要执行不返回数据的SQL语句,你可以使用Exec函数。

写代码

1. 在albumByID函数下方,粘贴以下addAlbum函数以在数据库中插入新专辑的数据:

// addAlbum函数添加指定专辑的数据到数据库,返回这条新记录的ID
func addAlbum(alb Album) (int64, error) {
    result, err := db.Exec("INSERT INTO album (title, artist, price) VALUES (?, ?, ?)", alb.Title, alb.Artist, alb.Price)
    if err != nil {
        return 0, fmt.Errorf("addAlbum: %v", err)
    }
    id, err := result.LastInsertId()
    if err != nil {
        return 0, fmt.Errorf("addAlbum: %v", err)
    }
    return id, nil
}

在此代码中,你:

Query函数一样,Exec函数接受SQL语句,后跟SQL语句的参数值。

  • 检查尝试INSERT时是否有错误发生。
  • 使用Result.LastInsertId函数检索插入到数据库的新行的ID。
  • 检查尝试检索ID时是否有错误发生。

2. 更新main函数以调用新的addAlbum函数。

func main的末尾,添加以下代码:

albID, err := addAlbum(Album{
    Title:  "The Modern Sound of Betty Carter",
    Artist: "Betty Carter",
    Price:  49.99,
})
if err != nil {
    log.Fatal(err)
}
fmt.Printf("ID of added album: %v\n", albID)

在新代码中,你现在:

  • 调用addAlbum函数,传入新的专辑数据,然后把添加的这行专辑数据的ID返回给albID变量。

运行代码

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

$ go run .
Connected!
Albums found: [{1 Blue Train John Coltrane 56.99} {2 Giant Steps John Coltrane 63.99}]
Album found: {2 Giant Steps John Coltrane 63.99}
ID of added album: 5

结论

恭喜你刚刚使用Go对关系数据库执行了简单的操作。

建议你进行下一个主题:

  • 看看数据访问指南,其中包括更多关于此处仅涉及的主题的信息。
  • 如果你是Go语言新手,你可以在Effective GoHow to write Go code中找到有用的最佳实践。
  • Go Tour是对Go基础知识的一个很好的循序渐进的介绍。

完成全部代码

本节给出本教程的全部代码:

package main

import (
    "database/sql"
    "fmt"
    "log"
    "os"

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

var db *sql.DB

type Album struct {
    ID     int64
    Title  string
    Artist string
    Price  float32
}

func main() {
    // 设置连接参数
    cfg := mysql.Config{
        User:   os.Getenv("DBUSER"),
        Passwd: os.Getenv("DBPASS"),
        Net:    "tcp",
        Addr:   "127.0.0.1:3306",
        DBName: "recordings",
    }
    // 获取一个数据库句柄
    var err error
    db, err = sql.Open("mysql", cfg.FormatDSN())
    if err != nil {
        log.Fatal(err)
    }

    pingErr := db.Ping()
    if pingErr != nil {
        log.Fatal(pingErr)
    }
    fmt.Println("Connected!")

    albums, err := albumsByArtist("John Coltrane")
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf("Albums found: %v\n", albums)

    // 获取ID为2的相册的信息,测试查询语句
    alb, err := albumByID(2)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf("Album found: %v\n", alb)

    albID, err := addAlbum(Album{
        Title:  "The Modern Sound of Betty Carter",
        Artist: "Betty Carter",
        Price:  49.99,
    })
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf("ID of added album: %v\n", albID)
}

// albumsByArtist函数查询具有指定艺术家(Artist)姓名的专辑。
func albumsByArtist(name string) ([]Album, error) {
    // 一个切片用于保存返回行数据的相册(Album)信息。
    var albums []Album

    rows, err := db.Query("SELECT * FROM album WHERE artist = ?", name)
    if err != nil {
        return nil, fmt.Errorf("albumsByArtist %q: %v", name, err)
    }
    defer rows.Close()
    // 遍历每一行,使用Scan函数将列数据分配给结构体的对应字段。
    for rows.Next() {
        var alb Album
        if err := rows.Scan(&alb.ID, &alb.Title, &alb.Artist, &alb.Price); err != nil {
            return nil, fmt.Errorf("albumsByArtist %q: %v", name, err)
        }
        albums = append(albums, alb)
    }
    if err := rows.Err(); err != nil {
        return nil, fmt.Errorf("albumsByArtist %q: %v", name, err)
    }
    return albums, nil
}

// albumByID函数使用指定ID来查询相册(album)信息。
func albumByID(id int64) (Album, error) {
    // 声明一个用来存储返回行数据的Album结构体变量。
    var alb Album

    row := db.QueryRow("SELECT * FROM album WHERE id = ?", id)
    if err := row.Scan(&alb.ID, &alb.Title, &alb.Artist, &alb.Price); err != nil {
        if err == sql.ErrNoRows {
            return alb, fmt.Errorf("albumsById %d: no such album", id)
        }
        return alb, fmt.Errorf("albumsById %d: %v", id, err)
    }
    return alb, nil
}

//addAlbum函数添加指定专辑的数据到数据库,返回这条新记录的ID
func addAlbum(alb Album) (int64, error) {
    result, err := db.Exec("INSERT INTO album (title, artist, price) VALUES (?, ?, ?)", alb.Title, alb.Artist, alb.Price)
    if err != nil {
        return 0, fmt.Errorf("addAlbum: %v", err)
    }
    id, err := result.LastInsertId()
    if err != nil {
        return 0, fmt.Errorf("addAlbum: %v", err)
    }
    return id, nil
}

JSON和Go

本文翻译自《JSON and Go》。

Andrew Gerrand

2011/01/25

介绍

JSON(JavaScript对象表示法)是一种简单的数据交换格式。在语法上,它类似于JavaScript的对象和列表。它最常用于web后端和浏览器中运行的JavaScript程序之间的通信,但也用于许多其他地方。它的主页json.org提供了一个非常清晰和简洁的标准定义。

使用json包,从Go程序读取和写入json数据很简单。

编码

为了编码JSON数据,我们使用Marshal函数。

func Marshal(v interface{}) ([]byte, error)

给定Go结构体Message

type Message struct {
    Name string
    Body string
    Time int64
}

Message的一个实例:

m := Message{"Alice", "Hello", 1294706395881547000}

我们可以使用json.Marshal函数得到m的JSON编码版本:

b, err := json.Marshal(m)

如果一切顺利,err将为nilb将是包含此JSON数据的[]byte

b == []byte(`{"Name":"Alice","Body":"Hello","Time":1294706395881547000}`)

只有可以表示为有效JSON的数据结构才可以被编码:

  • JSON对象只支持字符串作为键;要编码Go的map类型,它必须是map[string]T格式(其中T是json包支持的任何Go类型)。
  • 无法对通道、复数和函数类型进行编码。
  • 不支持循环数据结构;他们会使Marshal陷入无限循环。
  • 指针将被编码为它们指向的值(如果指针为空,则编码为“null”)。

json包只访问结构体类型的导出字段(以大写字母开头的字段)。因此,只有结构体的导出字段才会出现在JSON的输出中。

解码

要解码JSON数据,我们使用Unmarshal函数。

func Unmarshal(data []byte, v interface{}) error

我们必须首先创建一个存储解码数据的变量:

var m Message

并调用json.Unmarshal,将一个[]byte的JSON数据和一个指向m的指针传递给它:

err := json.Unmarshal(b, &m)

如果b包含适合m的有效JSON,则在调用之后err将为nil,并且来自b的数据将存储在结构体m中,就像通过如下赋值一样:

m = Message{
    Name: "Alice",
    Body: "Hello",
    Time: 1294706395881547000,
}

Unmarshal如何识别存储解码数据的字段?对于给定的JSON键“Foo”,Unmarshal将查看目标结构体的字段来查找(按以下优先级顺序):

  • 带有“Foo”标签(tag)的导出(公有)字段(有关结构体标签的更多信息,请参阅Go语言规范),
  • 名为“Foo”的导出字段,或
  • 其他“Foo”单词的不区分大小写的匹配项,例如名为“FOO”或“FoO”的导出字段。

当JSON数据的结构与Go类型不完全匹配时会发生什么?

b := []byte(`{"Name":"Bob","Food":"Pickle"}`)
var m Message
err := json.Unmarshal(b, &m)

Unmarshal将只解码它可以在目标类型中找到的字段。在这种情况下,只会填充mName字段,而忽略Food字段。当你希望从大型JSON数据中仅选择几个特定字段时,此行为特别有用。这也意味着目标结构体中任何未导出(私有)的字段都不会受到Unmarshal的影响。

但是,如果你事先不知道JSON数据的结构怎么办?

带接口的通用JSON

interface{}(空接口)类型描述了一个具有零个方法(没有一个方法)的接口。每个Go类型都至少实现了零个方法,因此都实现了空接口。

空接口可以用作通用的容器类型:

var i interface{}
i = "a string"
i = 2011
i = 2.777

类型断言访问底层的具体类型:

r := i.(float64)
fmt.Println("the circle's area", math.Pi*r*r)

或者,如果底层类型未知,则可以使用switch语句来确定类型:

switch v := i.(type) {
case int:
    fmt.Println("twice i is", v*2)
case float64:
    fmt.Println("the reciprocal of i is", 1/v)
case string:
    h := len(v) / 2
    fmt.Println("i swapped by halves is", v[h:]+v[:h])
default:
    // i的类型不是以上类型中的一种
}

json包使用map[string]interface{}[]interface{}值来存储任意JSON对象或数组;它会愉快地将任何有效的JSON blob解码为一个普通的interface{}值。interface{}值默认使用的底层Go类型是:

  • bool用于JSON布尔值,
  • float64用于JSON数字值,
  • string用于JSON字符串值,以及
  • nil用于JSON的null(空值)。

解码任意数据

考虑这个存储在变量b中的JSON数据:

b := []byte(`{"Name":"Wednesday","Age":6,"Parents":["Gomez","Morticia"]}`)

在不知道此数据的内部结构的情况下,我们可以使用Unmarshal将其解码为interface{}值:

var f interface{}
err := json.Unmarshal(b, &f)

此时,f中的Go值将是一个map,其键为字符串,其值存储为空接口interface{}值:

f = map[string]interface{}{
    "Name": "Wednesday",
    "Age":  6,
    "Parents": []interface{}{
        "Gomez",
        "Morticia",
    },
}

要访问此数据,我们可以使用类型断言来访问f的底层map[string]interface{}

m := f.(map[string]interface{})

然后我们可以使用range语句遍历这个map,并使用switch语句来判断其值的具体类型:

for k, v := range m {
    switch vv := v.(type) {
    case string:
        fmt.Println(k, "is string", vv)
    case float64:
        fmt.Println(k, "is float64", vv)
    case []interface{}:
        fmt.Println(k, "is an array:")
        for i, u := range vv {
            fmt.Println(i, u)
        }
    default:
        fmt.Println(k, "is of a type I don't know how to handle")
    }
}

通过这种方式,你可以使用未知内部结构的JSON数据,同时仍然享受类型安全的好处。

引用类型

让我们定义一个Go类型来包含上一个示例中的数据:

type FamilyMember struct {
    Name    string
    Age     int
    Parents []string
}

var m FamilyMember
err := json.Unmarshal(b, &m)

将该数据解码为FamilyMember值按预期工作,但如果我们仔细观察,我们会发现发生了一件了不起的事情。通过var语句,我们分配了一个FamilyMember结构体,然后将指向该值的指针提供给Unmarshal函数,但此时Parents字段是一个nil切片值。为了填充Parents字段,Unmarshal函数在幕后分配了一个新切片。这是Unmarshal解码它支持的引用类型(指针、切片和映射)的典型方式。

考虑解码到这个数据结构中:

type Foo struct {
    Bar *Bar
}

如果JSON中有一个Bar字段,Unmarshal函数将会分配一个新的Bar实例并填充它。否则,Bar将被保留为nil指针。

由此产生了一个有用的模式:如果你有一个接收几种不同消息类型的应用程序,你可以定义“接收者”结构,例如

type IncomingMessage struct {
    Cmd *Command
    Msg *Message
}

发送方可以填充JSON对象的Cmd字段和/或Msg字段,具体取决于他们想要传达的消息类型。Unmarshal函数在将JSON解码为IncomingMessage结构时,将仅分配JSON数据中存在的那个数据结构。具体要处理哪种消息,程序员只需测试CmdMsg是否不为nil。

数据流的编码器和解码器

json包提供了DecoderEncoder类型来支持读写JSON数据流的操作。NewDecoderNewEncoder函数包装了io.Readerio.Writer接口类型。

func NewDecoder(r io.Reader) *Decoder
func NewEncoder(w io.Writer) *Encoder

下面是一个示例程序,它从标准输入流中读取一系列JSON对象,从每个对象中删除除了Name字段以外的所有字段,然后将对象写入标准输出流:

package main

import (
    "encoding/json"
    "log"
    "os"
)

func main() {
    dec := json.NewDecoder(os.Stdin)
    enc := json.NewEncoder(os.Stdout)
    for {
        var v map[string]interface{}
        if err := dec.Decode(&v); err != nil {
            log.Println(err)
            return
        }
        for k := range v {
            if k != "Name" {
                delete(v, k)
            }
        }
        if err := enc.Encode(&v); err != nil {
            log.Println(err)
        }
    }
}

由于io.Readerio.Writer的广泛使用,这些DecoderEncoder类型可以用于广泛的场景,例如读取和写入HTTP连接、WebSocket或文件等。

参考

有关详细信息,请参阅json包的文档。有关json的示例用法,请参阅jsonrpc包的源文件。

Go映射(map)实战

本文翻译自《Go maps in action》。

Andrew Gerrand

2013/02/06

介绍

哈希表是计算机科学中最有用的数据结构之一。许多哈希表的实现具有不同的属性,但通常它们都提供快速查找、添加和删除这些功能。Go提供了实现哈希表的内置的map类型。

定义和初始化

Go的map类型如下所示:

map[KeyType]ValueType

其中KeyType可以是任何可比较的类型(稍后将详细介绍),ValueType可以是任何类型,包括另一个map!

此变量m是字符串键到int值的映射:

var m map[string]int

map类型是引用类型,类似指针或切片,因此上面的m值为nil;它不指向已初始化的map。读取时,nil map的行为类似于空map,但尝试写入nil map会导致运行时panic;不要那样做。初始化一个map,请使用内置的make函数:

m = make(map[string]int)

make函数分配并初始化map数据结构,并返回指向它的map值。该数据结构在运行时的实现细节,不由语言本身决定。在本文中,我们将关注map的使用,而不是它们的实现。

使用map

Go提供了一种熟悉的语法来处理map。以下语句将键“route”设置为值66

m["route"] = 66

以下语句检索存储在键“route”下的值并将其赋值给新变量i

i := m["route"]

如果请求的键不存在,我们将获得值的类型的零值。在本例的情况下,值类型是int,因此零值是0

j := m["root"]
// j == 0

内置的len函数返回map中的元素的个数:

n := len(m)

内置的delete函数从map中删除一个元素:

delete(m, "route")

delete函数不返回任何内容,如果指定的键不存在,就什么也不做。

双值赋值运算可以测试键是否存在:

i, ok := m["route"]

在此语句中,第一个值i被赋予存储在键“route”下的值。如果该键不存在,则i是值类型的零值0。第二个值ok是一个布尔值,如果键存在于map中则为真,否则为假。

要在不检索值的情况下测试键是否存在,可以使用下划线来省略第一个返回值:

_, ok := m["route"]

要遍历map的内容,请使用range关键字:

for key, value := range m {
    fmt.Println("Key:", key, "Value:", value)
}

要使用一些数据初始化一个map,请使用map字面量:

commits := map[string]int{
    "rsc": 3711,
    "r":   2138,
    "gri": 1908,
    "adg": 912,
}

可以使用以下语法来初始化一个空map,这在功能上与使用make函数相同:

m = map[string]int{}

利用零值

当键不存在时,检索map返回零值是很有用的一个特性。

例如,map里的布尔值可以用作类似集合的数据结构(回想一下,布尔类型的零值为false)。此示例遍历链接列表的Nodes并打印它们的值。它使用Node指针的map来检测列表中的循环。

    type Node struct {
        Next  *Node
        Value interface{}
    }
    var first *Node

    visited := make(map[*Node]bool)
    for n := first; n != nil; n = n.Next {
        if visited[n] {
            fmt.Println("cycle detected")
            break
        }
        visited[n] = true
        fmt.Println(n.Value)
    }

如果n已被访问,表达式visited[n]为true,如果n不存在则为false。无需使用二值形式来测试map中是否存在n;默认返回零值已经够用了。

另一个有用的零值实例是map的切片值。append到一个nil切片会分配一个新的切片,所以把一个值append到一个map的切片值无需检查键是否存在。在以下示例中,切片people填充了Person值。每个Person都有一个Name字段和一个Likes切片字段。该示例创建了一个map,将每个爱好(作为likes的键)与喜欢它的那些人(作为likes的值)相关联。

    type Person struct {
        Name  string
        Likes []string
    }
    var people []*Person

    likes := make(map[string][]*Person)
    for _, p := range people {
        for _, l := range p.Likes {
            likes[l] = append(likes[l], p)
        }
    }

打印出所有喜欢奶酪的人:

for _, p := range likes["cheese"] {
        fmt.Println(p.Name, "likes cheese.")
    }

打印出喜欢培根的人数:

fmt.Println(len(likes["bacon"]), "people like bacon.")

请注意,由于rangelen都将nil切片视为零长度切片,因此即使没有人喜欢奶酪或培根(尽管不太可能),最后两个示例仍然能正常工作。

键的类型

如前所述,map的键可以是任何可比较的类型。Go语言规范对此进行了精确定义,但简而言之,可比较类型是布尔类型、数字类型、字符串类型、指针类型、通道类型和接口类型,以及仅包含这些类型的结构体或数组。值得注意的是,没有切片、map和函数,这些类型不能使用==进行比较,因此不能用作map的键。

显然,字符串、整数和其他基本类型应该可以用作map的键,但可能出乎意料的是结构体作为map的键。结构体可以从多个维度作为键。例如,以下map可用于按国家/地区统计网页点击率:

hits := make(map[string]map[string]int)

这个map的键是字符串类型,值是另一个map(字符串到整数的映射)类型。外部map的每个键是网页的路径。内部map的每个键都是两个字母的国家/地区代码。此表达式检索澳大利亚人加载某个网页的次数:

n := hits["/doc/"]["au"]

不幸的是,这种方法在添加数据时并不灵活,对于任何给定的外部键,你必须检查内部map是否存在,并在需要时创建它:

func add(m map[string]map[string]int, path, country string) {
    mm, ok := m[path]
    if !ok {
        mm = make(map[string]int)
        m[path] = mm
    }
    mm[country]++
}
add(hits, "/doc/", "au")

我们可以使用带有结构体键的单个map的设计来消除所有的复杂性:

type Key struct {
    Path, Country string
}
hits := make(map[Key]int)

当越南人访问主页时,增加(并可能创建)适当的计数器,使用一行代码就能实现:

hits[Key{"/", "vn"}]++

同样,看看有多少瑞士人看过/ref/spec网页:

n := hits[Key{"/ref/spec", "ch"}]

并发

map对于并发使用是不安全的:Go没有定义当你同时读取和写入它们时会发生什么。如果你需要从并发执行的goroutine读取和写入map,则访问必须通过某种同步机制进行调解。保护map的一种常见方法是使用sync.RWMutex

此语句声明一个counter变量,它是一个包含map和内嵌sync.RWMutex的匿名结构体。

var counter = struct{
    sync.RWMutex
    m map[string]int
}{m: make(map[string]int)}

要从counter读取,请获取读锁:

counter.RLock()
n := counter.m["some_key"]
counter.RUnlock()
fmt.Println("some_key:", n)

要写入counter,请获取写锁:

counter.Lock()
counter.m["some_key"]++
counter.Unlock()

迭代顺序

使用range循环遍历map时,Go语言没有指定迭代顺序,并且不保证从一次迭代到下一次迭代是相同顺序的。如果你需要稳定的迭代顺序,则必须维护一个单独的数据结构来指定该顺序。以下例子使用单独排序的键切片,来按键在切片里的顺序打印输出map[int]string

import "sort"

var m map[int]string
var keys []int
for k := range m {
    keys = append(keys, k)
}
sort.Ints(keys)
for _, k := range keys {
    fmt.Println("Key:", k, "Value:", m[k])
}

Go切片(slice):用法和内部结构

本文翻译自《https://go.dev/blog/slices-intro》。

Andrew Gerrand

2011年1月5日

介绍

Go的切片(slice)类型提供了一种方便有效的方法来处理有类型的数据的序列。切片类似于其他语言中的数组,但具有一些不同寻常的属性。本文将介绍切片是什么以及它们的使用方法。

数组

切片类型是建立在Go的数组类型之上的抽象,因此要理解切片我们必须首先理解数组。

数组类型通过指定长度和元素类型来定义。例如,类型[4]int表示一个包含四个整数的数组。数组的大小是固定的;它的长度是其类型的一部分(例如[4]int[5]int是不同的、不兼容的类型)。数组可以用通常的方式索引,所以表达式s[n]访问第n个元素,从0开始。

var a [4]int
a[0] = 1
i := a[0]
// i == 1

数组不需要显式初始化;数组的零值是其所有元素本身为零值的现成(ready-to-use)数组:

// a[2] == 0,是int类型的零值

[4]int的内存表示形式仅为顺序排列的四个整数值:

Go语言的数组是值类型的。数组变量表示整个数组;它不是指向第一个数组元素的指针(C语言中的情况)。这意味着当你分配或传递数组值时,你将拷贝其所有内容(为了避免拷贝,你可以用一个指针指向数组,这是指向数组的指针,而不是数组本身)。可以将数组视为一种结构体,它具有索引字段而非命名字段,它是一种固定大小的复合值。

数组字面量可以这样指定:

b := [2]string{"Penn", "Teller"}

或者,你也可以让编译器为你计数数组元素的个数:

b := [...]string{"Penn", "Teller"}

这两种情况b的类型都是[2]string

切片

数组有它的空间,但有点不灵活,所以你不会在Go代码中经常看到数组。不过,切片无处不在。它们以数组为基础,提供强大的功能和便利。

切片的类型是[]T,其中T是切片里的元素的类型。与数组类型不同,切片类型没有指定长度。

切片字面量的声明就像数组字面量一样,只是你省略了元素计数:

letters := []string{"a", "b", "c", "d"}

可以使用内置函数make创建切片,该函数的签名如下:

func make([]T, len, cap) []T

其中T代表要创建的切片的元素的类型。make函数接收一个类型T、一个长度len和一个可选的容量cap。调用时,make分配一个数组并返回一个引用该数组的切片。

var s []byte
s = make([]byte, 5, 5)
// s == []byte{0, 0, 0, 0, 0}

当省略cap参数时,它默认等于指定的长度len。这是同一代码的更简洁的版本:

s := make([]byte, 5)

可以使用内置的lencap函数检查切片的长度和容量。

len(s) == 5
cap(s) == 5

接下来的两节讨论长度和容量之间的关系。

切片的零值为nil。对于nil切片,lencap函数都将返回0。

切片也可以通过“切片”现有切片或数组来形成。切片是通过指定一个半开放区间来完成的,其中两个索引由冒号分隔。例如,表达式b[1:4]创建一个包含b的下标从1到3的元素的切片(所得切片的索引将是0到2)。

b := []byte{'g', 'o', 'l', 'a', 'n', 'g'}
// b[1:4] == []byte{'o', 'l', 'a'}, sharing the same storage as b

切片表达式的开始和结束索引是可选的;它们分别默认为0和切片的长度:

// b[:2] == []byte{'g', 'o'}
// b[2:] == []byte{'l', 'a', 'n', 'g'}
// b[:] == b

这也是在给定一个数组的情况下,创建一个指向它的切片的语法:

x := [3]string{"Лайка", "Белка", "Стрелка"}
s := x[:] // 一个指向数组x的切片

切片内部

一个切片是一个数组片段的描述符。它由指向数组的指针、片段的长度及其容量(片段的最大长度)组成。

之前由make([]byte, 5)创建的变量s的结构如下:

长度是切片引用的数组元素的个数。容量是底层数组中的元素的个数(从切片指针指向的元素开始数)。在接下来的几个示例中,长度和容量之间的区别将变得更加清晰。

当我们对s进行切片时,观察切片数据结构的变化及其与底层数组之间的关系:

s = s[2:4]

切片不会复制底层数组的数据。它将创建一个指向原始数组的新切片值。这使得切片操作与处理数组索引一样高效。因此,修改新切片值的元素(而不是新切片值本身)会修改原始切片的元素:

d := []byte{'r', 'o', 'a', 'd'}
e := d[2:]
// e == []byte{'a', 'd'}
e[1] = 'm'
// e == []byte{'a', 'm'}
// d == []byte{'r', 'o', 'a', 'm'}

之前我们将s切成比其容量短的长度。 我们可以通过再次切片来增加s的容量:

s = s[:cap(s)]

切片不能超出其容量。尝试这样做会导致运行时panic,就像索引超出切片或数组边界时一样。同样,不能将切片重新切片到0以下来访问数组中较早的元素。

增长切片的元素(copyappend函数)

为了增加切片的容量,必须创建一个新的、更大的切片,并将原始切片的内容复制到其中。这项技术是其他语言的动态数组在幕后的实现方式。下一个示例通过创建一个新的切片t,将s的内容复制到t,然后将t赋值给s,从而使s的容量加倍:

t := make([]byte, len(s), (cap(s)+1)*2) // +1是为了防止cap(s) == 0的情况
for i := range s {
        t[i] = s[i]
}
s = t

内置的copy函数使这种常见的循环操作变得更容易。顾名思义,copy将数据从源切片复制到目标切片。它返回复制的元素个数。

func copy(dst, src []T) int

copy函数支持在不同长度的切片之间进行复制(长度较短的那个切片复制或被复制完毕就不再继续)。此外,copy可以处理共享同一底层数组的源切片和目标切片,正确地处理元素部分重叠的切片。

使用copy函数,我们可以简化上面的代码片段:

t := make([]byte, len(s), (cap(s)+1)*2)
copy(t, s)
s = t

一个常见的操作是将数据追加到切片的末尾。此函数将字节元素附加到字节切片,必要时会增大切片的容量,返回更新后的切片:

func AppendByte(slice []byte, data ...byte) []byte {
    m := len(slice)
    n := m + len(data)
    if n > cap(slice) { // 如果有必要,重新分配一个底层数组
        // 考虑到未来的数据增长,在此处加倍底层数组的容量。
        newSlice := make([]byte, (n+1)*2)
        copy(newSlice, slice)
        slice = newSlice
    }
    slice = slice[0:n]
    copy(slice[m:n], data)
    return slice
}

可以这样使用AppendByte

p := []byte{2, 3, 5}
p = AppendByte(p, 7, 11, 13)
// p == []byte{2, 3, 5, 7, 11, 13}

AppendByte这样的函数很有用,因为它们提供了对切片增长方式的完全控制。 根据程序的特性,可能需要分配更小或更大的块,或者对重新分配的大小设置上限。

但是大多数程序不需要完全控制,因此Go提供了一个适合大多数用途的内置append函数,签名如下:

func append(s []T, x ...T) []T

append函数将元素x附加到切片s的末尾,并在需要更大容量时扩大切片。

a := make([]int, 1)
// a == []int{0}
a = append(a, 1, 2, 3)
// a == []int{0, 1, 2, 3}

要将一个切片附加到另一个切片,请使用...将第二个参数展开为列表。

a := []string{"John", "Paul"}
b := []string{"George", "Ringo", "Pete"}
a = append(a, b...) // 等价于"append(a, b[0], b[1], b[2])"
// a == []string{"John", "Paul", "George", "Ringo", "Pete"}

由于切片的零值(nil)就像一个零长度切片,你可以声明一个切片变量,然后在循环中附加到它:

// Filter函数返回一个新切片,它只包含s切片中那些使fn函数返回true的元素
func Filter(s []int, fn func(int) bool) []int {
    var p []int // == nil
    for _, v := range s {
        if fn(v) {
            p = append(p, v)
        }
    }
    return p
}

一个可能的“陷阱”

如前所述,重新切片不会复制底层数组。整个数组将保存在内存中,直到不再被引用为止。有时这会导致程序只需要一小部分数据,但将所有数据保存在内存中。

例如,FindDigits函数将一个文件加载到内存中,并在其中搜索第一组连续数字,将它们作为一个切片返回。

var digitRegexp = regexp.MustCompile("[0-9]+")

func FindDigits(filename string) []byte {
    b, _ := ioutil.ReadFile(filename)
    return digitRegexp.Find(b)
}

此代码的行为确实满足要求,但返回的[]byte指向包含整个文件的数组。由于切片引用了原始数组,只要切片一直存在,垃圾收集器就无法释放数组;为了文件的几个有用字节,就将文件的全部内容保存在内存中。

要解决此问题,可以在返回之前将感兴趣的数据复制到新切片:

func CopyDigits(filename string) []byte {
    b, _ := ioutil.ReadFile(filename)
    b = digitRegexp.Find(b)
    c := make([]byte, len(b))
    copy(c, b)
    return c
}

可以使用append函数简化上述函数的代码。这留给读者作为练习。

进一步阅读

Effective Go包含对切片数组的深入处理,Go语言规范定义了切片及其相关的辅助函数。

建议:迁移到Go 2

建议:迁移到Go 2

本文翻译自《Proposal: Go 2 transition》。

作者:Ian Lance Taylor

最后更新时间:2018年10月15日

摘要

本文是关于如何在尽可能少地破坏的情况下,从Go 1迁移到Go 2进行不兼容更改的建议。

背景

目前Go语言和标准库都遵守Go 1兼容性保证。该文档的目标是承诺Go的新版本不会破坏现有的程序。

Go 2进展的目标之一是对会破坏兼容性保证的语言和标准库进行更改。由于Go是在分布式开源环境中使用的,因此我们不能依赖卖旗日(flag day)(译者注:指一种既不向前兼容也不向后兼容的软件更改,其制作成本高且撤消成本高)。我们必须允许使用不同版本的Go编写的不同包可以互相操作。

每种语言都会经历版本转换。作为背景,这里有一些关于其他语言所做的事情的记录。你可以随意跳过本节的其余部分。

C

C语言版本由ISO标准化过程驱动。C语言开发非常注重向后兼容性。在第一个ISO标准C90之后,每个后续标准都保持严格的向后兼容性。在引入新关键字的地方,它们被引入到C90保留的命名空间中(一个下划线后跟一个大写的ASCII字母),并且可以通过特定头文件中的#define宏访问它们,这些头文件以前并不存在(例如_Complex<complex.h>中定义为复数,而_Bool<stdbool.h>中定义为bool)。C90中定义的基本语言语义都没有改变。

此外,大多数C编译器都提供选项来精确定义代码应该针对哪个版本的C标准进行编译(例如,-std=c90)。大多数标准库实现都支持在包含头文件之前使用#define定义的特性宏,用来准确指定应该提供哪个版本的库(例如,_ISOC99_SOURCE)。虽然这些特性以前存在过Bug,但它们相当可靠并且被广泛使用。

这些选项的一个关键特性是,能让使用不同的语言版本和库版本编写的代码,通常都可以链接在一起并按照预期工作。

第一个标准C90确实对以前的C语言实现进行了重大更改,可以非正式地认为以前的C语言就是K&R C。引入了新关键字,例如volatile(实际上这可能是C90中唯一的新关键字)。 整数表达式中整数提升的精确实现从无符号保留(unsigned-preserving)更改为值保留(value-preserving)。幸运的是,很容易检测到由于使用了新关键字而编译错误的代码,并且很容易调整该代码。整数提升的变化实际上让新手用户没有那么难理解,有经验的用户大多使用显式转换,来确保在具有不同整数大小的系统之间的可移植性,因此虽然没有自动检测该问题,但在实践中并没有多少代码被破坏。

也有一些恼人的变化。C90引入了三字母词,它改变了一些字符串常量的行为。编译器适应了诸如-no-trigraphs-Wtrigraphs之类的选项。

更严重的是,C90引入了未定义行为(undefined behavior)的概念,并声明调用未定义行为的程序可能会采取任何操作。在K&R C中,被C90描述为未定义行为的情况大多被视为C90中所谓的依赖具体实现的行为(implementation-defined behavior):程序将采取一些不可移植但可以预测行为的操作。编译器编写者吸收了未定义行为的概念,并开始编写假定该行为不会发生的编译优化。这造成了令不熟悉C标准的人感到惊讶的影响。我不会在这里详细介绍,但其中一个示例(来自我的博客)是有符号数的栈溢出

当然C仍然是内核开发的首选语言和计算行业的胶水语言。尽管它已被更加新的语言部分取代了,但这并不是新版本的C做出的任何选择之过。

我在这里看到的教训是:

  • 向后兼容性很重要。
  • 小部分破坏兼容性是可以的,只要人们可以通过编译器选项或编译器错误发现这些破坏。
  • 可以选择特定语言/库版本的编译器选项很有用,前提是使用不同选项编译的代码可以链接在一起。
  • 没有限制的未定义行为会让用户感到困惑。

C++

C++语言的版本现在也由ISO标准化过程驱动。与C一样,C++也非常关注向后兼容性。历史上,C++在添加新关键字方面更加自由(C++ 11中有10个新关键字)。这很正常,因为较新的关键字往往相对较长(constexprnullptrstatic_assert),使得使用新关键字作为标识符的代码很容易找到编译错误。

C++使用与C中相同的选项来指定语言和库的标准版本。在未定义的行为方面,C++遇到与C相同的问题。

C++中一个突破性变化的例子是在for循环的初始化语句中声明的变量范围的变化。在C++的预标准版本中,该变量的范围扩展到for循环所在的封闭块的末尾,就好像它是在for循环之前声明的一样。在第一个C++标准C++ 98的开发过程中,对其进行了修改,使其范围仅限于for循环本身。编译器通过引入诸如-ffor-scope之类的选项进行了调整,以便用户可以控制变量的预期范围(在一段时间内,当既不使用-ffor-scope也不使用-fno-for-scope进行编译时,GCC编译器使用了旧的范围,但警告任何依赖这一行为的代码)。

尽管向后兼容性相对较强,但用新版本的C++(如C++ 11)编写代码往往与用旧版本的C++编写代码有着非常不同的感觉。这是因为样式已更改为使用新的语言和库功能。原始指针不太常用,使用范围循环而不是迭代器,诸如右值引用和移动语义等新概念被广泛使用,等等。熟悉C++旧版本的人很难理解用新版本编写的代码。

C++当然是一种非常流行的语言,正在进行的语言修改过程并没有损害它的流行性。

除了C的教训,我还想补充一点:

  • 在保持向后兼容的同时,新版本可能会有非常不同的感觉。

Java

与我讨论的其他语言相比,我对Java的了解较少,因此这里可能存在更多错误,当然也存在更多偏见。

Java在字节码级别很大程度上向后兼容,这意味着Java N+1版本的库可以调用由Java版本N(以及N-1、N-2等)编写和编译的代码。Java源代码也大多是向后兼容的,尽管它们会不时添加新的关键字。

Java文档非常详细地介绍了从一个版本迁移到另一个版本时可能出现的兼容性问题。

Java标准库非常庞大,每个新版本都会添加新包。包也会不时被弃用。使用已弃用的包将在编译时引发一个警告(警告可能会被关闭),并且在几次发布后,已弃用的包将被删除(至少在理论上是这样)。

Java似乎没有太多的向后兼容性问题。问题集中在JVM上:较旧的JVM通常不会运行较新版本的库,因此你必须确保你的JVM至少与你要使用的最新库所需的一样新。

Java按理说具有某种前向兼容性问题,因为JVM字节码提供了比CPU更高级别的接口,这使得引入不能使用现有字节码直接表示的新特性变得更加困难。

这种前向兼容性问题是Java泛型使用类型擦除的部分原因。更改现有字节码的定义会破坏已经编译成字节码的现有程序。扩展字节码以支持泛型类型需要定义大量额外的字节码。

从某种程度上说,这种前向兼容性问题对于Go来说并不存在。由于Go编译为机器代码,并通过生成额外的机器代码来实现所有必需的运行时检查,因此不存在类似的前向兼容性问题。

但总的来说:

  • 请注意兼容性问题可能会限制编程语言未来的更改。

Python

Python 3.0(也称为Python 3000)于2006年开始开发,最初于2008年发布。2018年过渡仍未完成。有些人继续使用Python 2.7(2010年发布)。这不是Go 2想要效仿的路径。

这种缓慢过渡的主要原因似乎是缺乏向后兼容性。Python 3.0故意与早期版本的Python不兼容。值得注意的是,print从语句更改为函数,字符串更改为使用Unicode。Python通常与C代码结合使用,后者的变化意味着任何将字符串从Python传递到C的代码都需要调整C代码。

因为Python是一种解释型语言,并且因为没有向后兼容性,所以不可能在同一个程序中混合使用Python 2和Python 3代码。这意味着对于使用一系列库的典型的程序,每个库都必须先转换为Python 3,然后才能转换程序。由于程序处于各种转换状态,库必须同时支持Python 2和3。

Python支持from __future__ import FEATURE形式的语句。像这样的语句以某种方式改变了Python对文件其余部分的解释。例如,from __future__ import print_functionprint从语句(如在 Python 2中)更改为函数(如在Python 3中)。这是一种渐进地把代码更新到新的语言版本的方式,并使在不同的语言版本之间共享相同代码变得更加容易。

因此我们知道:

  • 向后兼容性是必不可少的。
  • 与其他语言的接口的兼容性很重要。
  • 升级到新语言版本也受到你使用的代码库所支持的版本的限制。

Perl

Perl 6的开发过程始于2000年。Perl 6规范的第一个稳定版本于2015年发布。这不是Go 2想要效仿的路径。

这条道路如此缓慢有很多原因。Perl 6有意不向后兼容:它旨在修复语言中的缺陷。Perl 6旨在通过规范来表示,而不是像以前版本的Perl那样,通过实现来表示。Perl 6从一组更改建议开始,随着时间的推移会不断发展,再继续发展得更多。

Perl支持use feature,类似于Python的from __future__ import。它更改了文件其余部分的解释方式,以使用新语言的指定功能。

  • 不要成为Perl 6。
  • 设定并遵守最后期限。
  • 不要一下子改变一切。

建议

语言更改

迂腐地说,我们必须使用一种方式来谈论特定的语言版本。Go语言的每个更改首先出现在Go的发行版中。我们将使用Go版本号来定义语言版本。这是唯一合理的选择,但它可能会造成混淆,因为标准库的更改也与Go版本号相关联。在考虑兼容性时,有必要在概念上将Go语言版本与标准库版本分开。

作为特定更改的一个示例,类型别名(type aliase)首先在Go语言1.9版本中可用。类型别名是向后兼容语言更改的一个示例。所有用Go语言1.0到1.8版本编写的代码在Go语言1.9版本继续与以前相同的方式工作。但使用类型别名的代码需要使用Go语言1.9或更高版本才能编译运行。

增加语言特性

类型别名是增加语言特性的一个示例。使用类型别名语法type A = B的代码无法在1.9版本之前的Go中编译。

类型别名和自Go 1.0以来的其他向后兼容的更改向我们表明,对于语言特性的增加,包没有必要显式声明它们所需的最低语言版本。一些包使用类型别名这一新特性。当用Go 1.8工具编译这样的包时,编译失败。包作者可以简单地说:升级到Go 1.9,或者降级到包的早期版本。Go工具不需要知道这个要求;因为无法使用旧版本的工具进行编译暗示了这一点。

程序员当然需要了解语言特性的增加,但工具不需要。Go 1.8工具和Go 1.9工具都不需要明确知道Go 1.9中增加了类型别名,除了Go 1.9编译器可以编译类型别名而Go 1.8编译器不能,这个有限的意义之外。指定最低语言版本以获得更好的不支持语言特性的错误消息的可能性,将在下文讨论。

移除语言特性

我们还必须考虑从语言中删除特性的语言更改。例如,issue 3939建议我们删除string(i)转换整数值i。如果我们在Go版本1.20中进行此更改,那么使用此语法的包将在Go 1.20中停止编译。(如果你更愿意将向后不兼容的更改限制在新的主版本中,那么在此讨论中将1.20替换为2.0;问题仍然存在。)

在这种情况下,使用旧语法的包没有简单的修改方法。虽然我们可以提供将1.20之前的代码转换为可工作的1.20代码的工具,但我们不能强制包作者运行这些工具。一些软件包可能没有维护但仍然有用。一些组织可能希望升级到1.20而不必重新验证他们所依赖的软件包的版本。一些软件包作者可能希望使用1.20,他们的软件包现在已经损坏,但没有时间修复他们的软件包。

这些场景表明我们需要一种机制,来指定可以用来构建软件包的Go语言的最高版本。

重要的是,指定Go语言的最高版本不应被视为要使用的Go工具的最高版本。随Go 1.20版本发布的Go编译器必须能够构建Go 1.19版本编写的包。这可以通过模仿C编译器支持的-std选项向编译器(以及,如果需要,汇编器和链接器)添加一个选项来完成。当编译器看到选项时,可能是-lang=go1.19,它将使用Go 1.19语法来编译代码。

这需要编译器以某种方式支持所有以前的版本。如果证明支持旧语法很麻烦,则可以通过将代码从旧版本转换到当前版本来实现-lang选项。这将使对旧版本的支持不在编译器的合理范围内,并且转换器对于想要更新其代码的人可能很有用。支持旧的语言版本不太可能会成为一个重大问题。

当然,即使包是用语言版本1.19的语法构建的,它在其他方面也必须是1.20版本的包:它必须与1.20版本的代码链接,能够调用和被1.20版本的代码调用,等等。

go工具需要知道最大语言版本,以便它知道如何调用编译器。我们继续考虑模块,此信息的逻辑位置位于go.mod文件。模块M的go.mod文件可以为其定义的包指定最大语言版本。当M作为其他模块的依赖项被下载时,它的最大语言版本将会被遵循。

最高语言版本不是最低语言版本。如果一个模块需要1.19版本的语言特性,但可以用1.20构建,我们可以说最大语言版本是1.20。如果我们使用Go 1.19版本来构建,我们低于最大版本值,但使用1.19语言版本来构建不是不可以。可以忽略大于当前工具支持的最大语言版本。如果我们稍后使用Go 1.21版本来构建该模块,我们可以使用-lang=go1.20选项使用最高1.20版本的语言特性。

这意味着这些工具可以自动设置最大语言版本。当我们使用Go 1.30发布模块时,我们可以将模块标记为具有最大语言版本1.30。该模块的所有用户都会看到这个最高版本并做正确的事情。

这意味着我们将不得不无限期地支持该语言的旧版本。如果我们在1.25版本之后删除了一个语言特性,如果使用-lang=go1.25选项(或-lang=go1.24或任何其他包含支持功能)。当然,如果没有使用-lang选项,或者选项是-lang=go1.26或更高版本,则该功能将不可用。由于我们不希望大规模删除现有语言功能,因此这应该是一个可管理的负担。

我相信这种方法足以实现移除语言特性

最小语言版本

为了获得更好的错误消息,允许模块文件指定最低语言版本可能很有用。但这不是必需的:如果一个模块使用了语言版本1.N中引入的特性,那么用1.N-1版本构建它将编译失败。这可能令人困惑,但在实践中,问题很可能是显而易见的。

也就是说,如果模块可以指定最低语言版本,那么在使用1.N-1构建时,go工具可以立即生成一条清晰的错误消息。

最低语言版本可能由编译器或其他工具设置。编译每个文件时,查看它使用的特性,并使用这些特性确定最低语言版本。不需要很精确。

这只是建议,不是要求。随着Go语言的变化,它可能会提供更好的用户体验。

语言重定义

Go语言也能够以不添加或删除的方式进行更改,即对特定语言结构的工作方式进行更改。例如,在Go 1.1中,64位主机上int类型的大小从32位更改为64位。这一变化相对无害,因为该语言本就没有指定int的确切大小。然而,一些Go 1.0程序使用Go 1.1进行编译后,可能会停止工作。

重新定义语言是这样一种情况,即我们的代码在版本1.N和版本1.M中都能成功编译,其中M>N,但两个版本中代码的含义不同。例如,issue 20733提出范围循环(range loop)中的变量应在每次迭代中重新定义。尽管在实践中,这种变化似乎更可能是一种修复程序而不是破坏程序,但从理论上来说,这种变化可能会破坏某些程序。

请注意,新关键字通常不会导致语言重新定义,但我们必须小心确保在引入新关键字之前确实如此。例如,如果我们按照错误处理草案设计中的建议引入关键字 check,并且我们允许像check(f())这样的代码,如果check被定义为同一个包中的函数名,这可能看起来是一个语言重新定义。但是在引入check关键字之后,任何定义这样一个函数名的尝试都将失败。因此,使用check的代码无论在何种意义上都不可能同时使用1.N和1.M版本进行编译。新关键字可以作为移除语言特性(check作为非关键字使用时)或添加语言特性(check是关键字时)来处理。

为了让Go生态系统在向Go 2的过渡中幸存下来,我们必须尽量减少此类语言重新定义。如前所述,成功的语言通常基本上没有超出特定程度的重新定义。

当然,语言重新定义的复杂性在于我们不再能依赖编译器来检测问题。查看重新定义的语言结构时,编译器无法知道其含义。在存在重新定义的语言结构时,我们也无法确定最大语言版本。因为我们不知道该语言结构是打算用旧含义还是新含义进行编译。

唯一的可行性可能是让程序员设置软件包的语言版本。在这种情况下,它可能是最低或最高语言版本,视情况而定。它必须以不会被任何工具自动更新的方式设置。当然,设置这样的版本很容易出错。随着时间的推移,最大的语言版本会导致令人惊讶的结果,因为人们试图使用新的语言功能,但都失败了。

我认为唯一可行的安全方法是不允许重新定义语言。

我们被当前的语义所困扰。这并不意味着我们无法改进它们。例如,对于issue 20733,即range问题,我们可以更改range循环,以便禁止获取range参数的地址,或从函数字面值中引用它。这不是一个语言重新定义;这将是一个语言特性移除。这种方法可能会消除Bug,而不会意外破坏代码。

构建标签(Build tags)

构建标签是一种现有机制,程序可以使用它来根据要发布的程序版本选择要编译的文件。

构建标签可用于给出发布的程序的版本号,它们看起来就像Go语言的版本号,但是,学究式地说,它们是不同的。在上面的讨论中,我们讨论了使用Go版本 1.N的编译器来编译Go语言版本为1.N-1的代码。使用构建标签是不可能做到的。

构建标签可设置用于编译特定文件的最大版本号或最小版本号,或同时设置两者。这是一种方便的方式来利用只有在特定版本之后才可用的语言更改;也就是说,这可用于在编译文件时设置最低语言版本号。

如上所述,对于语言更改最有用的是可以根据这种更改设置最大语言版本。构建标签并没有以有用的方式提供这一点。如果使用构建标签将当前版本设置为最大版本,则你的包将不会再生成以后的版本。只有将最高语言版本号设置为当前版本之前的版本号时,才能设置最高语言版本,并且还需要一个用于发布后续版本的当前包的副本。也就是说,如果你使用1.N进行构建,那么使用!1.N+1构建标签是没有帮助的。你可以使用!1.M构建标签,其中M<N,并且在几乎所有情况下,你都还需要一个单独的文件,其构建标记为!1.M+1

构建标签可用于处理语言重新定义:如果语言版本1.N有语言重新定义,程序员可以使用!1.N构建标签,使用旧语义和使用一个1.N构建标签的不同文件。然而,这些重复实现需要大量的工作,一般来说很难知道何时需要,而且很容易出错。构建标签的可用性不足以克服先前关于不允许任何语言重新定义的讨论。

导入“go2”

可以为Go添加一种机制,类似于Python的from __future_import和Perl的use feature。例如,我们可以使用一个特殊的导入路径,import "go2/type-aliases"。这将把所需的语言功能放在使用它们的文件中,而不是隐藏在go.mod文件中。

这将提供一种方法来描述文件所需的语言特性添加集合。它更复杂,因为它不依赖于语言版本,而是将语言分解为单独的功能。没有明显的办法消除这些特殊import,因此它们会随着时间的推移而积累。Python和Perl通过故意进行向后不兼容的更改来避免累积的问题。在转到Python3或Perl6之后,可以丢弃累积的特性。由于Go试图避免向后不兼容,因此没有明确的方法可以消除这些导入。

此机制无法处理移除语言特性的情况。我们可以引入删除导入,例如import "go2/no-int-to-string",但不清楚为什么会有人使用它。实际上,根本没有办法删除语言特性,即使是那些容易混淆和出错的特性。

这种方法似乎不适合Go。

标准库改变

迁移到Go 2的好处之一是有机会发布一些兼容Go 1的标准库包。另一个好处是有机会将许多(也许是大部分)软件包移出六个月的发布周期。如果模块实验成功,甚至有可能尽早开始做这件事,使一些包的发布周期更快。

我建议继续保持六个月的发布周期,但将其视为编译器/运行时的发布周期。我们希望Go发行版开箱即用,因此发行版将继续包含与今天大致相同的软件包集合的当前版本。然而,其中许多包实际上有它们自己的发布周期。使用给定Go版本的人将能够明确选择使用标准库包的新版本。事实上,在某些情况下,他们可能会使用旧版本的标准库包。

不同的发布周期需要包维护者投入更多的资源。只有当我们有足够的人手来管理它,有足够的测试资源来测试它时,我们才能做到这一点。

我们还可以继续对所有内容使用六个月的发布周期,但将可分离的包单独提供兼容的、不同的版本。

核心标准库

尽管如此,标准库的某些部分仍必须被视为核心库。这些库与编译器和其他工具紧密相关,必须严格遵循发布周期。不得使用这些库的旧版本或新版本。

理想情况下,这些库将保留在当前版本1上。如果似乎有必要将其中任何一个更改为版本2,则必须根据具体情况进行讨论。目前我看不出有什么理由这样做。

核心库的暂定清单是:

  • os/signal
  • plugin
  • reflect
  • runtime
  • runtime/cgo
  • runtime/debug
  • runtime/msan
  • runtime/pprof
  • runtime/race
  • runtime/tsan
  • sync
  • sync/atomic
  • testing
  • time
  • unsafe

我可能乐观地从这个列表中省略了net、os和syscall包。我们将看看我们能管理什么。

伴生标准库

伴生标准库是那些包含在Go发行版中但独立维护的包。当前标准库中的大部分内容其实都是这种包。这些软件包将遵循与今天相同的规则,并可选择在适当的情况下迁移到v2。可以使用go get升级或降级这些标准库包。特别是,这些包可以独立于每六个月的核心库发布周期,修复Bug发布自己的次版本。

go工具必须能够区分核心库和伴生库。我不知道这将如何运作,但它似乎是可行的。

将标准库包移动到v2时,必须规划好同时使用包的v1和v2版本的程序。这些程序必须按预期运行,如果不可能,就必须干净利落地迅速运行失败。在某些情况下,这将涉及修改v1版本以使用也由v2版本使用的核心库。

标准库包必须能够使用Go语言的旧版本进行编译,至少是我们目前支持的前两个发布周期的Go语言版本。

从标准库中移除包

标准库包支持的go get能力将允许我们从发布中删除包。这些包将继续存在和维护,人们能够在需要时检索它们。但是,默认情况下它们不会随Go版本一起发布。

这将包括像以下这样的包:

  • index/suffixarray
  • log/syslog
  • net/http/cgi
  • net/http/fcgi

以及其他似乎没有被广泛使用的包。

我们应该在适当的时候为旧包制定弃用政策,将这些包设置为不再维护。弃用政策也适用于移至v2版本的v1版本的软件包。

或者这可能被证明是有问题的,我们不应该弃用任何现有的包,也不应该将它们从标准的Go发行版本中删除。

Go 2

如果上述过程按计划进行,那么在某种重要意义上永远不会有Go 2。或者,换句话说,我们将慢慢过渡到新的语言和库特性。我们可以在过渡期间的任何时候决定我们现在是Go 2,这可能是一个很好的营销方式。或者我们可以跳过它(从来没有C 2.0,为什么要有Go 2.0?)。

C、C++和Java等流行语言从来没有v2版本。实际上,它们始终处于版本1.N,尽管它们使用不同的名称来称呼该状态。我认为我们应该效仿它们。事实上,从不兼容的新版本语言或核心库的意义上说,完全意义上的Go 2对我们的用户来说不是一个很好的选择。不夸张地说,一个真正的Go 2版本可能是有害的。

开发和发布模块

本文翻译自《Developing and publishing modules》。

目录

开发和发布模块的工作流程

设计和开发

分布式发布

发现包

版本控制

你可以将相关的包收集到模块中,然后发布模块供其他开发人员使用。本主题概述开发和发布模块。

要支持开发、发布和使用模块,请使用:

  • 开发和发布模块,并随时间使用新版本对其进行修改的工作流。参见开发和发布模块的工作流
  • 帮助模块用户理解并以稳定的方式升级到新版本的设计实践。参见设计和开发
  • 用于发布模块和检索其代码的分布式去中心化系统。你可以让其他开发人员从自己的存储库中使用你的模块,并使用版本号发布。请参见分布式发布
  • 一个软件包搜索引擎和文档浏览器(pkg.go.dev),开发人员可以在其中找到你的模块。请参阅发现包
  • 模块版本编号规约,用于向使用你的模块的开发人员传达稳定性和向后兼容性的信息。请参见版本控制
  • 让其他开发人员更容易管理依赖关系的Go工具,包括获取模块的源代码、升级等。请参阅管理依赖项

另请参阅

开发和发布模块的工作流程

当你想为其他人发布你的模块时,你可以采用一些约定来让这些模块更容易使用。

模块发布和版本控制工作流程中更详细地描述了以下高级步骤。

1 设计并编写模块将包含的软件包。

2 根据规约将代码提交到你的存储库,以确保其他人可以通过Go工具使用该代码。

3 发布模块以使开发人员能够发现它。

4 随着时间的推移,使用版本编号规约来修订模块,可以从版本编号中看出每个版本的稳定性和向后兼容性。

设计和开发

如果你的模块中的函数和包形成一个连贯的整体,那么开发人员将更容易找到和使用你的模块。当你设计模块的公共API时,请尽量使其功能目标明确且各不相关(译者注:高内聚,低耦合)。

此外,在设计和开发模块时考虑向后兼容性,有助于用户升级,同时最大限度地减少对用户代码的干扰。你可以在代码中使用某些技术来避免发布破坏向后兼容性的版本。有关这些技术的更多信息,请参阅Go博客上的保持模块兼容

在发布模块之前,可以使用replace指令在本地文件系统上引用它。这使得在模块仍在开发中时,编写调用其内函数的客户端代码更加容易。有关详细信息,请参阅模块发布和版本控制工作流程中的“针对未发布的模块进行编程”。

分布式发布

在Go中,你通过在存储库中为模块的代码打标签来发布模块,以便其他开发人员使用。你不需要将模块推送到一个集中式服务器,因为Go工具可以直接从存储库(使用模块路径,该路径是省略了网络协议的URL)或代理服务器下载模块。

在代码中导入包后,开发人员使用Go工具(包括go get命令)下载模块的源代码进行编译。为了支持此模式,你应该遵循惯例和最佳实践,使Go工具(代表另一个开发人员)可以从存储库中检索模块的源代码。例如,Go工具使用你模块的模块路径,以及用于标记模块以供发布的模块版本号,为其用户定位和下载你的模块。

有关源代码和发布约定以及最佳实践的更多信息,请参阅管理模块的源代码

有关发布模块的分步说明,请参阅发布一个模块

发现包

在你发布了模块并有人使用Go工具获取了它之后,它将在pkg.go.dev这个Go包发现站点上可见。在那里,开发人员可以搜索该站点找到它并阅读它的文档。

为了开始使用模块,开发人员从模块中导入包,然后运行go get命令下载其源代码进行编译。

有关开发人员如何查找和使用模块的更多信息,请参阅管理依赖项

版本控制

随着时间的推移,当你修改和改进模块时,你会分配版本号(基于语义版本控制模型),以表示每个版本的稳定性和向后兼容性。这有助于使用你的模块的开发人员确定模块何时稳定,以及升级模块是否在代码行为上有重大变化。你可以通过在存储库中打标签模块的源代码,来指派模块的版本号。

有关开发模块主版本更新的详细信息,请参阅开发一个主版本更新

有关如何为Go模块使用语义版本号模型的更多信息,请参阅模块版本编号

go.mod文件简明参考手册

本文翻译自《go.mod file reference》。

目录

例子

模块

语法

例子

笔记

go

语法

例子

笔记

需求(require)

语法

例子

笔记

替换(replace)

语法

例子

笔记

排除(exclude)

语法

例子

笔记

回收(retract)

语法

例子

笔记

每个Go模块都由一个go.mod文件定义,该文件描述模块的属性,包括它对其他模块和Go版本的依赖关系。

这些属性包括:

  • 当前模块的模块路径。这应该是Go工具可以从中下载模块代码的位置,例如模块代码的存储库的位置。当与模块的版本号结合使用时,它用作唯一标识符。它也是模块中所有包的包路径的前缀。有关Go如何定位模块的更多信息,请参阅Go模块参考手册
  • 当前模块所需的最低Go版本
  • 当前模块所需的其他模块的最低版本的一个列表。
  • 可选地,用另一个模块版本或本地目录替换所需模块,或排除所需模块的特定版本。

当你运行go mod init命令时,Go会生成一个go.mod文件。以下示例创建一个go.mod文件,将模块的模块路径设置为example/mymodule

$ go mod init example/mymodule

使用go命令来管理依赖项。这些命令确保go.mod文件中描述的需求保持一致,并且go.mod文件的内容有效。这些命令包括go getgo mod tidy以及go mod edit命令。

有关go命令的参考,请参阅Command go。你可以通过键入go help command-name从命令行获得帮助,就像go help mod tidy一样。

另请参阅

  • 当你使用Go工具来管理依赖项时,Go工具会对你的go.mod文件进行更改。有关更多信息,请参阅管理依赖项
  • 有关go.mod文件的更多详细信息和约束,请参阅Go模块参考手册

例子

go.mod文件包含指令,如下所示。这些将在本主题的其他地方进行描述。

module example.com/mymodule

go 1.14

require (
    example.com/othermodule v1.2.3
    example.com/thismodule v1.2.3
    example.com/thatmodule v1.2.3
)

replace example.com/thatmodule => ../thatmodule
exclude example.com/thismodule v1.3.0

模块

声明模块的模块路径,这是模块的唯一标识符(与模块版本号组合使用时)。模块路径成为模块包含的所有包的导入前缀。

有关更多信息,请参阅Go模块参考手册中的module指令

语法

module module-path

其中module-path代表模块的模块路径,通常是Go工具可以从中下载模块代码的存储库的位置。对于模块v2及更高版本,模块路径必须以主版本号结尾,例如/v2

例子

以下示例中的example.com代表可以从中下载模块代码的存储库的域名。

v0或v1模块的模块声明:

module example.com/mymodule

v2模块的模块路径:

module example.com/mymodule/v2

笔记

模块路径必须能唯一标识你的模块。对于大多数模块,其路径是一个URL,go命令可以在其中找到模块的代码(或重定向到模块的代码)。对于永远不会直接被下载的模块,模块路径可以是由你决定的某个名称,确保唯一性即可。前缀example/保留用于示例。

有关详细信息,请参阅管理依赖项

实际上,模块路径通常是模块源代码的存储库的域名和模块代码在存储库中的路径。go命令在下载模块版本或解决依赖关系时,就依赖于此形式。

即使你一开始并不打算让你的模块可供其他人使用,按照此形式确定其存储库路径也是一种最佳实践,这将帮助你在以后发布模块时避免重命名该模块。

如果一开始你不知道模块的最终存储库位置,请暂时使用安全的替代品,例如你拥有的域名或你能控制的名称(例如你的公司名称),然后紧跟着模块的名称或源代码的目录名。有关更多信息,请参阅管理依赖项

例如,如果你在stringtools目录中进行开发,你的临时模块路径可以是<company-name>/stringtools,如下例所示,其中company-name是你公司的名称:

go mod init <company-name>/stringtools

go

表示模块是在go指令指定的Go版本下编写的。

有关更多信息,请参阅Go模块参考手册中的go指令

语法

go minimum-go-version

其中minimum-go-version代表编译此模块中的包所需的最低的Go版本。

例子

模块必须在Go 1.14或更高版本上运行:

go 1.14

笔记

go指令最初旨在支持对 Go 语言的向后不兼容更改(请参阅Go 2转换)。自从引入模块以来没有不兼容的语言更改,但go指令仍然影响新语言功能的使用:

  • 对于模块中的包,编译器拒绝使用在go指令指定的版本之后引入的语言功能。例如,如果一个模块有指令go 1.12,它的包可能不会使用像1_000_000这样的数值字面量,因为这是在Go 1.13中引入的语言新特性。
  • 如果用较旧的Go版本构建模块的其中一个包,并遇到编译错误,则该错误会指出该模块是为较新的Go版本编写的。例如,假设一个模块有go 1.13指令,并且一个包使用了数值字面量1_000_000。如果该包使用Go 1.12来构建,编译器会注意到代码是为Go 1.13编写的。

此外,go命令根据go指令指定的版本号更改其行为。这具有以下效果:

  • go 1.14或更高版本中,可以启用自动vendor(译者注:vendor模式的模块管理属于Go语言在1.11版本之前的其中一种模块管理方式,Go 1.11引入的基于go.mod文件的模块管理方式才是最先进的模块管理方式)。如果文件vendor/modules.txt存在并且与go.mod一致,则无需显式使用-mod=vendor标志。
  • go 1.16或更高版本中,all包模式仅匹配主模块(main模块)中的包和测试导入的包。这是自引入模块以来由go mod vendor保留的同一组包。在较低Go版本中,all还包括主模块中导入的包及其测试文件等等。
  • go 1.17或更高版本中:
    • go.mod文件为每个模块包含一个明确的require指令,该指令提供由主模块中的包或测试文件导入的任何包。(在go 1.16及更低版本中,仅当最小版本选择会选择不同版本时才会包含间接依赖。)此额外信息支持模块图化简模块懒加载
    • 因为可能比以前的go版本有更多// indirect,间接依赖被记录在go.mod文件中的一个单独的块中。
    • go mod vendor会忽略vendor目录中依赖项的go.mod和go.sum文件。(这允许在vendor的子目录中调用go命令以识别正确的主模块。)
    • go mod vendor将每个依赖项的go.mod文件中的Go版本记录在vendor/modules.txt文件中。

go.mod文件最多可以包含一个go指令。大多数命令都会在当前Go版本中添加一个go指令(如果还不存在的话)。

需求(require)

将模块声明为当前模块的依赖项,指定所需模块的最低版本。

有关更多信息,请参阅Go模块参考手册中的require指令

语法

require module-path module-version

module-path代表模块的模块路径,通常是模块源代码的存储库的域名再加上模块名称。对于模块版本v2及更高版本,此值必须以主版本号结尾,例如/v2

module-version代表模块的版本号。这可以是发行版的版本号,例如v1.23,也可以是Go生成的伪版本号,例如v0.0.0-20200921210052-fa0125251cc4。

例子

需求已发布的版本v1.2.3:

require example.com/othermodule v1.2.3

需求在其存储库中尚未加标签的版本,使用Go工具生成伪版本号:

require example.com/othermodule v0.0.0-20200921210052-fa0125251cc4

笔记

当你运行go命令(例如go get)时,go命令会插入包含导入包的每个模块的指令。当模块在其存储库中尚未加标签时,Go会分配一个伪版本号,它会在你运行命令时自动生成。

通过使用replace指令,你可以让Go从存储库以外的位置需求模块。

有关版本号的更多信息,请参阅模块版本编号

有关管理依赖关系的详细信息,请参阅以下内容:

添加一个依赖项

获取特定依赖项的某个版本

发现可用更新

升级或降级一个依赖项

同步你的代码的依赖项

替换(replace)

用另一个模块版本或本地目录替换特定模块版本(或所有版本)的内容。Go工具将在解析依赖关系时使用替换路径。

有关更多信息,请参阅Go模块参考手册中的replace指令

语法

replace module-path [module-version] => replacement-path [replacement-version]

module-path代表要替换的模块路径。

module-version,可选,代表要替换的特定版本号。如果省略此版本号,则模块的所有版本都将被替换为箭头右侧的内容。

replacement-path代表Go应查找的所需模块的路径。这可以是模块路径,也可以是本地文件系统上的模块源代码所在目录的路径。如果这是模块路径,则必须指定replacement-version。如果这是本地路径,则不能使用replacement-version

replacement-version,可选,代表用来替换的模块的版本号。只有当replacement-path 是模块路径(而不是本地目录路径)时,才能指定replacement-version

例子

  • 被替换为模块存储库的分叉(fork)版本

在以下示例中,example.com/othermodule的任何版本都将被替换为其分支版本。

require example.com/othermodule v1.2.3
replace example.com/othermodule => example.com/myfork/othermodule v1.2.3-fixed

用另一个模块路径替换一个模块时,不要更改被替换模块里的包的导入语句。

有关使用模块代码的分叉版本的更多信息,请参阅从自己的存储库分叉版本请求外部模块代码

  • 被替换为模块的其他版本

以下示例指定使用v1.2.3版本,而不是该模块的任何其他版本。

require example.com/othermodule v1.2.2
replace example.com/othermodule => example.com/othermodule v1.2.3

以下示例将模块v1.2.5版本替换为同一模块的v1.2.3版本。

replace example.com/othermodule v1.2.5 => example.com/othermodule v1.2.3
  • 被替换为本地代码

以下示例指定使用本地目录里的代码替换模块的所有版本。

require example.com/othermodule v1.2.3
replace example.com/othermodule => ../othermodule

以下示例指定本地目录里的代码仅替换v1.2.5版本。

require example.com/othermodule v1.2.5
replace example.com/othermodule v1.2.5 => ../othermodule

有关使用模块代码的本地副本的更多信息,请参阅需求本地目录中的模块代码

笔记

如果要使用其他路径来查找模块的源,请使用replace指令将模块路径值临时替换为其他值。这具有将Go对模块的搜索重定向到替换的位置的效果。不需要更改包导入路径即可使用替换路径。

可以使用excludereplace指令来控制构建时依赖关系的解析。这些指令在依赖当前模块的模块中会被忽略。

replace指令在以下情况下很有用:

  • 你正在开发一个新模块,其代码尚未在存储库中。你希望使用客户端对本地版本进行测试。
  • 你发现了某个依赖项的一个问题,你克隆该依赖项的存储库到本地,修复这个问题,并使用该本地存储库测试修复是否成功。

请注意,单独的replace指令不会将模块添加到模块图中。require指令还需要引用在主(main)模块的go.mod文件或依赖项的go.mod文件中被替换的模块版本。如果没有要替换的特定版本,可以使用假版本,如下例所示。请注意,这将破坏依赖于该模块的模块,因为replace指令仅应用于主模块。

require example.com/mod v0.0.0-replace
replace example.com/mod v0.0.0-replace => ./mod

有关替换所需的模块(包括使用Go工具进行更改)的更多信息,请参阅:

有关版本号的更多信息,请参阅模块版本编号

排除(exclude)

指定要从当前模块的依赖关系图中排除的模块或模块版本。

有关详细信息,请参阅Go模块参考手册中的exclude指令

语法

exclude module-path module-version

module-path代表要排除的模块的模块路径。

module-version代表特定的版本号。

例子

排除example.com/their模块版本v1.3.0:

exclude example.com/theirmodule v1.3.0

笔记

使用exclude指令排除间接需求但由于某种原因无法加载的模块的特定版本。例如,你可以使用它排除校验和(checksum)无效的模块版本。

在构建当前模块(主模块)时,使用excludereplace指令来控制对构建时依赖关系的解析。这些指令在依赖于当前模块的模块中被忽略。

你可以使用go mod edit命令来排除模块,如下例所示。

go mod edit -exclude=example.com/[email protected]

有关版本号的更多信息,请参阅模块版本编号

撤回(retract)

指示由go.mod定义的模块的版本或某个范围的版本不应该被依赖。当版本未成熟就发布或发布版本后发现严重问题时,retract指令非常有用。

有关更多信息,请参阅Go模块参考手册中的retract指令

retract version // rationale
retract [version-low,version-high] // rationale

version代表要撤回的单个版本。

version-low代表要撤回的版本范围的下限。

version-high代表要撤回的版本范围的上限。version-lowversion-high都包含在范围内。

rationale是解释撤回原因的可选注释。可能显示在发给用户的消息中。

例子

撤回单个版本:

retract v1.1.0 // 意外发布。

撤回一个范围的版本:

retract [v1.0.0,v1.0.5] // 在某些平台会破坏构建。

笔记

使用retract指令指示某个模块的早期版本不应被使用。用户无法使用go getgo mod tidy或其他命令自动升级到被撤回的版本。用户使用go list -m -u命令将在可更新到的版本列表中看不到被撤回的版本。

撤回的版本应该保持可用,以便已经依赖它们的用户能够构建他们的包。即使从源存储库中删除了撤回的版本,它也可能在proxy.golang.org等镜像上仍然可用。依赖撤回的版本的用户在相关模块上运行go getgo list -m -u时可能会收到通知。

go命令通过读取模块最新版本的go.mod文件中的retract指令来发现被撤回的版本。最新版本按优先顺序为:

1 其最高版本号(如果有的话)

2 其最高预发布版本号(如果有的话)

3 存储库默认分支顶端的伪版本号。

新增一个撤回版本时,几乎总是需要加标签一个新的更高的版本,以便go命令在模块的最新版本中看到retract指令。

你可以发布一个唯一目的是发出撤回信息的新版本。在这种情况下,该新版本也可以自行撤回。

例如,如果意外加标签v1.0.0,可以使用以下指令加标签v1.0.1:

retract v1.0.0 // 意外发布。
retract v1.0.1 // 只包含撤回指令。

不幸的是,一旦发布了版本,就无法更改。如果稍后为其他commit加标签v1.0.0,go命令可能会在go.sum或校验和数据库中检测到不匹配的值。

模块的撤回版本通常不会出现在go list -m -versions的输出中,但你可以使用-reacted选项来显示它们。有关更多信息,请参阅Go模块参考手册中的go list -m