👉导读
数据显示,中国 Gopher 人数全球占比最高,Go 语言在国内的火热态势甚至让创始人 Rob Pike 惊讶到不敢想象,颇有一种 Golang 正统在中国的感觉。Go 语言也是腾讯内部最热门的编程语言,随着云计算技术的快速普及,使用 Go 语言编写的 IT 基础设施也变得更为广泛流行,让 Go 语言的热度和人才需求度都进一步得到提升。本文作者从设计、规范、陷阱到相关实现以例证说明并结合自己思考,详细解释了该如何写好 Go 代码,值得你的点赞分享转发收藏!
👉目录
1 Golang 实现 SOLID 设计原则
2 Golang 实现常见设计模式
3 Golang 易疏忽规范
4 Golang 编码陷阱
5 Golang 编码相关工具
6 如何做好 CR?
7 结语
01、Golang 实现 SOLID 设计原则
1.1 单一职责原则
类的设计尽量做到只有一个原因引起变化。在交易的场景中,我们需要做一些交易存储、验证,我们可以声明交易的结构体,这个结构体是为了存储每笔交易。但是验证的功能我们可以拆开,这样代码更具有维护性、测试的编写也更简单方便。
type Trade struct { | |
TradeID int | |
Symbol string | |
Quantity float64 | |
Price float64 | |
} | |
type TradeRepository struct { | |
db *sql.DB | |
} | |
func (tr *TradeRepository) Save(trade *Trade) error { | |
_, err := tr.db.Exec("INSERT INTO trades (trade_id, symbol, quantity, price) VALUES (?, ?, ?, ?)", trade.TradeID, trade.Symbol, trade.Quantity, trade.Price) | |
if err != nil { | |
return err | |
} | |
return nil | |
} | |
type TradeValidator struct {} | |
func (tv *TradeValidator) Validate(trade *Trade) error { | |
if trade.Quantity <= 0 { | |
return errors.New("Trade quantity must be greater than zero") | |
} | |
if trade.Price <= 0 { | |
return errors.New("Trade price must be greater than zero") | |
} | |
return nil | |
} |
1.2 开闭原则
对扩展开放,对修改关闭。实现常见的方法是,通过接口或者多态继承。当我们的系统要增加期权交易的功能时,我们可以扩展接口实现,声明 TradeProcessor,而不是在声明一个统一的处理器中,写各种兼容逻辑。
type TradeProcessor interface { | |
Process(trade *Trade) error | |
} | |
type FutureTradeProcessor struct {} | |
func (ftp *FutureTradeProcessor) Process(trade *Trade) error { | |
// process future trade | |
return nil | |
} | |
type OptionTradeProcessor struct {} | |
func (otp *OptionTradeProcessor) Process(trade *Trade) error { | |
// process option trade | |
return nil | |
} |
1.3 里氏替换原则
所有引用父类的地方必须能透明地使用其子类的对象。里氏替换可以简单地理解为开闭原则的一种拓展,目的是通过父子类继承部分实现子类替换父类,为了更好实现代码可扩展性。Golang 没有明确的继承机制,但是可以通过 Trade 接口当做面向对象对象的父类,FutureTrade 是具体的实现,通过这样的机制可以实现里氏替换。当其它函数需要调用 Trade 时,完全替换为 FutureTrade 是没有任何问题的。
type Trade interface { | |
Process() error | |
} | |
type FutureTrade struct { | |
Trade | |
} | |
func (ft *FutureTrade) Process() error { | |
// process future trade | |
return nil | |
} |
1.4 接口隔离原则
建立单一接口,不要建立臃肿庞大的接口;即接口要尽量细化,同时接口中的方法要尽量少。Go 中接口方法越少越好,这样有利于封装、隔离。示例中,定义 Trade 接口,OptionTrade 接口,只有当我们进行期权交易时可以实现隐含波动率。这样做到了接口的隔离,如果我们在 Trade 接口中定义了 CalculateImpliedVolatility 方法,这样无关的期货交易也需要实现 CalculateImpliedVolatility 方法。
type Trade interface { | |
Process() error | |
} | |
type OptionTrade interface { | |
CalculateImpliedVolatility() error | |
} | |
type FutureTrade struct { | |
Trade | |
} | |
func (ft *FutureTrade) Process() error { | |
// process future trade | |
return nil | |
} | |
type OptionTrade struct { | |
Trade | |
} | |
func (ot *OptionTrade) Process() error { | |
// process option trade | |
return nil | |
} | |
func (ot *OptionTrade) CalculateImpliedVolatility() error { | |
// calculate implied volatility | |
return nil | |
} |
1.5 依赖倒置原则
依赖接口不依赖实例。当我们进行处理交易需要将交易信息存储时,我们只需要指定我们实际存储的操作结构实现 TradeService 接口,这样我们的 TradeProcessor 结构体可以根据实际需要指定我们存储的数据库类型。
type TradeService interface { | |
Save(trade *Trade) error | |
} | |
type TradeProcessor struct { | |
tradeService TradeService | |
} | |
func (tp *TradeProcessor) Process(trade *Trade) error { | |
err := tp.tradeService.Save(trade) | |
if err != nil { | |
return err | |
} | |
// process trade | |
return nil | |
} | |
type SqlServerTradeRepository struct { | |
db *sql.DB | |
} | |
func (str *SqlServerTradeRepository) Save(trade *Trade) error { | |
_, err := str.db.Exec("INSERT INTO trades (trade_id, symbol, quantity, price) VALUES (?, ?, ?, ?)", trade.TradeID, trade.Symbol, trade.Quantity, trade.Price) | |
if err != nil { | |
return err | |
} | |
return nil | |
} | |
type MongoDbTradeRepository struct { | |
session *mgo.Session | |
} | |
func (mdtr *MongoDbTradeRepository) Save(trade *Trade) error { | |
collection := mdtr.session.DB("trades").C("trade") | |
err := collection.Insert(trade) | |
if err != nil { | |
return err | |
} | |
return nil | |
} |
02、Golang 实现常见设计模式
2.1 单例设计模式
全局只存在一个单例,new 创建的单例只存在一个。类图(摘自设计模式之禅):
应用场景:全局只能存在一个对象,用于生成全局的序列号、IO 资源访问、全局配置信息等等。Golang 实现:并发场景下需要注意正确的实现方式:
var once sync.Once | |
var instance interface{} | |
func GetInstance() *singleton { | |
once.Do(func() { | |
instance = &singleton{} | |
}) | |
return instance | |
} |
有限多列模式作为单例模式扩展,全局只存在固定的数量的模式,这种有限的多例模式。一般这种模式使用的比较多,也可以配合下文所提到的工厂模式构建,例如采用了多个链接的数据库连接池等等。
2.2 工厂模式
定义一个用于创建对象的接口,让子类决定实例化哪一个类。类图:
示例:
package main | |
// Factory interface | |
type simpleInterest struct { | |
principal int | |
rateOfInterest int | |
time int | |
} | |
type compoundInterest struct { | |
principal int | |
rateOfInterest int | |
time int | |
} | |
// Interface | |
type InterestCalculator interface { | |
Calculate() | |
} | |
func (si *simpleInterest) Calculate() { | |
// logic to calculate simple interest | |
} | |
func (si *compoundInterest) Calculate() { | |
// logic to calculate compound interest | |
} | |
func NewCalculator(kind string) InterestCalculator { | |
if kind == "simple" { | |
return &simpleInterest{} | |
} | |
return &compoundInterest{} | |
} | |
func Factory_Interface() { | |
siCalculator := NewCalculator("simple") | |
siCalculator.Calculate() // Invokes simple interest calculation logic | |
ciCalculator := NewCalculator("compound") | |
ciCalculator.Calculate() // Invokes compound interest calculation logic | |
} |
工厂模式是典型的解耦框架。高层模块只需要知道产品的抽象类。其他的实现都不用关心,符合迪米特法则,符合依赖倒置原则只依赖产品的抽象,符合里氏替换原则,使用产品子类替换产品的父类。
2.3 代理模式
其他对象提供一种代理以控制对这个对象的访问。类图:
示例:
// zkClient backend request struct. | |
type zkClient struct { | |
ServiceName string | |
Client client.Client | |
opts []client.Option | |
} | |
// NewClientProxy create new zookeeper backend request proxy, | |
// required parameter zookeeper name service: trpc.zookeeper.xxx.xxx. | |
func NewClientProxy(name string, opts ...client.Option) Client { | |
c := &zkClient{ | |
ServiceName: name, | |
Client: client.DefaultClient, | |
opts: opts, | |
} | |
c.opts = append(c.opts, client.WithProtocol("zookeeper"), client.WithDisableServiceRouter()) | |
return c | |
} | |
// Get execute zookeeper get command. | |
func (c *zkClient) Get(ctx context.Context, path string) ([]byte, *zk.Stat, error) { | |
req := &Request{ | |
Path: path, | |
Op: OpGet{}, | |
} | |
rsp := &Response{} | |
ctx, msg := codec.WithCloneMessage(ctx) | |
defer codec.PutBackMessage(msg) | |
msg.WithClientRPCName(fmt.Sprintf("/%s/Get", c.ServiceName)) | |
msg.WithCalleeServiceName(c.ServiceName) | |
msg.WithSerializationType(-1) // non-serialization | |
msg.WithClientReqHead(req) | |
msg.WithClientRspHead(rsp) | |
if err := c.Client.Invoke(ctx, req, rsp, c.opts...); err != nil { | |
return nil, nil, err | |
} | |
return rsp.Data, rsp.Stat, nil | |
} |
代理的目的是在目标对象方法的基础上做增强。这种增强本质通常就是对目标对象方法进行拦截和过滤。
2.4 观察者模式
对象间一种一对多的依赖关系,使得每当一个对象改变状态,则所有依赖于它的对象都会得到通知并被自动更新。类图:
示例:
package main | |
import "fmt" | |
type Item struct { | |
observerList []Observer | |
name string | |
inStock bool | |
} | |
func newItem(name string) *Item { | |
return &Item{ | |
name: name, | |
} | |
} | |
func (i *Item) updateAvailability() { | |
fmt.Printf("Item %s is now in stock\n", i.name) | |
i.inStock = true | |
i.notifyAll() | |
} | |
func (i *Item) register(o Observer) { | |
i.observerList = append(i.observerList, o) | |
} | |
func (i *Item) notifyAll() { | |
for _, observer := range i.observerList { | |
observer.update(i.name) | |
} | |
} |
使用场景,事件多级触发,关联行为,跨系统消息的交换场景,级联通知情况下,运行效率和开发效率可能会有问题。
03、Golang 易疏忽规范
3.1 声明
- 错误使用 util 命名的包,不容易正常识别功能的用途,导致 util 包越来越臃肿。
- slice 的创建使用 var arr []int,初始化切片使用 var s []string 而不是 s := make([]string),初始化,如果确定大小建议使用 make 初始化。
- import . 只能用于测试文件,且必须是为了解决循环依赖,才能使用。
3.2 函数定义
- 不要通过参数返回数据。
- 尽量用 error 表示执行是否成功,而不是用 bool 或者 int。
- 多使用指针接收器,尽量避免使用值接收器。
3.3 函数实现
- 除0、1、“”不要使用字面量。
- if else 通常可以简写为 if return。
- 尽量将 if 和变量定义应该放在一行。
bad case:
err := r.updateByAttaIDs(fMd5OneTime, sMd5OneTime) | |
if err != nil { |
- 不要添加没必要的空行。
- 使用 == "" 判断字符串是否为空。
- 通过 %v 打印错误信息,%v 建议加:。
- Fail Fast 原则,如果出现失败应该立即返回 error,如果继续处理,则属于特殊情况需要添加注释。
3.4 命名规范
- array 和 map 的变量命名时,添加后缀 s。
- _, xxx for xxxs 一般要求 xxx 相同。
- 正则表达式变量名以 RE 结尾。
- 不要用注释删除代码。
- TODO 格式:TODO(rtx_name): 什么时间/什么时机,如何解决。19.导出的函数/变量的职责必须与包&文件职责高度一致。
3.5 基本类型
- 时间类型尽量使用内置定义,如,time.Second,不要使用 int。
- 建议所有不对外开源的工程的 module name 使用 xxxxxx/group/repo ,方便他人直接引用。
- 应用服务接口建议有 README.md。
3.6 安全问题
- 代码中是否存在 token 密码是否加密。
- 日志中是否输出用户敏感信息。
- PB 是否开启 validation。
- 字符串占位符,如果输入数据来自外部,建议使用 %q 进行安全转义。
04、Golang 编码陷阱
4.1 值拷贝
值拷贝是 Go 采取参数传值策略,因次涉及到传值时需要注意。
package main | |
import ( | |
"fmt" | |
) | |
func main() { | |
x := [3]int{1, 2, 3} | |
func(arr [3]int) { | |
arr[0] = 7 | |
fmt.Println(arr) | |
}(x) | |
fmt.Println(x) // 1 2 3 | |
} |
有人可能会问,我记得我传 map、slice 怎么不会有类似的问题?底层实现本质是指针指向了存储区域,变量代表了这个指针。
4.2 管道操作
管道操作,谨记口诀:“读关闭空值,读写空阻塞,写关闭异常,关闭空、关闭已关闭异常”。个人建议管道除非在一些异步处理的场景建议使用外,其它场景不建议过多使用,有可能会影响代码的可读性。
检测管道关闭示例:
func IsClosed(ch <-chan T) bool { | |
select { | |
case <-ch: | |
return true | |
default: | |
} | |
return false | |
} |
关闭 channel 的原则:我们只应该在发送方关闭,当 channel 只有一个发送方时。
4.3 匿名函数变量捕获
匿名函数捕获的数据是变量的引用,在一些开发的场景中,异步调用函数的输出不符合预期的场景。
type A struct { | |
id int | |
} | |
func main() { | |
channel := make(chan A, 5) | |
var wg sync.WaitGroup | |
wg.Add(1) | |
go func() { | |
defer wg.Done() | |
for a := range channel { | |
wg.Add(1) | |
go func() { | |
defer wg.Done() | |
fmt.Println(a.id) // 输出的数字是无法确定的,输出依赖具体的调度时机。 | |
// go vet 提示 loop variable a captured by func literal | |
}() | |
} | |
}() | |
for i := 0; i < 10; i++ { | |
channel <- A{id:i} | |
} | |
close(channel) | |
wg.Wait() | |
} |
4.4 defer 执行流程
defer 执行流程,第一步 return 执行将结果写入返回值,第二步执行 defer 会被按照先进后出的顺序执行,第三步返回当前结果。示例1:这里返回引用,我们达到了 defer 修改返回值的目的,如果我们这里不是以引用返回会产生什么结果呢?这里需要留意之前说的 Go 里是值拷贝,如果不是引用返回这里返回的是0。
package main | |
import ( | |
"fmt" | |
) | |
func main() { | |
fmt.Println("c return:", *(c())) // 打印结果为 c return: 2 | |
} | |
func c() *int { | |
var i int | |
defer func() { | |
i++ | |
fmt.Println("c defer2:", i) // 打印结果为 c defer: 2 | |
}() | |
defer func() { | |
i++ | |
fmt.Println("c defer1:", i) // 打印结果为 c defer: 1 | |
}() | |
return &i | |
} |
示例2 :实际返回的为1,原因是我们采用了命名返回变量,返回时值的空间已预分配好了。
package main | |
import "fmt" | |
func main() { | |
fmt.Println(test()) | |
} | |
func test() (result int) { | |
defer func() { | |
result++ | |
}() | |
return 0 // result = 0 | |
// result++ | |
} |
4.5 recover 正确执行方式
recover 函数在 defer 捕获异常时必须在 defer 函数里调用,否则是无效调用。
// 无效 | |
func main() { | |
recover() | |
panic(1) | |
} | |
// 无效 | |
func main() { | |
defer recover() | |
panic(1) | |
} | |
// 无效 | |
func main() { | |
defer func() { | |
func() { recover() }() | |
}() | |
panic(1) | |
} | |
// 有效 | |
func main() { | |
defer func() { | |
recover() | |
}() | |
panic(1) | |
} |
4.6 sync.Mutex 错误传递
sync.Mutex 的拷贝,导致锁失效引发 race condition。传参时我们需要通过指针进行传递。示例:
package main | |
import ( | |
"fmt" | |
"sync" | |
"time" | |
) | |
type Container struct { | |
sync.Mutex // <-- Added a mutex | |
counters map[string]int | |
} | |
func (c Container) inc(name string) { | |
c.Lock() // <-- Added locking of the mutex | |
defer c.Unlock() | |
c.counters[name]++ | |
} | |
func main() { | |
c := Container{counters: map[string]int{"a": 0, "b": 0}} | |
doIncrement := func(name string, n int) { | |
for i := 0; i < n; i++ { | |
c.inc(name) | |
} | |
} | |
go doIncrement("a", 100000) | |
go doIncrement("a", 100000) | |
// Wait a bit for the goroutines to finish | |
time.Sleep(300 * time.Millisecond) | |
fmt.Println(c.counters) | |
} |
05、Golang 编码相关工具
5.1 go vet
vet 检查 Go 的源码并报告可以的问题,我们可以在提交代码前,或者是在流水线配置 Go 代码的强制检验。
asmdecl report mismatches between assembly files and Go declarations | |
assign check for useless assignments | |
atomic check for common mistakes using the sync/atomic package | |
bools check for common mistakes involving boolean operators | |
buildtag check that +build tags are well-formed and correctly located | |
cgocall detect some violations of the cgo pointer passing rules | |
composites check for unkeyed composite literals | |
copylocks check for locks erroneously passed by value | |
httpresponse check for mistakes using HTTP responses | |
loopclosure check references to loop variables from within nested functions | |
lostcancel check cancel func returned by context.WithCancel is called | |
nilfunc check for useless comparisons between functions and nil | |
printf check consistency of Printf format strings and arguments | |
shift check for shifts that equal or exceed the width of the integer | |
slog check for incorrect arguments to log/slog functions | |
stdmethods check signature of methods of well-known interfaces | |
structtag check that struct field tags conform to reflect.StructTag.Get | |
tests check for common mistaken usages of tests and examples | |
unmarshal report passing non-pointer or non-interface values to unmarshal | |
unreachable check for unreachable code | |
unsafeptr check for invalid conversions of uintptr to unsafe.Pointer | |
unusedresult check for unused results of calls to some functions |
5.2 goimports
goimports 可以合理地整合整理包的分组,也可以将其纳入到项目流水线当中。
5.3 gofmt
大部分的格式问题可以通过 gofmt 解决, gofmt 自动格式化代码,保证所有的 Go 代码与官方推荐的格式保持一致,于是所有格式有关问题,都以 gofmt 的结果为准。
06、如何做好 CR?
CR 的目的是让我们的代码更具有规范、排查出错误、代码设计的统一,从而降低不好代码所带来的误解、重复、错误等问题。无论是 contributor 或者是 codereviewer,都有职责去执行好 CR 的每个环节,这样我们才能写出更好更优秀的代码。
前置工作
- 发起人自己先做一次 review。
- 做好单测、自测,不要依赖 CodeReview 机制排查问题。
- 是否有现成的依赖包、工具、复用的代码使用。
- 仓库配置相应的 CodeCC、单测覆盖率检测流水线。
发起 Codereview
- 准备好本次 CR 的背景知识,如 TAPD、设计文档等。
- COMMIT 里详细介绍本次改动的目的。
- 控制规模,一次提交最好能在30分钟内 review 完成。
CodeReviewer
- 友好语气。
- 认真提出合理的建议与改进方案,是对代码编写者的尊重。
- 避免纯主观判断。
- 不要高高在上。
- 不要吝啬称赞。
- 适度容忍、没有必要必须完美。
- 无重要的设计、bug 可以先 approve,后续有时间修改。
冲突解决
- 寻求第三人评估。
- 组内讨论。
07、结语
不断重复才是学习的诀窍,只有在实践中不断重复 Golang 编程技巧,我们才有可能成为更好的工程师。最后希望读者能从本篇文章有所收获,知易行难,与君共勉。
-End-
原创作者|刘泽欣