https://ggdocs.cn/styleguide/go/best-practices
注意: 这是概述 Google Go 风格 的一系列文档的一部分。本文档既不是规范性的,也不是权威性的,而是核心风格指南的辅助文档。有关更多信息,请参阅概述。
本文档记录了关于如何最好地应用 Go 风格指南的指导。本指导旨在用于经常出现的常见情况,但可能不适用于所有情况。在可能的情况下,会讨论多种替代方法,以及关于何时以及何时不应用这些方法的决策考虑因素。
有关完整的风格指南文档集,请参阅概述。
在选择函数或方法的名称时,请考虑阅读该名称的上下文。考虑以下建议,以避免在调用站点上过度重复
以下内容通常可以从函数和方法名称中省略
对于函数,不要重复包的名称。
// Bad:
package yamlconfig
func ParseYAMLConfig(input string) (*Config, error)
// Good:
package yamlconfig
func Parse(input string) (*Config, error)
对于方法,不要重复方法接收者的名称。
// Bad:
func (c *Config) WriteConfigTo(w io.Writer) (int64, error)
// Good:
func (c *Config) WriteTo(w io.Writer) (int64, error)
不要重复作为参数传递的变量的名称。
// Bad:
func OverrideFirstWithSecond(dest, source *Config) error
// Good:
func Override(dest, source *Config) error
不要重复返回值的名称和类型。
// Bad:
func TransformToJSON(input *Config) *jsonconfig.Config
// Good:
func Transform(input *Config) *jsonconfig.Config
当需要消除相似名称的函数歧义时,可以包含额外信息。
// Good:
func (c *Config) WriteTextTo(w io.Writer) (int64, error)
func (c *Config) WriteBinaryTo(w io.Writer) (int64, error)
在选择函数和方法的名称时,还有一些其他的常见约定
返回某些内容的函数被赋予类似名词的名称。
// Good:
func (c *Config) JobName(key string) (value string, ok bool)
由此得出的一个推论是,函数和方法名称应避免前缀 Get
。
// Bad:
func (c *Config) GetJobName(key string) (value string, ok bool)
执行某些操作的函数被赋予类似动词的名称。
// Good:
func (c *Config) WriteDetail(w io.Writer) (int64, error)
仅因涉及的类型而不同的相同函数在名称末尾包含类型名称。
// Good:
func ParseInt(input string) (int, error)
func ParseInt64(input string) (int64, error)
func AppendInt(buf []byte, value int) []byte
func AppendInt64(buf []byte, value int64) []byte
如果存在明确的“主要”版本,则可以从该版本的名称中省略类型
// Good:
func (c *Config) Marshal() ([]byte, error)
func (c *Config) MarshalText() (string, error)
您可以应用几个规范来命名提供测试助手,尤其是 测试替身的包和类型。 测试替身可以是存根、伪造、模拟或间谍。
这些示例主要使用存根。 如果您的代码使用伪造或其他类型的测试替身,请相应地更新您的名称。
假设您有一个重点明确的包,它提供类似于以下内容的生产代码
package creditcard
import (
"errors"
"path/to/money"
)
// ErrDeclined indicates that the issuer declines the charge.
var ErrDeclined = errors.New("creditcard: declined")
// Card contains information about a credit card, such as its issuer,
// expiration, and limit.
type Card struct {
// omitted
}
// Service allows you to perform operations with credit cards against external
// payment processor vendors like charge, authorize, reimburse, and subscribe.
type Service struct {
// omitted
}
func (s *Service) Charge(c *Card, amount money.Money) error { /* omitted */ }
假设您想创建一个包含另一个测试替身的包。 在此示例中,我们将使用 package creditcard
(来自上面)
一种方法是基于用于测试的生产代码引入一个新的 Go 包。 一个安全的选择是将单词 test
附加到原始包名称 (“creditcard” + “test”)
// Good:
package creditcardtest
除非另有明确说明,否则以下各节中的所有示例都在 package creditcardtest
中。
您想为 Service
添加一组测试替身。 因为 Card
实际上是一个哑数据类型,类似于 Protocol Buffer 消息,所以它在测试中不需要特殊处理,因此不需要替身。 如果您预计只有一种类型的测试替身(例如 Service
),您可以采用简洁的方法来命名替身
// Good:
import (
"path/to/creditcard"
"path/to/money"
)
// Stub stubs creditcard.Service and provides no behavior of its own.
type Stub struct{}
func (Stub) Charge(*creditcard.Card, money.Money) error { return nil }
这严格优于像 StubService
或非常糟糕的 StubCreditCardService
这样的命名选择,因为基本包名称及其域类型暗示了 creditcardtest.Stub
是什么。
最后,如果该包是使用 Bazel 构建的,请确保该包的新 go_library
规则标记为 testonly
# Good:
go_library(
name = "creditcardtest",
srcs = ["creditcardtest.go"],
deps = [
":creditcard",
":money",
],
testonly = True,
)
上述方法是传统的,并且会被其他工程师很好地理解。
另请参阅
当一种存根不够用时(例如,您还需要一个总是失败的存根),我们建议根据它们模拟的行为来命名存根。 在这里,我们将 Stub
重命名为 AlwaysCharges
并引入一个名为 AlwaysDeclines
的新存根
// Good:
// AlwaysCharges stubs creditcard.Service and simulates success.
type AlwaysCharges struct{}
func (AlwaysCharges) Charge(*creditcard.Card, money.Money) error { return nil }
// AlwaysDeclines stubs creditcard.Service and simulates declined charges.
type AlwaysDeclines struct{}
func (AlwaysDeclines) Charge(*creditcard.Card, money.Money) error {
return creditcard.ErrDeclined
}
但是现在假设 package creditcard
包含多个值得创建替身的类型,如下面使用 Service
和 StoredValue
看到的那样
package creditcard
type Service struct {
// omitted
}
type Card struct {
// omitted
}
// StoredValue manages customer credit balances. This applies when returned
// merchandise is credited to a customer's local account instead of processed
// by the credit issuer. For this reason, it is implemented as a separate
// service.
type StoredValue struct {
// omitted
}
func (s *StoredValue) Credit(c *Card, amount money.Money) error { /* omitted */ }
在这种情况下,更明确的测试替身命名是明智的
// Good:
type StubService struct{}
func (StubService) Charge(*creditcard.Card, money.Money) error { return nil }
type StubStoredValue struct{}
func (StubStoredValue) Credit(*creditcard.Card, money.Money) error { return nil }
当测试中的变量引用替身时,选择一个最能根据上下文将替身与其他生产类型区分开来的名称。 考虑一些您想要测试的生产代码
package payment
import (
"path/to/creditcard"
"path/to/money"
)
type CreditCard interface {
Charge(*creditcard.Card, money.Money) error
}
type Processor struct {
CC CreditCard
}
var ErrBadInstrument = errors.New("payment: instrument is invalid or expired")
func (p *Processor) Process(c *creditcard.Card, amount money.Money) error {
if c.Expired() {
return ErrBadInstrument
}
return p.CC.Charge(c, amount)
}
在测试中,一个名为“间谍”的 CreditCard
测试替身与生产类型并列,因此前缀名称可以提高清晰度。
// Good:
package payment
import "path/to/creditcardtest"
func TestProcessor(t *testing.T) {
var spyCC creditcardtest.Spy
proc := &Processor{CC: spyCC}
// declarations omitted: card and amount
if err := proc.Process(card, amount); err != nil {
t.Errorf("proc.Process(card, amount) = %v, want nil", err)
}
charges := []creditcardtest.Charge{
{Card: card, Amount: amount},
}
if got, want := spyCC.Charges, charges; !cmp.Equal(got, want) {
t.Errorf("spyCC.Charges = %v, want %v", got, want)
}
}
这比没有前缀名称时更清晰。
// Bad:
package payment
import "path/to/creditcardtest"
func TestProcessor(t *testing.T) {
var cc creditcardtest.Spy
proc := &Processor{CC: cc}
// declarations omitted: card and amount
if err := proc.Process(card, amount); err != nil {
t.Errorf("proc.Process(card, amount) = %v, want nil", err)
}
charges := []creditcardtest.Charge{
{Card: card, Amount: amount},
}
if got, want := cc.Charges, charges; !cmp.Equal(got, want) {
t.Errorf("cc.Charges = %v, want %v", got, want)
}
}
注意: 此解释使用两个非正式术语,踩踏 和 遮蔽。 它们不是 Go 语言规范中的官方概念。
像许多编程语言一样,Go 具有可变变量:为变量赋值会更改其值。
// Good:
func abs(i int) int {
if i < 0 {
i *= -1
}
return i
}
当使用 短变量声明 与 :=
运算符时,在某些情况下不会创建新变量。 我们可以称之为踩踏。 当不再需要原始值时,这样做是可以的。
// Good:
// innerHandler is a helper for some request handler, which itself issues
// requests to other backends.
func (s *Server) innerHandler(ctx context.Context, req *pb.MyRequest) *pb.MyResponse {
// Unconditionally cap the deadline for this part of request handling.
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
ctxlog.Info(ctx, "Capped deadline in inner request")
// Code here no longer has access to the original context.
// This is good style if when first writing this, you anticipate
// that even as the code grows, no operation legitimately should
// use the (possibly unbounded) original context that the caller provided.
// ...
}
但在新作用域中使用短变量声明时要小心:这会引入一个新变量。 我们可以称之为 遮蔽 原始变量。 块结束后,代码会引用原始变量。 这是一个有缺陷的尝试,有条件地缩短截止日期
// Bad:
func (s *Server) innerHandler(ctx context.Context, req *pb.MyRequest) *pb.MyResponse {
// Attempt to conditionally cap the deadline.
if *shortenDeadlines {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
ctxlog.Info(ctx, "Capped deadline in inner request")
}
// BUG: "ctx" here again means the context that the caller provided.
// The above buggy code compiled because both ctx and cancel
// were used inside the if statement.
// ...
}
正确的代码版本可能是
// Good:
func (s *Server) innerHandler(ctx context.Context, req *pb.MyRequest) *pb.MyResponse {
if *shortenDeadlines {
var cancel func()
// Note the use of simple assignment, = and not :=.
ctx, cancel = context.WithTimeout(ctx, 3*time.Second)
defer cancel()
ctxlog.Info(ctx, "Capped deadline in inner request")
}
// ...
}
在我们称之为踩踏的情况下,因为没有新变量,所以要赋值的类型必须与原始变量的类型匹配。 使用遮蔽时,会引入一个全新的实体,因此它可以具有不同的类型。 有意的遮蔽可能是一种有用的做法,但如果可以提高清晰度,您可以始终使用新名称。
在非常小的作用域之外,最好不要使用与标准包名称相同的变量,因为这会使该包的自由函数和值无法访问。 相反,在为您的包选择名称时,请避免可能需要 导入重命名 或导致客户端遮蔽其他良好的变量名称的名称。
// Bad:
func LongFunction() {
url := "https://example.com/"
// Oops, now we can't use net/url in code below.
}
Go 包在 package
声明上指定了一个名称,与导入路径分开。 包名称对于可读性比路径更重要。
Go 包名称应该与 包提供的功能相关。 将包命名为 util
、helper
、common
或类似名称通常是一个糟糕的选择(但它可以作为名称的一部分使用)。 含义不明确的名称使代码更难阅读,如果使用范围太广,则容易导致不必要的 导入冲突。
相反,请考虑调用站点会是什么样子。
// Good:
db := spannertest.NewDatabaseFromFile(...)
_, err := f.Seek(0, io.SeekStart)
b := elliptic.Marshal(curve, x, y)
即使不知道导入列表(cloud.google.com/go/spanner/spannertest
、io
和 crypto/elliptic
),您也可以大致了解这些操作的功能。 使用不太集中的名称,这些可能读作
// Bad:
db := test.NewDatabaseFromFile(...)
_, err := f.Seek(0, common.SeekStart)
b := helper.Marshal(curve, x, y)
如果您正在问自己 Go 包应该有多大,以及是否将相关类型放在同一个包中或将它们拆分为不同的包,一个好的起点是关于包名称的 Go 博客文章。 尽管文章标题如此,但它不仅仅是关于命名的。 它包含一些有用的提示,并引用了几篇有用的文章和演讲。
以下是一些其他注意事项和注释。
用户在一个页面上看到包的 godoc,并且由包提供的类型导出的任何方法都按其类型分组。 Godoc 还将构造函数与其返回的类型分组在一起。 如果客户端代码 可能需要两种不同类型的值来相互交互,那么用户可以将它们放在同一个包中可能会很方便。
包中的代码可以访问包中未导出的标识符。 如果您有一些相关的类型,它们的 实现 紧密耦合,那么将它们放在同一个包中可以让您在不使用这些细节污染公共 API 的情况下实现这种耦合。 这种耦合的一个很好的测试是想象两个包的假设用户,这些包涵盖紧密相关的主题:如果用户必须导入这两个包才能以任何有意义的方式使用任何一个包,那么将它们组合在一起通常是正确的做法。 标准库通常很好地展示了这种范围界定和分层。
话虽如此,将整个项目放在一个包中可能会使该包太大。 当某些东西在概念上不同时,给它自己的小包可以使其更易于使用。 客户端已知的包的短名称与导出的类型名称一起工作,以创建一个有意义的标识符:例如 bytes.Buffer
、ring.New
。包名称博客文章 有更多示例。
Go 风格在文件大小方面具有灵活性,因为维护人员可以在包中将代码从一个文件移动到另一个文件,而不会影响调用者。 但作为一般准则:通常不建议拥有包含数千行代码的单个文件,或拥有许多小文件。 与某些其他语言一样,没有“一个类型,一个文件”的约定。 根据经验,文件应足够集中,以至于维护人员可以知道哪个文件包含某些内容,并且文件应足够小,以便一旦存在即可轻松找到。 标准库通常将大型包拆分为多个源文件,按文件对相关代码进行分组。package bytes
的源代码就是一个很好的例子。 具有长包文档的包可以选择专门使用一个名为 doc.go
的文件,该文件具有 包文档、包声明,仅此而已,但这不是必需的。
在 Google 代码库和使用 Bazel 的项目中,Go 代码的目录布局与开源 Go 项目不同:您可以在单个目录中拥有多个 go_library
目标。如果期望将来开源项目,那么为每个包提供自己的目录是个好主意。
以下是一些非规范的参考示例,以帮助演示这些想法的实际应用
包含一个有凝聚力的想法的小型软件包,不需要添加任何内容,也不需要删除任何内容
csv
: CSV 数据编码和解码,职责分别在 reader.go 和 writer.go 之间分配。expvar
: 完全包含在 expvar.go 中的 whitebox 程序遥测。包含一个大型领域及其多个职责的中等大小的软件包
flag
: 命令行标志管理,完全包含在 flag.go 中。将几个密切相关的领域划分到多个文件中的大型软件包
http
: HTTP 的核心:client.go,支持 HTTP 客户端;server.go,支持 HTTP 服务器;cookie.go,cookie 管理。os
: 跨平台操作系统抽象:exec.go,子进程管理;file.go,文件管理;tempfile.go,临时文件。另请参阅
由于其跨语言特性,Proto 库导入的处理方式与标准 Go 导入不同。重命名的 proto 导入的约定基于生成包的规则
pb
后缀通常用于 go_proto_library
规则。grpc
后缀通常用于 go_grpc_library
规则。通常使用描述包的单个单词
// Good:
import (
foopb "path/to/package/foo_service_go_proto"
foogrpc "path/to/package/foo_service_go_grpc"
)
遵循 包名 的样式指南。 首选完整的单词。 短名称很好,但要避免歧义。 如果不确定,请使用 proto 包名称,最多到 _go,并加上 pb 后缀
// Good:
import (
pushqueueservicepb "path/to/package/push_queue_service_go_proto"
)
注意: 以前的指南鼓励使用非常短的名称,例如“xpb”甚至只是“pb”。 新代码应首选更具描述性的名称。 使用短名称的现有代码不应作为示例使用,但无需更改。
导入通常分为以下两个(或更多)块,按顺序排列
"fmt"
)fpb "path/to/foo_go_proto"
)_ "path/to/package"
)如果文件没有上述可选类别之一的组,则相关导入将包含在项目导入组中。
任何清晰易懂的导入分组通常都可以。 例如,团队可以选择将 gRPC 导入与 protobuf 导入分开分组。
注意: 对于仅维护两个强制组(标准库一个组,所有其他导入一个组)的代码,
goimports
工具生成的输出与本指南一致。但是,
goimports
不知道强制组之外的组; 可选组容易被该工具失效。 当使用可选组时,作者和审阅者都需要注意以确保分组保持合规。两种方法都可以,但不要将 imports 部分置于不一致的、部分分组的状态。
在 Go 中,错误是值; 它们由代码创建,由代码使用。 错误可以是
错误消息也会出现在各种不同的界面上,包括日志消息、错误转储和渲染的 UI。
处理(产生或使用)错误的代码应该有意识地进行。 很容易忽略或盲目地传播错误返回值。 但是,始终值得考虑调用帧中的当前函数是否能够最有效地处理错误。 这是一个很大的主题,很难给出分类建议。 运用你的判断力,但请记住以下几点
虽然通常忽略错误是不合适的,但一个合理的例外是协调相关操作时,通常只有第一个错误才有用。 包 errgroup
为一组可能全部失败或作为一组取消的操作提供了一个方便的抽象。
另请参阅
errors
upspin.io/errors
如果调用者需要询问错误(例如,区分不同的错误条件),请为错误值赋予结构,以便可以以编程方式完成,而不是让调用者执行字符串匹配。 此建议适用于生产代码以及关心不同错误条件的测试。
最简单的结构化错误是未参数化的全局值。
type Animal string
var (
// ErrDuplicate occurs if this animal has already been seen.
ErrDuplicate = errors.New("duplicate")
// ErrMarsupial occurs because we're allergic to marsupials outside Australia.
// Sorry.
ErrMarsupial = errors.New("marsupials are not supported")
)
func process(animal Animal) error {
switch {
case seen[animal]:
return ErrDuplicate
case marsupial(animal):
return ErrMarsupial
}
seen[animal] = true
// ...
return nil
}
调用者可以简单地将函数的返回错误值与已知错误值之一进行比较
// Good:
func handlePet(...) {
switch err := process(an); err {
case ErrDuplicate:
return fmt.Errorf("feed %q: %v", an, err)
case ErrMarsupial:
// Try to recover with a friend instead.
alternate = an.BackupAnimal()
return handlePet(..., alternate, ...)
}
}
以上使用 sentinel 值,其中错误必须等于(在 ==
的意义上)预期值。 这在许多情况下是完全足够的。 如果 process
返回包装的错误(如下所述),您可以使用 errors.Is
。
// Good:
func handlePet(...) {
switch err := process(an); {
case errors.Is(err, ErrDuplicate):
return fmt.Errorf("feed %q: %v", an, err)
case errors.Is(err, ErrMarsupial):
// ...
}
}
不要尝试根据错误的字符串形式来区分错误。 (有关更多信息,请参阅 Go Tip #13:设计用于检查的错误。)
// Bad:
func handlePet(...) {
err := process(an)
if regexp.MatchString(`duplicate`, err.Error()) {...}
if regexp.MatchString(`marsupial`, err.Error()) {...}
}
如果错误中有调用者以编程方式需要的额外信息,则理想情况下应该以结构化方式呈现。 例如,os.PathError
类型被记录为将失败操作的路径名放置在结构字段中,调用者可以轻松访问该字段。
可以根据需要使用其他错误结构,例如包含错误代码和详细信息字符串的项目结构。 Package status
是一种常见的封装; 如果你选择这种方法(你没有义务这样做),请使用规范代码。 请参阅 Go Tip #89:何时使用规范状态代码作为错误,以了解使用状态代码是否是正确的选择。
任何返回错误的函数都应努力使错误值有用。 通常,该函数位于调用链的中间,并且仅仅是从它调用的某个其他函数(甚至可能来自另一个包)传播错误。 在这里,有机会使用额外信息注释错误,但程序员应确保错误中包含足够的信息,而无需添加重复或不相关的细节。 如果你不确定,请在开发过程中触发错误条件:这是评估错误的观察者(无论是人还是代码)最终将获得什么的好方法。
约定和良好的文档有所帮助。 例如,标准包 os
声明其错误在可用时包含路径信息。 这是一种有用的风格,因为返回错误的调用者不需要使用他们已经提供给失败函数的信息来注释它。
// Good:
if err := os.Open("settings.txt"); err != nil {
return err
}
// Output:
//
// open settings.txt: no such file or directory
如果有一些关于错误的含义的有趣的内容,当然可以添加。 只要考虑调用链的哪个级别最适合理解此含义。
// Good:
if err := os.Open("settings.txt"); err != nil {
// We convey the significance of this error to us. Note that the current
// function might perform more than one file operation that can fail, so
// these annotations can also serve to disambiguate to the caller what went
// wrong.
return fmt.Errorf("launch codes unavailable: %v", err)
}
// Output:
//
// launch codes unavailable: open settings.txt: no such file or directory
与此处的冗余信息形成对比
// Bad:
if err := os.Open("settings.txt"); err != nil {
return fmt.Errorf("could not open settings.txt: %w", err)
}
// Output:
//
// could not open settings.txt: open settings.txt: no such file or directory
向传播的错误添加信息时,您可以包装错误或呈现新的错误。 使用 fmt.Errorf
中的 %w
动词包装错误允许调用者访问原始错误中的数据。 这有时非常有用,但在其他情况下,这些细节具有误导性或对调用者不感兴趣。 有关更多信息,请参阅关于错误包装的博客文章。 包装错误还以不明显的方式扩展了包的 API 表面,如果更改包的实现细节,这可能会导致中断。
最好避免使用 %w
,除非您还记录(并进行验证)您暴露的底层错误。 如果您不希望调用者调用 errors.Unwrap
、errors.Is
等,请不要使用 %w
。
相同的概念适用于结构化错误,例如 *status.Status
(参见 规范代码)。 例如,如果你的服务器将格式错误的请求发送到后端并收到 InvalidArgument
代码,则不应将此代码传播到客户端,假设客户端没有做错任何事情。 而是向客户端返回一个 Internal
规范代码。
但是,注释错误有助于自动化日志记录系统保留错误的 Status Payload。 例如,在内部函数中注释错误是合适的
// Good:
func (s *Server) internalFunction(ctx context.Context) error {
// ...
if err != nil {
return fmt.Errorf("couldn't find remote file: %w", err)
}
}
直接位于系统边界(通常是 RPC、IPC、存储和类似)的代码应使用规范错误空间报告错误。 此处的代码有责任处理特定于域的错误并以规范方式表示它们。 例如
// Bad:
func (*FortuneTeller) SuggestFortune(context.Context, *pb.SuggestionRequest) (*pb.SuggestionResponse, error) {
// ...
if err != nil {
return nil, fmt.Errorf("couldn't find remote file: %w", err)
}
}
// Good:
import (
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func (*FortuneTeller) SuggestFortune(context.Context, *pb.SuggestionRequest) (*pb.SuggestionResponse, error) {
// ...
if err != nil {
// Or use fmt.Errorf with the %w verb if deliberately wrapping an
// error which the caller is meant to unwrap.
return nil, status.Errorf(codes.Internal, "couldn't find fortune database", status.ErrInternal)
}
}
另请参阅
建议将 %w
放在错误字符串的末尾。
可以使用 %w
动词包装错误,或者将其放置在实现 Unwrap() error
的结构化错误中(例如:fs.PathError
)。
被包装的错误会形成错误链:每一层新的包装都会在错误链的前面添加一个新的条目。可以使用 Unwrap() error
方法遍历错误链。例如:
err1 := fmt.Errorf("err1")
err2 := fmt.Errorf("err2: %w", err1)
err3 := fmt.Errorf("err3: %w", err2)
这会形成以下形式的错误链:
flowchart LR
err3 == err3 wraps err2 ==> err2;
err2 == err2 wraps err1 ==> err1;
无论 %w
动词放置在哪里,返回的错误始终代表错误链的前端,而 %w
是下一个子项。类似地,Unwrap() error
始终从最新到最旧的错误遍历错误链。
然而,%w
动词的位置确实会影响错误链的打印顺序,是从最新到最旧,还是从最旧到最新,或者都不是。
// Good:
err1 := fmt.Errorf("err1")
err2 := fmt.Errorf("err2: %w", err1)
err3 := fmt.Errorf("err3: %w", err2)
fmt.Println(err3) // err3: err2: err1
// err3 is a newest-to-oldest error chain, that prints newest-to-oldest.
// Bad:
err1 := fmt.Errorf("err1")
err2 := fmt.Errorf("%w: err2", err1)
err3 := fmt.Errorf("%w: err3", err2)
fmt.Println(err3) // err1: err2: err3
// err3 is a newest-to-oldest error chain, that prints oldest-to-newest.
// Bad:
err1 := fmt.Errorf("err1")
err2 := fmt.Errorf("err2-1 %w err2-2", err1)
err3 := fmt.Errorf("err3-1 %w err3-2", err2)
fmt.Println(err3) // err3-1 err2-1 err1 err2-2 err3-2
// err3 is a newest-to-oldest error chain, that neither prints newest-to-oldest
// nor oldest-to-newest.
因此,为了使错误文本反映错误链的结构,建议将 %w
动词放在末尾,形式为 [...]: %w
。
函数有时需要将错误告知外部系统,而无需将其传播给调用者。在这里,日志记录是一个明显的选择;但要注意记录错误的内容和方式。
像 良好的测试失败消息一样,日志消息应清楚地表达出了什么问题,并通过包含相关信息来帮助维护人员诊断问题。
避免重复。如果您返回一个错误,通常最好不要自己记录它,而是让调用者处理它。调用者可以选择记录错误,或者使用 rate.Sometimes
限制日志记录的频率。其他选项包括尝试恢复,甚至 停止程序。在任何情况下,让调用者控制有助于避免日志垃圾。
然而,这种方法的缺点是,任何日志都是使用调用者的行坐标编写的。
小心 PII。许多日志接收器不适合存放敏感的最终用户信息。
谨慎使用 log.Error
。ERROR 级别的日志记录会导致刷新,并且比更低的日志级别更昂贵。这可能会对您的代码产生严重的性能影响。在确定错误级别和警告级别时,请考虑最佳实践,即错误级别的消息应该是可操作的,而不是比警告“更严重”。
在 Google 内部,我们有监控系统,可以设置用于比写入日志文件并希望有人注意到它更有效的警报。这类似于标准库 包 expvar
,但并不完全相同。
充分利用详细日志记录 (log.V
)。详细日志记录对于开发和跟踪很有用。围绕详细级别建立约定可能会有所帮助。例如:
V(1)
处编写少量额外信息V(2)
中跟踪更多信息V(3)
中转储大型内部状态为了最大限度地降低详细日志记录的成本,您应确保即使在 log.V
关闭时,也不会意外调用昂贵的函数。log.V
提供了两个 API。更方便的一个存在意外支出的风险。如有疑问,请使用稍微更详细的样式。
// Good:
for _, sql := range queries {
log.V(1).Infof("Handling %v", sql)
if log.V(2) {
log.Infof("Handling %v", sql.Explain())
}
sql.Run(...)
}
// Bad:
// sql.Explain called even when this log is not printed.
log.V(2).Infof("Handling %v", sql.Explain())
程序初始化错误(例如错误的标志和配置)应向上传播到 main
,该函数应使用解释如何修复错误的错误来调用 log.Exit
。在这些情况下,通常不应使用 log.Fatal
,因为指向检查的堆栈跟踪不太可能像人工生成的、可操作的消息那样有用。
如反对 panic 的决策中所述,标准错误处理应围绕错误返回值构建。库应首选向调用者返回错误,而不是中止程序,尤其是对于瞬态错误。
有时需要对不变量执行一致性检查,并在违反时终止程序。一般来说,只有当不变量检查失败意味着内部状态已变得无法恢复时,才会这样做。在 Google 代码库中执行此操作最可靠的方法是调用 log.Fatal
。在这些情况下使用 panic
是不可靠的,因为延迟的函数可能会死锁或进一步破坏内部或外部状态。
同样,抵制恢复 panic 以避免崩溃的诱惑,因为这样做可能导致传播损坏的状态。您距离 panic 越远,您对程序状态的了解就越少,程序可能持有锁或其他资源。然后,程序可能会出现其他意外的故障模式,这会使问题更难以诊断。不要试图在代码中处理意外的 panic,而是使用监控工具来显示意外的故障,并以高优先级修复相关的错误。
注意:标准 net/http
server 违反了此建议,并从请求处理程序中恢复了 panic。经验丰富的 Go 工程师普遍认为这是一个历史性的错误。如果您从其他语言的应用程序服务器中采样服务器日志,通常会发现大量未处理的堆栈跟踪。避免在您的服务器中出现此陷阱。
标准库会在 API 误用时发生 panic。例如,reflect
在许多情况下会发出 panic,即以表明该值被误解的方式访问该值。这类似于核心语言错误(例如访问超出切片范围的元素)的 panic。代码审查和测试应发现此类错误,这些错误预计不会出现在生产代码中。这些 panic 充当不依赖于库的不变量检查,因为标准库无权访问 Google 代码库使用的分级 log
包。
panic 另一个有用但不太常见的情况是作为包的内部实现细节,该包始终在调用链中具有匹配的 recover。解析器和类似的深度嵌套、紧密耦合的内部函数组可以从此设计中受益,在这种设计中,管道错误返回会增加复杂性而没有价值。
此设计的关键属性是,这些 panic 永远不允许逃脱包边界,并且不构成包 API 的一部分。这通常通过顶级延迟函数完成,该函数使用 recover
将传播的 panic 转换为公共 API 边界处返回的错误。它要求发生 panic 和恢复的代码区分代码本身引发的 panic 和未引发的 panic。
// Good:
type syntaxError struct {
msg string
}
func parseInt(in string) int {
n, err := strconv.Atoi(in)
if err != nil {
panic(&syntaxError{"not a valid integer"})
}
}
func Parse(in string) (_ *Node, err error) {
defer func() {
if p := recover(); p != nil {
sErr, ok := p.(*syntaxError)
if !ok {
panic(p) // Propagate the panic since it is outside our code's domain.
}
err = fmt.Errorf("syntax error: %v", sErr.msg)
}
}()
... // Parse input calling parseInt internally to parse integers
}
警告:采用此模式的代码必须注意管理与此类延迟管理部分中运行的代码关联的任何资源(例如,关闭、释放或解锁)。
当编译器无法识别无法访问的代码时,也会使用 Panic,例如,当使用像 log.Fatal
这样不会返回的函数时
// Good:
func answer(i int) string {
switch i {
case 42:
return "yup"
case 54:
return "base 13, huh"
default:
log.Fatalf("Sorry, %d is not the answer.", i)
panic("unreachable")
}
}
在解析标志之前,不要调用 log
函数。如果您必须在包初始化函数 (init
或 “must” 函数) 中死亡,则可以使用 panic 代替致命日志调用。
另请参阅
本节补充了决策文档的评论部分。
以熟悉的风格记录的 Go 代码比记录不当或根本没有记录的代码更容易阅读,并且不太可能被误用。可运行的示例出现在 Godoc 和代码搜索中,是解释如何使用您的代码的绝佳方式。
并非每个参数都必须在文档中枚举。这适用于
记录容易出错或不明显的字段和参数,说明它们为什么有趣。
在以下代码段中,突出显示的注释为读者添加的信息很少有用
// Bad:
// Sprintf formats according to a format specifier and returns the resulting
// string.
//
// format is the format, and data is the interpolation data.
func Sprintf(format string, data ...any) string
但是,此代码段演示了一个类似于前一个代码的代码场景,其中的注释改为陈述了一些不明显或对读者有实质性帮助的内容
// Good:
// Sprintf formats according to a format specifier and returns the resulting
// string.
//
// The provided data is used to interpolate the format string. If the data does
// not match the expected format verbs or the amount of data does not satisfy
// the format specification, the function will inline warnings about formatting
// errors into the output string as described by the Format errors section
// above.
func Sprintf(format string, data ...any) string
在选择要记录的内容和深度时,请考虑您的潜在受众。维护人员、团队新手、外部用户,甚至六个月后的您自己,可能更喜欢与您第一次编写文档时所想到的信息略有不同的信息。
另请参阅
这意味着取消上下文参数会中断提供给它的函数。如果函数可以返回错误,则通常为 ctx.Err()
。
不需要重述此事实
// Bad:
// Run executes the worker's run loop.
//
// The method will process work until the context is cancelled and accordingly
// returns an error.
func (Worker) Run(ctx context.Context) error
因为这是暗示的,所以以下内容更好
// Good:
// Run executes the worker's run loop.
func (Worker) Run(ctx context.Context) error
如果以下任何一项为真,则应明确记录上下文行为不同或不明显的地方。
当上下文被取消时,函数返回的错误不是 ctx.Err()
// Good:
// Run executes the worker's run loop.
//
// If the context is cancelled, Run returns a nil error.
func (Worker) Run(ctx context.Context) error
该函数有其他可能会中断它或影响生命周期的机制
// Good:
// Run executes the worker's run loop.
//
// Run processes work until the context is cancelled or Stop is called.
// Context cancellation is handled asynchronously internally: run may return
// before all work has stopped. The Stop method is synchronous and waits
// until all operations from the run loop finish. Use Stop for graceful
// shutdown.
func (Worker) Run(ctx context.Context) error
func (Worker) Stop()
该函数对上下文生命周期、沿袭或附加值有特殊期望
// Good:
// NewReceiver starts receiving messages sent to the specified queue.
// The context should not have a deadline.
func NewReceiver(ctx context.Context) *Receiver
// Principal returns a human-readable name of the party who made the call.
// The context must have a value attached to it from security.NewContext.
func Principal(ctx context.Context) (name string, ok bool)
警告:避免设计对此类要求(如上下文没有截止日期)的 API。以上只是一个说明如何记录此内容的示例(如果无法避免),而不是对该模式的认可。
Go 用户假设概念上只读的操作对于并发使用是安全的,并且不需要额外的同步。
可以在此 Godoc 中安全地删除关于并发的额外说明
// Len returns the number of bytes of the unread portion of the buffer;
// b.Len() == len(b.Bytes()).
//
// It is safe to be called concurrently by multiple goroutines.
func (*Buffer) Len() int
但是,变异操作假定对于并发使用是不安全的,并且要求用户考虑同步。
类似地,关于并发的额外说明可以安全地在此处移除。
// Grow grows the buffer's capacity.
//
// It is not safe to be called concurrently by multiple goroutines.
func (*Buffer) Grow(n int)
如果以下任何一项为真,强烈建议编写文档。
不清楚操作是只读还是修改。
// Good:
package lrucache
// Lookup returns the data associated with the key from the cache.
//
// This operation is not safe for concurrent use.
func (*Cache) Lookup(key string) (data []byte, ok bool)
为什么?当查找键时,缓存命中会在内部修改 LRU 缓存。 这种实现方式可能对所有读者来说并不明显。
API 提供同步。
// Good:
package fortune_go_proto
// NewFortuneTellerClient returns an *rpc.Client for the FortuneTeller service.
// It is safe for simultaneous use by multiple goroutines.
func NewFortuneTellerClient(cc *rpc.ClientConn) *FortuneTellerClient
为什么?Stubby 提供同步。
注意:如果 API 是一种类型,并且该 API 完整地提供同步,则通常只有类型定义会记录语义。
API 使用用户实现的类型或接口,并且接口的使用者有特定的并发要求。
// Good:
package health
// A Watcher reports the health of some entity (usually a backend service).
//
// Watcher methods are safe for simultaneous use by multiple goroutines.
type Watcher interface {
// Watch sends true on the passed-in channel when the Watcher's
// status has changed.
Watch(changed chan<- bool) (unwatch func())
// Health returns nil if the entity being watched is healthy, or a
// non-nil error explaining why the entity is not healthy.
Health() error
}
为什么?API 是否可以安全地被多个 goroutine 使用是其约定的一部分。
记录 API 的任何显式清理要求。否则,调用者将无法正确使用 API,从而导致资源泄漏和其他可能的错误。
指出需要调用者进行清理的情况。
// Good:
// NewTicker returns a new Ticker containing a channel that will send the
// current time on the channel after each tick.
//
// Call Stop to release the Ticker's associated resources when done.
func NewTicker(d Duration) *Ticker
func (*Ticker) Stop()
如果可能不清楚如何清理资源,请解释如何操作。
// Good:
// Get issues a GET to the specified URL.
//
// When err is nil, resp always contains a non-nil resp.Body.
// Caller should close resp.Body when done reading from it.
//
// resp, err := http.Get("http://example.com/")
// if err != nil {
// // handle error
// }
// defer resp.Body.Close()
// body, err := io.ReadAll(resp.Body)
func (c *Client) Get(url string) (resp *Response, err error)
另请参阅
记录您的函数返回给调用者的重要错误哨兵值或错误类型,以便调用者可以预期他们可以在代码中处理哪些类型的条件。
// Good:
package os
// Read reads up to len(b) bytes from the File and stores them in b. It returns
// the number of bytes read and any error encountered.
//
// At end of file, Read returns 0, io.EOF.
func (*File) Read(b []byte) (n int, err error) {
当函数返回特定错误类型时,正确记录该错误是指针接收器还是非指针接收器。
// Good:
package os
type PathError struct {
Op string
Path string
Err error
}
// Chdir changes the current working directory to the named directory.
//
// If there is an error, it will be of type *PathError.
func Chdir(dir string) error {
记录返回值是否为指针接收器,使调用者可以使用 errors.Is
、errors.As
和 package cmp
正确比较错误。 这是因为非指针值不等同于指针值。
注意:在 Chdir
示例中,由于 nil 接口值的工作方式,返回类型写为 error
而不是 *PathError
。
当行为适用于包中的大多数错误时,在包的文档中记录总体错误约定。
// Good:
// Package os provides a platform-independent interface to operating system
// functionality.
//
// Often, more information is available within the error. For example, if a
// call that takes a file name fails, such as Open or Stat, the error will
// include the failing file name when printed and will be of type *PathError,
// which may be unpacked for more information.
package os
深思熟虑地应用这些方法可以在不费力的情况下为错误添加额外信息,并帮助调用者避免添加冗余注释。
另请参阅
Go 具有一个 文档服务器。建议在代码审查过程之前和期间预览代码生成的文档。 这有助于验证 godoc 格式是否正确呈现。
需要一个空行来分隔段落
// Good:
// LoadConfig reads a configuration out of the named file.
//
// See some/shortlink for config file format details.
测试文件可以包含可运行的示例,这些示例显示在 godoc 中相应的文档中。
// Good:
func ExampleConfig_WriteTo() {
cfg := &Config{
Name: "example",
}
if err := cfg.WriteTo(os.Stdout); err != nil {
log.Exitf("Failed to write config: %s", err)
}
// Output:
// {
// "name": "example"
// }
}
将行缩进额外的两个空格可以逐字格式化它们
// Good:
// Update runs the function in an atomic transaction.
//
// This is typically used with an anonymous TransactionFunc:
//
// if err := db.Update(func(state *State) { state.Foo = bar }); err != nil {
// //...
// }
但是请注意,将代码放在可运行的示例中,而不是将其包含在注释中,通常更合适。
这种逐字格式可用于格式化 godoc 本身不具备的格式,例如列表和表格
// Good:
// LoadConfig reads a configuration out of the named file.
//
// LoadConfig treats the following keys in special ways:
// "import" will make this configuration inherit from the named file.
// "env" if present will be populated with the system environment.
以大写字母开头、不包含标点符号(括号和逗号除外)且后跟另一段落的单行将格式化为标题
// Good:
// The following line is formatted as a heading.
//
// Using headings
//
// Headings come with autogenerated anchor tags for easy linking.
有时,一行代码看起来很常见,但实际上并非如此。 其中一个最好的例子是 err == nil
检查(因为 err != nil
更常见)。 以下两个条件检查很难区分
// Good:
if err := doSomething(); err != nil {
// ...
}
// Bad:
if err := doSomething(); err == nil {
// ...
}
您可以添加注释来“增强”条件的信号
// Good:
if err := doSomething(); err == nil { // if NO error
// ...
}
该注释引起了对条件差异的注意。
为了保持一致性,使用非零值初始化新变量时,最好使用 :=
而不是 var
。
// Good:
i := 42
// Bad:
var i = 42
以下声明使用零值
// Good:
var (
coords Point
magic [4]byte
primes []int
)
当您想要传达一个已准备好供以后使用的空值时,应使用零值声明值。 使用带有显式初始化的复合字面量可能会很笨拙
// Bad:
var (
coords = Point{X: 0, Y: 0}
magic = [4]byte{0, 0, 0, 0}
primes = []int(nil)
)
零值声明的一个常见应用是在反序列化时将变量用作输出
// Good:
var coords Point
if err := json.Unmarshal(data, &coords); err != nil {
当您需要指针类型的变量时,也可以使用以下形式的零值
// Good:
msg := new(pb.Bar) // or "&pb.Bar{}"
if err := proto.Unmarshal(data, msg); err != nil {
如果您的结构体中需要一个锁或其他不得复制的字段,您可以将其设为值类型以利用零值初始化。 这确实意味着包含类型现在必须通过指针传递,而不是值。 该类型上的方法必须采用指针接收器。
// Good:
type Counter struct {
// This field does not have to be "*sync.Mutex". However,
// users must now pass *Counter objects between themselves, not Counter.
mu sync.Mutex
data map[string]int64
}
// Note this must be a pointer receiver to prevent copying.
func (c *Counter) IncrementBy(name string, n int64)
即使组合(例如结构体和数组)的局部变量包含此类不可复制的字段,也可以使用值类型。 但是,如果该组合由函数返回,或者对其的所有访问最终都需要获取地址,则最好从一开始就将变量声明为指针类型。 同样,protobuf 应声明为指针类型。
// Good:
func NewCounter(name string) *Counter {
c := new(Counter) // "&Counter{}" is also fine.
registerCounter(name, c)
return c
}
var msg = new(pb.Bar) // or "&pb.Bar{}".
这是因为 *pb.Something
满足 proto.Message
,而 pb.Something
不满足。
// Bad:
func NewCounter(name string) *Counter {
var c Counter
registerCounter(name, &c)
return &c
}
var msg = pb.Bar{}
重要提示:必须先显式初始化 Map 类型,然后才能对其进行修改。 但是,从零值 Map 读取是完全可以的。
对于 Map 和 Slice 类型,如果代码对性能特别敏感,并且您提前知道大小,请参阅大小提示部分。
以下是 复合字面量 声明
// Good:
var (
coords = Point{X: x, Y: y}
magic = [4]byte{'I', 'W', 'A', 'D'}
primes = []int{2, 3, 5, 7, 11}
captains = map[string]string{"Kirk": "James Tiberius", "Picard": "Jean-Luc"}
)
当您知道初始元素或成员时,应使用复合字面量声明一个值。
相比之下,使用复合字面量声明空值或无成员值可能比零值初始化在视觉上更嘈杂。
当您需要指向零值的指针时,您有两个选择:空复合字面量和 new
。 两者都可以,但是 new
关键字可以提醒读者,如果需要非零值,则复合字面量将不起作用
// Good:
var (
buf = new(bytes.Buffer) // non-empty Buffers are initialized with constructors.
msg = new(pb.Message) // non-empty proto messages are initialized with builders or by setting fields one by one.
)
以下是利用大小提示来预分配容量的声明
// Good:
var (
// Preferred buffer size for target filesystem: st_blksize.
buf = make([]byte, 131072)
// Typically process up to 8-10 elements per run (16 is a safe assumption).
q = make([]Node, 0, 16)
// Each shard processes shardSize (typically 32000+) elements.
seen = make(map[string]bool, shardSize)
)
大小提示和预分配是与代码及其集成的经验分析相结合时,创建对性能敏感且资源高效的代码的重要步骤。
大多数代码不需要大小提示或预分配,并且可以允许运行时根据需要增长切片或 Map。 当最终大小已知时(例如,在 Map 和切片之间转换时),可以接受预分配,但这不是可读性要求,并且在小情况下可能不值得杂乱。
警告:预分配比您需要的更多的内存可能会浪费集群中的内存,甚至会损害性能。 如果有疑问,请参阅 GoTip #3:基准测试 Go 代码 并默认为 零初始化 或 复合字面量声明。
尽可能指定通道方向。
// Good:
// sum computes the sum of all of the values. It reads from the channel until
// the channel is closed.
func sum(values <-chan int) int {
// ...
}
这可以防止在没有指定的情况下可能发生的意外编程错误
// Bad:
func sum(values chan int) (out int) {
for v := range values {
out += v
}
// values must already be closed for this code to be reachable, which means
// a second close triggers a panic.
close(values)
}
当指定方向时,编译器会捕获这样的简单错误。 它还有助于向类型传达一定程度的所有权。
另请参阅 Bryan Mills 的演讲“重新思考经典并发模式”:幻灯片 视频。
不要让函数的签名变得太长。 随着越来越多的参数被添加到函数中,单个参数的角色变得不太清楚,并且相同类型的相邻参数更容易混淆。 具有大量参数的函数不太容易记住,并且在调用点更难以阅读。
在设计 API 时,请考虑将签名变得复杂的高度可配置函数拆分为几个更简单的函数。 如果需要,这些可以共享一个(未导出的)实现。
在函数需要许多输入的情况下,请考虑为某些参数引入 选项结构 或采用更高级的可变选项技术。 选择哪种策略的主要考虑因素应该是函数调用在所有预期用例中的外观。
以下建议主要适用于导出的 API,这些 API 的标准高于未导出的 API。 这些技术可能对您的用例不必要。 使用您的判断,并平衡清晰和最少机制的原则。
选项结构是一种结构体类型,它收集函数或方法的一些或全部参数,然后作为最后一个参数传递给函数或方法。 (该结构体只有在导出的函数中使用时才应导出。)
使用选项结构有很多好处
这是一个可以改进的函数示例
// Bad:
func EnableReplication(ctx context.Context, config *replicator.Config, primaryRegions, readonlyRegions []string, replicateExisting, overwritePolicies bool, replicationInterval time.Duration, copyWorkers int, healthWatcher health.Watcher) {
// ...
}
上面的函数可以使用选项结构重写如下
// Good:
type ReplicationOptions struct {
Config *replicator.Config
PrimaryRegions []string
ReadonlyRegions []string
ReplicateExisting bool
OverwritePolicies bool
ReplicationInterval time.Duration
CopyWorkers int
HealthWatcher health.Watcher
}
func EnableReplication(ctx context.Context, opts ReplicationOptions) {
// ...
}
然后可以在不同的包中调用该函数
// Good:
func foo(ctx context.Context) {
// Complex call:
storage.EnableReplication(ctx, storage.ReplicationOptions{
Config: config,
PrimaryRegions: []string{"us-east1", "us-central2", "us-west3"},
ReadonlyRegions: []string{"us-east5", "us-central6"},
OverwritePolicies: true,
ReplicationInterval: 1 * time.Hour,
CopyWorkers: 100,
HealthWatcher: watcher,
})
// Simple call:
storage.EnableReplication(ctx, storage.ReplicationOptions{
Config: config,
PrimaryRegions: []string{"us-east1", "us-central2", "us-west3"},
})
}
注意:上下文永远不会包含在选项结构中。
当以下某些情况适用时,通常首选此选项
使用可变选项,创建的导出函数会返回闭包,这些闭包可以传递给函数的可变参数 (...
)。该函数将其选项值(如果有)作为参数,并且返回的闭包接受一个可变引用(通常是指向结构体类型的指针),该引用将根据输入进行更新。
使用可变选项可以提供许多好处:
cartesian.Translate(dx, dy int) TransformOption
)。注意: 使用可变选项需要大量的额外代码(请参阅以下示例),因此仅应在优势超过开销时使用。
这是一个可以改进的函数示例
// Bad:
func EnableReplication(ctx context.Context, config *placer.Config, primaryCells, readonlyCells []string, replicateExisting, overwritePolicies bool, replicationInterval time.Duration, copyWorkers int, healthWatcher health.Watcher) {
...
}
上面的示例可以使用可变选项重写如下:
// Good:
type replicationOptions struct {
readonlyCells []string
replicateExisting bool
overwritePolicies bool
replicationInterval time.Duration
copyWorkers int
healthWatcher health.Watcher
}
// A ReplicationOption configures EnableReplication.
type ReplicationOption func(*replicationOptions)
// ReadonlyCells adds additional cells that should additionally
// contain read-only replicas of the data.
//
// Passing this option multiple times will add additional
// read-only cells.
//
// Default: none
func ReadonlyCells(cells ...string) ReplicationOption {
return func(opts *replicationOptions) {
opts.readonlyCells = append(opts.readonlyCells, cells...)
}
}
// ReplicateExisting controls whether files that already exist in the
// primary cells will be replicated. Otherwise, only newly-added
// files will be candidates for replication.
//
// Passing this option again will overwrite earlier values.
//
// Default: false
func ReplicateExisting(enabled bool) ReplicationOption {
return func(opts *replicationOptions) {
opts.replicateExisting = enabled
}
}
// ... other options ...
// DefaultReplicationOptions control the default values before
// applying options passed to EnableReplication.
var DefaultReplicationOptions = []ReplicationOption{
OverwritePolicies(true),
ReplicationInterval(12 * time.Hour),
CopyWorkers(10),
}
func EnableReplication(ctx context.Context, config *placer.Config, primaryCells []string, opts ...ReplicationOption) {
var options replicationOptions
for _, opt := range DefaultReplicationOptions {
opt(&options)
}
for _, opt := range opts {
opt(&options)
}
}
然后可以在不同的包中调用该函数
// Good:
func foo(ctx context.Context) {
// Complex call:
storage.EnableReplication(ctx, config, []string{"po", "is", "ea"},
storage.ReadonlyCells("ix", "gg"),
storage.OverwritePolicies(true),
storage.ReplicationInterval(1*time.Hour),
storage.CopyWorkers(100),
storage.HealthWatcher(watcher),
)
// Simple call:
storage.EnableReplication(ctx, config, []string{"po", "is", "ea"})
}
当以下许多条件适用时,首选此选项:
error
)。这种风格的选项应该接受参数,而不是使用存在来指示其值;后者会使参数的动态组合变得更加困难。例如,二进制设置应接受一个布尔值(例如,rpc.FailFast(enable bool)
比 rpc.EnableFailFast()
更可取)。枚举选项应接受一个枚举常量(例如,log.Format(log.Capacitor)
比 log.CapacitorFormat()
更可取)。另一种选择会使得必须以编程方式选择传递哪些选项的用户来说更加困难;这些用户被迫更改参数的实际组成,而不是简单地更改选项的参数。不要假设所有用户都知道所有的选项。
一般来说,选项应该按顺序处理。如果存在冲突或多次传递非累积选项,则最后一个参数应获胜。
在这种模式中,选项函数的参数通常是未导出的,以限制选项仅在包本身内部定义。这是一个很好的默认设置,尽管有时允许其他包定义选项是合适的。
有关如何使用这些选项的更深入了解,请参阅 Rob Pike 的原始博客文章 和 Dave Cheney 的演讲。
一些程序希望向用户呈现一个包含子命令的丰富命令行界面。例如,kubectl create
、kubectl run
和许多其他子命令都由程序 kubectl
提供。目前至少有以下库被普遍用于实现此目的。
如果您没有偏好或其它考虑因素相同,建议使用 subcommands,因为它最简单并且易于正确使用。但是,如果您需要它不提供的不同功能,请选择其他选项之一。
警告:cobra 命令函数应使用 cmd.Context()
来获取上下文,而不是使用 context.Background
创建自己的根上下文。使用 subcommands 包的代码已经作为函数参数接收到正确的上下文。
您不需要将每个子命令放在单独的包中,而且通常没有必要这样做。应用与任何 Go 代码库中相同的包边界注意事项。如果您的代码可以同时用作库和二进制文件,通常最好将 CLI 代码和库分开,使 CLI 只是其另一个客户端。(这并非特定于具有子命令的 CLI,但在此处提及是因为这是一个常见的出现的地方。)
Test
函数Go 区分“测试助手”和“断言助手”
测试助手是执行设置或清理任务的函数。测试助手中发生的所有失败都应被视为环境的失败(而不是来自被测代码的失败)——例如,由于计算机上没有更多可用端口而无法启动测试数据库。对于这些函数,调用 t.Helper
通常适合将它们标记为测试助手。有关更多详细信息,请参阅测试助手中错误处理。
断言助手是检查系统正确性并在未满足期望时使测试失败的函数。断言助手在 Go 中不被认为是惯用的。
测试的目的是报告被测代码的通过/失败情况。使测试失败的理想位置是在 Test
函数本身中,因为这确保了失败消息和测试逻辑清晰。
随着测试代码的增长,可能需要将某些功能分解为单独的函数。标准的软件工程注意事项仍然适用,因为测试代码仍然是代码。如果该功能不与测试框架交互,则所有常规规则都适用。但是,当公共代码与框架交互时,必须小心避免可能导致无信息失败消息和无法维护的测试的常见陷阱。
如果许多单独的测试用例需要相同的验证逻辑,请以下列方式之一安排测试,而不是使用断言助手或复杂的验证函数:
Test
函数中,即使它是重复的。这在简单情况下效果最佳。Test
中的验证和失败。error
),而不是采用 testing.T
参数并使用它来使测试失败。使用 Test
中的逻辑来决定是否失败,并提供有用的测试失败。您还可以创建测试助手来分解常见的样板设置代码。最后一点中概述的设计保持了正交性。例如,package cmp
的设计目的不是使测试失败,而是比较(和差异)值。因此,它不需要知道进行比较的上下文,因为调用者可以提供该上下文。如果您的通用测试代码为您的数据类型提供了 cmp.Transformer
,这通常是最简单的设计。对于其他验证,请考虑返回 error
值。
// Good:
// polygonCmp returns a cmp.Option that equates s2 geometry objects up to
// some small floating-point error.
func polygonCmp() cmp.Option {
return cmp.Options{
cmp.Transformer("polygon", func(p *s2.Polygon) []*s2.Loop { return p.Loops() }),
cmp.Transformer("loop", func(l *s2.Loop) []s2.Point { return l.Vertices() }),
cmpopts.EquateApprox(0.00000001, 0),
cmpopts.EquateEmpty(),
}
}
func TestFenceposts(t *testing.T) {
// This is a test for a fictional function, Fenceposts, which draws a fence
// around some Place object. The details are not important, except that
// the result is some object that has s2 geometry (github.com/golang/geo/s2)
got := Fencepost(tomsDiner, 1*meter)
if diff := cmp.Diff(want, got, polygonCmp()); diff != "" {
t.Errorf("Fencepost(tomsDiner, 1m) returned unexpected diff (-want+got):\n%v", diff)
}
}
func FuzzFencepost(f *testing.F) {
// Fuzz test (https://golang.ac.cn/doc/fuzz) for the same.
f.Add(tomsDiner, 1*meter)
f.Add(school, 3*meter)
f.Fuzz(func(t *testing.T, geo Place, padding Length) {
got := Fencepost(geo, padding)
// Simple reference implementation: not used in prod, but easy to
// reason about and therefore useful to check against in random tests.
reference := slowFencepost(geo, padding)
// In the fuzz test, inputs and outputs can be large so don't
// bother with printing a diff. cmp.Equal is enough.
if !cmp.Equal(got, reference, polygonCmp()) {
t.Errorf("Fencepost returned wrong placement")
}
})
}
polygonCmp
函数与其调用方式无关;它不接受具体的输入类型,也不强制规定在两个对象不匹配的情况下该怎么办。因此,更多的调用者可以使用它。
注意: 测试助手和普通库代码之间存在类比。库中的代码通常不应 panic,除非在极少数情况下;除非没有理由继续,否则从测试调用的代码不应停止测试。
样式指南中关于测试的大部分建议都与测试您自己的代码有关。本节介绍如何提供工具,供其他人测试他们编写的代码,以确保它符合您的库的要求。
这种测试称为验收测试。这种测试的前提是,使用测试的人并不了解测试中发生的每一个细节;他们只是将输入交给测试工具来完成工作。这可以被认为是控制反转的一种形式。
在典型的 Go 测试中,测试函数控制程序流程,并且no assert 和 test 函数指导原则鼓励您保持这种状态。本节介绍如何以与 Go 风格一致的方式编写对这些测试的支持。
在深入研究如何操作之前,请考虑 io/fs
中的一个示例,摘录如下:
type FS interface {
Open(name string) (File, error)
}
虽然存在 fs.FS
的众所周知的实现,但 Go 开发人员可能会被期望编写一个。为了帮助验证用户实现的 fs.FS
是否正确,在 testing/fstest
中提供了一个名为 fstest.TestFS
的通用库。此 API 将实现视为一个黑盒,以确保它维护 io/fs
契约的最基本部分。
现在我们知道了什么是验收测试以及为什么您可能会使用它,让我们来探讨如何为 package chess
构建验收测试,package chess
是一个用于模拟国际象棋游戏的包。chess
的用户应该实现 chess.Player
接口。这些实现是我们主要要验证的内容。我们的验收测试关注的是玩家的实现是否做出合法的移动,而不是移动是否明智。
为验证行为创建一个新包,按照惯例,通过将单词 test
附加到包名(例如,chesstest
)来命名该包。
通过接受被测实现作为参数并执行它来创建执行验证的函数。
// ExercisePlayer tests a Player implementation in a single turn on a board.
// The board itself is spot checked for sensibility and correctness.
//
// It returns a nil error if the player makes a correct move in the context
// of the provided board. Otherwise ExercisePlayer returns one of this
// package's errors to indicate how and why the player failed the
// validation.
func ExercisePlayer(b *chess.Board, p chess.Player) error
测试应记录哪些不变式被破坏以及如何被破坏。您的设计可以在两种报告故障的规则之间进行选择:
快速失败:一旦实现违反不变式,立即返回错误。
这是最简单的方法,如果期望验收测试能快速执行,那么这种方法效果很好。简单的错误哨兵和自定义类型可以很容易地在此处使用,反过来,这使得验收测试的测试变得容易。
for color, army := range b.Armies {
// The king should never leave the board, because the game ends at
// checkmate.
if army.King == nil {
return &MissingPieceError{Color: color, Piece: chess.King}
}
}
汇总所有失败:收集所有失败,并全部报告。
这种方法类似于继续进行的指导,如果预期验收测试执行缓慢,则可能更可取。
您应如何汇总失败应取决于您是否希望用户或您自己能够查询单个失败(例如,为了您测试您的验收测试)。 下面演示了使用一个自定义错误类型,该类型聚合错误
var badMoves []error
move := p.Move()
if putsOwnKingIntoCheck(b, move) {
badMoves = append(badMoves, PutsSelfIntoCheckError{Move: move})
}
if len(badMoves) > 0 {
return SimulationError{BadMoves: badMoves}
}
return nil
验收测试应遵循继续进行的指导,除非测试检测到正在运行的系统中存在损坏的不变量,否则不应调用t.Fatal
。
例如,t.Fatal
应像往常一样,仅用于设置失败等特殊情况
func ExerciseGame(t *testing.T, cfg *Config, p chess.Player) error {
t.Helper()
if cfg.Simulation == Modem {
conn, err := modempool.Allocate()
if err != nil {
t.Fatalf("No modem for the opponent could be provisioned: %v", err)
}
t.Cleanup(func() { modempool.Return(conn) })
}
// Run acceptance test (a whole game).
}
此技术可以帮助您创建简洁、规范的验证。但是,不要试图使用它来绕过关于断言的指导。
最终产品应采用与终端用户类似的格式
// Good:
package deepblue_test
import (
"chesstest"
"deepblue"
)
func TestAcceptance(t *testing.T) {
player := deepblue.New()
err := chesstest.ExerciseGame(t, chesstest.SimpleGame, player)
if err != nil {
t.Errorf("Deep Blue player failed acceptance test: %v", err)
}
}
在测试组件集成时,尤其是在 HTTP 或 RPC 用作组件之间底层传输的情况下,最好使用真实的底层传输连接到后端的测试版本。
例如,假设您要测试的代码(有时称为“被测系统”或 SUT)与实现长时间运行操作 API 的后端进行交互。 要测试您的 SUT,请使用一个真实的 OperationsClient,该客户端连接到 测试替身(例如,OperationsServer 的 mock、桩或 fake)。
由于正确模仿客户端行为的复杂性,因此建议不要手动实现客户端。 通过使用带有测试专用服务器的生产客户端,您可以确保测试尽可能多地使用真实代码。
提示: 在可能的情况下,使用被测服务作者提供的测试库。
t.Error
与 t.Fatal
正如在决策中讨论的那样,测试通常不应在遇到第一个问题时中止。
但是,某些情况下要求测试不要继续进行。 当某些测试设置失败时,尤其是在测试设置助手中,调用t.Fatal
是合适的,如果没有它们,您将无法运行其余的测试。 在表驱动的测试中,t.Fatal
适用于在测试循环之前设置整个测试功能的失败。 影响测试表中单个条目的失败,使得无法继续该条目,应按以下方式报告
t.Run
子测试,请使用t.Error
,然后使用continue
语句来转到下一个表条目。t.Run
内部),请使用t.Fatal
,它将结束当前的子测试,并允许您的测试用例继续进行到下一个子测试。警告: 调用t.Fatal
和类似函数并不总是安全的。 更多详细信息请参见此处。
注意: 本节讨论 Go 使用的测试助手的含义:执行测试设置和清理的函数,而不是常见的断言工具。 有关更多讨论,请参见测试函数部分。
测试助手执行的操作有时会失败。 例如,设置包含文件的目录涉及 I/O,这可能会失败。 当测试助手失败时,它们的失败通常表示测试无法继续,因为设置先决条件失败了。 发生这种情况时,最好在助手程序中调用Fatal
函数之一
// Good:
func mustAddGameAssets(t *testing.T, dir string) {
t.Helper()
if err := os.WriteFile(path.Join(dir, "pak0.pak"), pak0, 0644); err != nil {
t.Fatalf("Setup failed: could not write pak0 asset: %v", err)
}
if err := os.WriteFile(path.Join(dir, "pak1.pak"), pak1, 0644); err != nil {
t.Fatalf("Setup failed: could not write pak1 asset: %v", err)
}
}
这使调用方比助手将错误返回给测试本身更干净
// Bad:
func addGameAssets(t *testing.T, dir string) error {
t.Helper()
if err := os.WriteFile(path.Join(d, "pak0.pak"), pak0, 0644); err != nil {
return err
}
if err := os.WriteFile(path.Join(d, "pak1.pak"), pak1, 0644); err != nil {
return err
}
return nil
}
警告: 调用t.Fatal
和类似函数并不总是安全的。 更多详细信息请参见此处。
失败消息应包括对发生情况的描述。 这很重要,因为您可能正在向许多用户提供测试 API,尤其是在助手程序中产生错误的步骤的数量增加时。 当测试失败时,用户应该知道在何处以及为什么。
提示: Go 1.14 引入了 t.Cleanup
函数,该函数可用于注册在测试完成时运行的清理函数。 该函数也适用于测试助手。 有关简化测试助手的指导,请参见GoTip #4:清理您的测试。
下面在名为 paint_test.go
的虚构文件中的代码段演示了 (*testing.T).Helper
如何影响 Go 测试中的失败报告
package paint_test
import (
"fmt"
"testing"
)
func paint(color string) error {
return fmt.Errorf("no %q paint today", color)
}
func badSetup(t *testing.T) {
// This should call t.Helper, but doesn't.
if err := paint("taupe"); err != nil {
t.Fatalf("Could not paint the house under test: %v", err) // line 15
}
}
func mustGoodSetup(t *testing.T) {
t.Helper()
if err := paint("lilac"); err != nil {
t.Fatalf("Could not paint the house under test: %v", err)
}
}
func TestBad(t *testing.T) {
badSetup(t)
// ...
}
func TestGood(t *testing.T) {
mustGoodSetup(t) // line 32
// ...
}
这是运行时的此输出示例。 注意突出显示的文本以及它的不同之处
=== RUN TestBad
paint_test.go:15: Could not paint the house under test: no "taupe" paint today
--- FAIL: TestBad (0.00s)
=== RUN TestGood
paint_test.go:32: Could not paint the house under test: no "lilac" paint today
--- FAIL: TestGood (0.00s)
FAIL
paint_test.go:15
的错误是指 badSetup
中失败的设置函数的行
t.Fatalf("无法绘制正在测试的房屋:%v", err)
而 paint_test.go:32
指的是 TestGood
中失败的测试行
goodSetup(t)
正确使用 (*testing.T).Helper
可以更好地确定失败的位置
提示: 如果助手调用 (*testing.T).Error
或 (*testing.T).Fatal
,请在格式字符串中提供一些上下文,以帮助确定出错的原因和原因。
提示: 如果助手所做的任何事情都不会导致测试失败,则它无需调用 t.Helper
。 通过从函数参数列表中删除 t
来简化其签名。
t.Fatal
正如 在包测试中记录的那样,从除运行 Test 函数(或子测试)的 goroutine 之外的任何 goroutine 调用 t.FailNow
、t.Fatal
等都是不正确的。 如果您的测试启动了新的 goroutine,则它们不得从这些 goroutine 内部调用这些函数。
测试助手通常不会从新的 goroutine 发出失败信号,因此它们可以使用 t.Fatal
。 如果有疑问,请调用 t.Error
并返回。
// Good:
func TestRevEngine(t *testing.T) {
engine, err := Start()
if err != nil {
t.Fatalf("Engine failed to start: %v", err)
}
num := 11
var wg sync.WaitGroup
wg.Add(num)
for i := 0; i < num; i++ {
go func() {
defer wg.Done()
if err := engine.Vroom(); err != nil {
// This cannot be t.Fatalf.
t.Errorf("No vroom left on engine: %v", err)
return
}
if rpm := engine.Tachometer(); rpm > 1e6 {
t.Errorf("Inconceivable engine rate: %d", rpm)
}
}()
}
wg.Wait()
if seen := engine.NumVrooms(); seen != num {
t.Errorf("engine.NumVrooms() = %d, want %d", seen, num)
}
}
将 t.Parallel
添加到测试或子测试不会使调用 t.Fatal
变得不安全。
当对 testing
API 的所有调用都在测试函数中时,通常很容易发现不正确的用法,因为 go
关键字很容易看到。 传递 testing.T
参数使得跟踪此类用法更加困难。 通常,传递这些参数的原因是引入测试助手,而这些测试助手不应依赖于被测系统。 因此,如果测试助手注册致命测试失败,它可以并且应该从测试的 goroutine 中执行此操作。
在表驱动的测试中,最好在初始化测试用例结构字面量时指定字段名称。 当测试用例覆盖大量的垂直空间(例如,超过 20-30 行)、当存在具有相同类型的相邻字段时,以及当您希望省略具有零值的字段时,这很有帮助。 例如
// Good:
func TestStrJoin(t *testing.T) {
tests := []struct {
slice []string
separator string
skipEmpty bool
want string
}{
{
slice: []string{"a", "b", ""},
separator: ",",
want: "a,b,",
},
{
slice: []string{"a", "b", ""},
separator: ",",
skipEmpty: true,
want: "a,b",
},
// ...
}
// ...
}
在可能的情况下,资源和依赖项的设置应尽可能限定为特定的测试用例。 例如,给定一个设置函数
// mustLoadDataSet loads a data set for the tests.
//
// This example is very simple and easy to read. Often realistic setup is more
// complex, error-prone, and potentially slow.
func mustLoadDataset(t *testing.T) []byte {
t.Helper()
data, err := os.ReadFile("path/to/your/project/testdata/dataset")
if err != nil {
t.Fatalf("Could not load dataset: %v", err)
}
return data
}
在需要它的测试函数中显式调用 mustLoadDataset
// Good:
func TestParseData(t *testing.T) {
data := mustLoadDataset(t)
parsed, err := ParseData(data)
if err != nil {
t.Fatalf("Unexpected error parsing data: %v", err)
}
want := &DataTable{ /* ... */ }
if got := parsed; !cmp.Equal(got, want) {
t.Errorf("ParseData(data) = %v, want %v", got, want)
}
}
func TestListContents(t *testing.T) {
data := mustLoadDataset(t)
contents, err := ListContents(data)
if err != nil {
t.Fatalf("Unexpected error listing contents: %v", err)
}
want := []string{ /* ... */ }
if got := contents; !cmp.Equal(got, want) {
t.Errorf("ListContents(data) = %v, want %v", got, want)
}
}
func TestRegression682831(t *testing.T) {
if got, want := guessOS("zpc79.example.com"), "grhat"; got != want {
t.Errorf(`guessOS("zpc79.example.com") = %q, want %q`, got, want)
}
}
测试函数 TestRegression682831
不使用数据集,因此不调用 mustLoadDataset
,这可能很慢且容易出错
// Bad:
var dataset []byte
func TestParseData(t *testing.T) {
// As documented above without calling mustLoadDataset directly.
}
func TestListContents(t *testing.T) {
// As documented above without calling mustLoadDataset directly.
}
func TestRegression682831(t *testing.T) {
if got, want := guessOS("zpc79.example.com"), "grhat"; got != want {
t.Errorf(`guessOS("zpc79.example.com") = %q, want %q`, got, want)
}
}
func init() {
dataset = mustLoadDataset()
}
用户可能希望隔离于其他函数运行某个函数,因此不应受到这些因素的惩罚
# No reason for this to perform the expensive initialization.
$ go test -run TestRegression682831
TestMain
入口点如果包中的所有测试都需要通用设置,并且设置需要拆卸,则可以使用自定义 testmain 入口点。 如果测试用例所需的资源设置成本特别高,并且应该分摊成本,则可能会发生这种情况。 通常,您已经从测试套件中提取了任何不相关的测试。 它通常仅用于功能测试。
由于正确使用需要小心,因此使用自定义 TestMain
不应是您的首选。 首先考虑分摊常见测试设置部分中的解决方案或普通的测试助手是否足以满足您的需求。
// Good:
var db *sql.DB
func TestInsert(t *testing.T) { /* omitted */ }
func TestSelect(t *testing.T) { /* omitted */ }
func TestUpdate(t *testing.T) { /* omitted */ }
func TestDelete(t *testing.T) { /* omitted */ }
// runMain sets up the test dependencies and eventually executes the tests.
// It is defined as a separate function to enable the setup stages to clearly
// defer their teardown steps.
func runMain(ctx context.Context, m *testing.M) (code int, err error) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
d, err := setupDatabase(ctx)
if err != nil {
return 0, err
}
defer d.Close() // Expressly clean up database.
db = d // db is defined as a package-level variable.
// m.Run() executes the regular, user-defined test functions.
// Any defer statements that have been made will be run after m.Run()
// completes.
return m.Run(), nil
}
func TestMain(m *testing.M) {
code, err := runMain(context.Background(), m)
if err != nil {
// Failure messages should be written to STDERR, which log.Fatal uses.
log.Fatal(err)
}
// NOTE: defer statements do not run past here due to os.Exit
// terminating the process.
os.Exit(code)
}
理想情况下,测试用例在自身调用之间以及其他测试用例之间是封闭的。
至少,确保单个测试用例在修改任何全局状态后重置它们(例如,如果测试正在使用外部数据库)。
如果以下所有条件都适用于常见设置,则使用 sync.Once
可能是合适的,但不是必需的
// Good:
var dataset struct {
once sync.Once
data []byte
err error
}
func mustLoadDataset(t *testing.T) []byte {
t.Helper()
dataset.once.Do(func() {
data, err := os.ReadFile("path/to/your/project/testdata/dataset")
// dataset is defined as a package-level variable.
dataset.data = data
dataset.err = err
})
if err := dataset.err; err != nil {
t.Fatalf("Could not load dataset: %v", err)
}
return dataset.data
}
当在多个测试函数中使用 mustLoadDataset
时,其成本会被分摊
// Good:
func TestParseData(t *testing.T) {
data := mustLoadDataset(t)
// As documented above.
}
func TestListContents(t *testing.T) {
data := mustLoadDataset(t)
// As documented above.
}
func TestRegression682831(t *testing.T) {
if got, want := guessOS("zpc79.example.com"), "grhat"; got != want {
t.Errorf(`guessOS("zpc79.example.com") = %q, want %q`, got, want)
}
}
通用清理函数很棘手的原因是没有统一的地方来注册清理程序。如果设置函数(在本例中是 loadDataset
)依赖于上下文,那么 sync.Once
可能会有问题。这是因为对设置函数的两个竞争调用中的第二个调用需要等待第一个调用完成后才能返回。这段等待时间不能轻易地服从上下文的取消。
在 Go 中,有几种方法可以拼接字符串。一些例子包括
fmt.Sprintf
strings.Builder
text/template
safehtml/template
虽然没有适用于所有情况的规则来选择哪种方法,但以下指南概述了何时首选每种方法。
当拼接少量字符串时,首选使用 “+”。这种方法在语法上最简单,并且不需要导入任何包。
// Good:
key := "projectid: " + p
fmt.Sprintf
当构建带有格式的复杂字符串时,首选使用 fmt.Sprintf
。使用过多的 “+” 运算符可能会使最终结果变得模糊不清。
// Good:
str := fmt.Sprintf("%s [%s:%d]-> %s", src, qos, mtu, dst)
// Bad:
bad := src.String() + " [" + qos.String() + ":" + strconv.Itoa(mtu) + "]-> " + dst.String()
最佳实践: 当字符串构建操作的输出是 io.Writer
时,不要为了将它发送到 Writer 而使用 fmt.Sprintf
构建临时字符串。相反,使用 fmt.Fprintf
直接输出到 Writer。
当格式化更复杂时,根据需要首选 text/template
或 safehtml/template
。
strings.Builder
当逐段构建字符串时,首选使用 strings.Builder
。strings.Builder
采用分摊线性时间,而 “+” 和 fmt.Sprintf
在顺序调用以形成更大的字符串时,采用平方时间。
// Good:
b := new(strings.Builder)
for i, d := range digitsOfPi {
fmt.Fprintf(b, "the %d digit of pi is: %d\n", i, d)
}
str := b.String()
注意: 更多讨论,请参见 GoTip #29: Building Strings Efficiently。
在构建常量多行字符串字面量时,首选使用反引号 (`)。
// Good:
usage := `Usage:
custom_tool [args]`
// Bad:
usage := "" +
"Usage:\n" +
"\n" +
"custom_tool [args]"
库不应强迫其客户端使用依赖于 全局状态 的 API。建议他们不要公开 API 或导出 包级别 变量作为其 API 的一部分来控制所有客户端的行为。本节的其余部分将 “全局” 和 “包级别状态” 作为同义词使用。
相反,如果您的功能维护状态,请允许您的客户端创建和使用实例值。
重要提示: 虽然此指南适用于所有开发人员,但对于向其他团队提供库、集成和服务的基础设施提供商而言,这一点至关重要。
// Good:
// Package sidecar manages subprocesses that provide features for applications.
package sidecar
type Registry struct { plugins map[string]*Plugin }
func New() *Registry { return &Registry{plugins: make(map[string]*Plugin)} }
func (r *Registry) Register(name string, p *Plugin) error { ... }
您的用户将实例化他们需要的数据(一个 *sidecar.Registry
),然后将其作为显式依赖项传递
// Good:
package main
func main() {
sidecars := sidecar.New()
if err := sidecars.Register("Cloud Logger", cloudlogger.New()); err != nil {
log.Exitf("Could not setup cloud logger: %v", err)
}
cfg := &myapp.Config{Sidecars: sidecars}
myapp.Run(context.Background(), cfg)
}
有不同的方法可以将现有代码迁移到支持依赖项传递。您将使用的主要方法是将依赖项作为参数传递给调用链上的构造函数、函数、方法或结构字段。
另请参阅
随着客户端数量的增加,不支持显式依赖项传递的 API 会变得脆弱
// Bad:
package sidecar
var registry = make(map[string]*Plugin)
func Register(name string, p *Plugin) error { /* registers plugin in registry */ }
考虑一下在测试练习以传递方式依赖于 sidecar 进行云日志记录的代码时会发生什么。
// Bad:
package app
import (
"cloudlogger"
"sidecar"
"testing"
)
func TestEndToEnd(t *testing.T) {
// The system under test (SUT) relies on a sidecar for a production cloud
// logger already being registered.
... // Exercise SUT and check invariants.
}
func TestRegression_NetworkUnavailability(t *testing.T) {
// We had an outage because of a network partition that rendered the cloud
// logger inoperative, so we added a regression test to exercise the SUT with
// a test double that simulates network unavailability with the logger.
sidecar.Register("cloudlogger", cloudloggertest.UnavailableLogger)
... // Exercise SUT and check invariants.
}
func TestRegression_InvalidUser(t *testing.T) {
// The system under test (SUT) relies on a sidecar for a production cloud
// logger already being registered.
//
// Oops. cloudloggertest.UnavailableLogger is still registered from the
// previous test.
... // Exercise SUT and check invariants.
}
默认情况下,Go 测试是按顺序执行的,因此上面的测试按以下顺序运行:
TestEndToEnd
TestRegression_NetworkUnavailability
,它覆盖了 cloudlogger 的默认值TestRegression_InvalidUser
,它需要 package sidecar
中注册的 cloudlogger 的默认值这创建了一个与顺序相关的测试用例,这会破坏使用测试过滤器运行,并阻止测试并行运行或被分片。
使用全局状态会给您和 API 的客户端带来难以回答的问题
如果客户端需要在同一进程空间中使用不同且独立运行的 Plugin
集(例如,为了支持多个服务器),会发生什么?
如果客户端想要在测试中用替代实现(如 测试替身)替换已注册的 Plugin
,会发生什么?
如果客户端的测试需要 Plugin
实例之间或所有已注册插件之间的密封性,会发生什么?
如果多个客户端以相同的名称 Register
一个 Plugin
,会发生什么?哪个获胜,如果有的话?
应该如何 处理 错误?如果代码出现 panic 或调用 log.Fatal
,这是否总是 适合调用 API 的所有位置?客户端是否可以验证它在这样做之前没有做坏事?
在程序的启动阶段或生命周期中,Register
可以在哪些阶段被调用,又有哪些阶段不能被调用?
如果在错误的时间调用 Register
会发生什么?客户端可以在 func init
中、在解析标志之前或在 main
之后调用 Register
。调用函数的阶段会影响错误处理。如果 API 的作者假设 API *仅* 在程序初始化期间被调用,并且没有要求必须这样做,那么该假设可能会促使作者设计错误处理来 中止程序,从而将 API 建模为类似 Must
的函数。中止不适用于可以在任何阶段使用的一般用途的库函数。
如果客户端和设计者的并发需求不匹配,会发生什么?
另请参阅
全局状态对 Google 代码库的健康 有着级联效应。应该以极其谨慎的态度对待全局状态。
全局状态有几种形式,您可以使用一些 石蕊测试来识别何时安全。
下面列举了一些最常见的有问题 API 形式
顶层变量,无论它们是否被导出。
// Bad:
package logger
// Sinks manages the default output sources for this package's logging API. This
// variable should be set at package initialization time and never thereafter.
var Sinks []Sink
请参阅 石蕊测试,以了解何时这些是安全的。
回调和类似行为的注册表。
// Bad:
package health
var unhealthyFuncs []func
func OnUnhealthy(f func()) {
unhealthyFuncs = append(unhealthyFuncs, f)
}
用于后端、存储、数据访问层和其他系统资源的 Thick-Client 单例。这些通常会给服务可靠性带来额外的问题。
// Bad:
package useradmin
var client pb.UserAdminServiceClientInterface
func Client() *pb.UserAdminServiceClient {
if client == nil {
client = ... // Set up client.
}
return client
}
注意: Google 代码库中的许多遗留 API 并不遵循此指南;事实上,一些 Go 标准库允许通过全局值进行配置。然而,遗留 API 对本指南的违反 **不应被用作继续该模式的先例**。
现在投资于正确的 API 设计,胜过以后为重新设计付费。
当 使用上述模式的 API 不安全时
func init
,是否已经解析标志等。如果避免了上述情况,则在 **一些有限的情况下,这些 API 是安全的**,即当满足以下任一条件时
注意: Sidecar 进程 可能 **并非** 严格意义上的进程本地。它们可以并且经常与多个应用程序进程共享。此外,这些 sidecar 通常与外部分布式系统交互。
此外,除了上述基本考虑因素外,相同的无状态、幂等和本地规则也将适用于 sidecar 进程本身的代码!
这些安全情况的一个例子是 package image
及其 image.RegisterFormat
函数。考虑一下从上面应用于典型解码器的石蕊测试,例如用于处理 PNG 格式的解码器
package image
的 API 的多次调用(例如,image.Decode
)不能相互干扰,测试也是如此。唯一的例外是 image.RegisterFormat
,但这可以通过以下几点来缓解。虽然不建议这样做,但如果您需要最大限度地提高用户的便利性,则可以提供使用包级别状态的简化 API。
在这种情况下,请遵循 石蕊测试 和以下指南
http.Handle
在内部调用包变量 http.DefaultServeMux
上的 (*http.ServeMux).Handle
。这个包级别的 API 必须仅由 二进制构建目标 使用,而不是 库,除非库正在进行重构以支持依赖传递。可以被其他包导入的基础设施库不能依赖于它们所导入包的包级别状态。
例如,实现 sidecar 的基础设施提供商,如果想让其他团队使用顶层的 API 与之共享,则应提供一个 API 来适应这种情况。
// Good:
package cloudlogger
func New() *Logger { ... }
func Register(r *sidecar.Registry, l *Logger) {
r.Register("Cloud Logging", l)
}
另请参阅