总结go特性(1.13~1.16)

219 阅读17分钟

go1.13

1.对数字字面量进行了改动

在1.13版本之前的golang仅支持十进制和十六进制的字面量,而其他语言使用广泛的二进制和八进制却不能支持例如下面代码就无法编译:

fmt.Println(0b101) 
fmt.Println(0o10)

在go1.13中上述字面量语法已经被支持了你可以通过0b或0B前缀来表明一个二进制数字的字面量,以及用0o和0O来表明八进制字面量。值得注意的是虽然两种写法都可以,但是gofmt默认会全部转换为小写

1.13以下版本运行则会出现以下错误信息:

# command-line-arguments
usercode/file.go:6: syntax error: unexpected name, expecting )
usercode/file.go:7: syntax error: unexpected name, expecting )    

在1.13版本以前的版本并不支持通过0b或0B来表明一个二进制数字的字面量,甚至用0o和0O来表明八进制字面量也是同样不支持的

最后对于数字字面量还有一个小小的改进,那就是现在可以用下划线分隔数字增加可读性。

fmt.Println(100000000)
fmt.Println(1_0000_0000)
fmt.Println(0xff_ff_ff)

1.13返回结果:

100000000
100000000
16777215

分隔符可以出现在任意位置,但是像0x之类的算是一个完整的符号的中间不可以插入下划线,分隔符之间字符的数量没有规定必须相等,但为了可读性最好按照现有的习惯每3个数字或四个数字进行一次分隔

1.13以下版本运行则会出现以下错误信息:

# command-line-arguments
./code.go:9: syntax error: unexpected _0000_0000, expecting comma or )
./code.go:10: syntax error: unexpected _ff_ff, expecting comma or )

在1.13版本以前的版本并不支持通过下划线分割数字来增加可读性

###2.越界索引报错的完善(运行时改进) 首先golang对数组和slice的越界引用是0容忍的,一旦越界就会panic,例如下面例子

package main

import "fmt"

func main() {
        arr := [...]int{1,2,3,4,5}
        for i := 0; i <= len(arr); i++ {
                fmt.Println(arr[i])
        }
}

1.13以下版本运行则会出现以下错误信息:

1
2
3
4
5
panic: runtime error: index out of range

1.13以前版本panic并不会把越界的值输出出来,虽然调用堆栈信息追溯起来不是很困难,可以方便得定位问题,但如果调用链较深或者你处于一个高并发程序之中,事情就变得麻烦了,要么我们日志调试并最终分析排除大量杂音来定位问题,要么依赖断点进行单步调式,无论哪种都需要耗费大量的精力而核心问题只是我们想到为什么会越界,再浅一步,我们有时候或许只要知道导致越界的值就可以大致确定问题的原因,遗憾的是panic提供的信息中不包含上述内容,直到golang1.13。而现在golang会将导致越界的值打印出来,无疑是雪中送碳:

1
2
3
4
5
panic: runtime error: index out of range [5] with length 5

goroutine 1 [running]:
main.main()
        /Users/chengaosheng/go/test/test.go:46 +0xec
exit status 2

当然,panic信息再完善也不是灵丹妙药,完善的单元测试和严谨的工作态度才是bug最好的预防针。

3.工具链改进

除了去除了godoc程序,最大的变化仍旧集中在go modules上。

3.1 GOPROXY

其实这个变量在1.12中就引入了,这次为其加上了默认值proxy.golang.org,direct, 这是一个逗号分隔的列表,后面两个变量的值和它相同,其中direct表示不经过代理直接连接,如果设置为off,则进制下载任何package。

在go get等命令获取package时,会从左至右依次查找,如果都没有找到匹配的package,则会报错。

proxy的好处自然不用多说,它可以使国内开发者畅通无阻地访问某些国内环境无法获取的包。更重要的是默认的proxy是官方提供和维护的,比起第三方方案来说安全性有了更大的保障。

3.2 GOSUMDB

这个变量实际上相当于指定了一个由官方管理的在线的go.sum数据库。具体介绍之前我们先来看看golang是如何验证packages的:

go get下载的package会根据go.mod文件和所有下载文件分别建立一个hash字符串,存储在go.sum文件中; 下载的package会被cache,每次编译或者手动go mod verify时会重新计算与go.sum中的值比较,出现不一致就会报安全错误。 这个机制是建立在本地的cache在整个开发生命周期中不会变动之上的(因为依赖库的版本很少会进行更新,除非出现重大安全问题),上述机制可以避免他人误更新依赖或是本地的恶意篡改,然而现在更多的安全问题是发生在远程环境的,因此这一机制有很大的安全隐患。

好在加入了GOSUMDB,它的默认值为“sum.golang.org”,国内部分地区无法访问,可以改为“sum.golang.google.cn”。现在的工作机制是这样的:

go get下载包并计算校验和,计算好后会先检查是否已经出现在go.sum文件中,如果没有则去GOSUMDB中检查,校验和一致则写入go.sum文件;否则报错 如果对应版本的包的校验和已经在go.sum中,则不会请求GOSUMDB,其余步骤和旧机制一样。 安全性得到了增强。

3.3 GOPRIVATE

最后要介绍的是GOPRIVATE,默认为空,你可以在其中使用类似Linux glob通配符的语法来指定某些或某一类包不从proxy下载,比如某些rpc套件自动生成的package,这些在proxy中并不会存在,而且即使上传上去也没有意义,因此你需要把它写入GOPRIVATE中。

还有一个与其类似的环境变量叫GONOPROXY,值的形式一样,作用也基本一样,不过它会覆盖GOPRIVATE。比如将其设为none时所有的包都会从proxy进行获取。

4.标准库的新功能

每次新版本发布都会给标准库带来大把的新功能新特性,这次也不例外。

4.1 判断变量是否为0值

golang中任何类型的0值都有明确的定义,然而遗憾的是不同的类型0值不同,特别是那些自定义类型,如果你要判断一个变量是否0值那么将会写出复杂繁琐而且扩展困难的代码。

因此reflect中新增了这一功能简化了操作:

package main

import (
        "fmt"
        "reflect"
)
 
func main() {
        a := 0
        b := 1
        c := ""
        d := "a"
        fmt.Println(reflect.ValueOf(a).IsZero()) // true
        fmt.Println(reflect.ValueOf(b).IsZero()) // false
        fmt.Println(reflect.ValueOf(c).IsZero()) // true
        fmt.Println(reflect.ValueOf(d).IsZero()) // false
}

当然,反射一劳永逸的代价是更高的性能消耗,所以具体取舍还要参照实际环境。

4.2 错误处理的革新

其实算不上革新,只是对现有做法的小修小补。golang团队始终有觉得error既然是值那就一定得体现value的equal操作的怪癖,所以整体上还是很怪。

首先要介绍错误链(error chains)的概念。

在1.13中,我们可以给error实现一个Unwrap的方法,从而实现对error的包装,比如:

type PermError {
        os.SyscallError
        Pid uint
        Uid uint
}

func (err *PermError) String() string {
        return fmt.Sprintf("permission error:\npid:%v\nuid:\ninfo:%v", err.Pid, err.Uid, err.SyscallError)
}

func (err *PermError) Error() string {
        return err.String()
}

// 重点在这里
func (err *PermError) Unwrap() error {
        return err.SyscallError
}

假设我们包装了一个基于SyscallError的权限错误,包括了所有因为权限问题而触发的error。String和Error方法都是常规的自定义错误中会实现的方法,我们重点看Unwrap方法。

Unwrap字面意思就是去包装,也就是我们把包装好的上一层错误重新分离出来并返回。os.SyscallError也实现了Unwrap,于是你可以继续向上追溯直达最原始的没有实现Unwrap的那个error为止。我们称从PermError开始到最顶层的error为一条错误链。

如果我们用→指向Unwrap返回的对象,会形成下面的结构:

PermError → os.SyscallError → error

还可以出现更复杂的结构: A → Err1 ___________

|

V

B → Err2 → Err3 → error

这样无疑提升了错误的表达力,如果不想自己单独定义一个错误类型,只想附加某些信息,可以依赖fmt.Errorf:

newErr := fmt.Errorf("permission error:\npid:%v\nuid:\ninfo:%w", pid, uid, sysErr)
sysErr == newErr.(interface {Unwrap() error}).Unwrap()

fmt.Errorf新的占位符%w只能在一个格式化字符串中出现一次,他会把error的信息填充进去,然后返回一个实现了Unwrap的新error,它返回传入的那个error。另外提案里的Wrapper接口目前还没有实现,但是标准库用了我在上面的做法暂时实现了Wrapper的功能。

因为错误链的存在,我们不能在简单的用等于号基于判断基于值的error了,但好处是我们现在还可以判断基于类型的error。

为了能继续让error表现自己的值语义,errors包里增加了Is和As以及辅助它们的Unwrap函数。

Unwrap errors.Unwrap会调用传入参数的Unwrap方法,As和Is使用它来追溯整个错误链。

像上一小节的代码就可以简化成这样:

newErr := fmt.Errorf("permission error:\npid:%v\nuid:\ninfo:%w", pid, uid, sysErr)
sysErr == errors.Unwrap(newErr).Unwrap()

我们提到等于号的比较很多时候已经不管用了,有的时侯一个error只是对另一个的包装,当这个error产生时另一个也已经发生了,这时候我们只需要比较处于上层的error值即可,这时候你就需要errors.Is帮忙了:

newErr := fmt.Errorf("permission error:\npid:%v\nuid:\ninfo:%w", pid, uid, sysErr)
errors.Is(newErr, sysErr)
errors.Is(newErr, os.ErrExists)

你永远也不知道程序会被怎样扩展,也不知道error之间的关系未来会怎样变化,因此总是用Is代替==是不会犯错的。

不过凡事总有例外,例如io.EOF就不需要使用Is去比较,因为它程序意义上算不上是error,而且一般也不会有人包装它。

As 除了传统的基于值的判断,对某个类型的错误进行处理也是一个常见需求。例如前文的A,B都来自error,假设我们现在要处理所有基于这个error的错误,常见的办法是switch进行比较或者依赖于基类的多态能力。

显而易见的是switch判断的做法会导致大量重复的代码,而且扩展困难;而在golang里没有继承只有组合,所以有运行时多态能力的只有interface,这时候我们只能借助错误链让errors.As帮忙了:

// 注意As的第二个参数只能是你需要判断的类型的指针,不可以直接传一个nil进去

var p1 *os.SyscallError
var p2 *os.PathError
errors.As(newErr, &p1)
errors.As(newErr, &p2)

如果p1和p2的类型在newErr所在的错误链上,就会返回true,实现了一个很简陋的多态效果。As总是用于替代if _, ok := err.(type); ok这样的代码。

当然,上面的函数一方面让你少写了很多代码,另一方面又严重依赖反射,特别是错误链很长的时候需要反复追溯多次,所以这里有两条忠告:

不要过渡包装,没什么是加间接层解决不了的,但是中间层太多不仅影响性能也会干扰后续维护; 如果你实在在意性能,而且保证不存在对现有error的扩展(例如io.EOF),那么使用传统方案也无伤大雅。

go1.14

1.工具

1.go build等命令默认将会使用 -mod=vendor,如果需要使用mod cache需要显示指定 -mod=mod2.go mod init 设置go.mod文件是-mod=readonly,go.mod是只读模式的。
3.go mod tidy之外的go命令不再编辑go.mod文件
4.除非明确要求或已经要求该版本,否则 go get 将不再升级到该模块的不兼容主要版本。直接从版本控制中获取时,go list 还会忽略此模块的不兼容版本
5.在 module 模式下,go 命令支持 SVN 仓库
6.go test -v 现在将 t.Log 输出流式传输,而不是在所有测试数据结束时输出

2.goroutine 支持异步抢占

在Go1.1版本中,调度器还不支持抢占式调度,只能依靠 goroutine 主动让出 CPU 资源,存在非常严重的调度问题。

Go1.12中编译器在特定时机插入函数,通过函数调用作为入口触发抢占,实现了协作式的抢占式调度。但是这种需要函数调用主动配合的调度方式存在一些边缘情况,就比如说下面的例子:

func main() {
    runtime.GOMAXPROCS(1)  
    go func() {
            for {
            }
    }()
    time.Sleep(time.Millisecond)
    println("OK")
}

上面代码中,其中创建一个goroutine并挂起, main goroutine 优先调用了 休眠,此时唯一的 P 会转去执行 for 循环所创建的 goroutine,进而 main goroutine 永远不会再被调度。换一句话说在Go1.14之前,上边的代码永远不会输出OK,因为这种协作式的抢占式调度是不会使一个没有主动放弃执行权、且不参与任何函数调用的goroutine被抢占。 Go1.14 实现了基于信号的真抢占式调度解决了上述问题。Go1.14 程序启动时, 会在函数runtime.sighandler 中注册了 SIGURG 信号的处理函数 runtime.doSigPreempt,在触发垃圾回收的栈扫描时,调用函数挂起goroutine,并向M发送信号,M收到信号后,会让当前goroutine陷入休眠继续执行其他的goroutine。

3.testing 包新增CleanUp 方法

testing包的T、B和TB都加上了CleanUp方法,它将以后进先出的方式执行 f(如果注册多个的话)。 如下代码,输出结果是 test cleanup, clear resource:

func TestCleanup(t *testing.T) {
   t.Cleanup(func() {
      t.Log("clear resource")
   })
   t.Log("test cleanup")
}

4.允许嵌入具有重叠方法集的接口

下面接口定义在 Go1.14 之前是不允许的

type ReadWriteCloser interface {
    io.ReadCloser
    io.WriteCloser
}

因为 io.ReadCloser 和 io.WriteCloser 中 Close 方法重复了,编译时会提示:duplicate method Close。在Go1.14中支持了这种重复接口集

go1.15

1. 1.15版本变化

1.对Go链接器的实质性改进
2.改进了对高核心计数的小对象的分配
3.X.509 CommonName弃用
4.GOPROXY支持跳过返回错误的代理
5.新增了一个time/tzdata包
6.核心库的一些改进

2. 核心库的一些改进

2.1 time.Ticker增加了一个Reset方法支持改变ticker的duration。 time.Ticker是一个周期性的定时器,内置一个周期性传递时间的Channel。 使用time.NewTicker(d Duration)函数创建一个Ticker,这个Ticker内置一个通道字段,每个时间间隔会向这个通道发送当前的时间。ticker会调整时间间隔或者丢弃消息以适应反应慢的接收者。
func TestTickerReset(t *testing.T) {
	wait := make(chan struct{})
	ticker := time.NewTicker(time.Second * 1)
	go func() {
		defer close(wait)
		for i := 0; i < 5; i++ {
			t.Log(<-ticker.C)
			if i == 2 {
				ticker.Reset(time.Second * 2)
			}
		}
	}()
	<-wait
}
2.2 time/tzdata是Go 1.15新增加的包,当系统找不到时区数据时,通过导入这个包,可以在程序中内嵌时区数据。 导入这个包会使程序大小增加大约800KB,注意time/tzdata这个包应该是在程序的main包中导入的,而不要在一个libary项目中导入和使用。 另外也可以通过编译时传递-tags timetzdata来实现同样的效果。

3.panic展现形式变化

在Go 1.15之前,如果传给panic的值是bool, complex64, complex128, float32, float64, int, int8, int16, int32, int64, string, uint, uint8, uint16, uint32, uint64, uintptr等原生类型的值,那么panic在触发时会输出具体的值,比如:

package main
func foo() {
    var i uint32 = 17
    panic(i)
}
func main() {
    foo()
}

1.15以上版本:

panic: main.myint(27)

goroutine 1 [running]:
main.bar(...)
        /Users/chengaosheng/go/test/test.go:63
main.main()
        /Users/chengaosheng/go/test/test.go:56 +0x39
exit status 2

1.15以下版本:

panic: (main.myint) (0x105fca0,0xc00008e000)

goroutine 1 [running]:
main.bar(...)
    /Users/tonybai/go/src/github.com/bigwhite/experiments/go1.15-examples/runtime/panic.go:12
main.main()
    /Users/tonybai/go/src/github.com/bigwhite/experiments/go1.15-examples/runtime/panic.go:17 +0x39
exit status 2

Go 1.15针对此情况作了展示优化,即便是派生于这些原生类型的自定义类型变量,panic也可以输出其值。

4. 标准库

4.1 增加json解码限制

json包是日常使用最多的go标准库包之一,在Go 1.15中,go按照json规范的要求,为json的解码增加了一层限制。如果一旦传入的json文本数据缩进深度超过maxNestingDepth,那json包就会panic。当然,绝大多数情况下,我们是碰不到缩进10000层的超大json文本的。因此,该limit对于99.9999%的gopher都没啥影响。

4.2 reflect包

Go 1.15版本之前reflect包

例子:

package main

import "reflect"
 
type u struct{}
 
func (u) M() { println("M") }
 
type t struct {
    u
    u2 u
}
 
func call(v reflect.Value) {
    defer func() {
        if err := recover(); err != nil {
            println(err.(string))
        }
    }()
    v.Method(0).Call(nil)
}
 
func main() {
    v := reflect.ValueOf(t{}) // v := t{}
    call(v)                   // v.M()
    call(v.Field(0))          // v.u.M()
    call(v.Field(1))          // v.u2.M()
}

1.15以上版本:

172-15-70-45:test chengaosheng$ go run test.go 
M
reflect: reflect.Value.Call using value obtained using unexported field
reflect: reflect.Value.Call using value obtained using unexported field

我们看到reflect无法调用非导出字段u和u2的导出方法了。但是reflect依然可以通过提升到类型t的方法来间接使用u的导出方法,正如运行结果中的第一行输出。 这一改动可能会影响到遗留代码中使用reflect调用以类型嵌入形式存在的非导出字段方法的代码,如果你的代码中存在这样的问题,可以直接通过提升(promote)到包裹类型(如例子中的t)中的方法(如例子中的call(v))来替代之前的方式。

go1.16

1.Go 语言所打包的二进制文件中会包含配置文件的联同编译和打包

无法将静态资源编译打包进二进制文件的话,通常会有两种解决方法:

第一种是识别这类静态资源,是否需要跟着程序走。
第二种就是考虑将其打包进二进制文件中。

第二种情况的话,Go 以前是不支持的,大家就会去借助各种花式的开源库,例如:go-bindata/go-bindata 来实现。

但从在 Go1.16 起,Go 语言自身正式支持了该项特性

演示代码:

import _ "embed"

//go:embed hello.txt
var s string

func main() {
 print(s)
}

首先在对应目录下创建hello.txt文件并写"Hello world" 在代码中编写了最为核心的 //go:embed hello.txt 注解。注解的格式很简单,就是 go:embed 指令声明,外加读取的内容的地址,可支持相对和绝对路径。

输出结果:

Hello world

读取到静态文件中的内容后自动赋值给了变量 s,并且在主函数中成功输出。

而针对其他的基础类型,Go embed 也是支持的:

//go:embed hello.txt
var s string

//go:embed hello.txt
var b []byte

//go:embed hello.txt
var f embed.FS

func main() {
 print(s)
 print(string(b))

 data, _ := f.ReadFile("hello.txt")
 print(string(data))
}

输出结果:

Hello world
Hello world
Hello world

我们同时在一个代码文件中进行了多个 embed 的注解声明。

并且针对 string、slice、byte、fs 等多种类型进行了打包,也不需要过多的处理,非常便利。

2.拓展用法:

除去基本用法完,embed 本身在指令上也支持多种变形:

//go:embed hello1.txt hello2.txt
var f embed.FS

func main() {
 data1, _ := f.ReadFile("hello1.txt")
 fmt.Println(string(data1))

 data2, _ := f.ReadFile("hello2.txt")
 fmt.Println(string(data2))
}

在指定 go:embed 注解时可以一次性多个文件来读取,并且也可以一个变量多行注解:

//go:embed hello1.txt 
//go:embed hello2.txt
var f embed.FS

也可以通过在注解中指定目录 helloworld,再对应读取文件:

//go:embed helloworld
var f embed.FS

func main() {
 data1, _ := f.ReadFile("helloworld/hello1.txt")
 fmt.Println(string(data1))

 data2, _ := f.ReadFile("helloworld/hello2.txt")
 fmt.Println(string(data2))
}

同时既然能够支持目录读取,也能支持贪婪模式的匹配:

//go:embed helloworld/*
var f embed.FS

embed.FS 也能调各类文件系统的接口,其实本质是 embed.FS 实现了 io/fs 接口

3.只读属性

在 embed 所提供的 FS 中,其实可以发现都是打开和只读方法:

type FS
func (f FS) Open(name string) (fs.File, error)
func (f FS) ReadDir(name string) ([]fs.DirEntry, error)
func (f FS) ReadFile(name string) ([]byte, error)

根据此也可以确定 embed 所打包进二进制文件的内容只允许读取,不允许变更。

更抽象来讲就是在编译期就确定了 embed 的内容,在运行时不允许修改,保证了一致性。