Skip to Content
Go最佳实践

Go 风格最佳实践

https://google.github.io/styleguide/go/best-practices 

概述 | 指南 | 决策 | 最佳实践

注意: 这是概述 Google Go 风格的系列文档之一。本文档既非规范性的也非权威性的,是核心风格指南的辅助文档。更多信息请参见概述

关于

本文档记录了关于如何最佳应用 Go 风格指南的指导意见。这些指导意见旨在解决经常出现的常见情况,但可能并非适用于所有情况。在可能的情况下,会讨论多种替代方法,以及做出何时应用、何时不应用这些方法的考量因素。

参见概述获取完整的风格指南文档集。

命名

函数和方法命名

避免重复

在选择函数或方法的名称时,请考虑名称将在什么上下文中被阅读。请考虑以下建议,以避免在调用点产生过多的重复

  • 以下内容通常可以从函数和方法名中省略:

    • 输入和输出的类型(当没有冲突时)
    • 方法接收者(receiver)的类型
    • 输入或输出是否是指针
  • 对于函数,不要重复包名

    // 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)

测试替身(Test Double)和辅助包

命名为测试辅助程序和特别是测试替身(test double) 提供的包和类型时,有几个可以应用的规则。测试替身可以是存根(stub)、伪造(fake)、模拟(mock)或间谍(spy)。

这些示例大多使用存根(stub)。如果你的代码使用伪造(fake)或其他类型的测试替身,请相应更新名称。

假设你有一个提供生产代码的、功能聚焦的包,类似于此:

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 包含多个值得创建替身的类型,如下所示的 ServiceStoredValue

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) }

在测试中,一个名为 “spy” 的 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) } }

变量遮蔽(Shadowing)

注意: 本说明使用了两个非正式术语:覆盖(stomping)遮蔽(shadowing)。它们不是 Go 语言规范中的官方概念。

与许多编程语言一样,Go 有可变变量:对变量赋值会改变其值。

// Good: func abs(i int) int { if i < 0 { i *= -1 } return i }

使用 := 运算符的短变量声明 时,在某些情况下不会创建新变量。我们可以称之为覆盖(stomping)。当不再需要原始值时,这样做是可以的。

// 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. // ... }

但在新作用域中使用短变量声明时要小心:这会引入一个新变量。我们可以称之为遮蔽(shadowing) 原始变量。块结束后的代码引用的是原始变量。以下是一个有缺陷的、试图有条件缩短截止时间的尝试:

// 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") } // ... }

在我们称之为覆盖(stomping)的情况下,因为没有新变量,所以赋值的类型必须与原始变量的类型匹配。而对于遮蔽(shadowing),会引入一个全新的实体,因此它可以有不同的类型。有意的遮蔽可以是一种有用的做法,但如果能提高清晰性,你可以随时使用新名称。

在非常小的作用域之外,使用与标准包相同名称的变量不是一个好主意,因为这会使该包的自由函数和值变得不可访问。相反,在为包选择名称时,应避免可能需要导入重命名或在客户端导致遮蔽常用变量名的名称。

// Bad: func LongFunction() { url := "https://example.com/" // Oops, now we can't use net/url in code below. }

工具包(Util Packages)

Go 包有一个在 package 声明中指定的名称,它与导入路径是分开的。包名对可读性的影响比路径更大。

Go 包名应该与包提供的功能相关。将包仅命名为 utilhelpercommon 或类似名称通常是一个糟糕的选择(不过可以用作名称的一部分)。不提供信息的名称使代码更难阅读,如果使用过于广泛,还可能导致不必要的导入冲突

相反,请考虑调用点看起来会是什么样子。

// Good: db := spannertest.NewDatabaseFromFile(...) _, err := f.Seek(0, io.SeekStart) b := elliptic.Marshal(curve, x, y)

即使不知道导入列表(cloud.google.com/go/spanner/spannertestiocrypto/elliptic),你也能大致猜到每个调用的作用。而使用不太聚焦的名称时,可能会变成:

// Bad: db := test.NewDatabaseFromFile(...) _, err := f.Seek(0, common.SeekStart) b := helper.Marshal(curve, x, y)

包大小

如果你在思考 Go 包应该多大、是否应该将相关类型放在同一个包中还是拆分到不同包中,一个好的起点是 Go 博客关于包命名的文章 。尽管文章标题如此,但它不仅仅是关于命名的。它包含一些有用的提示,并引用了几篇有用的文章和演讲。

以下是一些其他考虑因素和注意事项。

用户在一个页面上看到包的 godoc ,包提供的类型的导出方法按类型分组。Godoc 还将构造函数与其返回的类型分组在一起。如果客户端代码可能需要使用不同类型的两个值相互交互,将它们放在同一个包中可能会方便用户。

包内的代码可以访问包中未导出的标识符。如果你有几个相关类型,它们的实现是紧密耦合的,将它们放在同一个包中可以实现这种耦合,而不会用这些细节污染公共 API。对这种耦合的一个好的测试是:想象一下假设有两个包的用户,这两个包涵盖了密切相关的主题——如果用户必须同时导入这两个包才能以任何有意义的方式使用其中一个,那么将它们合并在一起通常是正确的做法。标准库通常很好地展示了这种作用域划分和分层。

话虽如此,将整个项目放在一个包中可能会使该包过大。当某些东西在概念上是不同的时候,给它自己的小包可以使它更容易使用。客户端所知的包的短名称与导出的类型名称一起构成一个有意义的标识符:例如 bytes.Bufferring.New包命名博客文章 有更多示例。

Go 风格对文件大小很灵活,因为维护者可以将代码在包内的文件之间移动而不影响调用者。但作为一般准则:通常不应该有一个包含数千行的单一文件,或者有很多微小的文件。没有像某些其他语言那样的”一个类型、一个文件”的惯例。作为经验法则,文件应该足够聚焦,以便维护者能判断哪个文件包含什么内容,同时文件应该足够小,以便到达后容易找到。标准库经常将大型包拆分为多个源文件,按文件分组相关代码。bytes的源码就是一个好例子。具有较长包文档的包可以选择用一个名为 doc.go 的专用文件来放置包文档、包声明和其他什么都没有,但这不是必需的。

在 Google 代码库和使用 Bazel 的项目中,Go 代码的目录布局与开源 Go 项目不同:你可以在单个目录中拥有多个 go_library 目标。如果你预计将来会开源你的项目,那么给每个包一个自己的目录是一个好理由。

一些非权威的参考示例,用来帮助展示这些想法的实际应用:

另请参阅:

导入

Protocol Buffer 消息和存根

由于 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" )

遵循包名 的风格指导。优先使用完整单词。简短的名称是好的,但要避免歧义。如有疑问,请使用到 _go 为止的 proto 包名并加上 pb 后缀:

// Good: import ( pushqueueservicepb "path/to/package/push_queue_service_go_proto" )

注意: 先前的指导建议使用非常短的名称,如 “xpb” 甚至只是 “pb”。新代码应优先使用更具描述性的名称。使用短名称的现有代码不应被用作示例,但也不需要修改。

导入排序

参见 Go 风格决策:导入分组

错误处理

在 Go 中,错误是值 ;它们由代码创建并由代码消费。错误可以被:

  • 转换为供人类查看的诊断信息
  • 供维护者使用
  • 由终端用户解读

错误消息还会出现在各种不同的场景中,包括日志消息、错误转储和渲染的 UI。

处理(产生或消费)错误的代码应该有意识地这样做。忽略或盲目传播错误返回值可能很诱人。然而,总是值得考虑当前调用帧中的函数是否最适合处理该错误。这是一个很大的话题,很难给出分类建议。请使用你的判断,但请记住以下考虑因素:

  • 在创建错误值时,决定是否给它结构
  • 在处理错误时,考虑添加你拥有但调用者和/或被调用者可能没有的信息。
  • 另请参阅关于错误日志记录的指导。

虽然忽略错误通常是不合适的,但一个合理的例外是在编排相关操作时,通常只有第一个错误是有用的。errgroup 包为一组可以作为一个整体失败或取消的操作提供了方便的抽象。

另请参阅:

错误结构

如果调用者需要检查错误(例如,区分不同的错误条件),请给错误值结构,以便可以通过编程方式进行检查,而不是让调用者进行字符串匹配。此建议适用于生产代码以及关心不同错误条件的测试。

最简单的结构化错误是无参数的全局值。

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 value),其中错误必须等于(在 == 的意义上)预期值。在许多情况下这完全足够了。如果 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: Designing Errors for Checking 。)

// Bad: func handlePet(...) { err := process(an) if regexp.MatchString(`duplicate`, err.Error()) {...} if regexp.MatchString(`marsupial`, err.Error()) {...} }

如果错误中有调用者需要以编程方式获取的额外信息,理想情况下应以结构化方式呈现。例如,os.PathError 类型被文档记录为将失败操作的路径名放在一个结构体字段中,调用者可以轻松访问。

也可以根据需要使用其他错误结构,例如包含错误代码和详细字符串的项目结构体。status是一种常见的封装;如果你选择这种方法(这不是必须的),请使用规范代码 。请参见 Go Tip #89: When to Use Canonical Status Codes as Errors  了解使用状态码是否是正确的选择。

向错误添加信息

在向错误添加信息时,应避免底层错误已经提供的冗余信息。例如,os 包已经在其错误中包含了路径信息。

// Good: if err := os.Open("settings.txt"); err != nil { return fmt.Errorf("launch codes unavailable: %v", err) } // Output: // // launch codes unavailable: open settings.txt: no such file or directory

这里,“launch codes unavailable” 为 os.Open 错误添加了与当前函数上下文相关的特定含义,而没有重复底层的文件路径信息。

// Bad: if err := os.Open("settings.txt"); err != nil { return fmt.Errorf("could not open settings.txt: %v", err) } // Output: // // could not open settings.txt: open settings.txt: no such file or directory

如果注解的唯一目的是表示失败而没有添加新信息,则不要添加注解。错误的存在已经足以向调用者传达失败信息。

// Bad: return fmt.Errorf("failed: %v", err) // just return err instead

在使用 fmt.Errorf 包装错误时选择 %v 还是 %w 是一个细微的决定,它对错误在应用程序中的传播、处理、检查和记录方式有重大影响。核心原则是使错误值对其观察者有用,无论这些观察者是人还是代码。

  1. %v 用于简单注解或新错误

    %v 动词是你对任何 Go 值进行字符串格式化的通用工具,包括错误。当与 fmt.Errorf 一起使用时,它将错误的字符串表示(其 Error() 方法返回的内容)嵌入到一个新错误值中,丢弃原始错误的任何结构化信息。使用 %v 的示例:

    • 添加有趣的、非冗余的上下文:如上面的示例所示。

    • 记录或显示错误:当主要目标是在日志中或向用户呈现人类可读的错误消息,并且你不打算让调用者以编程方式 errors.Iserrors.As 该错误时(注意:这里通常不推荐使用 errors.Unwrap,因为它不处理多错误)。

    • 创建全新的、独立的错误:有时需要将错误转换为新的错误消息,从而隐藏原始错误的细节。这种做法在系统边界处特别有益,包括但不限于 RPC、IPC 和存储,在这些边界处我们将特定领域的错误转换为规范错误空间。

      // Good: func (*FortuneTeller) SuggestFortune(context.Context, *pb.SuggestionRequest) (*pb.SuggestionResponse, error) { // ... if err != nil { return nil, fmt.Errorf("couldn't find fortune database: %v", err) } }

      我们也可以在上面的示例中显式添加 RPC 代码 Internal

      // 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) } }
  2. %w(wrap)用于编程检查和错误链

    %w 动词专门设计用于错误包装。它创建一个新错误,提供 Unwrap() 方法,允许调用者使用 errors.Iserrors.As 以编程方式检查错误链。使用 %w 的示例:

    • 在保留原始错误以供编程检查的同时添加上下文:这是在应用程序辅助函数中的主要用例。你想用额外的上下文(例如,失败时正在执行什么操作)来丰富错误,但仍然允许调用者检查底层错误是否是特定的哨兵错误或类型。

      // Good: func (s *Server) internalFunction(ctx context.Context) error { // ... if err != nil { return fmt.Errorf("couldn't find remote file: %w", err) } }

      这允许更高层的函数执行 errors.Is(err, fs.ErrNotExist),即使底层错误 fs.ErrNotExist 已被包装。

      在你的系统与外部系统交互的点上,如 RPC、IPC 或存储,通常最好将特定领域的错误转换为标准化的错误空间(例如 gRPC 状态码),而不是简单地用 %w 包装原始的底层错误。客户端通常不关心确切的内部文件系统错误;他们关心的是规范结果(例如 InternalNotFoundPermissionDenied)。

    • 当你明确记录并测试你暴露的底层错误时:如果你的包的 API 保证某些底层错误可以被调用者解包和检查(例如,“此函数可能返回包装在更一般错误中的 ErrInvalidConfig”),那么 %w 是合适的。这构成了你的包契约的一部分。

另请参阅:

%w 在错误中的位置

如果你要使用带有 %w 格式化动词的错误包装 ,优先将 %w 放在错误字符串的末尾。

错误可以用 %w 动词包装,或者通过将它们放在实现了 Unwrap() error结构化错误 中(例如:fs.PathError)。

包装的错误形成错误链:每个新的包装层在错误链的前面添加一个新条目。错误链可以用 Unwrap() error 方法遍历。例如:

err1 := fmt.Errorf("err1") err2 := fmt.Errorf("err2: %w", err1) err3 := fmt.Errorf("err3: %w", err2)

这形成了如下形式的错误链,

无论 %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 的形式。

哨兵错误的位置

此规则的一个例外是在包装哨兵错误(sentinel error)时。哨兵错误是作为失败的主要分类的错误。这有助于观察者快速理解失败的性质(如 “not found” 或 “invalid argument”),而无需解析整个错误消息。尽早在错误字符串中识别该错误类型是有益的。

哨兵错误的示例包括 os 错误(例如 os.ErrInvalid)和包级错误。

在这些情况下,将 %w 动词放在错误字符串的开头可以通过立即识别错误类别来提高可读性。

// Good: package parser var ErrParse = fmt.Errorf("parse error") // This is another package error that could be returned. var ErrParseInvalidHeader = fmt.Errorf("%w: invalid header", ErrParse) func parseHeader() error { err := checkHeader() return fmt.Errorf("%w: invalid character in header: %v", ErrParseInvalidHeader, err) } err := fmt.Errorf("%w: couldn't find fortune database: %v", ErrInternal, err)

将状态放在开头确保最相关的分类信息最为突出。

// Bad: package parser var ErrParse = fmt.Errorf("parse error") // This is another package error that could be returned. var ErrParseInvalidHeader = fmt.Errorf("%w: invalid header", ErrParse) func parseHeader() error { err := checkHeader() return fmt.Errorf("invalid character in header: %v: %w", err, ErrParseInvalidHeader) } var ErrInternal = status.Error(codes.Internal, "internal") err2 := fmt.Errorf("couldn't find fortune database: %v: %w", err, ErrInternal)

当你将它放在末尾时,由于它被埋在具体的错误细节中,阅读错误文本时更难识别错误类别。

另请参阅:

记录错误日志

函数有时需要将错误告知外部系统而不将其传播给调用者。日志记录是一个明显的选择;但要注意记录什么以及如何记录错误。

  • 好的测试失败消息 一样,日志消息应该清楚地表达出了什么问题,并通过包含相关信息来帮助维护者诊断问题。

  • 避免重复。如果你返回了一个错误,通常最好不要自己记录它,而是让调用者处理。调用者可以选择记录错误,或者使用 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())

程序初始化

程序初始化错误(例如错误的标志和配置)应该向上传播到 mainmain 应该调用 log.Exit 并附带解释如何修复错误的错误消息。在这些情况下,通常不应使用 log.Fatal,因为指向检查点的堆栈跟踪不太可能像人工生成的、可操作的消息那样有用。

程序检查和 panic

关于 panic 的决策 所述,标准错误处理应围绕错误返回值构建。库应该优先向调用者返回错误,而不是中止程序,特别是对于瞬态错误。

偶尔有必要对不变量(invariant)执行一致性检查,并在违反时终止程序。一般来说,只有当不变量检查失败意味着内部状态变得不可恢复时才会这样做。在 Google 代码库中,最可靠的方法是调用 log.Fatal。在这些情况下使用 panic 是不可靠的,因为延迟函数可能会死锁或进一步破坏内部或外部状态。

类似地,抵制恢复 panic 以避免崩溃的诱惑,因为这样做可能导致传播损坏的状态。你离 panic 越远,你对程序状态的了解就越少,程序可能持有锁或其他资源。然后程序可能发展出其他意外的故障模式,这可能使问题更难诊断。与其尝试在代码中处理意外的 panic,不如使用监控工具来暴露意外的故障,并以高优先级修复相关的错误。

注意: 标准 net/http 服务器违反了这个建议,并从请求处理程序中恢复 panic。经验丰富的 Go 工程师的共识是这是一个历史性错误。如果你从其他语言的应用服务器中采样服务器日志,很常见会发现大量未处理的堆栈跟踪。请在你的服务器中避免这个陷阱。

何时使用 panic

标准库在 API 误用时会 panic。例如,reflect 在许多情况下当值以暗示被误解的方式访问时会发出 panic。这类似于核心语言错误(如访问越界的切片元素)时的 panic。代码审查和测试应该发现这些错误,它们不应出现在生产代码中。这些 panic 充当不依赖库的不变量检查,因为标准库无法访问 Google 代码库使用的分级 log 包。

另一种 panic 可能有用的情况(虽然不常见)是作为包的内部实现细节,在调用链中始终有匹配的 recover。解析器和类似的深度嵌套、紧密耦合的内部函数组可以受益于这种设计,其中透传错误返回值会增加复杂性而没有价值。

这种设计的关键属性是这些 panic 永远不允许跨越包边界逃逸,不构成包的 API 的一部分。这通常通过一个顶层的延迟函数来实现,该函数使用 recover 在公共 API 边界将传播的 panic 转换为返回的错误。它要求引发 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 }

警告: 采用此模式的代码必须注意管理在此类 defer 管理的部分中运行的代码关联的任何资源(例如关闭、释放或解锁)。

参见:Go Tip #81: Avoiding Resource Leaks in API Design 

当编译器无法识别不可达代码时,也会使用 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 和代码搜索中,是解释如何使用代码的绝佳方式。

参数和配置

并非每个参数都需要在文档中列举。这适用于:

  • 函数和方法参数
  • 结构体字段
  • 选项 API

通过说明它们为什么有趣来记录容易出错或不明显的字段和参数。

在以下代码片段中,高亮的注释几乎没有为读者添加有用信息:

// 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

在选择记录什么和记录到什么深度时,请考虑你可能的受众。维护者、团队新人、外部用户,甚至六个月后的你自己,可能会需要与你最初编写文档时脑海中不同的信息。

另请参阅:

上下文(Context)

隐含的约定是取消 context 参数会中断它被提供给的函数。如果函数可以返回错误,按惯例它是 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

当 context 行为不同或不明显时,如果以下任何情况为真,应明确记录。

  • 当 context 被取消时,函数返回 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()
  • 函数对 context 的生命周期、继承关系或附加的值有特殊期望:

    // 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)

    警告: 避免设计对调用者有此类要求(如 context 不能有截止时间)的 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.Iserrors.Aspackage cmp 比较错误。这是因为非指针值不等于指针值。

注意:Chdir 示例中,返回类型写为 error 而不是 *PathError,因为 nil 接口值的工作方式 

包文档中记录整体错误惯例,当行为适用于包中大多数错误时:

// 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 格式化是否正确渲染。

Godoc 格式化

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) )

大小提示和预分配是结合代码及其集成的经验分析时,创建性能敏感和资源高效代码的重要步骤。

大多数代码不需要大小提示或预分配,可以让运行时根据需要增长 slice 或 map。当最终大小已知时(例如在 map 和 slice 之间转换时),预分配是可以接受的,但这不是可读性要求,在小型情况下可能不值得增加混乱。

警告: 预分配比你需要的更多内存可能会浪费集群中的内存,甚至损害性能。如有疑问,请参见 GoTip #3: Benchmarking Go Code  并默认使用零值初始化复合字面量声明

通道方向

尽可能指定通道方向 

// 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 的演讲 “Rethinking Classical Concurrency Patterns”:幻灯片  视频 

函数参数列表

不要让函数的签名变得太长。随着更多参数添加到函数中,各个参数的角色变得不太清晰,相同类型的相邻参数更容易混淆。参数过多的函数更难记忆,在调用点也更难阅读。

在设计 API 时,考虑将签名日益复杂的高度可配置函数拆分为几个更简单的函数。如有必要,这些函数可以共享一个(未导出的)实现。

当函数需要许多输入时,考虑为某些参数引入选项结构体或采用更高级的可变选项技术。选择哪种策略的主要考虑因素应该是函数调用在所有预期用例中的外观。

以下建议主要适用于导出的 API,它们被要求达到比未导出的更高的标准。这些技术对你的用例可能不是必需的。请使用你的判断,并平衡清晰性最小机制原则。

另请参阅:Go Tip #24: Use Case-Specific Constructions 

选项结构体(Option Structure)

选项结构体是一种结构体类型,它收集函数或方法的一些或全部参数,然后作为最后一个参数传递给函数或方法。(该结构体仅在用于导出函数时才应导出。)

使用选项结构体有许多好处:

  • 结构体字面量同时包含每个参数的字段和值,使其自文档化且更难交换。
  • 不相关或”默认”字段可以省略。
  • 调用者可以共享选项结构体并编写辅助函数来操作它。
  • 结构体提供的每字段文档比函数参数更清晰。
  • 选项结构体可以随时间增长而不影响调用点。

以下是一个可以改进的函数示例:

// 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"}, }) }

注意: Context 永远不要包含在选项结构体中

当以下情况中的某些适用时,通常优先选择此选项:

  • 所有调用者都需要指定一个或多个选项。
  • 大量调用者需要提供许多选项。
  • 选项在用户将调用的多个函数之间共享。

可变选项(Variadic Options)

使用可变选项时,创建的导出函数返回闭包,这些闭包可以传递给函数的可变参数 (...)。该函数将选项的值(如果有的话)作为其参数,返回的闭包接受一个可变引用(通常是指向结构体类型的指针),该引用将根据输入进行更新。

使用可变选项可以提供许多好处:

  • 当不需要配置时,选项在调用点不占空间。
  • 选项仍然是值,因此调用者可以共享、编写辅助函数和累积它们。
  • 选项可以接受多个参数(例如 cartesian.Translate(dx, dy int) TransformOption)。
  • 选项函数可以返回命名类型,以在 godoc 中将选项分组在一起。
  • 包可以允许(或阻止)第三方包定义(或阻止定义)它们自己的选项。

注意: 使用可变选项需要大量额外代码(参见以下示例),因此只有在优势超过开销时才应使用。

以下是一个可以改进的函数示例:

// 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 createkubectl run 和许多其他子命令都由 kubectl 程序提供。至少有以下常用的库可以实现这一功能。

如果你没有偏好或其他考虑因素相同,推荐使用 subcommands ,因为它最简单且易于正确使用。但是,如果你需要它不提供的不同功能,请选择其他选项之一。

  • cobra 

    • 标志惯例:getopt
    • 在 Google 代码库外很常见。
    • 许多额外功能。
    • 使用中的陷阱(见下文)。
  • subcommands 

    • 标志惯例:Go
    • 简单且易于正确使用。
    • 如果不需要额外功能,推荐使用。

警告:cobra 命令函数应该使用 cmd.Context() 获取 context,而不是用 context.Background() 创建自己的根 context。使用 subcommands 包的代码已经将正确的 context 作为函数参数接收。

你不需要将每个子命令放在单独的包中,这通常也不是必需的。像任何 Go 代码库一样,对包边界应用相同的考虑因素。如果你的代码既可以作为库使用也可以作为二进制文件使用,将 CLI 代码和库分开通常是有益的,使 CLI 只是库的另一个客户端。(这不是特定于具有子命令的 CLI 的,但在此处提及是因为这是一个常见的出现场景。)

测试

将测试留给 Test 函数

Go 区分”测试辅助函数(test helper)“和”断言辅助函数(assertion helper)”:

  • 测试辅助函数是执行设置或清理任务的函数。测试辅助函数中发生的所有失败都应该是环境故障(而非被测代码的故障)——例如因为机器上没有更多空闲端口而无法启动测试数据库。对于这样的函数,调用 t.Helper标记它们为测试辅助函数通常是合适的。有关更多详细信息,请参见测试辅助函数中的错误处理

  • 断言辅助函数是检查系统正确性的函数,如果不满足预期则使测试失败。断言辅助函数在 Go 中不被认为是惯用的

测试的目的是报告被测代码的通过/失败条件。失败测试的理想位置是在 Test 函数本身内,因为这确保了失败消息和测试逻辑是清晰的。

随着测试代码的增长,可能需要将某些功能提取到单独的函数中。标准的软件工程考虑仍然适用,因为测试代码仍然是代码。如果功能不与测试框架交互,那么所有通常的规则都适用。然而,当公共代码与框架交互时,必须注意避免可能导致信息不足的失败消息和不可维护测试的常见陷阱。

如果许多独立的测试用例需要相同的验证逻辑,请以以下方式之一安排测试,而不是使用断言辅助函数或复杂的验证函数:

  • 将逻辑(包括验证和失败)内联到 Test 函数中,即使它是重复的。这在简单情况下效果最好。
  • 如果输入类似,考虑将它们统一到表驱动测试中,同时保持逻辑内联在循环中。这有助于避免重复,同时将验证和失败保留在 Test 中。
  • 如果有多个调用者需要相同的验证函数,但表驱动测试不适合(通常因为输入不够简单或验证需要作为操作序列的一部分),安排验证函数使其返回一个值(通常是 error)而不是接受 testing.T 参数并用它来使测试失败。在 Test 中使用逻辑来决定是否失败,并提供有用的测试失败消息。你也可以创建测试辅助函数来提取常见的样板设置代码。

最后一点中概述的设计保持了正交性。例如,cmp不是为了使测试失败而设计的,而是为了比较(和 diff)值。因此,它不需要知道比较在什么上下文中进行,因为调用者可以提供这些信息。如果你的公共测试代码为你的数据类型提供了 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://go.dev/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,除非在极少数情况下;从测试中调用的代码不应停止测试,除非没有继续的意义

设计可扩展的验证 API

风格指南中关于测试的大部分建议是关于测试你自己的代码。本节是关于如何为其他人提供设施来测试他们编写的代码,以确保其符合你的库的要求。

验收测试(Acceptance Testing)

这种测试被称为验收测试 。这种测试的前提是使用测试的人不知道测试中发生的每一个细节;他们只是将输入交给测试设施来完成工作。这可以被认为是一种控制反转(inversion of control) 的形式。

在典型的 Go 测试中,测试函数控制程序流程,而无断言测试函数的指导鼓励你保持这种方式。本节解释如何以符合 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(一个用于模拟国际象棋游戏的包)构建验收测试。chess 的用户需要实现 chess.Player 接口。这些实现是我们要验证的主要内容。我们的验收测试关注的是玩家实现是否走了合法的棋步,而不是棋步是否聪明。

  1. 为验证行为创建一个新包,按惯例命名为在包名后附加 test 一词(例如 chesstest)。

  2. 创建接受被测实现作为参数并执行它的验证函数:

    // 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

    测试应该注明哪些不变量被破坏以及如何破坏。你的设计可以在两种失败报告方式之间选择:

    • 快速失败(Fail fast):一旦实现违反不变量就返回错误。

      这是最简单的方法,如果验收测试预计执行很快,则效果很好。简单的错误哨兵值 自定义类型 可以在这里轻松使用,这反过来也使测试验收测试变得容易。

      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} } }
    • 聚合所有失败(Aggregate all failures):收集所有失败,并一起报告。

      这种方法在感觉上类似于继续执行的指导,如果验收测试预计执行缓慢,可能更可取。

      如何聚合失败应取决于你是否想让用户或自己能够检查单个失败(例如,用于测试你的验收测试)。下面演示使用自定义错误类型 聚合错误 

      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,请使用连接到 OperationsServer 测试替身 (例如 mock、stub 或 fake)的真实 OperationsClient 

推荐这样做而非手动实现客户端,因为正确模仿客户端行为的复杂性。通过使用带有测试专用服务器的生产客户端,你可以确保测试尽可能多地使用真实代码。

提示: 在可能的情况下,使用被测服务作者提供的测试库。

t.Error vs. 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: Cleaning Up Your Tests  获取简化测试辅助函数的指导。

下面在一个名为 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 goodSetup(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) { goodSetup(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("Could not paint the house under test: %v", err)

paint_test.go:32 指向 TestGood 中失败的测试行:

goodSetup(t)

正确使用 (*testing.T).Helper 在以下情况下能更好地定位失败位置:

  • 辅助函数增长
  • 辅助函数调用其他辅助函数
  • 测试函数中辅助函数的使用量增加

提示: 如果辅助函数调用 (*testing.T).Error(*testing.T).Fatal,请在格式字符串中提供一些上下文,以帮助确定出了什么问题以及为什么。

提示: 如果辅助函数做的事情不会导致测试失败,它就不需要调用 t.Helper。通过从函数参数列表中移除 t 来简化其签名。

不要从单独的 goroutine 调用 t.Fatal

测试包文档 所述,从运行 Test 函数(或子测试)的 goroutine 以外的任何 goroutine 调用 t.FailNowt.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 入口点

如果包中的所有测试都需要共同的设置且设置需要拆卸(teardown),你可以使用自定义 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 可能是合适的(但不是必需的):

  • 它是昂贵的。
  • 它只适用于某些测试。
  • 它不需要拆卸(teardown)。
// 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) } }

公共拆卸(teardown)之所以棘手,是因为没有统一的地方来注册清理例程。如果设置函数(在此例中为 mustLoadDataset)依赖于 context,sync.Once 可能会有问题。这是因为两个竞争调用中的第二个需要等待第一个调用完成才能返回。这个等待期不能轻易地响应 context 的取消。

字符串拼接

在 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 时,不要用 fmt.Sprintf 构建临时字符串然后发送给 Writer。而是使用 fmt.Fprintf 直接输出到 Writer。

当格式化更加复杂时,根据需要优先使用 text/templatesafehtml/template

逐步构建字符串时优先使用 strings.Builder

当逐步构建字符串时,优先使用 strings.Builderstrings.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 测试默认按顺序执行,因此上面的测试按以下顺序运行:

  1. TestEndToEnd
  2. TestRegression_NetworkUnavailability,覆盖了 cloudlogger 的默认值
  3. 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 的主要形式

下面列举了几种最常见的有问题的 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) }
  • 后端、存储、数据访问层和其他系统资源的重客户端单例(singleton)。这些通常还会带来额外的服务可靠性问题。

    // 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 是不安全的:

  • 多个函数在同一程序中通过全局状态进行交互,尽管它们在其他方面是独立的(例如,由不同作者在完全不同的目录中编写)。
  • 独立的测试用例通过全局状态相互交互。
  • API 的用户被诱导为了测试目的而交换或替换全局状态,特别是将状态的任何部分替换为测试替身 ,例如存根、伪造、间谍或模拟。
  • 用户在与全局状态交互时必须考虑特殊的排序要求:func init、标志是否已解析等。

如果避免了上述条件,在以下有限的几种情况下这些 API 是安全的,即以下任何一条为真:

  • 全局状态在逻辑上是常量(示例 )。
  • 包的可观察行为是无状态的。例如,公共函数可以使用私有全局变量作为缓存,但只要调用者无法区分缓存命中和未命中,该函数就是无状态的。
  • 全局状态不会泄漏到程序外部的事物中,如 sidecar 进程或共享文件系统上的文件。
  • 没有可预测行为的期望(示例 )。

注意: Sidecar 进程 可能不是严格进程本地的。它们可以也经常与多个应用程序进程共享。此外,这些 sidecar 经常与外部分布式系统交互。

此外,除了上述基本考虑因素外,相同的无状态、幂等和本地规则也适用于 sidecar 进程本身的代码!

package image 及其 image.RegisterFormat 函数就是一个安全情况的示例。考虑上面的试金石测试应用于典型的解码器,如处理 PNG  格式的解码器:

  • 对使用注册解码器的 package image API(例如 image.Decode)的多次调用不会相互干扰,测试也一样。唯一的例外是 image.RegisterFormat,但这由以下几点所缓解。
  • 用户极不可能想用测试替身 替换解码器,因为 PNG 解码器是我们代码库中偏好真实对象的一个典型案例。然而,如果解码器与操作系统资源(例如网络)有状态地交互,用户更可能用测试替身替换解码器。
  • 注册冲突是可以想象的,尽管在实践中可能很少见。
  • 解码器是无状态的、幂等的和纯粹的。

提供默认实例

虽然不推荐,但如果你需要最大化用户的便利性,提供使用包级状态的简化 API 是可以接受的。

在这种情况下,遵循试金石测试和以下准则:

  1. 包必须为客户端提供创建如上所述的包类型的隔离实例的能力。

  2. 使用全局状态的公共 API 必须是前述 API 的薄代理。一个很好的例子是 http.Handle 在内部调用包变量 http.DefaultServeMux 上的 (*http.ServeMux).Handle

  3. 此包级 API 只应由二进制构建目标 使用,而不是 ,除非库正在进行重构以支持依赖传递。可以被其他包导入的基础设施库不得依赖它们导入的包的包级状态。

    例如,实现将与其他团队共享的 sidecar 的基础设施提供者,应该使用顶部的 API 来提供适应此需求的 API:

    // Good: package cloudlogger func New() *Logger { ... } func Register(r *sidecar.Registry, l *Logger) { r.Register("Cloud Logging", l) }
  4. 此包级 API 必须记录并强制执行其不变量(例如,在程序生命周期的哪个阶段可以调用它,是否可以并发使用)。此外,它必须提供将全局状态重置为已知良好默认值的 API(例如,以便于测试)。

另请参阅:

接口(Interface)

Go 中的接口非常强大,但可能被过度使用或误解。由于 Go 接口是隐式满足的,它们是结构性工具而非声明性工具。以下指导提供了如何在不过度工程化代码库的情况下设计和返回接口的最佳实践。

请参阅决策中关于接口的部分获取摘要。

避免不必要的接口

最常见的错误是在真正需要之前就创建接口。

  1. 不要将概念与关键字混淆: 仅仅因为你在设计”服务”或”仓储”或类似的模式,并不意味着你需要命名的接口类型(例如 type Service interface)。首先关注行为及其具体实现。

  2. 复用现有接口: 如果接口已经存在,特别是在生成的代码中(如 RPC 客户端或服务器),使用它(测试 RPC )。不要仅仅为了抽象或测试的目的而将生成的 RPC 代码包装在一个新的手动接口中。而应使用真实传输层

  3. 不要仅为测试定义后门: 不要从消费接口的 API 中导出测试替身 实现。相反,优先设计 API 使其可以通过真实实现的公共 API 进行测试。

    每个导出的类型都增加了读者的认知负担。当你在真实实现旁边导出测试替身时,你迫使读者理解三个实体(接口、真实实现和测试替身),而不是一个。

    当你有实质需要支持替换时,才导出测试替身的接口。

何时确实需要创建接口:

  1. 多种实现: 当有两个或更多具体类型必须由相同的逻辑处理时(例如,需要同时操作 json.Encoder gob.GobEncoder  的东西),API 消费者可以定义一个接口。

  2. 解耦包: 为了打破两个包之间的循环依赖(参见示例),API 生产者可以定义一个接口。

    注意: 仔细遵循关于包大小的指导。引入接口来打破依赖循环通常是包结构不当的信号。

  3. 隐藏复杂性: 当具体类型具有庞大的 API 表面,但特定函数只需要一两个方法时,API 消费者可以定义一个接口。

接口所有权和可见性

  1. 不要不必要地导出接口类型: 如果接口仅在包内部用于满足特定的逻辑流程,请保持接口未导出。导出接口意味着你承诺为外部调用者维护该 API。

  2. 消费者定义接口: 在 Go 中,接口通常属于使用它们的包,而不是实现它们的包。消费者应该只定义他们实际使用的方法 GoTip #78: Minimal Viable Interfaces ,遵循接口越大,抽象越弱 的理念。

    有一些常见场景中,生产者(提供逻辑的包)导出接口通常是有意义的:

    • 接口即产品: 当包的主要目的是提供许多不同实现必须遵循的公共协议时,生产者定义接口。例如 io.Writer hash.Hash 。“协议”的概念包括关于关键行为(例如预期用例、边缘情况、并发)的文档等方面,需要集中和权威地阐述。另一个突出的例子是 protobuf 生成的接口。它不抽象特定行为,而是定义边界。其目的是确保你的服务器实现与 .proto 文件中定义的模式完全匹配。在这里,接口充当服务与其客户端之间的严格法律契约。

      对于大型系统,如果接口位于一个庞大的实现包内,每个客户端都被迫导入整个世界仅仅为了引用接口。你可以在一个独立的、无实现的包中定义接口,避免不必要的符号和潜在的循环依赖。这也是 protobuf 生成代码使用的相同理念。

    • 防止接口膨胀: 在大型代码库中,如果许多包使用相同的 AuthService 而每个都定义了相同的 type Authorizer interface,维护变得困难。虽然 Go 通常偏好一点复制胜过一点依赖 ,但请记住,在许多包中维护完全镜像的接口(见上一点)可能会造成不必要的负担。

    • 解决循环依赖: 参见下面的示例

设计有效的接口

  1. 保持接口小巧: 接口越大,越难实现和编写利用它的代码 。小接口在需要时更容易组合成更大的接口。

  2. 文档: 将每个接口视为你抽象的”用户手册”。文档的深度应与接口的认知负担成正比,而不仅仅是方法的数量。无论接口有十个方法还是像 io.Writer  那样只有一个 Write,如果期望程序员与该类型交互,API 必须被彻底记录。

    • 单方法接口: 类型本身的文档通常就足够了(例如 io.Writer)。解释其契约、边缘情况和预期的错误。
    • 多方法接口: 每个单独的方法都需要自己的文档。
    • 未导出接口: 也考虑记录它们。它们通常是将复杂内部逻辑粘合在一起的胶水,由于对外部用户不可见,它们很容易成为未来维护者(包括未来的你自己)的神秘代码。
  3. 接受接口,返回具体类型: 返回具体类型允许调用者使用值的全部功能,而不被锁定在特定的接口抽象中 GoTip #49: Accept Interfaces, Return Concrete Types 

有几种常见场景中返回接口是惯用的选择:

  1. 封装: 虽然接口不能严格隐藏导出的方法(因为它们仍然可以通过类型断言访问),但返回接口是限制默认 API 表面和引导调用者行为的强大工具。最常见的例子是 error 接口;你几乎不会返回具体的错误类型*MyCustomError

    考虑一个实现了 io.Reader 但也有用于内部桶管理的 Refill 方法的 ThrottledReader。返回具体的 *ThrottledReader 会邀请调用者手动管理桶,这可能导致竞态条件或破坏限流逻辑。通过返回接口,你告诉调用者你唯一的工作就是消费这个 reader。如果你试图将其转回 ThrottledReaderRefill 内部桶,你就是在违反契约。

    // Good: type ThrottledReader struct { source io.Reader limit int // bytes per second balance int // current allowance of bytes lastRefill time.Time } // Read implements the io.Reader interface with rate-limiting logic. func (t *ThrottledReader) Read(p []byte) (int, error) { ... } // Refill manually adds tokens to the bucket. // INTERNAL USE ONLY: Calling this from outside breaks the rate limit logic. func (t *ThrottledReader) Refill(amount int) { t.balance = min(t.balance + amount, t.limit) } // New returns the io.Reader with rate-limiting. func New(r io.Reader, bytesPerSec int) io.Reader { return &ThrottledReader{ source: r, limit: bytesPerSec, balance: bytesPerSec, // start with a full bucket lastRefill: time.Now(), } }

    这引出一个自然的问题:如果 Refill 是危险的,为什么还要导出它?在复杂系统中,你经常需要内部编排。例如,一个 AggregateReader 管理多个 ThrottledReader 值以确保所有流的总带宽保持在全局限制之下。这个协调器需要调用 Refill 来分配令牌,但处理数据的非高级用户永远不应该看到该功能。

    注意: 在返回接口以隐藏实现之前,请问自己:“用户调用这些额外的方法是否真的会破坏系统的完整性或显著限制可维护性?“如果额外的细节允许用户绕过安全检查,或者如果暴露具体类型使得以后无法在不造成破坏性变更的情况下更改底层提供者,你可以返回接口。不要无故地机械封装。

  2. 某些模式: 如果函数设计为根据运行时决策返回多种不同的具体类型之一,它必须返回接口。这对于命令、链式、工厂和策略 模式来说通常是正确的。考虑这段根据请求的格式选择使用哪个编码器的代码:

    // Good: func NewWriter(format string) io.Writer { switch format { case "json": return &jsonWriter{} case "xml": return &xmlWriter{} default: return &textWriter{} } }

    以下链式 API 的示例展示了返回接口如何启用多态行为。通过允许调用者使用 client.Do(req)client.WithAuth("token").Do(req),你可以在不破坏调用代码的情况下交换实现。

    // Good: type Client interface { WithAuth(token string) Client Do(req *Request) error }

    这些模式是指导方针,不是规则。如果单个健壮的具体类型可以在内部处理抽象,就不要强制使用接口。例如,标准 database/sql  库导出单个具体的 DB 类型,而不是强制使用接口来处理 MySQLDBOracleDB 之类的类型。

  3. 避免循环依赖: 如果返回具体类型需要导入一个已经导入当前包的包,你必须返回接口以打破循环依赖。

    例如:

    // Bad: package app import "myproject/plugin" type Config struct { APIKey string } func Start() { p := plugin.New() }
    // Bad: package plugin import "myproject/app" // ERROR: Import cycle! func New() *app.Config { return &app.Config{APIKey: "secret"} }

    在这种情况下,pluginNew 不能返回 *app.Config,因为这会创建循环导入。为了打破这个循环,我们利用接口是隐式满足的这一事实。我们将”契约”移到一个中性的地方,或者让生产者返回一个消费者已经理解的接口。

    如果 pluginNew 返回接口而不是具体的 *app.Config 结构体,它就不再需要导入 app 包。

    package plugin type Configurer interface { APIKey() string } type localConfig struct { key string } func (c localConfig) APIKey() string { return c.key } // New returns the interface Configurer instead of the concrete app.Config func New() Configurer { return &localConfig{key: "secret"} }
    package app import "myproject/plugin" func Start() { conf := plugin.New() // 'conf' is now a Configurer interface fmt.Println(conf.APIKey()) }

    注意: 仔细遵循关于包大小的指导。引入接口来打破依赖循环通常是包结构不当的信号。合并后的包通常优于太多太小的、无法独立存在的包。

Last updated on