Go 2.0该如何满足开发者的期待?

2023-06-01 00:00:00 开发者 该如何 期待

虽然 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的改进:不要复制值

虽然文档很齐全,但 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

在 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:  returndefault:}

我很想看到更简洁的检查,比如像下面这样:

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 类型嵌入到结构字段中,然后将这个结构进行JSON序列化,"error"就会被序列化成{}?

// https://play.golang.org/p/gl7BPJOgmjrpackage 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/

相关文章