阅读完文章,如果你也有想在Go2中看到的特性,欢迎留言讨论。
目录
关于作者
现代模板引擎
改进 range,以免Copy值
确定性的`select`
结构化日志记录接口
多错误处理
针对 JSON Marshal的error
标准库中不再有公共变量
对缓冲渲染器的本地支持
结束语
Go是我最喜欢的编程语言之一,但它仍然远非完美。在过去的10年里,我用Go来构建小型的辅助项目和大型的应用程序。虽然这门语言与2009年最初发布时相比已经有了很大的发展,但这篇文章强调了我认为Go仍有改进空间的一些领域。
在我们开始之前,我想明确一点:我不是在批评个人或他们的贡献。我唯一的意图是努力使Go成为最好的编程语言。
关于作者
Seth Vargo是谷歌的工程师。此前他曾在HashiCorp、Chef Software、CustomInk和一些匹兹堡的创业公司工作。他是《Learning Chef[2]》一书的作者,热衷于减少技术上的不平等。当他没有写作、从事开源工作、教学或在会议上发言时,Seth 喜欢与他的朋友共度时光并为非营利组织提供建议。
现代模板引擎
Go 标准库有两个模板包:`text/template`[3]和`html/template`[4].它们使用大致相同的语法,但html/template
处理实体转义和其他一些特定于 Web 的结构。不幸的是,这两个软件包都不适合或不够强大,在没有大量开发人员投资的情况下,可以满足足够高级的使用情况。
编译时错误。与 Go 本身不同,Go 模板包会很乐意让您将整数作为字符串传递,但会在运行时呈现错误。这意味着开发人员需要严格测试其模板中所有可能的输入,而不是能够依赖类型系统。Go 的模板包应该支持编译时类型检查。
与 Go 匹配
range
子句。 我仍然把Go模板中的范围子句的顺序弄得一团糟,因为它有时与Go本身的顺序是相反的。对于两个参数,模板引擎与标准库相匹配。{{ range $a, $b := .Items }} // [$a = 0, $b = "foo"]
for a, b := range items { // [a = 0, b = "foo"]
但是,只有一个参数,模板引擎产生的是值,而 Go 渲染产生的是索引:
{{ range $a := .Items }} // [$a = "foo"]
for a := range items { // [a = 0]
Go 的模板包应该符合标准库的工作方式。
开箱即用(Batteries included),反射是可选的。作为一般规则,我认为大多数开发人员不应该需要与反射进行交互。但是,如果你想做任何基本的加法和减法之外的事情,Go的模板包将迫使你使用反射。内置函数非常小,而且只能满足一小部分用例。
在我写完Consul Template[5]之后,很明显,标准的Go模板功能不足以满足用户的需求。超过一半的问题是关于尝试使用Go的模板语言。今天,Consul Template有超过[超过50个"辅助"函数](https://github.com/hashicorp/consul-template/blob/master/docs/templating-language.md "超过50个"辅助"函数"),其中绝大部分确实应该在标准模板语言中使用。
Consul Template在这里并不孤单。Hugo[6]还有一个相当广泛的辅助函数列表[7],同样,其中绝大多数应该真正使用标准模板语言。即使在我最近的项目中,Exposure Notification[8]我们也无法逃脱反射[9].
Go的模板语言确实需要具有更广泛的函数表面积。
短路评估。
编辑: 正如许多人所指出的那样,这个特性将出现在Go 1.18[10].
Go的模板语言总是在子句中评估整个条件,这会产生一些非常有趣的bug(直到运行时才会再次出现)。请考虑以下情况,其中
$foo
可能为零:{{ if (and $foo $foo.Bar) }}
看起来这似乎很好,但是将评估这两个条件 - 表达式中没有短路逻辑。这意味着
$foo
如果为 nil,这将引发运行时异常。要解决此问题,你必须分离条件子句:
{{ if $foo }}
{{ if $foo.Bar }}
{{ end }}Go的模板语言应该像标准库一样工作,在第一个真值时停止执行条件。
对特定web工具的投资。我当
Ruby on Rails
开发人员已经很多年了,我真的很喜欢建立漂亮的网络应用程序是如此简单。使用Go的模板语言,即使是最简单的任务 - 例如将一个项目列表打印成一个句子 - 对于初学者来说也是难以企及的,特别是与Rails的Enumerable#to_sentence
的相比。
改进range
,以免Copy值
虽然它有很好的文档记录,但总是意外地复制范围子句中的值。例如,请思考以下代码:
type Foo struct {
bar string
}
func main() {
list := []Foo{{"A"}, {"B"}, {"C"}}
cp := make([]*Foo, len(list))
for i, value := range list {
cp[i] = &value
}
fmt.Printf("list: %q\n", list)
fmt.Printf("cp: %q\n", cp)
}
cp
的价值是什么?如果你说[A B C]
,可悲的是你是错误的。而实际上是:
[C C C]
这是因为 Go 在子句中使用了值的副本,而不是值本身。在 Go 2.0 中,Range
子句应通过引用传递值。在这个领域已经有一些关于Go 2.0的建议,包括改善 for-loop 人体工程学设计[11]和在每次迭代中重新定义范围循环变量[12],所以我对此抱有谨慎的希望。
确定性的select
在select语句的多个条件为真的情况下,会通过统一的伪随机来选择case的[13].这是一个非常微妙的错误来源,并且它被看起来外观相似的switch
语句而加剧,该语句确实按其编写的顺序进行评估。
考虑一下下面的代码,我们希望它的行为是 "如果系统停止了,什么都不做。否则等待新的工作,最多5秒,然后超时"。:
for {
select {
case <-doneCh: // or <-ctx.Done():
return
case thing := <-thingCh:
// ... long-running operation
case <-time.After(5*time.Second):
return fmt.Errorf("timeout")
}
}
如果在输入语句时满足多个条件(例如 doneCh
已关闭且已超过 5 秒),那么哪个路径将被执行是不确定的行为。这使得编写正确的取消代码变得恼人的冗长:
for {
// Check here in case we've been CPU throttled for an extended time, we need to
// check graceful stop or risk returning a timeout error.
// 优雅地停止关闭
select {
case <-doneCh:
return
default:
}
select {
case <-doneCh:
return
case thing := <-thingCh:
// Even though this case won, we still might ALSO be stopped.
// 尽管选中这个case,我们也会被停止
select {
case <-doneCh:
return
default:
}
// ...
default <-time.After(5*time.Second):
// Even though this case won, we still might ALSO be stopped.
// 尽管超时,我们也会被停止
select {
case <-doneCh:
return
default:
}
return fmt.Errorf("timeout")
}
}
如果select被更新为确定性的,原来的代码(在我看来,它更简单,更容易达到)将按原定计划工作。然而,由于select的非确定性,我们必须不断检查主导条件。
与此相关的是,我很想看到一种 "如果这个通道包含任何消息,就从这个通道读取,否则继续 "的速记语法。目前的语法是冗长的。
select {
case <-doneCh:
return
default:
}
我很想看到这个检查的更简洁的版本,也许是这样的语法:
select <-?doneCh: // not valid Go 不过在Go是无效的
结构化日志记录接口
Go 的标准库包括`log`[14]包,这对于基本用途来说是不错的。但是,大多数生产系统都希望进行结构化日志记录,Go 中不乏结构化日志库[15]:
apex/log[16] go-kit/log[17] golang/glog[18] hashicorp/go-hclog[19] inconshreveable/log15[20] rs/zerolog[21] sirupsen/logrus[22] uber/zap[23]
Go在这一领域缺乏主见,导致了这些包的泛滥,其中大部分都有不兼容的函数和签名。因此,一个库的作者不可能发出结构化的日志。例如,我希望能够在 go-retry[24]、 go-envconfig[25]、或 go-githubactions[26]中发射结构化日志,但这样做需要与这些库中的一个紧密耦合。理想情况下,我希望我的库的用户可以选择他们的结构化日志解决方案,但由于缺乏一个通用的结构化日志接口,这一点非常困难。
Go 标准库需要定义一个结构化的日志接口, 所有这些现有的上游包都可以选择实现该接口。然后,作为库作者,我可以选择接受一个接口log.StructuredLogger
,实现者可以做出自己的选择:
func WithLogger(l log.StructuredLogger) Option {
return func(f *Foo) *Foo {
f.logger = l
return f
}
}
我把这样一个接口的草图快速勾勒了出来。
// StructuredLogger is an interface for structured logging.
type StructuredLogger interface {
// Log logs a message.
Log(message string, fields ...LogField)
// LogAt logs a message at the provided level. Perhaps we could also have
// Debugf, Infof, etc, but I think that might be too limiting for the standard
// library.
LogAt(level LogLevel, message string, fields ...LogField)
// LogEntry logs a complete log entry. See LogEntry for the default values if
// any fields are missing.
LogEntry(entry *LogEntry)
}
// LogLevel is the underlying log level.
type LogLevel uint8
// LogEntry represents a single log entry.
type LogEntry struct {
// Level is the log level. If no level is provided, the default level of
// LevelError is used.
Level LogLevel
// Message is the actual log message.
Message string
// Fields is the list of structured logging fields. If two fields have the same
// Name, the later one takes precedence.
Fields []*LogField
}
// LogField is a tuple of the named field (a string) and its underlying value.
type LogField struct {
Name string
Value interface{}
}
围绕着实际的接口会是什么样子,如何最小化分配,以及如何最大化兼容性,有很多讨论,但目标是定义一个其他日志库可以轻松实现的接口。
在我的Ruby时代,有大量的Ruby版本管理器,每个都有自己的dotfile名称和语法。Fletcher Nichol设法说服了这些Ruby版本管理器的所有维护者,使其标准化为.ruby-version,只需写一个gist[27]。我希望我们能在Go 社区中使用结构化日志记录做类似的事情。
多错误处理
有很多情况,特别是对于后台工作或周期性任务,系统可能会并行处理一些事情或在出现错误时继续处理。在这些情况下,返回一个多重错误是有帮助的。在标准库中没有对处理错误集合的内置支持。
围绕多错误处理有清晰简洁的标准库定义,可以统一社区,减少错误处理不当的风险,正如我们看到的错误包装(wrap)和解包(unwrap)。
针对 JSON Marshal的error
说到error,你知道吗,将error类型嵌入到一个结构字段中,然后将该结构作为JSON进行marshal
,将 "error"字段marshal
为{}?
// https://play.golang.org/p/gl7BPJOgmjr
package main
import (
"encoding/json"
"fmt"
)
type Response1 struct {
Err error `json:"error"`
}
func main() {
v1 := &Response1{Err: fmt.Errorf("oops")}
b1, err := json.Marshal(v1)
if err != nil {
panic(err)
}
// got: {"error":{}}
// want: {"error": "oops"}
fmt.Println(string(b1))
}
至少对于内置的errorString
类型,Go应该为.Error()的结果进行marshal
。另外,对于Go 2.0来说,当试图marshal
一个没有实现自定义marshal
逻辑的error类型时,JSON marshal
会返回一个错误。
标准库中不再有公共变量
仅举一个例子,http.DefaultClient
和http.DefaultTransport
都是具有共享状态的全局变量。http.DefaultClient
没有配置超时,这使得DOS你自己的服务和创造瓶颈变得很容易。许多软件包都会突变,http.DefaultClient
和http.DefaultTransport
,这可能会浪费开发者数天的资源来追踪错误。
Go 2.0应该把这些东西变成私有的,并通过一个函数调用来公开它们,返回有关变量的唯一分配。另外,Go 2.0还可以实现 "冻结 "的全局变量,这样它们就不能被其他包所改变。
从软件供应链的角度来看,我也担心这类问题。如果我可以开发一个有用的包,秘密地修改http.DefaultTransport
,使用一个自定义的RoundTripper
,将你的所有流量通过我的服务器输送出去,那将是一个非常糟糕的瞬间。
对缓冲渲染器的本地支持
这更像是一个"不为人所知或有据可查的事情"。大多数示例(包括 Go 文档中的示例)都鼓励执行以下操作,以便通过 Web 请求对 JSON 进行marshal或渲染 HTML:
func toJSON(w http.ResponseWriter, i interface{}) {
if err := json.NewEncoder(w).Encode(i); err != nil {
http.Error(w, "oops", http.StatusInternalServerError)
}
}
func toHTML(w http.ResponseWriter, tmpl string, i interface{}) {
if err := templates.ExecuteTemplate(w, tmpl, i); err != nil {
http.Error(w, "oops", http.StatusInternalServerError)
}
}
然而,对于这两种情况,如果i
足够大,有可能在发送第一个字节(和200状态码)后,编码/执行失败。在这一点上,请求是无法恢复的,因为你无法改变响应代码。
大体上被接受的缓解方案是先渲染,然后复制到w。这仍然会有很小的出错空间(由于连接问题导致向w写入失败),但它确保在发送第一个字节之前,编码/执行是成功的。然而,在每个请求中分配一个字节片是很昂贵的,所以你通常会使用缓冲池[28]。
这种方法非常冗长,并将许多不必要的复杂性推给实现者。相反,如果 Go能自动处理此缓冲池管理,可能会使用EncodePooled
等函数,那就更好了。
结束语
Go仍然是我最喜欢的编程语言之一,这就是为什么我觉得可以强调这些批评的原因。与任何编程语言一样,Go也在不断发展。你认为这些是好主意吗?还是说它们是糟糕的建议?请在Twitter[29]上告诉我。
参考资料
What I'd like to see in Go 2.0: https://www.sethvargo.com/what-id-like-to-see-in-go-2
[2]Learning Chef: https://www.amazon.com/Learning-Chef-Configuration-Management-Automation/dp/1491944935
[3]text/template
: https://pkg.go.dev/text/template
html/template
: https://pkg.go.dev/html/template
Consul Template: https://github.com/hashicorp/consul-template
[6]Hugo: https://gohugo.io/
[7]相当广泛的辅助函数列表: https://gohugo.io/functions/
[8]Exposure Notification: https://g.co/ens
[9]无法逃脱反射: https://github.com/google/exposure-notifications-verification-server/blob/0ec489ba95137d5be10e1617d1dcdc2d1ee6e5e9/pkg/render/renderer.go#L232-L280
[10]Go 1.18: https://tip.golang.org/doc/go1.18#text/template
[11]改善 for-loop 人体工程学设计: https://github.com/golang/go/issues/24282
[12]在每次迭代中重新定义范围循环变量: https://github.com/golang/go/issues/20733
[13]会通过统一的伪随机来选择case的: https://golang.org/ref/spec#Select_statements
[14]log
: https://pkg.go.dev/log
Go 中不乏结构化日志库: https://www.client9.com/logging-packages-in-golang/
[16]apex/log: https://github.com/apex/log
[17]go-kit/log: https://github.com/go-kit/kit/tree/master/log
[18]golang/glog: https://github.com/golang/glog
[19]hashicorp/go-hclog: https://github.com/hashicorp/go-hclog
[20]inconshreveable/log15: https://github.com/inconshreveable/log15
[21]rs/zerolog: https://github.com/rs/zerolog
[22]sirupsen/logrus: https://github.com/sirupsen/logrus
[23]uber/zap: https://github.com/uber-go/zap
[24]go-retry: https://github.com/sethvargo/go-retry
[25]go-envconfig: https://github.com/sethvargo/go-envconfig
[26]go-githubactions: https://github.com/sethvargo/go-githubactions
[27]gist: https://gist.github.com/fnichol/1912050
[28]使用缓冲池: https://github.com/google/exposure-notifications-verification-server/blob/08797939a56463fe85f0d1b7325374821ee31448/pkg/render/html.go#L65-L91
[29]Twitter: https://twitter.com/sethvargo
推荐阅读