Go2.0 展望

What id like to see in go2

Go 是我最喜欢的编程语言之一,但它离完美还很远。在过去的 10 年里,我使用 Go 来构建小项目和大规模应用程序。虽然从 2009 年的最初版本开始,Go 已经有了很大的发展,但本文仍然要强调一些我认为 Go 语言可以改进的地方。

在我们开始之前,我想明确一点:我不是在批评一些人或他们的贡献。我唯一的目的就是让 Go 成为最好的编程语言。

一个现代模板引擎

Go 标准库有两个模板包: text/template 和 html/template. 他们几乎用着同样的语法, 但是 html/template 处理实体转义和一些其他的 web 特定构造. 不幸的是,如果不进行大量的开发人员投入,这两个包对于高级用例来说都不够合适和强大。

  • 编译时错误。 不像 Go 本身,Go 模板包会很乐意让你以字符串形式传递一个整数,只是在运行时呈现一个错误。这意味着开发人员需要严格地测试模板中所有可能的输入,而不是依赖于类型系统。Go 的模板包应该支持编译时类型检查。

  • 匹配 Go 的 range 子句 。10 年过去了,我仍然把 Go 模板中 range 子句的顺序搞得一团糟,因为它有时是向后的。使用两个参数,模板引擎匹配标准库:

    1. {{ range $a, $b := .Items }} // [$a = 0, $b = "foo"]
    2. for a, b := range items { // [a = 0, b = "foo"]

    然而,只有一个参数时,模板引擎会生成值,而 Go 呈现时会生成索引:

    1. {{ range $a := .Items }} // [$a = "foo"]
    2. for a := range items { // [a = 0]

    Go 的模板包应该与标准库的工作方式相匹配。

  • 功能齐备,反射可选。作为一个普遍的规则,我认为大多数开发者不应该需要与反射交互。然而,如果你想做一些基本的加法和减法之外的事情,Go 的模板包将迫使你使用反射。内置函数非常小,只满足用例的一小部分。

    在我编写了Consul Template之后,很明显,标准的 Go 模板功能不足以满足用户的需求。超过一半的问题是关于尝试使用 Go 的模板语言。今天,Consul Template 拥有超过 50 个“助手”函数,其中绝大多数应该使用标准模板语言。

    Consul Template 并不是唯一一个这样做的。Hugo也有一个非常广泛的助手函数列表,同样,其中绝大多数应该是在标准的模板语言中。甚至在我最近的项目Exposure Notifications中,我们无法逃避反射

    Go 的模板语言确实需要更大的函数表。

  • 短路评估

    编辑:正如许多人指出的那样,这个特性将会在 Go 1.18 中出现(https://tip.golang.org/doc/go1.18#text/template)。

    Go 的模板语言总是在子句中计算整个条件,这导致了一些非常有趣的 bug(同样,这些 bug 在运行时才会显示出来)。考虑下面的例子,其中$foo 可以是 nil:

    1. {{if (and $foo $foo. bar)}}

    这看起来似乎很好,但是和条件都将被求值——表达式中没有短路逻辑。这意味着如果$foo 为 nil,这将抛出一个运行时异常。

    为了解决这个问题,你必须将条件句分开:

    1. {{if $foo}}
    2. {{如果$ foo。酒吧}}
    3. {{结束}}

    Go 的模板语言应该像标准库一样,在第一个值为真时停止执行条件。

  • 往网络专用库投入精力。我是一名经验丰富的 Ruby on Rails 开发人员,我非常喜欢它能够轻松地构建漂亮的 web 应用程序。使用 Go 的模板语言,即使是最简单的任务——比如将条目列表打印成一个句子——对于初学者来说也是无法实现的,尤其是与 Rails 的 Enumerable#to_sentence 相比

改进 range ,以便不用复制值

虽然有很好的文档,但是 range 子句中的值总是会被复制。例如,考虑以下代码:

  1. type Foo struct {
  2. bar string
  3. }
  4. func main() {
  5. list := []Foo{{"A"}, {"B"}, {"C"}}
  6. cp := make([]*Foo, len(list))
  7. for i, value := range list {
  8. cp[i] = &value
  9. }
  10. fmt.Printf("list: %q\n", list)
  11. fmt.Printf("cp: %q\n", cp)
  12. }

cp 的值是什么?如果你认为是[A B C],不好意思你错了,正确的值是[C C C]

  1. [C C C]

这是因为 go 在 range 子句里使用了复制品而不是元素本身。 在 Go2.0 中,range 子句应该采取引用传值. 在这方面已经有了些许提案, 包括 improve for-loop ergonomicsredefine range loop variables in each iteration, 所以我非常希望能有这样的改动.

决定性的 select

在一个 select 语句的多个条件为真的情况下,胜出的情况是通过一致的伪随机选择来选择的。这是一个非常微妙的错误来源,而且类似的 switch 语句会加重这种错误,switch 语句按照写入的顺序求值。

考虑下面的代码,我们希望其表现是“如果系统停止了,什么也不做;否则等待 5 秒,然后提示超时”:

  1. for {
  2. select {
  3. case <-doneCh: // or <-ctx.Done():
  4. return
  5. case thing := <-thingCh:
  6. // ... long-running operation
  7. case <-time.After(5*time.Second):
  8. return fmt.Errorf("timeout")
  9. }
  10. }

如果在进入 select 语句时多个条件为真(例如 doneCh 是关闭的,已经超过 5 秒),则该路径将执行的行为是未知的。这使得编写正确的取消代码非常繁琐:

  1. for {
  2. // Check here in case we've been CPU throttled for an extended time, we need to
  3. // check graceful stop or risk returning a timeout error.
  4. select {
  5. case <-doneCh:
  6. return
  7. default:
  8. }
  9. select {
  10. case <-doneCh:
  11. return
  12. case thing := <-thingCh:
  13. // Even though this case won, we still might ALSO be stopped.
  14. select {
  15. case <-doneCh:
  16. return
  17. default:
  18. }
  19. // ...
  20. default <-time.After(5*time.Second):
  21. // Even though this case won, we still might ALSO be stopped.
  22. select {
  23. case <-doneCh:
  24. return
  25. default:
  26. }
  27. return fmt.Errorf("timeout")
  28. }
  29. }

如果 select 被更新为确定性的,那么原始代码(在我看来更简单、更容易获得)就会像预期的那样工作。然而,由于选择的非确定性,我们必须不断地检查优势条件。

与此相关的是,我希望看到“如果这个通道包含任何消息,则从它读取,否则继续”的简写语法,而当前的语法是冗余的:

  1. select {
  2. case <-doneCh:
  3. return
  4. default:
  5. }
  6. 我希望看到这个检查的更简洁的版本,可能是这样的语法:
  7. select <-?doneCh: // not valid Go

结构化日志接口

Go 的标准库包含了日志包,这对于基本的使用是很好的。然而,大多数生产系统都需要结构化日志记录,并且 Go 语言不缺少结构化的日志库:

Go 在这方面的意见缺乏导致了这些包的泛滥,其中大多数具有不功能和标识符都不兼容。因此,库作者不可能作出结构化的日志。例如,我希望能够实现结构化登录go-retry, go-envconfig,或go-githubactions,但这样做需要与其中一个库紧密耦合。理想情况下,我希望我的库用户能够选择他们的结构日志记录解决方案,但是缺乏用于结构日志记录的通用接口使这变得非常困难。

Go 标准库需要定义一个结构化的日志接口,所有这些现有的上游包可以选择实现这个接口。然后,作为一个库作者,我可以选择接受 log.StructuredLogger 接口并且实现者可以做出自己的选择:

  1. func WithLogger(l log.StructuredLogger) Option {
  2. return func(f *Foo) *Foo {
  3. f.logger = l
  4. return f
  5. }
  6. }

我做出了这种 interface 的草稿

  1. // StructuredLogger is an interface for structured logging.
  2. type StructuredLogger interface {
  3. // Log logs a message.
  4. Log(message string, fields ...LogField)
  5. // LogAt logs a message at the provided level. Perhaps we could also have
  6. // Debugf, Infof, etc, but I think that might be too limiting for the standard
  7. // library.
  8. LogAt(level LogLevel, message string, fields ...LogField)
  9. // LogEntry logs a complete log entry. See LogEntry for the default values if
  10. // any fields are missing.
  11. LogEntry(entry *LogEntry)
  12. }
  13. // LogLevel is the underlying log level.
  14. type LogLevel uint8
  15. // LogEntry represents a single log entry.
  16. type LogEntry struct {
  17. // Level is the log level. If no level is provided, the default level of
  18. // LevelError is used.
  19. Level LogLevel
  20. // Message is the actual log message.
  21. Message string
  22. // Fields is the list of structured logging fields. If two fields have the same
  23. // Name, the later one takes precedence.
  24. Fields []*LogField
  25. }
  26. // LogField is a tuple of the named field (a string) and its underlying value.
  27. type LogField struct {
  28. Name string
  29. Value interface{}
  30. }

关于实际的接口可能是什么样子、如何最小化分配以及如何最大化兼容性有很多讨论,但我们的目标是定义一个其他日志库可以轻松实现的接口。

在我的 Ruby 时代,Ruby 版本管理器的数量激增,每个版本管理器都有自己的 dotfile 名称和语法。Fletcher Nichol 仅仅通过写一个 gist就成功地说服了所有 Ruby 版本管理器的维护者对.ruby-version 进行标准化。我希望我们可以在 Go 社区做一些类似的结构化日志记录。

多错误处理

在很多情况下,特别是在后台任务或周期性任务中,系统可能并行处理一些事情或在错误时继续处理。在这些情况下,返回一个多重错误是有帮助的。标准库中没有处理错误集合的内置支持。

围绕多错误处理拥有清晰而简明的标准库定义可以统一社区,并减少错误处理不当的风险,就像我们在 error wrapping 和 unwrapping 时看到的那样。

JSON 序列化 error

说到 error,您知道将 error 类型嵌入到结构字段中,然后将该结构序列化为 JSON 时候会将 error 字段序列化为{}吗?

  1. // https://play.golang.org/p/gl7BPJOgmjr
  2. package main
  3. import (
  4. "encoding/json"
  5. "fmt"
  6. )
  7. type Response1 struct {
  8. Err error `json:"error"`
  9. }
  10. func main() {
  11. v1 := &Response1{Err: fmt.Errorf("oops")}
  12. b1, err := json.Marshal(v1)
  13. if err != nil {
  14. panic(err)
  15. }
  16. // got: {"error":{}}
  17. // want: {"error": "oops"}
  18. fmt.Println(string(b1))
  19. }

至少对于内置的 errorString 类型,Go 应该作为.Error()的结果进行序列化。另外,对于 Go2.0,当试图序列化一个 error 类型而没有实现自定义序列化逻辑时,JSON 序列化可能会返回一个 error。

标准库中不再有公共变量

这只是一个例子,两者都是 http.DefaultClient 和 http.DefaultTransport 具有共享状态的全局变量。http.DefaultClient 没有配置超时,这使得创建自己的服务很简单,并容易产生瓶颈。许多包会改变 http.DefaultClient 和 http.DefaultTransport,这可能会浪费开发人员数天的成本来追踪错误。

Go 2.0 应该将这些变量设为私有的,并通过一个函数调用来公开它们,该函数调用将返回有问题的变量的唯一分配。或者,Go 2.0 可以实现“冻结”全局变量,这样它们就不会被其他包改变。

从软件供应链的角度来看,我也担心这类问题。如果我能开发一个有用的包,秘密修改 http.DefaultTransport 使用自定义的 RoundTripper,通过我的服务器过滤您的所有流量,这将是一个非常糟糕的时刻。

缓冲渲染的原生支持

这更像是一件“不为人知或没有记录在案的事情”。大多数例子,包括 Go 文档中的例子,都鼓励通过 web 请求来序列化 JSON 或渲染 HTML:

  1. func toJSON(w http.ResponseWriter, i interface{}) {
  2. if err := json.NewEncoder(w).Encode(i); err != nil {
  3. http.Error(w, "oops", http.StatusInternalServerError)
  4. }
  5. }
  6. func toHTML(w http.ResponseWriter, tmpl string, i interface{}) {
  7. if err := templates.ExecuteTemplate(w, tmpl, i); err != nil {
  8. http.Error(w, "oops", http.StatusInternalServerError)
  9. }
  10. }

但是,对于这两种情况,如果 i 足够大,那么在第一个字节(和 200 状态码)被发送之后,encoding/execution 就可能失败。此时,请求是不可恢复的,因为您不能更改响应代码。

为了缓解这个问题,被广泛接受的解决方案是先渲染,然后复制到 w。这仍然为错误留下一个小空间(写入 w 会由于连接问题失败),但它确保在发送第一个字节之前 encoding/execution 是成功的。然而,为每个请求分配一个 byte slice 的代价可能很高,所以通常使用缓冲池

这种方法非常冗长,给实现者带来了很多不必要的复杂性。相反,如果 Go 能够自动管理这个缓冲池将会很棒,可能可以使用 EncodePooled 这样的函数。

结束语

Go 仍然是我最喜欢的编程语言之一,这也是我乐于强调这些批评的原因。与任何编程语言一样,Go 也在不断发展。你认为这些是好主意吗?或者它们是糟糕的建议?请在Twitter上告诉我!

Copyright © 2022 Seth Vargo • Licensed under the CC BY-NC 4.0 license.