100 Go Mistakes and How to Avoid Them 一书主要描述了使用 Go 语言编程时的常见问题。本文是博主对第二章 - Code and project organization 的阅读笔记。
Table of Contents
引言
最近发现了一本书籍,叫做 100 Go Mistakes and How to Avoid Them。主要描述了使用 Go 语言编程时的常见问题。
其中第一章文中即很有趣地引用了句谚语:
Tell me and I forget.
Teach me and I remember.
Involve me and I learn.
于是博主决定挑出其中自己阅读过程中印象深刻的示例,结合自己粗浅的经验,跑一跑例程,同时也激励自己勤耕不辍写一点点笔记出来。
笔者不想完全 copy 书中的内容,所以随记尽量精简。如果读者能在碎片化时间中对某些 "Mistakes" 有印象,是本系列笔记的荣幸!同时推荐有时间的读者直接阅读原文。
第 2 章: 代码和工程组织
#2 不合适的代码嵌套
刚刚接触 go 语言时,我们可以发现其惯用 if err!= nil
这种形式处理错误。
即优先判断错误,如果出错即返回抛出。
func join(s1, s2 string, max int) (string, error) {
if s1 == "" {
return "", errors.New("s1 is empty")
}
if s2 == "" {
return "", errors.New("s2 is empty")
}
concat, err := concatenate(s1, s2)
if err != nil {
return "", err
}
if len(concat) > max {
return concat[:max], nil
}
return concat, nil
}
func concatenate(s1 string, s2 string) (string, error) {
// ...
}
其实对于这种错误有一些争议,认为 if err!= nil
这种语句占用了过多的阅读空间。Goland 对其处理是在 IDE 层面对排版进行格式化。
书中引用 Go Time
博客提出 'happy path' 准则,即让符合预期的行尽量靠前对齐。笔者认为自己在日常编程时有意识到这条准则即可。
#3 误用 init
函数
Go 中约定的 init
函数根据依赖顺序有遵循自己的初始化顺序。且失败处理是受限的。
书中提出,如果在初始化 sql 或者 redis,失败即 panic 的场景使用 init
函数。如果初始化一定不会失败,可以使用。
笔者自己曾经有项目被 code review 指出初始化的错误。也有的项目,mysql
初始化时需要保证 apollo
配置中心已经初始化成功并拉取最新的配置。因此笔者喜欢自己指定初始化顺序。
总之,使用约定的 init
函数时要格外注意顺序和错误处理。
#5 interface
滥用
interface
是 Go 语言的特色之一。 接口的滥用是指过度设计和使用,从而引入过于抽象却不必要的代码。
最适合示范的接口设计就是标准库中的 io.Reader
和 io.Writer
。
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
// ReadWriter is the interface that groups the basic Read and Write methods.
type ReadWriter interface {
Reader
Writer
}
包括标准库在内,Go 更倾向于使用 1~2 个方法的接口。如 Go 语言之父 Rob Pike 解释过的:
The bigger the interface, the weaker the abstraction.
笔者引用一张 Go 标准库接口大小的统计图。可以看到大部分接口大小在 1~3 个。
图:stdlib, docker, kubernets 的 Go 接口规模统计
使用时机
书中给出了 3 种时机:
- 共通行为: 比如
sort.Interface
,为几乎所有排序算法提供了统一的调用接口type Interface interface { Len() int Less(i, j int) bool Swap(i, j int) }
- 解耦合: 比如避免读写类和数据库强耦合,有助于编写单元测试
- 限制行为: 比如屏蔽掉 set 方法,只暴露给外部 set 方法。
书中最后建议我们:
接口应当是需要的时候才被发现的,而不是强行创建的。我们应该在需要的时候创建它,而不是在早期就预见我们需要它。
所以,如果没有充分的理由需要添加接口,就应当反问自己,为什么不直接去实现?
如 Go 语言之父 Rob Pike 解释过的:
Don’t design with interfaces, discover them.
#6 接口在生产者端定义
图:接口在生产者或消费者端定义
在 Go 语言中,接口在实际使用端定义可以更好地满足里氏替换原则。(比如 store
中暴露了很多方法,但 client
中只想用其中几个,这其自己定义会更加符合最小需求。)
但这一经验不是绝对的,比如标准库 encoding/json
就在生产端定义了接口。除非已经能够明确该种抽象能够被广泛需要,否则一般定义在使用端。
#7 返回 interface
会带来副作用。RFC 761: Transmission Control Protocol
提到的一种设计哲学:
Be conservative in what you do, be liberal in what you accept from others. 自己做的时候要谨慎,接受别人的时候开放。
那么对于 Go
来讲,就是
- 返回
struct
,而不是interface
- 尽可能接收
interface
其实一般没有 100% 禁止或推荐;包括接下来的建议。有一些在标准库中,由于顶层设计知道即使违反经验也会带来好处,那么就不是 "Mistakes"。
#8 使用 Any
除非在一些格式化场景,使用 Any
即 interface{}
会带来严重的代码解释性下降。也许多写几行代码覆盖函数签名中所有可能的 type 传入,也比直接使用 Any
要靠谱。
#9 错误使用泛型
Go 1.18 引入的泛型,具体使用这篇笔记不表。
但是和接口一样,必须有足够的理由使用泛型,且不能大大增加代码的复杂性。
#10 没有注意 type embedding 引入的问题
比如以下 type embedding
实现会把 sync.Mutex
暴露给使用者。
type InMem struct {
sync.Mutex
m map[string]int
}
func New() *InMem {
return &InMem{m: make(map[string]int)}
}
func (i *InMem) Get(key string) (int, bool) {
i.Lock()
v, contains := i.m[key]
i.Unlock()
return v, contains
}
使用者可以意外使用 mutex 方法
m := inmem.New()
m.Lock() // unexpected
因此如下定义
type InMem struct {
mu sync.Mutex
m map[string]int
}
书中给出使用 embedding 的 2 个原则
embedding
不要被简单地当做语法糖使用(比如为了使用Foo.Baz()
,而不是使用Foo.Bar.Baz()
)。- 如果我们要隐藏数据和方法,就不要使用
embedding
。比如上面意外暴露Lock()
方法的例子
#11 不会使用函数选项模式(functional options pattern)
适用于提供很多选项初始化结构体。同时提供默认选项。
笔者直接列举书中的例子,就明白了。
type options struct {
port *int
}
type Option func(options *options) error
func WithPort(port int) Option {
return func(options *options) error {
if port < 0 {
return errors.New("port should be positive")
}
options.port = &port
return nil
}
}
func NewServer(addr string, opts ...Option) (
*http.Server, error) {
var options options
for _, opt := range opts {
err := opt(&options)
if err != nil {
return nil, err
}
}
// At this stage, the options struct is built and contains the config
// Therefore, we can implement our logic related to port configuration
var port int
if options.port == nil {
port = defaultHTTPPort
} else {
if *options.port == 0 {
port = randomPort()
} else {
port = *options.port
}
}
// ...
}
使用时,直接使用 withXXX()
即可。也可以不提供选项。
server, err := httplib.NewServer("localhost",
httplib.WithPort(8080),
httplib.WithTimeout(time.Second))
#12 项目布局错乱
https://github.com/golang-standards/project-layout
不过一般只是参考。以组织约定的项目结构为准。
#13 创建 utility
包
一般认为在 Go 工程中创建 utils/base/common
没什么意义。不如根据其用途直接命名,比如 stringset
#15 文档不规范
根据 Go 的规范标明包描述、方法描述、是否 deprecated
等。这样做不光能自动生成 html 文档。
对于笔者来说,规范的文档能开发者直接在 IDE 中看到方法的用法和 deprecated
警告,一股对产出优秀文档开发者的敬意油然而生。
图:现代 IDE 会自动提示方法文档
#16 不使用 linters
在各个公司项目中,git commit 前使用 go lint 格式化工程基本上是强制的流程。
go lint 的格式,可能与开发者的审美相左,但是保证同一项目在所有开发者的机器上都是统一的,这对工程来讲是一件大好事。
小结
本篇笔记是对《第二章: 代码和工程组织》的总结。笔者觉得这本书很接地气,适合翻阅入门和进阶,推荐!