Go安全

本文翻译自《Security》。

此页面为Go开发人员提供一些资源,以提高Go项目的安全性。

(另请参阅:Go开发人员应该知道的Go项目安全最佳实践。)

查找并修复已知的安全漏洞

Go的漏洞检测旨在为开发人员提供低噪声、可靠的工具,以了解可能影响其项目的已知漏洞。有关概述,请从Go漏洞管理体系架构和常见问题页面开始。对于应用方法,请探索以下工具。

使用govulcheck扫描代码以查找漏洞

开发人员可以使用govulcheck工具来确定是否有任何已知的漏洞会影响他们的代码,并根据实际调用的有安全漏洞的函数和方法来确定下一步先做什么。

在编辑器中检测漏洞

VS Code Go扩展可以检查第三方依赖项并发现相关漏洞。

查找你的项目用到的Go模块

Pkg.go.dev是一个用于发现、评估和学习有关go包和模块的更多信息的网站。在pkg.go.dev上发现和评估go包时,如果该版本存在漏洞,你会在页面顶部看到一条横幅。此外,你可以在go包的版本历史页面上看到影响某个版本的它的安全漏洞有哪些。

浏览安全漏洞数据库

Go漏洞数据库直接从Go包维护者以及MITRE和GitHub等外部来源收集数据。报告由Go Security团队策划。

报告Go项目中的安全漏洞

有关如何报告Go项目中的安全漏洞的说明,请参阅Go安全策略。该文章还详细介绍了Go安全团队跟踪问题并向公众披露的过程。有关过去的修复安全漏洞的记录的详细信息,请参阅发布历史记录。根据发布策略,我们在Go包的两个最新主版本上进行安全修复。

使用模糊测试发现意外输入

Go原生提供的模糊测试是一种自动测试,它不断地往程序的入数据来发现Bug。从Go 1.18开始,标准工具链加入模糊测试工具。也可以使用OSS fuzz,它支持原生的Go模糊测试。

使用Go的加密库提供的安全服务

Go的加密库旨在帮助开发人员构建安全的应用程序。请参阅有关加密的Go包golang.org/x/crypto/的文档

教程:使用govulncheck发现和修复有安全问题的依赖项

本文翻译自《Tutorial: Find and fix vulnerable dependencies with govulncheck》。

Govulncheck是一个低噪音的工具,可以帮助你发现并修复Go项目中易受攻击的依赖项。它通过扫描项目的依赖项以查找已知的漏洞,并识别出对这些漏洞的任何直接或间接调用的项目代码。

在本教程中,你将学习如何使用govulcheck扫描一个简单的程序以查找漏洞,如何对漏洞进行优先级排序和评估,以便首先集中精力修复最重要的漏洞。

要了解更多关于govulcheck的信息,请参阅govulceck文档和这篇关于Go漏洞管理的博客文章。我们也很乐意听取你的反馈。

先决条件

  • 使用Go 1.18或更高版本。Govulncheck旨在与Go 1.18及以后的版本配合使用。我们建议使用最新版本的Go来遵循本教程。(有关安装Go的说明,请参阅安装Go。)
  • 一个代码编辑器。任何文本编辑都可以很好地工作。
  • 一个命令终端。Go在Linux和Mac的终端程序,以及Windows中的PowerShell或cmd上都能很好地工作。

本教程将带你完成以下步骤:

1 创建一个Go模块示例,它的某个依赖项有漏洞

2 安装并运行govulcheck

3 评估漏洞 4 升级并修复有漏洞的依赖项

创建一个Go模块示例

步骤1,首先,创建一个名为vulntutorial的新文件夹并初始化Go模块。例如,从当前目录运行以下命令:

$ mkdir vuln-tutorial
$ cd vuln-tutorial
$ go mod init vuln.tutorial

步骤2,在vuln-tutorial文件夹中创建一个名为main.go的文件,并将以下代码复制到其中:

package main

import (
        "fmt"
        "os"

        "golang.org/x/text/language"
)

func main() {
        for _, arg := range os.Args[1:] {
                tag, err := language.Parse(arg)
                if err != nil {
                        fmt.Printf("%s: error: %v\n", arg, err)
                } else if tag == language.Und {
                        fmt.Printf("%s: undefined\n", arg)
                } else {
                        fmt.Printf("%s: tag %s\n", arg, tag)
                }
        }
}

此示例程序将语言标签的一个列表作为命令行参数,并为每个标签打印一条消息,指示标签是否解析成功、是否未定义或者解析标签时是否出错。

步骤3,运行go mod tidy命令,把main.go的代码所需的所有依赖项记录到go.mod文件。在vuln-tutorial文件夹所在目录,运行以下命令:

$ go mod tidy

你应该能看到类似如下输出:

go: finding module for package golang.org/x/text/language
go: downloading golang.org/x/text v0.9.0
go: found golang.org/x/text/language in golang.org/x/text v0.9.0

第4步,打开go.mod文件,它的内容应该如下所示:

module vuln.tutorial

go 1.20

require golang.org/x/text v0.9.0

第5步,降级golang.org/x/text依赖项的版本到v0.3.5,这个版本包含一个众所周知的安全漏洞:

$ go get golang.org/x/[email protected]

你应该能看到类似如下输出:

go: downgraded golang.org/x/text v0.9.0 => v0.3.5

打开go.mod文件,它的内容应该如下所示:

module vuln.tutorial

go 1.20

require golang.org/x/text v0.3.5

接下来我们使用govulncheck来发现vuln-tutorial项目中的安全漏洞。

安装并运行govulncheck

第6步,使用go install命令安装govulncheck:

$ go install golang.org/x/vuln/cmd/govulncheck@latest

第7步,在vuln-tutorial目录里运行govulncheck来分析vuln-tutorial项目中的安全漏洞:

$ govulncheck ./...

你应该会看到如下输出:

govulncheck is an experimental tool. Share feedback at https://go.dev/s/govulncheck-feedback.

Using go1.20.3 and [email protected] with
vulnerability data from https://vuln.go.dev (last modified 2023-04-18 21:32:26 +0000 UTC).

Scanning your code and 46 packages across 1 dependent module for known vulnerabilities...
Your code is affected by 1 vulnerability from 1 module.

Vulnerability #1: GO-2021-0113
  Due to improper index calculation, an incorrectly formatted
  language tag can cause Parse to panic via an out of bounds read.
  If Parse is used to process untrusted user inputs, this may be
  used as a vector for a denial of service attack.

  More info: https://pkg.go.dev/vuln/GO-2021-0113

  Module: golang.org/x/text
    Found in: golang.org/x/[email protected]
    Fixed in: golang.org/x/[email protected]

    Call stacks in your code:
      main.go:12:29: vuln.tutorial.main calls golang.org/x/text/language.Parse

=== Informational ===

Found 1 vulnerability in packages that you import, but there are no call
stacks leading to the use of this vulnerability. You may not need to
take any action. See https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck
for details.

Vulnerability #1: GO-2022-1059
  An attacker may cause a denial of service by crafting an
  Accept-Language header which ParseAcceptLanguage will take
  significant time to parse.
  More info: https://pkg.go.dev/vuln/GO-2022-1059
  Found in: golang.org/x/[email protected]
  Fixed in: golang.org/x/[email protected]

解释一下上述输出

注意,不同Go版本,上述输出可能不同。

我们的代码受到漏洞GO-2021-0113的影响,因为它直接调用golang.org/x/text/language的Parse函数,而这个函数在v0.3.5版本存在安全漏洞。

golang.org/x/text模块v0.3.5中存在另一个漏洞GO-2022-1059。然而,它被报告为“Informational”,因为我们的代码没有(直接或间接)调用这个安全漏洞相关的函数。

现在,让我们评估vuln-tutorial项目中的漏洞并确定要采取的行动。

评估漏洞

a.评估漏洞

首先,阅读漏洞的描述信息,并确定它是否真的适用于你的代码和用例。如果你需要更多信息,请访问“More info”链接。

根据描述,漏洞GO-2021-0113在使用Parse函数处理不受信任的用户输入时可能会引发panic。假设我们打算让我们的程序承受不受信任的输入,那么该漏洞可能就会被利用。

漏洞GO-2022-1059不会影响我们的代码,因为我们的代码没有从该依赖项中调用任何有安全漏洞的函数。

b.决定采取什么行动

为了解决GO-2021-0113漏洞问题,我们有以下几个选择:

选项1:升级到修复后的版本。如果有可用的修复,我们可以通过升级到该模块的修复版本来删除安全漏洞。

选项2:停止使用有安全漏洞的标识符。我们可以删除代码中对有安全漏洞的函数的所有调用。但我们需要找到一个替代方案,或者自己实现相关函数。

在本例情况下,我们可以使用修复后的版本,Parse函数是我们程序不可或缺的一部分。让我们将依赖项升级到“fixed in”版本v0.3.7。

漏洞GO-2022-1059与漏洞GO-2021-0113在同一模块中,而且它的修复版本是v0.3.8,所以我们可以通过升级到v0.3.8轻松地同时删除这两个漏洞。

升级并修复有安全漏洞的依赖项

幸运的是,升级并修复有安全漏洞的依赖项非常简单。

第8步,升级golang.org/x/text至v0.3.8版本:

$ go get golang.org/x/[email protected]

你应该能看到如下输出:

go: upgraded golang.org/x/text v0.3.5 => v0.3.8

请注意,我们也可以选择升级到最新版本,或v0.3.8之后的任何其他版本。

第9步,现在再次运行govulcheck:

$ govulncheck ./...

你现在会看到如下输出:

govulncheck is an experimental tool. Share feedback at https://go.dev/s/govulncheck-feedback.

Using go1.20.3 and [email protected] with
vulnerability data from https://vuln.go.dev (last modified 2023-04-06 19:19:26 +0000 UTC).

Scanning your code and 46 packages across 1 dependent module for known vulnerabilities...
No vulnerabilities found.

govuncheck确认没有发现漏洞。

使用命令govulcheck定期扫描依赖项,你可以识别、排序和解决安全漏洞来保护你的项目代码。

Go安全政策

本文翻译自《Go Security Policy》。

概述

本文档介绍Go Security团队处理报告的问题的流程以及预期的回复。

报告一个安全漏洞

Go发行版中的所有安全漏洞都应通过电子邮件报告给[email protected]。此邮件会发送给Go Security团队。

为了确保你的报告不被标记为垃圾邮件,请在电子邮件中的任何位置包含“vulnerability”一词。请在电子邮件中使用一行描述主题。

你的电子邮件将在7天内得到确认,在解决问题之前,你将了解最新进展。你的问题将在90天内解决或公开。

如果你在7天内没有收到电子邮件回复,请再次与Go安全团队联系,地址为[email protected]。请确保你的电子邮件中包含“vulnerability”一词。

如果再过3天,你仍未收到对报告的确认,则你的电子邮件可能已被标记为垃圾邮件。在这种情况下,请在此处提交问题。选择“我想报告谷歌产品(SQLi、XSS等)中的技术安全或滥用风险相关的错误”,并选中“Go”为受影响的产品。

跟踪问题

根据问题的性质,Go安全团队会将其归类为PUBLIC、PRIVATE或URGENT问题。所有安全问题都将被分配一个CVE编号。

PUBLIC

PUBLIC的问题影响非常有限,或者已经广为人知。

PUBLIC的问题被标记为“Proposal-Security”,通过公开的Go提案审查过程进行讨论,并返回到下一个计划的次要发布(minor releases)(每月发布一次)。发布公告包括这些问题的详细信息,但不会预先发布。

以下是过去发布的PUBLIC的问题的例子:

  • #44916:archive/zip:调用Reader.Open时可能会引发panic
  • #44913:encoding/xml:把自定义TokenReader传给xml.NewTokenDecoder时会引发无限循环
  • #40928: net/http/cgi,net/http/fcgi:但没有指定Content-Type时存在Cross-Site Scripting (XSS)攻击风险
  • #40618: encoding/binary: ReadUvarint和ReadVarint可以从非法输入中读取无限个字节
  • #36834: crypto/x509:Windows 10可以绕过证书验证

PRIVATE

PRIVATE中的问题违反了已提交的安全属性。

PRIVATE问题在下一个计划发布的次要版本中得到修复,并在此之前保持私有状态。

发布前三到七天,将发布预公告,宣布即将发布的版本中存在一个或多个安全修复程序,以及这些问题是否会影响标准库或(和)工具链,以及每个修复程序的CVE ID号码。

以下是过去发布的RIVATE的问题的例子:

  • #53416: path/filepath: Glob包会导致栈空间耗尽
  • #53616: go/parser:所有Parse*函数都存在栈空间耗尽的问题
  • #54658: net/http:GOAWAY发送GOAWAY给服务器后引发错误
  • #56284: syscall, os/exec:在环境变量中没有清除NUL

URGENT

URGENT问题对Go的生态系统的完整性构成威胁,或者正在被黑客积极利用,导致严重破坏。虽然最近没有这方面的例子,但它们可能包括net/http中的远程代码执行,或crypto/tls中的密钥恢复等。

URGENT问题是私下解决的,并会立即进行安全版本的发布,可能不会有预公告。

发送安全相关问题

如果你认为现有的某个问题与安全相关,我们请求你发送电子邮件至[email protected]。电子邮件应包括问题ID和为什么应根据此安全策略进行处理的简短描述。

披露和处理一个安全漏洞的流程

Go使用以下流程来披露和处理一个安全漏洞的流程:

  • 一旦收到一个安全报告,就会为其分配一个主处理流程。有个人负责协调整个修复和发布过程。
  • 问题已得到确认,并确定了受影响软件的列表。
  • 对代码进行审计,以发现任何潜在的类似问题。
  • 如果在与提交者协商后确定需要CVE编号,则主处理流程将获得一个。
  • 为最近的两个主版本号和head/master分支准备好修复程序。修复程序是为最近的两个主版本号准备的,并合并到head/master分支中。
  • 在应用修复程序的当天,会有公告发送到golang-announcement、golang-dev和golang-nuts。

这个过程可能需要一些时间,尤其是当需要与其他项目的维护人员进行协调时。我们将尽一切努力及时处理漏洞,但重要的是,我们要遵循上述流程,确保披露的漏洞得到一致的处理过程。

对于包括分配CVE号码在内的安全问题,该问题会在CVE详细信息网站国家漏洞披露网站的“Golang”产品下公开列出。

接收安全更新

接收安全公告的最佳方式是订阅golang-announce邮件列表。任何与安全问题有关的消息都将以[security]作为前缀。

对此政策的评论

如果你对改进此Go安全政策有任何建议,请提交一个问题进行讨论。

Go开发人员应该知道的Go项目安全最佳实践

本文翻译自《Security Best Practices for Go Developers》。

点此回到《Go安全》。

此页面为Go开发人员提供了优先考虑项目安全的最佳实践。从使用自动化的模糊测试到轻松检查竞态条件(race condition),这些技巧可以帮助你的代码库更加安全可靠。

扫描源代码和二进制文件中的漏洞

定期扫描代码和二进制文件中的漏洞有助于及早发现潜在的安全风险。你可以使用由Go漏洞数据库支持的govulcheck来扫描代码中的漏洞,并分析哪些漏洞会真正影响到你。开始学习govuncheck教程

Govulncheck也可以集成到CI/CD工作流中。Go团队在GitHub Marketplace上为Govulcheck提供了一个GitHub动作( GitHub Action)。Govulncheck还支持-json标志,以帮助开发人员将漏洞扫描功能与其他CI/CD系统集成。

你还可以使用VS Code的Go扩展直接在编辑器中扫描漏洞。见本教程

使你的Go版本和依赖项保持最新

让你的Go版本保持最新,你就可以使用最新的语言功能、性能改进和已知安全漏洞的修补程序。更新的Go版本还确保了与新版本的依赖项的兼容性,有助于避免潜在的集成问题。查看Go版本的历史发布记录,查看在不同版本之间对Go进行了哪些更改。Go团队在整个发布周期中按照安全问题的议点发布,以解决安全漏洞。请确保更新到最新的Go的小版本号,以确保你拥有最新的安全修复程序。

维护最新的第三方依赖项对于Go生态系统中的软件的安全性、性能和遵守最新标准都至关重要。然而,在没有彻底审查的情况下更新到最新版本也可能存在风险,可能会引入新的Bug、不兼容的更改,甚至恶意代码。因此,虽然更新到最新的安全补丁和改进的依赖项至关重要,但每次更新都应该仔细审查和测试。

使用模糊测试来发现代码的边界漏洞

模糊测试是一种自动测试,它使用覆盖率导向(coverage guidance)来操纵随机输入并遍历代码,以发现和报告潜在的漏洞,例如SQL注入、缓冲区溢出、拒绝服务以及跨站点脚本攻击。模糊测试经常会触及程序员错过的边界测试用例,或者认为不太可能出错的边界测试用例。见本教程

使用Go的竞态检测器检查竞态情况

当两个或多个goroutine同时访问同一资源,并且其中至少有一个访问是写操作时,就会出现竞态情况。这可能会导致软件中出现不可预测、难以诊断的问题。使用内置的竞态检测器在Go代码中识别潜在的竞态情况,这可以帮助你确保并发程序的安全性和可靠性。不过,竞态检测器只会查找运行时发生的争用,没法在未执行的代码中查找。

要使用内置的竞态检测器,请在运行测试或构建应用程序时添加-race标志,例如go test -race。这将在启用竞态检测器的情况下编译代码,并报告它在运行时检测到的任何竞态情况。当竞态检测器在程序中发现数据冲突时,它将打印一份报告,其中包含冲突访问的堆栈跟踪,以及创建相关goroutine的堆栈。

使用Vet检查可疑的代码结构

Go的vet命令旨在分析源代码,并标记不一定是语法错误,但可能在运行时导致问题的潜在代码,例如无法访问到的代码、未使用的变量以及goroutine中常见的错误。在开发过程的早期发现这些问题,有助于保持代码质量,减少调试时间,并提高软件的整体可靠性。要为指定项目运行go vet,请运行:

go vet ./...

译者注:Goland这种IDE已经集成并会自动使用vet命令了。

订阅golang公告以获取安全相关发布的通知

包含安全修复程序的Go版本已预先发布到[email protected]邮件列表中。如果你想知道Go本身的安全修复何时开始,请订阅。

Go CNA政策

本文翻译自《Go CNA Policy》。

点此回到《Go安全》。

概述

Go CNA是一个CVE编号机构,负责发布CVE ID并发布Go生态系统中公开漏洞的CVE记录。它是Google CNA的子CNA。

范围

Go CNA涵盖Go项目(Go标准库子存储库)中的漏洞,以及其他CNA尚未涵盖的可导入的Go模块中的公开漏洞。

此范围旨在明确排除Go中编写的应用程序或不可导入的包中的漏洞(例如main中的任何包)。更多信息,请参阅go.dev/security/vuln/database#excluded-reports。

要报告Go项目中潜在的新漏洞,请参阅Go.dev/security/policy。

为一个公开漏洞请求一个CVE ID

重要提示:下面链接的表单在议题跟踪器上创建了一个公开议题,因此不得用于报告Go中未公开的漏洞(有关报告未公开问题的说明,请参阅我们的安全政策)。

要为Go生态系统中现有PUBLIC漏洞请求一个CVE ID,请通过此表单提交请求

如果漏洞已经公开披露,或者存在于你维护的包中,并且你准备公开披露,则该漏洞被视为公开漏洞。

Go漏洞管理

本文翻译自《Go Vulnerability Management》。

点此回到《Go安全》

概述

Go可以帮助开发人员检测、评估和解决有可能被攻击者利用的代码Bug或弱点。在幕后,Go团队运行一个管道来策划有关漏洞的报告,这些报告存储在Go漏洞数据库中。可以阅读和分析各种库和工具的这些报告,以了解特定用户项目可能受到的影响。此功能已集成到Go软件包发现站点和新的CLI工具govulcheck中。

该项目正在推进中,正在积极开发中。我们欢迎你的反馈,帮助我们改进!

注意:要报告Go项目中的漏洞,请参阅Go安全策略

架构

Go中的漏洞管理由以下高级部分组成:

资源

Go漏洞数据库

Go漏洞数据库包含来自许多现有来源的信息,此外还有Go包的维护人员直接向Go安全团队的报告。数据库中的每个条目都会被审查,以确保漏洞的描述、包和符号的信息以及版本的详细信息是准确的。

有关go漏洞数据库的更多信息,请参阅go.dev/security/vuln/database,在浏览器中查看数据库中的漏洞信息,请参阅pkg.go.dev/vuln

我们鼓励包的维护人员在自己的项目中提供有关漏洞的信息,并向我们发送如何减少该漏洞造成的影响的建议

Go漏洞检测

Go的漏洞检测旨在为Go用户提供一种低噪声、可靠的方式来了解可能影响其项目的已知漏洞。漏洞检测集成到Go的工具和服务中,包括一个新的命令行工具govulcheckGo包发现网站主流的编辑器,例如带有Go扩展的VS Code。

要开始使用govulcheck,请在项目中运行以下命令语句:

$ go install golang.org/x/vuln/cmd/govulncheck@latest
$ govulncheck ./...

要在编辑器中启用漏洞检测,请参阅编辑器集成漏洞检测插件页面中的说明。

Go CNA

Go安全团队是一个CVE编号机构(CVE Numbering Authority)。有关更多信息,请参阅go.dev/security/vuln/cna

反馈

我们希望你在以下方面做出贡献并帮助我们改进:

FAQ

如何报告Go项目中的漏洞?

通过电子邮件向[email protected]报告Go项目中的所有安全漏洞。有关我们流程的更多信息,请阅读Go的安全政策

如何将公共漏洞添加到Go漏洞数据库?

要将公共漏洞添加到Go漏洞数据库,请填写此表单

如果漏洞已经公开披露,或者存在于你维护的包中(并且你已准备好披露),则该漏洞被视为公开漏洞。该表单仅适用于不由Go团队维护的可导入的Go包中的公共漏洞(Go标准库、Go工具链和golang.org模块之外的任何包)。

该表单也可用于申请新的CVE ID。点击此处了解更多关于Go CVE编号机构的信息。

如何提议对漏洞进行编辑?

提议编辑Go漏洞数据库中的现有报告,请填写此处的表单

我如何报告问题或提供有关govulcheck的反馈?

Go问题跟踪器上提交你的问题或反馈。

我在另一个数据库中发现了此漏洞,为什么它不在Go漏洞数据库中?

由于各种原因,报告可能会被排除在Go漏洞数据库之外,包括相关漏洞不存在于Go包中,存在于可安装命令而非可导入包中,或者该漏洞被数据库中已存在的另一个漏洞所包含。你可以在此处了解更多关于Go Security团队排除报告的原因。如果你认为某个报告被错误地排除在vuln.gov之外,请告诉我们

为什么Go漏洞数据库不使用严重级别标签?

大多数漏洞报告格式使用严重性标签,如“LOW”、“MEDIUM”和“CRITICAL”,以指示不同漏洞造成的影响程度,并帮助开发人员确定安全问题的优先处理级别。然而,由于几个原因,Go避免使用此类标签。

漏洞的影响很少是普遍的,这意味着严重性指标往往具有欺骗性。例如,如果解析器用于解析用户提供的输入,并且可以被DoS攻击利用,那么解析器中的漏洞可能是一个严重的问题,但如果解析器只是用于分析本地配置文件,即使将严重性称为“低”也可能言过其实。

严重程度也必然是主观的。即使对于CVE程序也是如此,该程序假定了一个公式来分解漏洞的相关方面,如攻击向量、复杂性和可利用性。然而,所有这些都需要主观评价。

我们认为,对漏洞的良好描述比严重性指标更有用。一个好的描述可以分解什么是问题,如何触发问题,以及用户在确定对自己软件的影响时应该考虑什么。

如果你想与我们分享你对此主题的想法,请随时提交一个议题

使用IDE扫描Go依赖项的漏洞

本文翻译自《Vulnerability Scanning in IDE》。

点此回到《Go安全》

Go语言服务器集成的编辑器,例如装有Go扩展的VS Code,可以检测依赖项中的漏洞。

检测依赖关系中的漏洞有两种模式。两者都由Go漏洞数据库支持,并相互补充。

  • 基于导入的分析:在这种模式下,编辑器通过扫描工作区中导入的一组包来报告漏洞,并在go.mod文件中显示诊断结果。这很快,但如果你的代码导入了有漏洞的包,但并没有实际调用具有该漏洞的函数,则可能会误报。此模式可以通过“vulncheck”: “Imports”设置启用。
  • Govulncheck分析:这是基于gopls中嵌入的govulnchick命令行工具。这提供了一种低噪声、可靠的方法来确认代码是否真的调用了易受攻击的函数。由于此分析的计算成本可能很高,因此必须与基于导入的分析中的诊断报告相关的“Run govuncheck to verify”代码操作或使用go.mod文件上的“codelens.Run_govulcheck”代码操作手动触发。

切换Vulncheck (vulncheck.mp4)

这些功能在gopls v0.11.0或更新版本中可用。请在https://go.dev/s/vsc-vulncheck-feedback上分享你的反馈。

编辑器相关指导

VS Code

Go扩展提供了与gopls的集成。需要以下设置才能启用漏洞扫描功能:

"go.diagnostic.vulncheck": "Imports", // 默认启用基于导入的Govulncheck分析
"gopls": {
  "ui.codelenses": {
    "run_govulncheck": true  // 在go.mod文件里,鼠标移到依赖项上时显示"Run govulncheck"菜单选项
  }
}

Go Toggle Vulcheck”命令可用于打开和关闭当前工作空间的基于导入的分析。

Vim/NeoVim

使用coc.nvim插件时,以下设置将启用基于导入的分析。

{
    "codeLens.enable": true,
    "languageserver": {
        "go": {
            "command": "gopls",
            ...
            "initializationOptions": {
                "vulncheck": "Imports",
            }
        }
    }
}

注意事项和警告

  • 该扩展不扫描私有包,也不发送任何有关私有模块的信息。所有分析都是通过从Go漏洞数据库中提取已知有漏洞风险的模块的列表,然后在本地计算交集来完成的。
  • 基于导入的分析使用工作区(workspace)模块中的包列表,如果使用了go.work文件或replace/exclude指令,则该列表可能与你在go.mod文件中看到的不同。
  • 在修改代码或更新Go漏洞数据库后,govulcheck分析结果可能会变得过时。要手动使分析结果失效,请使用go.mod文件顶部显示的“Reset go.mod diagnostics”选项。否则,结果将在一小时后自动失效。
  • 这些功能目前不会报告标准库或工具链中的漏洞。我们仍在调查用户体验,了解在哪里显示这些发现比较合适,以及如何帮助用户处理这些问题。

教程:使用VS Code Go插件查找和修复可能有安全风险的依赖项

本文翻译自《Tutorial: Find and fix vulnerable dependencies with VS Code Go》。

点此回到《Go安全》

目录

先决条件

如何使用VS Code Go扫描漏洞

其他资源

你可以使用Visual Studio Code编辑器的Go插件直接在编辑器中扫描代码中的漏洞。

注意:有关以下图像中包含的漏洞修复相关的教程,请参阅govulcheck教程

先决条件

  • Go 1.18或更高版本。Govulncheck旨在与Go 1.18及以后的版本配合使用。有关安装说明,请参阅安装Go。我们建议你使用最新版本的Go来学习本教程。
  • VS Code编辑器,更新到最新版本。请在此处下载。你也可以使用Vim(有关详细信息,请参阅此处),但本教程的重点是VS Code Go插件。
  • VS Code Go插件,可以在这里下载。
  • VS Code编辑器特定设置更改。你需要根据这些规范修改VS Code的设置,然后才能复制下文的代码示例,运行后得到相应的结果。

如何使用VS Code Go扫描漏洞

第一步,运行“Go: Toggle Vulncheck”

Toggle Vulcheck命令显示在你的模块中列出的所有依赖项的漏洞分析。要使用此命令,请打开IDE中的命令面板(在Linux/Windows上的快捷键为Ctrl+Shift+P,在Mac OS上的快捷键为Cmd+Shift+P),然后运行“Go:Thoggle Vulcheck”。在Go.mod文件中,你将看到代码中可能会被直接和间接攻击的依赖项的诊断。

注意:要在自己的编辑器上重现本教程,请将下面的代码复制到main.go文件中。

// 这个程序从命令行获取一个或多个“语言标签(language tag)”参数,然后解析它们
package main

import (
  "fmt"
  "os"

  "golang.org/x/text/language"
)

func main() {
  for _, arg := range os.Args[1:] {
    tag, err := language.Parse(arg)
    if err != nil {
      fmt.Printf("%s: error: %v\n", arg, err)
    } else if tag == language.Und {
      fmt.Printf("%s: undefined\n", arg)
    } else {
      fmt.Printf("%s: tag %s\n", arg, tag)
    }
  }
}

然后,确保程序的go.mod文件的内容如下所示:

module module1

go 1.18

require golang.org/x/text v0.3.5

运行go mod tidy命令以确保你的go.sum文件已更新。

第二步,运行govulcheck。

使用代码操作(code action)运行govulcheck可以让你专注于代码中实际调用的依赖项。VS Code中的代码操作由灯泡图标标记;将鼠标悬停在相关依赖项上以查看有关该漏洞的信息,然后选择“快速修复(Quick Fix)”以显示选项菜单。再选择“运行govulcheck进行验证(run govulncheck to verify)”。这将在你的终端中返回相关的govulceck输出。

第三步,将鼠标悬停在go.mod文件中列出的依赖项上。

将鼠标悬停在go.mod文件中的依赖项上,也可以找到关于此依赖项的govulcheck输出。为了快速查看依赖项相关信息,这种方式甚至比使用代码操作更高效。

第四步,把你的依赖项升级到修复后的版本。

代码操作还可以用于快速升级到修复漏洞后的依赖项的版本。通过在代码操作的下拉菜单中选择“升级”选项来完成此操作。

其他资源

  • 有关IDE中漏洞扫描的详细信息,请参阅此页。特别是“注意和警告”小节讨论了漏洞扫描可能比上例中更复杂的特殊情况。
  • Go漏洞数据库包含来自许多现有源代码的信息,此外还有Go包维护人员向Go安全团队的直接报告。
  • 请参阅Go漏洞管理页面,该页面提供了Go用于检测、报告和管理漏洞的体系结构的高级视图。

Go数据结构

本文翻译自《Go Data Structures》。

2009/11/24

在向新程序员解释Go程序时,我发现解释Go值在内存中的样子通常有助于建立正确的直觉,了解哪些操作是昂贵的,哪些不昂贵。这篇文章是关于Go的基本类型、结构体、数组和切片的。

基本类型

让我们从一些简单的例子开始:

变量i的类型为int,在内存中表示为一个32位的字(word)。(所有这些图片都显示了32位内存布局;在当前的实现中,只有指针在64位机器上变长了——int仍然是32位——尽管可以选择使用64位的int64类型。)

由于显式转换,变量j的类型为int32。即使ij有相同的内存布局,它们也有不同的类型:赋值i=j会引起一个类型错误,必须使用显式转换:i=int(j)

变量f的类型为float,当前实现将其表示为32位浮点值。它具有与int32相同的内存占用空间,但内部布局不同。

结构体及其指针

现在情况开始变得有趣。变量bytes的类型为[5]byte,是一个由5个字节组成的数组。它的内存表示就是这5个字节,一个接一个,就像一个C数组。类似地,primes是一个由4个int组成的数组。

Go与C一样,但与Java不一样,它可以让程序员控制什么是指针,什么不是指针。例如,此类型定义:

type Point struct { X, Y int }

定义了一个名为Point的简单结构体类型,表现在内存中就是两个相邻的int字段。

复合字面量语法Point{10, 20}表示已初始化的一个Point实例。获取Point{10, 20}的地址&Point{10, 20}表示指向Point{10, 20}的指针。前者是内存中的两个字(word);后者是指向内存中这两个字的指针。

结构体中的字段在内存中并排排列。

type Rect1 struct { Min, Max Point }
type Rect2 struct { Min, Max *Point }

Rect1是一个具有两个Point字段的结构体,在内存中由一行中的两个Point字段(四个int)表示。Rect2是一个具有两个*Point字段的结构体。

使用过C语言的程序员可能不会对Point字段和*Point字段之间的区别感到惊讶,而只使用过Java或Python(或…)的程序员可能会感到惊讶。通过让程序员控制基本的内存布局,Go提供了控制给定数据结构集合的总大小、分配的元素的数量和内存访问模式的能力,所有这些对于构建性能良好的系统都很重要。

字符串

有了这些预备知识,我们可以继续研究更有趣的数据类型。

(灰色箭头表示字符串实现中存在但在程序中不直接可见的指针。)

一个字符串string在内存中表示为一个2字结构体,里面包含一个指向字符串数据(是一个字节数组)的指针和一个长度字段。由于字符串是不可变类型,因此多个字符串共享同一底层存储是安全的,因此对s进行切片会产生一个新的2字结构体,该结构体具有不同的指针和长度字段,但仍然引用相同的底层字节序列。这意味着可以在不重新分配或复制的情况下进行切片,从而使字符串切片与显式地使用下标索引一样高效。

(顺便说一句,Java和其他语言中有一个众所周知的难题,当你对一个字符串进行切片以保存一小段时,对原始字符串的引用会将整个原始字符串保留在内存中,即使只需要少量的字符串。Go也有这个难题。我们尝试过但拒绝了另一种选择,那就是让字符串切片变得如此昂贵——一次再分配和一个新副本——大多数程序都应该避开它。)

切片(slice)

切片是对某个数组的部分引用。在内存中,它是一个3字结构体,包含指向第一个数组元素的指针、切片的长度和容量。长度是x[i]等索引操作的上限,而容量是x[i:j]等切片操作的上限。

与对字符串进行切片一样,对数组进行切片不会产生新副本:它只会创建一个包含不同指针、长度和容量的新结构体。在本例中,一开始创建切片[]int{2,3,5,7,11}在底层会创建一个包含五个值的新数组,然后设置切片x的字段来描述该数组。但切片表达式x[1:3]没有分配更多的数据:它只是创建一个新的切片头结构体,以引用相同的底层数组。在本例中,它的长度为2,即y[0]y[1]是唯一有效的索引,但容量为4,即y[0:4]是有效的切片表达式。(有关长度和容量以及切片使用方式的详细信息,请参阅Effective Go。)

因为切片是多字结构体,而不是指针,所以切片操作不需要分配内存,甚至不需要为切片头分配内存,因为切片头通常可以保存在栈上。这种切片的使用成本与在C语言中显式传递指针和长度对一样低。Go最初将切片表示为指向上述结构体的指针,但这样做意味着每个切片操作都会分配一个新的内存对象。即使使用快速的内存分配器,也会给垃圾收集器带来很多不必要的工作。不使用指针和分配内存使得切片足够便宜。

new和make

Go有两个数据结构创建函数:newmake。它们的区别在早期是一个常见的混淆点,但似乎很快就变得很自然了。基本区别是new(T)返回一个*T,Go程序可以隐式地解引用该指针(下图中的黑色指针),而make(T,args)返回普通的T,而不是指针。通常,T内部有一些隐式指针(下图中的灰色指针)。new返回一个指向值全是0的一块内存区域的指针(如下图所示),而make返回一个复杂的结构体。

有一种方法可以将这两者统一起来,但这将是对C和C++传统的重大突破:定义make(*T)返回一个指向新分配的T的指针,这样当前的new(Point)就可以被改写为make(*Point)。我们尝试了几天,但认为这与人们对分配函数的期望太不一样了。

更多……

这已经有点长了。接口(interface)、映射(map)和通道(channel)将不得不等待将来的发布。

Go数组、切片以及字符串的append函数的机制

本文翻译自《Arrays, slices (and strings): The mechanics of ‘append’》。

Rob Pike

2013/09/26

介绍

面向过程的编程语言的最常见的特性之一是数组的概念。数组看起来很简单,但在将它们添加到编程语言中时,必须回答许多问题,例如:

  • 固定长度还是可变长度?
  • 长度是这个类型的一部分吗?
  • 多维数组是什么样子的?
  • 空数组有意义吗?

这些问题的答案会决定数组是编程语言的一个特性还是其设计的核心部分。

在Go的早期开发中,大约花了一年时间来决定这些问题的答案,然后才觉得设计是正确的。关键的一步是引入切片(slice),它建立在固定大小的数组上,以提供灵活的、可扩展的数据结构。然而,时至今日,刚接触Go的程序员经常会在切片的工作方式上磕磕碰碰,也许是因为其他语言的经验影响了他们的思维。

在这篇文章中,我们将试图消除混乱。我们将通过构建代码片段的方式来解释内置函数append是如何工作的,以及为什么它会这样工作。

数组

数组是Go中的一个重要的组成元素,但与建筑的基础一样,它通常隐藏在更显眼的组件下面。在我们继续讨论切片这个更有趣、更强大、更突出的概念之前,我们必须先简要地讨论一下数组。

数组在Go程序中并不常见,因为数组的大小是其类型的一部分,这限制了它的表达能力。

声明

var buffer [256]byte

声明了buffer变量,是一个数组,可容纳256个字节。buffer的类型包括其大小[256]byte。而具有512个字节长度的数组将是不同的类型:[512]byte

与数组相关联的数据就是数组的元素们。从以下示意图来看,我们的buffer数组在内存中是这样的,

buffer: byte byte byte ... 256 times ... byte byte byte

也就是说,该变量包含256字节的数据。我们可以通过buffer[255]使用熟悉的索引语法:buffer[0]buffer[1]等来访问它的元素。这里的buffer数组的索引范围是0到255,包含256个元素。试图用超出此范围的值对buffer数组进行索引将导致程序崩溃。

有一个名为len的内置函数,它返回数组或切片以及其他一些数据类型的元素数量。对于数组,len返回的内容是显而易见的。在我们的示例中,len(buffer)返回固定值256。

数组有自己的作用——例如,它们可以很好地表示矩阵——但它们在Go中最常见的用途是作为切片的底层存储。

切片

想使用好切片,必须准确地了解它们是什么以及它们的作用。

切片是一种数据结构,描述与切片变量本身分开存储的数组的连续部分。切片不是数组。切片描述某一个数组的一部分。

对于上一节中的buffer数组,我们可以通过对该数组进行切片来创建元素下标100到150(准确地说,是100到149,包括100到149)的切片:

var slice []byte = buffer[100:150]

在该代码段中,我们使用了显式的完整的变量声明:变量slice的类型为[]byte,发音为“slice of bytes”,通过对buffer数组元素从下标100(包含)到150(不包含)进行切片,来初始化。更惯用的语法是不写出切片的类型:

var slice = buffer[100:150]

在函数体中,我们也可以使用海象运算符来初始化一个切片:

slice := buffer[100:150]

切片变量究竟是什么?虽然这还不是全貌,但现在可以将切片视为一个包含两个元素的小数据结构:长度和指向数组元素的指针。你可以把它想象成是在底层构造的如下所示的结构:

type sliceHeader struct {
    Length        int
    ZerothElement *byte
}

slice := sliceHeader{
    Length:        50,
    ZerothElement: &buffer[100],
}

当然,这只是一个例子。尽管这个sliceHeader结构对程序员来说是不可见的,并且元素指针的类型取决于元素的类型,但这给出了切片底层机制的一般性概念。

到目前为止,我们已经对数组使用了切片操作,但我们也可以对切片进行切片,如下所示:

slice2 := slice[5:10]

与之前一样,此操作创建一个新的切片,具有原始切片的下标从5到9(包含9)的元素,这意味着这个新切片具有原始数组的下标从105到109的元素。slice2变量的底层sliceHeader结构如下所示:

slice2 := sliceHeader{
    Length:        5,
    ZerothElement: &buffer[105],
}

请注意,此结构的指针仍然指向存储在buffer变量中的底层数组。

我们也可以重新切片(再切片,reslice),也就是说对切片进行切片:

slice = slice[5:10]

这个slice变量的sliceHeader结构与slice2变量的结构类似。你将经常使用重新切片,例如截断一个切片。以下这行代码截除切片的第一个和最后一个元素:

slice = slice[1:len(slice)-1]

[练习:写出上述赋值后的slice变量的sliceHeader结构的样子。]

你经常会听到有经验的Go程序员谈论“切片头sliceHeader”,因为它实际上是存储在切片变量中的东西。例如,当你调用一个以切片为参数的函数时,例如bytes.IndexRune,切片头就是传递给函数的内容。在以下调用中,

slashPos := bytes.IndexRune(slice, '/')

传递给IndexRune函数的slice参数实际上是一个“切片头”。

切片头中还有一个数据项,我们将在下面讨论,但首先让我们看看当使用切片编程时,切片头的存在意味着什么。

把切片传递给函数

重要的是要理解,即使切片包含指针,它本身也是一个值。在底层,它是一个结构体值,包含一个指针和一个长度,而不是指向某个结构体值的指针。

这很重要。

当我们在前面的例子中调用IndexRune函数时,传递了一个切片头的副本。这种行为具有重要的影响。

考虑一下这个简单的函数:

func AddOneToEachElement(slice []byte) {
    for i := range slice {
        slice[i]++
    }
}

顾名思义,该函数迭代切片的索引(使用for range循环),使其元素的值加1。试试看:

func main() {
    slice := buffer[10:20]
    for i := 0; i < len(slice); i++ {
        slice[i] = byte(i)
    }
    fmt.Println("before", slice)
    AddOneToEachElement(slice)
    fmt.Println("after", slice)
}

(如果你想探索,可以编辑并执行上述可运行的代码段。)

即使切片头是按值传递的,它也包含指向数组元素的指针,因此原始切片头和传递给函数的切片头副本都描述了同一个底层数组。因此,当函数返回时,可以通过原始切片头看到被修改后的元素。

函数的切片实参确实是一个副本,如本例所示:

func SubtractOneFromLength(slice []byte) []byte {
    slice = slice[0 : len(slice)-1]
    return slice
}

func main() {
    fmt.Println("Before: len(slice) =", len(slice))
    newSlice := SubtractOneFromLength(slice)
    fmt.Println("After:  len(slice) =", len(slice))
    fmt.Println("After:  len(newSlice) =", len(newSlice))
}

在这里,我们看到切片参数的内容可以由函数修改,但其切片头不能。存储在切片变量中的长度不会被函数的调用所修改,因为函数传递的是切片头的副本,而不是原始切片头。因此,如果我们想编写一个修改切片头的函数,我们必须将其作为结果参数返回,就像我们在这里所做的那样。slice变量不变,但返回的值具有新的长度,然后将其存储在newSlice中,

切片指针:方法的接收者

让函数修改切片头的一种方法是将指针传递给它。下面是我们前面示例的一个变体:

func PtrSubtractOneFromLength(slicePtr *[]byte) {
    slice := *slicePtr
    *slicePtr = slice[0 : len(slice)-1]
}

func main() {
    fmt.Println("Before: len(slice) =", len(slice))
    PtrSubtractOneFromLength(&slice)
    fmt.Println("After:  len(slice) =", len(slice))
}

这个例子使用了指向切片的指针,但看起来很笨拙。要修改切片,我们通常使用指针接收者。

假设我们有一个方法,在最后一个斜杠处截断切片。我们可以这样写:

type path []byte

func (p *path) TruncateAtFinalSlash() {
    i := bytes.LastIndex(*p, []byte("/"))
    if i >= 0 {
        *p = (*p)[0:i]
    }
}

func main() {
    pathName := path("/usr/bin/tso") // Conversion from string to path.
    pathName.TruncateAtFinalSlash()
    fmt.Printf("%s\n", pathName)
}

如果运行此示例,你将看到它能合理地工作,更改函数中的切片。

[练习:将接收者的类型更改为值而不是指针,然后再次运行。解释会发生什么。]

另一方面,如果我们想为path编写一个方法,使path中的ASCII字母大写(简单地忽略非英文字母),则该方法可以传入一个切片值,因为切片值接收者仍将指向相同的底层数组。

type path []byte

func (p path) ToUpper() {
    for i, b := range p {
        if 'a' <= b && b <= 'z' {
            p[i] = b + 'A' - 'a'
        }
    }
}

func main() {
    pathName := path("/usr/bin/tso")
    pathName.ToUpper()
    fmt.Printf("%s\n", pathName)
}

在这里,ToUpper方法使用for range中的两个迭代变量来捕获slice的下标和元素。

[练习:将ToUpper方法转换为使用指针接收者,并查看其行为是否发生变化。]

[高级练习:改写ToUpper方法以处理Unicode字母,而不仅仅是ASCII。]

容量

看看下面的函数,它将元素是int类型的切片扩展了一个元素:

func Extend(slice []int, element int) []int {
    n := len(slice)
    slice = slice[0 : n+1]
    slice[n] = element
    return slice
}

看看切片是如何生长的,直到不能生长为止。

现在是时候讨论切片头的第三个组成部分了:它的容量。除了数组指针和长度之外,切片头还存储其容量:

type sliceHeader struct {
    Length        int
    Capacity      int
    ZerothElement *byte
}

Capacity字段记录切片的底层数组实际拥有的空间;它是长度Length可以达到的最大值。试图将切片增长到超出其容量的程度将超出底层数组空间的限制,并引发panic

上面的代码中,我们这么创建切片:

slice := iBuffer[0:0]

它的切片头类似于如下结构:

slice := sliceHeader{
    Length:        0,
    Capacity:      10,
    ZerothElement: &iBuffer[0],
}

Capacity字段等于切片的底层数组的长度,减去切片的第一个元素在数组中的索引(在上述情况下为零)。如果你想查询切片的容量,可以使用Go内置函数cap:

if cap(slice) == len(slice) {
    fmt.Println("slice is full!")
}

make

如果我们想让切片超出其容量,该怎么办?你不做不到!根据定义,容量是切片增长的极限。但是,你可以创建一个容量更大的新数组,复制旧切片的数据到这个新数组,然后让旧切片指向这个新数组。

让我们开始创建。我们可以使用内置函数new来分配一个更大的数组,然后对结果进行切片,但使用内置函数make会更简单,它创建一个新数组并创建一个切片头来指向它。函数make接受三个参数:切片的类型、初始长度和容量。容量即make创建的用于保存切片数据的底层数组的长度。以下这个调用创建了一个长度为10的切片,还有5个的额外的空间可以扩展:

slice := make([]int, 10, 15)
fmt.Printf("len: %d, cap: %d\n", len(slice), cap(slice))

以下这个代码片段使int切片的容量增加了一倍,但长度保持不变:

slice := make([]int, 10, 15)
fmt.Printf("len: %d, cap: %d\n", len(slice), cap(slice))
newSlice := make([]int, len(slice), 2*cap(slice))
for i := range slice {
    newSlice[i] = slice[i]
}
slice = newSlice
fmt.Printf("len: %d, cap: %d\n", len(slice), cap(slice))

运行此代码后,在需要再次重新分配空间之前,切片已经有更多的增长空间。

在创建切片时,长度和容量通常是相同的。内置函数make对这种常见情况有一个简写。length参数默认等于容量,因此可以省略容量参数,将两者设置为相同的值:

gophers := make([]Gopher, 10)

切片gophers的长度和容量都设置为10。

Copy

当我们在上一节中将切片的容量加倍时,我们编写了一个循环来将旧数据复制到新切片。Go有一个内置函数copy,可以让这变得更容易。它的参数是两个切片,并将数据从右侧参数复制到左侧参数。以下是我们使用copy的示例:

newSlice := make([]int, len(slice), 2*cap(slice))
copy(newSlice, slice)

copy很智能,它只复制它可以复制的内容,并注意两个切片参数的长度。 换句话说,它复制的元素数量是两个切片长度中的小的那个。此外,copy返回一个整数值,即它复制的元素数量,尽管并不总是值得检查这个返回值。

当源切片和目标切片重叠时,copy函数也能正确处理,这意味着它可以用于在单个切片中移动元素。以下示例如何使用copy将值插入切片的中间:

// Insert函数在切片slice指定的下标index处插入元素值value,index不能超出切片slice的下标范围,并且切片slice必须还有额外容量可供插入新元素
func Insert(slice []int, index, value int) []int {
    // 先给切片slice扩展一个元素的空间
    slice = slice[0 : len(slice)+1]
    // 使用copy函数把切片slice的从index下标开始的右半部分元素,往右移动一格位置,以在index处空出一个位置
    copy(slice[index+1:], slice[index:])
    // 把值value存入index处
    slice[index] = value
    
    return slice
}

在这个函数中有几点需要注意。首先,当然,它必须返回更新后的切片,因为它的长度已经改变。其次,它使用了一种方便的简写。表达式:

slice[i:]

与以下表达式等价:

slice[i:len(slice)]

此外,尽管我们还没有使用这个技巧,但我们也可以省略切片表达式的第一个元素;它默认为零。因此:

slice[:]

指切片本身,这在对数组进行切片时很有用。以下这个表达式是创建一个“描述数组所有元素的切片”的最短的表达式:

array[:]

现在,让我们运行Insert函数:

slice := make([]int, 10, 20) // 注意创建的切片的容量要大于长度,才能插入元素
for i := range slice {
    slice[i] = i
}
fmt.Println(slice)
slice = Insert(slice, 5, 99)
fmt.Println(slice)

Append函数示例

在前几节中,我们编写了一个Extend函数,该函数将切片扩展一个元素。不过,它有缺陷,因为如果切片的容量太小,该函数就会崩溃。(我们的Insert函数也有同样的问题。)现在我们已经准备好了解决这个问题的代码,所以让我们为整数切片编写一个健壮的Extend实现吧:

func Extend(slice []int, element int) []int {
    n := len(slice)
    if n == cap(slice) {
        // 切片容量满了,必须扩容。在这里把切片的容量变成原来的2倍
        newSlice := make([]int, len(slice), 2*len(slice)+1)
        copy(newSlice, slice)
        slice = newSlice
    }
    slice = slice[0 : n+1]
    slice[n] = element
    return slice
}

在这个函数里,尤为重要的是最后要返回切片,因为当源切片被重新分配容量时,得到的切片描述的是一个完全不同的底层数组。以下是一个小片段来演示填充切片时会发生什么:

slice := make([]int, 0, 5)
for i := 0; i < 10; i++ {
    slice = Extend(slice, i)
    fmt.Printf("len=%d cap=%d slice=%v\n", len(slice), cap(slice), slice)
    fmt.Println("address of 0th element:", &slice[0])
}

当大小为5的初始切片被填满时,会重新分配一个底层数组。分配新数组时,第零个元素的地址和数组容量都会发生变化。

有了强大的Extend函数作为指导,我们可以编写一个更好的函数,通过多个元素来扩展切片。为此,我们使用Go的语法,在调用函数时将函数参数列表转换为切片。也就是说,我们使用Go的参数列表长度可变的函数。

让我们调用Append函数。对于第一个版本,我们可以重复调用Extend函数,这样参数列表变长的函数的机制就很清楚了。Append函数的签名如下:

func Append(slice []int, items ...int) []int

这意味着Append函数接受一个参数,即一个切片,然后是零个或多个int参数。就Append的实现而言,这些参数正是int切片的一部分:

// Append函数把items添加到切片slice。
// 第一个版本:仅仅循环调用Extend函数。
func Append(slice []int, items ...int) []int {
    for _, item := range items {
        slice = Extend(slice, item)
    }
    return slice
}

请注意,for range循环在items参数的元素上迭代,该参数具有隐含的类型[]int。还要注意使用空白标识符_来丢弃循环中的索引,在这种情况下我们不需要它。

试试看:

slice := []int{0, 1, 2, 3, 4}
fmt.Println(slice)
slice = Append(slice, 5, 6, 7, 8)
fmt.Println(slice)

本例中的另一项新技术是,我们通过编写一个字面量来初始化切片slice,由切片的类型及其大括号中的元素组成:

slice := []int{0, 1, 2, 3, 4}

Append函数之所以有趣,还有一个原因。我们不仅可以附加元素,还可以通过使用符号来展开一个切片的所有元素:

slice1 := []int{0, 1, 2, 3, 4}
slice2 := []int{55, 66, 77}
fmt.Println(slice1)
slice1 = Append(slice1, slice2...) // ...符号
fmt.Println(slice1)

当然,我们可以在Extend函数的内部,通过不超过一次的分配来提高Append函数的效率:

// 更加高效的Append版本。
func Append(slice []int, elements ...int) []int {
    n := len(slice)
    total := len(slice) + len(elements)
    if total > cap(slice) {
        // 重新分配容量为原来的1.5倍
        newSize := total*3/2 + 1
        newSlice := make([]int, total, newSize)
        copy(newSlice, slice)
        slice = newSlice
    }
    slice = slice[:total]
    copy(slice[n:], elements)
    return slice
}

在这里,请注意我们如何使用copy函数两次,一次是将切片数据移动到新分配的内存,另一次是将要添加的元素复制到切片slice旧数据的末尾。

试试看;行为与以前相同:

slice1 := []int{0, 1, 2, 3, 4}
slice2 := []int{55, 66, 77}
fmt.Println(slice1)
slice1 = Append(slice1, slice2...)
fmt.Println(slice1)

Append内置函数

因此,我们得出了设计append内置函数的动机。它与我们的Append示例完全一样,具有同等的效率,但它适用于任何切片类型。

Go的一个弱点是任何泛型类型的操作都必须由运行时提供。总有一天,这种情况可能会改变,但目前,为了更容易地处理切片,Go提供了一个内置的通用的append函数。它的工作原理与我们的int切片版本相同,但适用于任何切片类型。

请记住,由于切片头总是会被append函数更新,因此需要在调用后保存返回的切片头。事实上,编译器不允许在不保存结果的情况下调用append函数。

// 创建两个切片
slice := []int{1, 2, 3}
slice2 := []int{55, 66, 77}
fmt.Println("Start slice: ", slice)
fmt.Println("Start slice2:", slice2)

// 添加元素到切片
slice = append(slice, 4)
fmt.Println("Add one item:", slice)

// 添加一个切片里的所有元素到另一个切片
slice = append(slice, slice2...)
fmt.Println("Add one slice:", slice)

// 复制一个切片,然后赋值给另一个切片
slice3 := append([]int(nil), slice...)
fmt.Println("Copy a slice:", slice3)

// 复制一个切片里的所有元素,然后追加到这个切片的尾部
fmt.Println("Before append to self:", slice)
slice = append(slice, slice...)
fmt.Println("After append to self:", slice)

值得花点时间详细思考以上代码的最后三行。

在社区构建的“Slice Tricks”Wiki页面上,还有更多关于函数appendcopy和其他使用切片的方法的示例。

Nil

根据我们新学到的知识,我们可以知道nil切片是什么。自然,它是切片头的零值(zero value):

sliceHeader{
    Length:        0,
    Capacity:      0,
    ZerothElement: nil,
}

或者仅是:

sliceHeader{}

关键细节是其指向底层数组元素的指针也是nil。由一下代码创建的切片:

array[0:0]

长度是0,也许容量也是0,但是其指向底层数组元素的指针不是nil,因此它不是nil切片。

应该清楚的是,空(empty)切片可以增长(假设它具有非零容量),但nil切片没有可放入值的数组,并且永远不能增长到容纳哪怕一个元素。

也就是说,nil切片在功能上等同于零长度的切片,即使它什么都不指向。它的长度为零,但可以被append函数使用。举个例子,看看上面的那一行代码,通过附加到一个nil切片来复制一个切片。

String

现在简要介绍一下Go中的与切片相关的字符串。

字符串实际上非常简单:它们只是只读的字节片,再加上Go语言提供了一些额外的语法支持。

因为它们是只读的,所以不需要容量(不能增长它们),但在其他方面,对于大多数目的,你可以将它们视为只读的字节片。 对于初学者,我们可以对它们进行索引以访问单个字节:

slash := "/usr/ken"[0] // 返回'/'

我们可以通过切片一个字符串来获取它的子串:

usr := "/usr/ken"[0:4] // 返回字符串"/usr"

现在,当我们切片一个字符串时,幕后发生的事情应该很明显了。

我们还可以从一个普通的字节切片,通过简单的强制类型转换从中创建一个字符串:

str := string(slice)

反过来也一样:

slice := []byte(usr)

字符串下面的数组在视图中是隐藏的;除了通过字符串之外,无法访问其内容。这意味着,当我们进行这两种转换时,必须制作数组的一个副本。Go当然会处理好这一点,所以你不必自己这么做。在这两种转换之后,对字节片底层的数组的修改就不会影响相应的字符串。

这种类似切片的字符串设计的一个重要结果是,创建子字符串非常高效。所需要做的就是创建一个字符串头。由于字符串是只读的,原始字符串和切片操作产生的字符串可以安全地共享同一个底层数组。

在Go的早期版本,字符串的最早实现是总会被分配一个底层数组,但当切片被添加到Go中时,它们提供了一个高效的字符串处理的模型。因此,在一些性能测试里表现出了巨大的加速。

当然,字符串还有很多内容可讲,另外一篇博客文章《Go中的字符串,字节,rune和字符(character)》对它进行了更深入的介绍。

结论

要了解切片是如何工作的,了解它们是如何实现的会有所帮助。有一个小数据结构,即切片头。当我们四处传递切片值时,切片头会被复制,但它指向的底层数组总是共享的。

一旦你了解了它们的工作原理,切片不仅易于使用,而且功能强大且富有表现力,尤其是在copyappend内置函数的帮助下。

更多切片相关文章

关于Go中的切片,还有很多值得学习的文章。如前所述,Wiki页面“Slice Tricks”有许多示例。Go Slices博客文章用清晰的图表描述了内存布局的细节。Russ Cox的《Go切片(slice):用法和内部结构》文章包括了对切片的讨论以及Go的一些其他内部数据结构。

还有更多的资料,但了解切片的最好方法是使用它们。