作者 | Seth Vargo 译者 | 弯月
出品 | CSDN(ID:CSDNnews)
虽然 Go 是我最喜欢的编程语言之一,但它远不够完美。在过去的 10 年里,我使用 Go 构建了很多小型个人项目和大型应用程序。自 2009 年第一版发布以来,Go 有了很大变化,但我希望通过本文表达我认为 Go 仍有待改进的一些领域。
在此之前,首先声明一点:我并不是在批评 Go 开发团队或个人的贡献。我的目的是让 Go 成为最好的编程语言。
Go的标准库有两个模板包:text/template 和 html/template。二者使用的语法大致相同,但 html/template 会处理实体转义和一些其他特定于 Web 的构造。然而不幸的是,对于一些高级的用例,这两个库都不够强大,依然需要进行大量开发。
编译时错误。与 Go本身不同,Go 的模板包允许将整数作为字符串传递,但会在运行时报错。这意味着,开发人员无法依赖类型系统,他们需要严格测试模板中所有可能的输入。Go 的模板包应该支持编译时类型检查。
与Go语言一致的range子句。虽然我使用 Go 已有10 年之久,但仍然不太理解 Go 模板中 range 子句的顺序,因为它有时与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的模板包应该与标准库相一致。
多提供标准功能,减少反射的使用。我认为大多数开发人员永远不需要使用反射。但是,如果想实现的功能超出了基本的加减法,那么 Go 的模板包就会强迫你使用反射,因为它的内置函数非常少,只能满足一小部分用例。
在编写完 Consul Template(https://github.com/hashicorp/consul-template)之后,我明显感觉到标准的 Go 模板功能不足以满足用户的需求。超过一半的问题都与使用 Go 的模板语言有关。如今,Consul Template 拥有 50 多个“辅助”功能,其中绝大多数都应该由标准模板语言提供。
不仅仅是我遇到了这个问题,Hugo 有一个广泛的辅助函数列表(https://gohugo.io/functions/),其中的绝大多数都应该由标准模板语言提供。即使在我最近的一个项目中,也无法避免使用反射。
Go的模板语言确实需要更广泛的函数集。
条件短路。Go 的模板语言总是在子句中对整个条件进行求值,这会产生一些非常可笑的错误(直到运行时才会显示出来。)考虑以下情况,假设 $foo 可能为 nil:
{{ if (and $foo $foo.Bar) }}
虽然代码看上去没问题,但是两个 and 条件都需要求值,也就是说表达式中没有短路逻辑。如果 $foo 为 nil,就会引发运行时异常。
为了解决这个问题,你必须分割条件子句:
{{ if $foo }} {{ if $foo.Bar }}{{ end }}
Go的模板语言应该像标准库一样运行,在遇到第一个真值条件后就停止。
特定于 Web 的小工具。多年来,我一直是一名 Ruby on Rails 开发人员,我时常感叹于用 Ruby on Rails 构建漂亮的 Web 应用程序是多么容易。然而使用 Go 的模板语言,即使是最简单的任务,比如输出句子中的每一个单词,初学者也无法完成,尤其是与 Rails 的 Enumerable#to_sentence 相比。
虽然文档很齐全,但 range 子句中的值被复制还是出人意料。例如,考虑以下代码:
type Foo struct {
bar string
}
func main() {
list :=[]Foo{{"A"}, {"B"}, {"C"}}
cp := make([]*Foo,len(list))
for i, value := rangelist {
cp[i] = &value
}
fmt.Printf("list:%q\n", list)
fmt.Printf("cp:%q\n", cp)
}
cp的值是什么?[A B C] ?不好意思,你错了。实际上,cp 的值为:
[C C C]
这是因为 Go 的 range 子句中使用的是值的副本,而不是值本身。在 Go 2.0 中,range 子句应该通过引用传递值。此外,我还有一些关于 Go 2.0 的建议,包括改进 for-loop,在每次迭代中重新定义范围循环变量。
在 select 语句中,如果有多个条件为真,那么究竟会执行哪个语句是不确定的。这个细微的差异会导致错误,这个问题与使用方法相似的switch语句相比更为明显,因为 switch 语句会按照写入的顺序逐个求值。
考虑以下代码,我们希望的行为是:如果系统停止,则什么也不做。否则等待 5 秒,然后超时。
for {
select {
case <-doneCh: // or<-ctx.Done():
return
case thing :=<-thingCh:
// ... long-runningoperation
case<-time.After(5*time.Second):
returnfmt.Errorf("timeout")
}
}
对于 select 语句,如果多个条件为真(例如 doneCh 已关闭且已超过 5 秒),则最后会执行哪个语句是不确定的行为。因此,我们不得不加上冗长的取消代码:
for {
// Check here in casewe've been CPU throttled for an extended time, we need to
// check graceful stopor risk returning a timeout error.
select {
case <-doneCh:
return
default:
}
select {
case <-doneCh:
return
case thing :=<-thingCh:
// Even though thiscase won, we still might ALSO be stopped.
select {
case <-doneCh:
return
default:
}
// ...
default<-time.After(5*time.Second):
// Even though thiscase 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的标准库包含 log 包,可用于处理基本操作。但是,大多数生产系统都需要结构化的日志记录,而 Go 中也不乏结构化日志记录库:
● apex/log
● go-kit/log
● golang/glog
● hashicorp/go-hclog
● inconshreveable/log15
● rs/zerolog
● sirupus/logrus
● uber/zap
由于 Go 在这个领域没有给出明确的意见,因此导致了这些包的泛滥,其中大多数都拥有不兼容的功能和签名。因此,库作者不可能发出结构化日志。例如,我希望能够在 go-retry、go-envconfig 或 go-githubactions 中发出结构化日志,但这样做就会与其中某个库紧密耦合。理想情况下,我希望库的用户可以自行选择结构化日志记录解决方案,但是由于缺乏通用接口,使得这种选择非常困难。
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 messageat 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 acomplete log entry. See LogEntry for the default values if
// any fields aremissing.
LogEntry(entry*LogEntry)
}
// LogLevel is the underlying log level.
type LogLevel uint8
// LogEntry represents a single log entry.
type LogEntry struct {
// Level is the loglevel. If no level is provided, the default level of
// LevelError is used.
Level LogLevel
// Message is the actuallog message.
Message string
// Fields is the list ofstructured logging fields. If two fields have the same
// Name, the later onetakes precedence.
Fields []*LogField
}
// LogField is a tuple of the named field (a string) and itsunderlying value.
type LogField struct {
Name string
Value interface{}
}
围绕具体的接口、如何最小化资源分配以及最大化兼容性的讨论有很多,但目标都是定义一个其他日志库可以轻松实现的接口。
回到我从事 Ruby 开发的时代,有一阵子 Ruby 的版本管理器激增,每个版本管理器的配置文件名和语法都不一样。Fletcher Nichol 写了一篇 gist,成功地说服所有 Ruby 版本管理器的维护者对 .ruby-version 进行标准化。我希望 Go 社区也能以类似的方式处理结构化日志。
在很多情况下,尤其是后台作业或周期性任务,系统可能会并行处理多个任务或采用continue-on-error策略。在这些情况下,返回多个错误会很有帮助。标准库中没有处理错误集合的内置支持。
Go社区可以围绕多错误处理建立清晰简洁的标准库,这样不仅可以统一社区,而且还可以降低错误处理不当的风险,就好象错误打包和展开那样。
说到错误,你知不知道如果将 error 类型嵌入到结构字段中,然后将这个结构进行JSON序列化,"error"就会被序列化成{}?
// 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()的结果进行序列化。或者在 Go 2.0 中,也可以在试图对 error 类型进行序列化时,如果没有定义序列化逻辑,则返回错误。
仅举一个例子,http.DefaultClient 和 http.DefaultTransport 都是具有共享状态的全局变量。http.DefaultClient 没有设置超时,因此很容易引发 DOS 攻击,并造成瓶颈。许多包都会修改 http.DefaultClient 和 http.DefaultTransport,这会导致开发人员需要浪费数天来跟踪错误。
Go2.0 应该将这些全局变量设为私有,并通过函数调用来公开它们,而这个函数的调用会返回一个唯一的已分配好的变量。或者,Go 2.0 也可以实现一种“冻结”的全局变量,这种全局变量无法被其他包修改。
从软件供应链的角度来看,这类问题也令我很担忧。如果我开发一个包,秘密地修改 http.DefaultTransport,然后使用自定义的 RoundTripper,将所有流量都转发到我的服务器,那就麻烦了。
有些问题是因为不为人知或没有文档记录。大多数示例,包括 Go 文档中的示例,都应该按照以下行为进行JSON序列化或通过 Web 请求呈现 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 失败),但可以确保在发送第一个字节之前编码/执行成功。但是,为每个请求分配一个字节切片可能会很昂贵,因此通常都会使用缓冲池。
这种方法非常罗嗦,并且将许多不必要的复杂性推给了实现者。相反,如果 Go 能够使用 EncodePooled 之类的函数,自动处理这个缓冲池管理就好了。
Go是我最喜欢的编程语言之一,这就是为什么我愿意说出自己的一些批评意见。与其他编程语言一样,Go 也在不断发展。你赞同本文指出的这些问题吗?请在下方留言。
参考链接:
https://www.sethvargo.com/what-id-like-to-see-in-go-2/
《新程序员003》正式上市,50余位技术专家共同创作,云原生和数字化的开发者们的一本技术精选图书。内容既有发展趋势及方法论结构,华为、阿里、字节跳动、网易、快手、微软、亚马逊、英特尔、西门子、施耐德等30多家知名公司云原生和数字化一手实战经验!
☞
☞