转眼加入蚂蚁已经三个多月,这期间主要维护一 Go 写的服务器。虽然用的时间不算长,但还是积累了一些心得体会,这里总结归纳一下,供想尝试 Go 的同学参考。
本文会依次介绍 Go 的设计理念、开发环境、语言特性。本文在谈及语言特性的时也会讨论一些 Go 的不足之处,旨在给读者提供一个全面的视角。
一般来说,编程语言都会有一个 slogan 来表示它们的特点。比如提到 Clojure,一般会想到这么几个词汇:lisp on JVM、immutable、persistent;Java 的话我能想到的是企业级开发、中规中矩。对于 Go ,官网介绍到:
Go is an open source programming language that makes it easy to build simple, reliable, and efficient software.
提取几个关键词:open(开放)、simple(简洁)、reliable(可靠)、efficient(高效)。这也可以说是它的设计目标。除了上面这些口号外,初学者还需要知道 Go 是一门命令式的静态语言(是指在编译时检查变量类型是否匹配),与 Java 属于同一类别。
Imperative | Functional | |
---|---|---|
Dynamic | Python/Ruby/Javascript | Lisp/Scheme/Clojure |
Static | Java/C++/Rust/Go | OCaml/Scala/Haskell |
由于 Hello World 太简洁,不具备展示 Go 的特点,所以下面展示一段访问 httpbin,打印 response 的完整代码。
package main import ( "fmt" "io/ioutil" "net/http" ) func main() { // http://httpbin.org/#/Anything/get_anything r, err := http.Get("http://httpbin.org/anything?hello=world") if err != nil { panic(err) } defer r.Body.Close() body, err := ioutil.ReadAll(r.Body) if err != nil { panic(err) } fmt.Printf("body = %s/n", string(body)) }
上面的代码片段包括了 Go 的主要组成:包的声明与引用、函数定义、错误处理、流程控制、defer。
通过上面的代码片段,可以看出 Go 语言 simple(简洁)的特点,所以找一个最熟悉的文本编辑器,一般通过配置插件,都可以达到快速开发的目的。很久之前我就已经把所有文本编辑放到 Emacs 上,这里介绍下我的配置。
除了 go-mode 这个 major mode,为了配置像 源码跳转、API 自动补全、查看函数文档等现代 IDE 必备功能,需要安装以下命令
go get -u github.com/rogpeppe/godef go get -u github.com/stamblerre/gocode # for go-eldoc/company-go go get -u golang.org/x/tools/cmd/goimports go get -u github.com/kisielk/errcheck go get -u github.com/lukehoban/go-outline # for go-imenu
然后再按照 setup-go.el 里的配置,就拥有了一个功能完备的开发环境。
Emacs G o 开发环境
不像 Java 语言需要运行时,Go 支持直接将整个项目 build 成一个二进制文件,方便部署,而支持交叉编译,不过在开发时,直接 go run XXX.go
更为便利,截止到 Go 1.12,还不支持 REPL,官方有提供在线版的 Playground 供分享、调试代码。
我个人的习惯是建一个 go-app 项目,每个要测试的逻辑放到一个 test 里面去,这样就可以使用 go test -v -run XXX
来运行。之所以不选用 go run
,是因为一个目录下只允许有一个 main 的 package,多个 IDE 会提示错误。
一般编程语言,数据类型分为基本的与复杂的两类。
基本的一般比较简单,表示一个值,Go 里面就有 string, bool, int8, int32(rune), int64, float32, float64, byte(uint8) 等基本类型
复杂类型一般表示多个值或具有某些高级用法,Go 里面有:
pointer Go 里只支持取地址 &
与间接访问 *
操作符,不支持对指针进行算术操作
struct 类似于 C 语言里面的 struct,Java 里面的对象
function 函数在 Go 里是一等成员
array 大小固定的数组
slice 动态的数组
map 哈希表
chan 用于在多个 goroutine 内通信
interface 类似于 Java 里面的接口,但是与 Java 里的用法不一样
下面将重点介绍 Go 里特有或用途最广的数据类型。
Go 里面的 struct 类似于 Java 里面的 Object,但是并没有继承,仅仅是对数据的一层包装(抽象)。相对于其他复杂类型,struct 是 值类型 ,也就是说作为函数参数或返回值时,会拷贝一份值,值类型分配在 stack 上,与之相对的引用类型,分配在 heap 上。
初学者一般会有这样的误区,认为传值比传引用要慢,实则不然,具体涉及到 Go 如何管理内存,这里暂不详述,感兴趣到可以阅读:
The Top 10 Most Common Mistakes I’ve Seen in Go Projects
pointer_test.go 这个测试在笔者机器上运行结果:
BenchmarkByPointer-8 20000000 86.7 ns/op BenchmarkByValue-8 50000000 31.9 ns/op
所以一般推荐直接使用值类型的 struct,如果确认这是瓶颈了,可以再尝试改为引用类型(&struct)
如果说 struct 是对状态的封装,那么 interface 就是对行为的封装,相当于对外的契约(contract)。而且 Go 里面有这么一条最佳实践
Accept interfaces, return concrete structs. (函数的参数尽量为 interface,返回值为 struct)
这样的好处也很明显,作为类库的设计者,对其要求的参数尽量宽松,方便使用,返回具体值方便后续的操作处理。一个极端的情况,可以用 interface{}
表示任意类型的参数,因为这个接口里面没有任何行为,所以所有类型都是符合的。又由于 Go 里面不支持范型,所以 interface{}
是唯一的解决手段。
相比较 Java 这类面向对象的语言,接口需要显式(explicit)继承(使用 implements 关键字),而在 Go 里面是隐式的(implicit),新手往往需要一段时间来体会这一做法的巧妙,这里举一例子来说明:
Go 的 IO 操作涉及到两个基础类型:Writer/Reader ,其定义如下:
type Reader interface { Read(p []byte) (n int, err error) } type Writer interface { Write(p []byte) (n int, err error) }
自定义类型如果实现了这两个方法,那么就实现了这两个接口,下面的 Example 就是这么一个例子:
type Example struct { } func (e *Example) Write(p byte[]) (n int, err error) { } func (e *Example) Read(p byte[]) (n int, err error) { }
由于隐式继承过于灵活,在 Go 里面可能会看到如下代码:
var _ blob.Fetcher = (*CachingFetcher)(nil)
这是通过将 nil 强转为 *CachingFetcher
,然后在赋值时,指定 blob.Fetcher
类型,保证 *CachingFetcher
实现了 blob.Fetcher
接口。
作为接口的设计者,如果想实现者显式继承一个接口,可以在接口中额外加一个方法。比如:
type Fooer interface { Foo() ImplementsFooer() }
这样,实现者必须实现 ImplementsFooer
方法才能说是继承了 Fooer
接口。所以说隐式继承有利有弊,需要开发者自己去把握。
Map/Slice 是 Go 里面最常用的两类数据结构,属于引用类型。在语言 runtime 层面实现,仅有的两个支持范型的结构。
Slice 是长度不固定的数组,类似于 Java 里面的 List。
// map 通过 make 进行初始化 // 如果提前知道 m 大小,建议通过 make 的第二个参数指定,避免后期的数据移动、复制 m := make(map[string]string, 10) // 赋值 m["zhangsan"] = "teacher" // 读取指定值,如不存在,返回其类型的默认值 v := m["zhangsan"] // 判断指定 key 知否在 map 内 v, ok := m["zhangsan"] // slice 通过 make 进行初始化 s := make([]int) // 增加元素 s = append(s, 1) // 也可以通过 make 第二个参数指定大小 s := make([]int, 10) for i:=0;i<10;i++ { s[i] = i } // 也可以使用三个参数的 make 初始化 slice // 第二个参数为初始化大小,第三个为最大容量 // 需要通过 append 增加元素 s := make([]int, 0 ,10) s = append(s, 1)
作为一门新语言,Goroutine 是 Go 借鉴 CSP 模型提供的并发解决方案,相比传统 OS 级别的线程,它有以下特点:
轻量,完全在用户态调度(不涉及OS状态直接的转化)
资源占用少,启动快
目前,Goroutine 调度器不保证公平(fairness),抢占(pre-emption)也支持的非常有限,一个空的 for{}
可能会一直不被调度出去。
一般可以使用 chan/select 来进行 Goroutine 之间的调度。chan 类似于 Java 里面的 BlockingQueue,且能保证 Goroutine-safe,也就是说多个 Goroutine 并发进行读写是安全的。
chan 里面的元素默认为1个,也可以在创建时指定缓冲区大小,读写支持堵塞、非堵塞两种模式,关闭一个 chan 后,再写数据时会 panic。
// chan 与 slice/map 一样,使用 make 初始化 ch := make(chan int, 2) // blocking read v := <-ch // nonblocking read, 需要注意 default 分支不能省略,否则会堵塞住 select { case v:=<-ch: default: } // blocking write ch <- v // nonblocking write select { case ch<-v: default: }
chan 作为 Go 内一重要数据类型,看似简单,实则暗藏玄妙,用时需要多加留意,这里不再展开叙述,后面打算专门写一篇文章去介绍,感兴趣的可以阅读下面的文章:
Curious Channels
Prosumer 基于 buffered chan 实现的生产者消费者,核心点在于关闭 chan 只意味着生产者不能再发送数据,消费者无法获知 chan 是否已经关闭,需要用其他方式去通信。
Go 相比 Java 来说,语言特性真的是少太多。推荐 Learn X in Y minutes 这个网站,快速浏览一遍即可掌握 Go 的语法。Go 的简洁程度觉得和 JavaScript 差不多,但却是一门静态语言,具有强类型,这两点又让它区别于一般的脚本语言。
Go 遵循约定大于配置(convention over configuratio)的设计理念,比如在构建一个项目时,直接 go build
一个命令就搞定了,不需要什么 Makefile、pom.xml 等配置文件。下面介绍几个常用的约定:
一个包内函数、变量的可见性是通过首字母大小写确定的。大写表示可见。
一般 {
放在行末,否则 Go 编辑器会自动插入一个逗号,导致编译错误
一个文件夹内,只能定义一个包
变量、函数命名尽量简短,标准库里面经常可以看到一个字母的变量
由于以上种种约定,在看别人代码时很舒服,有种 Python 的感觉。另外建议在编辑器中配置 goimports 来自动化格式代码。
Go 内没有 try catch 机制,而且已经明确拒绝了这个 Proposal,而是通过返回值的方式来处理。
f, err := os.Open(filename) if err != nil { return …, err // zero values for other results, if any }
Go 的函数一般通过返回多值的方式来传递 error(且一般是第二个位置),实际项目中一般使用 pkg/errors去处理、包装 err。
Go 的依赖管理,相比其他语言较弱。
在 Go 1.11 正式引入的 modules 之前,项目必须放在 $GOPATH/src/xxx.com/username/project 内,这样 Go 才能去正确解析项目依赖,而且 Go 社区没有统一的包托管平台,不像 Java 中 maven 一样有中央仓库的概念,而是直接引用 Git 的库地址,所以在 Go 里,一般会使用 github.com/username/package
的方式来表示。
go get
是下载依赖但命令,但一个个去 get 库不仅仅繁碎,而且无法固化依赖版本信息,所以 dep 应运而生,添加新依赖后,直接运行 dep ensure
就可以全部下下来,而且会把当前依赖的 commit id 记录到 Gopkg.lock 里面,这就能解决版本不固定的问题。
但 modules 才是正路,且在 1.13 版本会默认开启,所以这里只介绍它的用法。
# 首先导出环境变量 export GO111MODULE=on # 在一个空文件夹执行 init,创建一个名为 hello 的项目 go mod init hello # 这时会在当前文件夹内创建 go.mod ,内容为 module hello go 1.12 # 之后就可以编写 Go 文件,添加依赖后,执行 go run/ # 依赖会自动下载,并记录在 go.mod 内,版本信息记录在 go.sum
更多用法可以参考官方示例,这里只是想说明目前 Go 内的工具链大部分已经支持,但是 godoc 还不支持。
Go 也是具有垃圾回收的语言,但相比于 JVM,Go GC 可能显得及其简单,从 Go 1.10 开始,Go GC 采用 Concurrent Mark & Sweep (CMS) 算法,且不具有分代、compact 特性。读者如果对相关名词不熟悉,可以阅读:
https://engineering.linecorp.com/en/blog/go-gc/
而且 Go 里面调整 GC 的参数只有一个 GOGC
,表示下面的比率
新分配对象 / 上次 GC 后剩余对象
默认 100,表示新分配对象达到之前剩余对象大小时,进行 GC。 GOGC=off
可以关闭 GC,SetGCPercent 可以动态修改这个比率。
在启动一个 Go 程序时,可以设置 GODEBUG=gctrace=1
来打印 GC 日志,日志具体含义可参考 pkg/runtime,这里不再赘述。对调试感兴趣的可以阅读:
https://new.blog.cloudflare.com/go-dont-collect-my-garbage/
Go 最初由 Google 在 2007 为解决软件复杂度、提升开发效率的一试验品,到如今不过十二年,但无疑已经家喻户晓,成为云时代的首选。其面向接口的特有编程方式,也非常灵活,兼具动态语言的简洁与静态语言的高效,推荐大家尝试一下。Go Go Go!