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

作者 | Seth Vargo译者 | 弯月
出品 | CSDN(ID:CSDNnews)
虽然 Go 是我最喜欢的编程语言之一 , 但它远不够完美 。 在过去的 10 年里 , 我使用 Go 构建了很多小型个人项目和大型应用程序 。 自 2009 年第一版发布以来 , Go 有了很大变化 , 但我希望通过本文表达我认为 Go 仍有待改进的一些领域 。
在此之前 , 首先声明一点:我并不是在批评 Go 开发团队或个人的贡献 。 我的目的是让 Go 成为最好的编程语言 。
Vargo|Go 2.0 该如何满足开发者的期待?
文章图片

现代模板引擎
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)}
【Vargo|Go 2.0 该如何满足开发者的期待?】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:returncase thing :=<-thingCh:// ... long-runningoperationcase<-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:returndefault:}
select {case <-doneCh:returncase thing :=<-thingCh:// Even though thiscase won, we still might ALSO be stopped.select {case <-doneCh:returndefault:}// ...default<-time.After(5*time.Second):// Even though thiscase won, we still might ALSO be stopped.select {case <-doneCh:returndefault:}return fmt.Errorf("timeout")}}
如果能够将 select 语句改成确定的 , 则原始代码(更简单且更容易编写)就可以按预期工作 。 但是 , 由于 select 的非确定性 , 我们必须不断检查占主导地位的条件 。
此外 , 我希望看到“如果该分支通过条件判断 , 就执行下面的代码 , 否则继续下一个分支”的简写语法 。 当前的语法很冗长:
select {case <-doneCh:returndefault:}
我很想看到更简洁的检查 , 比如像下面这样:
select<-?doneCh: // notvalid 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 = lreturn 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 stringValue 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/
《新程序员003》正式上市 , 50余位技术专家共同创作 , 云原生和数字化的开发者们的一本技术精选图书 。 内容既有发展趋势及方法论结构 , 华为、阿里、字节跳动、网易、快手、微软、亚马逊、英特尔、西门子、施耐德等30多家知名公司云原生和数字化一手实战经验!
?IT = 加班多?外国小哥打破“魔咒”:“每天工作 10 分钟 , 工资近 9 万美元!”
?WebAssembly崛起 , Kubevirt成主流 , 2022年云原生的五大发展趋势
? 从“去IOE”的替代到开源创新 , 国产数据库的 2021 年

    推荐阅读