十条有用的 GO 技术
共 9312字,需浏览 19分钟
·
2021-06-25 22:27
这是我这几年写 Go 代码的一些经验总结。我相信他们在一些情况下能帮助到你们。例如:
你开发的应用依赖经常改变。你不希望每 3 - 4 个月就不能不重构这些代码。新的功能应该很轻易的添加上去。
你的应用由多人合作开发,它代码应该易读和方便维护的。
你的应用被很多人使用,有一些很容易发现并需要快速修复的 bug。
随着时间的推移,我发现这些事情不管在什么时候都是很重要的。有一些虽然比较次要,但他们影响着很多事情。下面是一些建议,如果它们能够在工作上帮到你,请让我知道。请随意发表你的看法 :)
0.0.1 1. 使用单一的 GOPATH
多个 GOPATH 的情况并不具有弹性。GOPATH 本身就是高度自我完备的(通过导入路径)。有多个 GOPATH 会导致某些副作用,例如可能使用了给定的库的不同的版本。你可能在某个地方升级了它,但是其他地方却没有升级。而且,我还没遇到过任何一个需要使用多个 GOPATH 的情况。所以只使用单一的 GOPATH,这会提升你 Go 的开发进度。
许多人不同意这一观点,接下来我会做一些澄清。像 [etcd](https: //github.com/coreos/etcd) 或 [camlistore](https: //camlistore.org/) 这样的大项目使用了像 [godep](https: //github.com/tools/godep) 这样的工具,将所有依赖保存到某个目录中。也就是说,这些项目自身有一个单一的 GOPATH。它们只能在这个目录里找到对应的版本。除非你的项目很大并且极为重要,否则不要为每个项目使用不同的 GOPATH。如果你认为项目需要一个自己的 GOPATH 目录,那么就创建它,否则不要尝试使用多个 GOPATH。它只会拖慢你的进度。
0.0.2 2. 将 「for-select」 语法结构封装成函数
如果需要中断 「for-select」 语法结构,通常是需要使用标签来实现的,示例如下:
func main() {
L:
for {
select {
case <-time.After(time.Second):
fmt.Println("hello")
default:
break L
}
}
fmt.Println("ending")
}
如你所见,在 「for-select」 语法结构中,需要结合标签的使用来实现中断。这是一种常见的用法,但是我并不喜欢。因为示例中的循环结构看起来很简短明了,这样使用并不显得有太大的问题,但在实际中通常要比这个复杂得多,并且追踪中断情形会显得十分冗长繁琐。
因此如果我需要在 「for-select」 语法结构中实现中断,我通常会将其封装成一个函数来实现:
func main() {
foo()
fmt.Println("ending")
}
func foo() {
for {
select {
case <-time.After(time.Second):
fmt.Println("hello")
default:
return
}
}
}
这就简洁优雅多了,当然你也可以在这个函数中返回错误(或任意其它值)来帮助函数调用者完善业务逻辑的处理,例如这样:
// blocking
if err := foo(); err != nil {
// 处理错误
}
0.0.3 3. 在初始化结构体时使用带有标签的语法
这是一个无标签语法的例子:
type T struct {
Foo string
Bar int
}
func main() {
t := T{"example", 123} // 无标签语法
fmt.Printf("t %+vn", t)
}
那么如果你添加一个新的字段到T结构体,代码会编译失败:
type T struct {
Foo string
Bar int
Qux string
}
func main() {
t := T{"example", 123} // 无法编译
fmt.Printf("t %+vn", t)
}
如果使用了标签语法,Go 的兼容性规则 (http://golang.org/doc/go1compat) 会处理代码。例如在向 net 包的类型添加叫做 Zone 的字段,参见:http://golang.org/doc/go1.1#library。回到我们的例子,使用标签语法:
type T struct {
Foo string
Bar int
Qux string
}
func main() {
t := T{Foo: "example", Bar: 123}
fmt.Printf("t %+vn", t)
}
这个编译起来没问题,而且弹性也好。不论你如何添加其他字段到T结构体。你的代码总是能编译,并且在以后的 Go 的版本也可以保证这一点。只要在代码集中执行 go vet,就可以发现所有的无标签的语法。
0.0.4 4. 将结构体 「struct」 的初始化拆分成多行
如果你要初始化的结构体多于2个字段,请你务必拆分成多行来进行。此举会使你的代码更简单明了、易于阅读,而非如下所示让人难以阅读:
T{Foo: "example", Bar:someLongVariable, Qux:anotherLongVariable, B: forgetToAddThisToo}
以上例子的初始化应当这样做:
T{
Foo: "example",
Bar: someLongVariable,
Qux: anotherLongVariable,
B: forgetToAddThisToo,
}
这样做会有以下几个优点:
清晰明了,易于阅读;
启/禁用某些字段初始化更简单(只需注释或删除对应行即可);
添加新的字段初始化更简单(只需新增一行即可)。
0.0.5 5. 为整型常量值增加一个 String() 方法
如果你使用 iota 的自定义整型用于枚举,请始终增加一个 String() 方法。假设你写了这样的代码:
type State int
const (
Running State = iota
Stopped
Rebooting
Terminated
)
如果你为 State 类型赋值,并且输出它,那么你将会看到一个数字 (http://play.golang.org/p/V5VVFB05HB):
func main() {
state := Running
// print: "state 0"
fmt.Println("state ", state)
}
在这里0是没有任何意义的,除非你去看回你声明变量的代码。如果你为你的 State 类型增加一个 String() 方法,就不用再去看回声明变量的代码了。(http://play.golang.org/p/ewMKl6K302):
func (s State) String() string {
switch s {
case Running:
return "Running"
case Stopped:
return "Stopped"
case Rebooting:
return "Rebooting"
case Terminated:
return "Terminated"
default:
return "Unknown"
}
}
新的输出是: state: Running. 正如你所见,这种输出具有可读性。如果你需要调试你的应用 ,这将会使你的工作更加轻松。你也可以通过实现 MarshalJSON(), UnmarshalJSON() 等等方法,达到同样的效果。
在最后, 其实这些都可以通过 Stringer 工具去自动化实现:
[https: //godoc.org/golang.org/x/tools/cmd/s...](https: //godoc.org/golang.org/x/tools/cmd/stringer)
这个工具通过 go generate 去自动化生成基于整型的高效 String 方法
0.0.6 6. 让 iota 从 a +1 开始增量
在前面的例子中同时也产生了一个我已经遇到过许多次的 bug。假设你有一个新的结构体,有一个 State 字段:
type T struct {
Name string
Port int
State State
}
现在如果基于 T 创建一个新的变量,然后输出,你会得到奇怪的结果(http://play.golang.org/p/LPG2RF3y39) :
func main() {
t := T{Name: "example", Port: 6666}
// prints: "t {Name:example Port:6666 State:Running}"
fmt.Printf("t %+vn", t)
}
看到 bug 了吗?State 字段没有被初始化,Go 默认使用对应类型的零值进行填充。由于 State 是一个整数,零值也就是 0,但在我们的例子中它表示 Running。
如何知道 State 被初始化了?如何得知是在 Running 模式?事实上,没有很好的办法区分它们,并且它们还会产生更多未知的、不可预测的 Bug。不过,修复这个很容易,只要让 iota 从 +1 开始 (http://play.golang.org/p/VyAq-3OItv):
const (
Running State = iota + 1
Stopped
Rebooting
Terminated
)
现在 t 变量将默认输出 Unknown,是不是?:) :
func main() {
t := T{Name: "example", Port: 6666}
// 输出: "t {Name:example Port:6666 State:Unknown}"
fmt.Printf("t %+vn", t)
}
不过让 iota 从零值开始也是一种解决办法。例如,你可以引入一个新的状态叫做 Unknown,将其修改为:
const (
Unknown State = iota
Running
Stopped
Rebooting
Terminated
)
0.0.7 7. 返回回调函数
我看过很多代码像 (http://play.golang.org/p/8Rz1EJwFTZ):
func bar() (string, error) {
v, err := foo()
if err != nil {
return "", err
}
return v, nil
}
然而你可以这样写:
func bar() (string, error) {
return foo()
}
简单易读 (除非你想记录一些中间值)。
0.0.8 8. 把 slice、map 等定义为自定义类型
将 slice 或 map 定义成自定义类型可以让你的代码更容易维护。假设有一个 Server 类型和一个返回服务器列表的函数:
type Server struct {
Name string
}
func ListServers() []Server {
return []Server{
{Name: "Server1"},
{Name: "Server2"},
{Name: "Foo1"},
{Name: "Foo2"},
}
}
现在假设需要获取某些特定名字的服务器。需要对 ListServers() 做一些改动,增加筛选条件:
// ListServers 返回服务器列表。只会返回包含 name 的服务器。空的 name 将会返回所有服务器。
func ListServers(name string) []Server {
servers := []Server{
{Name: "Server1"},
{Name: "Server2"},
{Name: "Foo1"},
{Name: "Foo2"},
}
// 返回所有服务器
if name == "" {
return servers
}
// 返回过滤后的结果
filtered := make([]Server, 0)
for _, server := range servers {
if strings.Contains(server.Name, name) {
filtered = append(filtered, server)
}
}
return filtered
}
现在可以用这个来筛选有字符串 Foo 的服务器:
func main() {
servers := ListServers("Foo")
// 输出: "servers [{Name:Foo1} {Name:Foo2}]"
fmt.Printf("servers %+vn", servers)
}
显然这个函数能够正常工作。不过它的弹性并不好。如果你想对服务器集合引入其他逻辑的话会如何呢?例如检查所有服务器的状态,为每个服务器创建一个数据库记录,用其他字段进行筛选等等……
现在引入一个叫做 Servers 的新类型,并且修改原始版本的 ListServers() 返回这个新类型:
type Servers []Server
// ListServers 返回服务器列表
func ListServers() Servers {
return []Server{
{Name: "Server1"},
{Name: "Server2"},
{Name: "Foo1"},
{Name: "Foo2"},
}
}
现在需要做的是只要为 Servers 类型添加一个新的 Filter() 方法:
// Filter 返回包含 name 的服务器。空的 name 将会返回所有服务器。
func (s Servers) Filter(name string) Servers {
filtered := make(Servers, 0)
for _, server := range s {
if strings.Contains(server.Name, name) {
filtered = append(filtered, server)
}
}
return filtered
}
现在可以针对字符串 Foo 筛选服务器:
func main() {
servers := ListServers()
servers = servers.Filter("Foo")
fmt.Printf("servers %+vn", servers)
}
哈!看到你的代码是多么的简单了吗?还想对服务器的状态进行检查?或者为每个服务器添加一条数据库记录?没问题,添加以下新方法即可:
func (s Servers) Check()
func (s Servers) AddRecord()
func (s Servers) Len()
...
0.0.9 9. withContext 封装函数
有时对于函数会有一些重复劳动,例如锁/解锁,初始化一个新的局部上下文,准备初始化变量等等……这里有一个例子:
func foo() {
mu.Lock()
defer mu.Unlock()
// foo 相关的工作
}
func bar() {
mu.Lock()
defer mu.Unlock()
// bar 相关的工作
}
func qux() {
mu.Lock()
defer mu.Unlock()
// qux 相关的工作
}
如果你想要修改某个内容,你需要对所有的都进行修改。如果它是一个常见的任务,那么最好创建一个叫做 withContext 的函数。这个函数的输入参数是另一个函数,并用调用者提供的上下文来调用它:
func withLockContext(fn func()) {
mu.Lock
defer mu.Unlock()
fn()
}
只需要将之前的函数用这个进行封装:
func foo() {
withLockContext(func() {
// foo 相关的工作
})
}
func bar() {
withLockContext(func() {
// bar 相关的工作
})
}
func qux() {
withLockContext(func() {
// qux 相关的工作
})
}
不要光想着加锁的情形。对此来说最好的用例是数据库链接。现在对 withContext 函数作一些小小的改动:
func withDBContext(fn func(db DB)) error {
// 从连接池获取一个数据库连接
dbConn := NewDB()
return fn(dbConn)
}
如你所见,它获取一个连接,然后传递给提供的参数,并且在调用函数的时候返回错误。你需要做的只是:
func foo() {
withDBContext(func(db *DB) error {
// foo 相关的工作
})
}
func bar() {
withDBContext(func(db *DB) error {
// bar 相关的工作
})
}
func qux() {
withDBContext(func(db *DB) error {
// qux 相关的工作
})
}
你在考虑一个不同的场景,例如作一些预初始化?没问题,只需要将它们加到 withDBContext 就可以了。这对于测试也同样有效。
这个方法有个缺陷,它增加了缩进并且更难阅读。再次提示,永远寻找最简单的解决方案。
0.0.10 10. 为访问 map 增加 setter,getters
如果你重度使用 map 读写数据,那么就为其添加 getter 和 setter 吧。通过 getter 和 setter 你可以将逻辑封分别装到函数里。这里最常见的错误就是并发访问。如果你在某个 goroutein 里有这样的代码:
m["foo"] = bar
还有这个:
delete(m, "foo")
会发生什么?你们中的大多数应当已经非常熟悉这样的竞态了。简单来说这个竞态是由于 map 默认并非线程安全。不过你可以用互斥量来保护它们:
mu.Lock() m["foo"] = "bar" mu.Unlock()
以及:
mu.Lock() delete(m, "foo") mu.Unlock()
假设你在其他地方也使用这个 map。你必须把互斥量放得到处都是!然而通过 getter 和 setter 函数就可以很容易的避免这个问题:
func Put(key, value string) {
mu.Lock()
m[key] = value
mu.Unlock()
}
func Delete(key string) {
mu.Lock()
delete(m, key)
mu.Unlock()
}
使用接口可以对这一过程做进一步的改进。你可以将实现完全隐藏起来。只使用一个简单的、设计良好的接口,然后让包的用户使用它们:
type Storage interface {
Delete(key string)
Get(key string) string
Put(key, value string)
}
这只是个例子,不过你应该能体会到。对于底层的实现使用什么都没关系。不光是使用接口本身很简单,而且还解决了暴露内部数据结构带来的大量的问题。
但是得承认,有时只是为了同时对若干个变量加锁就使用接口会有些过分。理解你的程序,并且在你需要的时候使用这些改进。
0.0.11 总结
抽象永远都不是容易的事情。有时,最简单的就是你已经实现的方法。要知道,不要让你的代码看起来太聪明。Go 天生就是个简单的语言,在大多数情况下只会有一种方法来作某事。简单是力量的源泉,也是为什么在人的层面它表现的如此有弹性。
如果必要的话,使用这些基数。例如将 []Server 转化为 Servers 是另一种抽象,仅在你有一个合理的理由的情况下这么做。不过有一些技术,如 iota 从 1 开始计数总是有用的。再次提醒,永远保持简单。
特别感谢 Cihangir Savas、Andrew Gerrand、Ben Johnson 和 Damian Gryski 提供的极具价值的反馈和建议。
如果你有任何疑问请及时反馈, 请随时在 Twitter上与我分享:[@fatih]
本文中的所有译文仅用于学习和交流目的,转载请务必注明文章译者、出处、和本文链接
我们的翻译工作遵照 [CC 协议](https: //learnku.com/docs/guide/cc4.0/6589),如果我们的工作有侵犯到您的权益,请及时联系我们。