Go书写建议

本文最后更新于:2022年10月9日 下午

类似于Java开发手册之类的

前言

本文中概述的一些标准都是客观性的评估,是根据场景、上下文、或者主观性的判断;

但是最重要的是,保持一致.

一致性的代码更容易维护、是更合理的、需要更少的学习成本、并且随着新的约定出现或者出现错误后更容易迁移、更新、修复 bug

相反,在一个代码库中包含多个完全不同或冲突的代码风格会导致维护成本开销、不确定性和认知偏差。所有这些都会直接导致速度降低、代码审查痛苦、而且增加 bug 数量

通用

注释

强制

  1. 每一个可导出(首字母大写)的函数、名称都应该有文档注释
  2. 关键流程和关键算法都应该有注释。
  3. 一些魔法数字或者妥协而加的代码都应该有注释说明为什么需要添加

注释不应该写是什么,而应该写为什么。比如说我有一段休眠代码

1
2
3
4
5
6
for i := range arr {
if i%500 == 0{
// 睡眠一段时间
time.Sleep(500)
}
}

写 “睡眠一段时间” 这个注释是无效的,后续看代码的同学还是不清楚为什么要休眠,

但是应该写 “为了缓解服务器压力,这里等待一段时间再做逻辑” 就很清晰了

Bad

1
func Complie(str string) (regexp *Regexp,err error)

Good

1
2
// Complie 用于解析正则表达式并返回,如果成功,则 Regexp 对象就可用于匹配所针对的文本。
func Complie(str string) (regexp *Regexp,err error)

推荐

  1. 修改代码的同时,注释也要相应的进行修改,尤其是参数、返回值、异常、核心逻辑等
  2. 与其“半吊子cv英文来注释,不如用中文注释把问题说清楚。专有名词与关键字保持 英文原文即可
  3. 谨慎注释掉代码。在上方详细说明,而不是简单地注释掉。如果无用,则删除。
  4. 对于注释的要求:第一、能够准确反映设计思想和代码逻辑;第二、能够描述业务含 义,使别的程序员能够迅速了解到代码背后的信息。注释是给自己看的,即使隔很长时间,也能清晰理解当时的思路,注释也是给接受人,使其能够快速接替自己的工作。

内存管理

强制

切片长度校验

对slice进行操作时,必须判断长度是否合法,防止程序Panic

Bad

1
2
3
func foo(data []int) bool {
return data[0] == 0
}

Good

1
2
3
4
5
6
func foo(data []int) bool {
if data == nil || len(data) == 0{
return false
}
return data[0] == 0
}

nil指针判空

进行指针操作时,需要判断该指针是否为空,防止程序Panic,在Get函数中时如果时指针引用也必须判断指针是否为空,尤其是在结构体进行Unmarshal之后

Bad

1
2
3
4
5
6
7
8
9
func foo(src []byte) error {
var dest = new(Data)
err := json.Unmarshal(src)
if err!=nil{
return err
}
use(dest.Member.UserName) // panic
return
}

Good

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func foo(src []byte) error {
if src == nil || len(src) == 0{
return ErrSliceIsEmpty
}
var dest = new(Data)
err := json.Unmarshal(src)
if err!=nil{
return err
}
if dest.Member == nil{
return Err
}
use(dest.Member.UserName)
return
}

Get函数中也必须判空

Bad

1
2
3
func (m *Member) UserName() string{
return m.UserName
}

Good

1
2
3
4
5
6
func (m *Member) UserName() string{
if m == nil{
return ""
}
return m.UserName
}

make预分配长度

在进行make内存分配时,尽可能指定容器容量,以便为容器预先分配内存

Bad

1
2
3
4
5
6
7
8
9
10
func foo(list []int) []int {
var res = make([]int, 0)
for _, item:=range list{
if item == 0{
continue
}
res = append(res, item) // append 函数会存在大量的内存拷贝
}
return res
}

Good

1
2
3
4
5
6
7
8
9
10
func foo(list []int) []int {
var res = make([]int, 0 ,len(list))
for _, item:=range list{
if item == 0{
continue
}
res = append(res, item)
}
return res
}

禁止重复释放channel

重复释放一般存在于异常流程判断中,如果恶意攻击者构造出异常条件使程序重复释放channel,则会触发运行时panic,从而造成DoS攻击。

Bad

1
2
3
4
5
6
7
8
9
10
func foo(c chan int) {
defer close(c)
err := processBusiness()
if err != nil {
c <- 0
close(c) // 重复释放channel
return
}
c <- 1
}

Good

1
2
3
4
5
6
7
8
9
func foo(c chan int) {
defer close(c) // 使用defer延迟关闭channel
err := processBusiness()
if err != nil {
c <- 0
return
}
c <- 1
}

确保每个协程都能退出

启动一个协程就会做一个入栈操作,在系统不退出的情况下,协程也没有设置退出条件,则相当于协程失去了控制,它占用的资源无法回收,可能会导致内存泄露。

1
2
3
4
5
6
7
// bad: 协程没有设置退出条件
func doWaiter(name string, second int) {
for {
time.Sleep(time.Duration(second) * time.Second)
fmt.Println(name, " is ready!")
}
}

推荐

不使用unsafe包

由于unsafe包绕过了 Golang 的内存安全原则,一般来说使用该库是不安全的,可导致内存破坏,尽量避免使用该包。若必须要使用unsafe操作指针,必须做好安全校验。

1
2
3
4
5
6
// bad: 通过unsafe操作原始指针
func unsafePointer() {
b := make([]byte, 1)
foo := (*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&b[0])) + uintptr(0xfffffffe)))
fmt.Print(*foo + 1)
}

字符串

强制

字符串拼接

禁止使用+号进行字符串拼接操作,如果需要使用请使用 strings.Builder

Bad

1
2
3
4
5
for foo(){
var str1 = "str1"
var str2 = "str2"
fmt.Println(str1+str2)
}

Good

1
2
3
4
5
6
7
8
9
for foo(){
var str1 = "str1"
var str2 = "str2"
var str3 strings.Builder
str3.Grow(len(str1)+len(str2))
str3.WriteString(str1)
str3.WriteString(str2)
fmt.Println(str3.String())
}

取字符串的某个字符

如果需要取字符串的某个字符,除非非常确定字符串中只含有ANSCII的字符,否则都必须将string转为[]rune类型

Bad

1
2
var str string = "你好"
var firstChar = str[0]

Good

1
2
var str string = "你好"
var firstChar = []rune(str)[0]

禁止使用fmt进行类型转化

如果要将 int64、float等数字类型转化为string的话,禁止使用fmt.Sprintf进行类型转化,它的内部使用反射判断类型,性能相对较差

Bad

1
2
3
var str string
var num =10
str = fmt.Sprintf("%v",num)

Good

1
2
3
var str string
var num =10
str = strconv.Itoa(num)

推荐

cast包

可以尝试使用cast包进行类型的转化

1
cast.ToString()

避免反复类型转化

不要反复从固定字符串创建字节切片。

Bad

1
2
3
for i := 0; i < b.N; i++ {
w.Write([]byte("Hello world"))
}

BenchmarkBad-4 50000000 22.2 ns/op

Good

1
2
3
4
data := []byte("Hello world")
for i := 0; i < b.N; i++ {
w.Write(data)
}

BenchmarkGood-4 500000000 3.25 ns/op

zero copy

对于明确只做使用而不做修改的[]byte转string的功能可以使用 stringx.ZeroCopyBytes2String 函数

异常处理

强制

协程中一定要recover

在协程中处理一定要recover掉panic,否则会导致整个进程都退出,并且要尽可能的打印全崩溃信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// good
func foo(f func()){
go func() {
defer func() {
if err := recover(); err != nil {
buf := debug.Stack()
logrus.Errorf("SafeGo.recover :%v stack:%v", err, string(buf))
}
}()
f()
}()
}

// bad
func foo(f func()){
go f()
}

合理使用panic

如果问题可以被屏蔽或者被解决,那么最好让程序继续运行,比如说在某个接口中因为客户端传入了某种特殊值导致崩溃的话最好使用 recover 兜住,但是针对于某些库不能正常工作并且程序就无法继续运行的情况下,最好就Panic出去。比如说数据库连接不上、某些重要配置未配置

1
2
3
4
5
6
7
var stage = os.Getenv("STAGE")

func init(){
if stage == ""{
panic("no value for $STAGE")
}
}

推荐

崩溃信息统计

崩溃的信息最好能够上报到Prometheus或者直接发到钉钉中

error处理

可以使用 errors 包来处理异常,具体使用方法见

时间日期

强制

  1. 不要在程序中写死一年为365天,一个月为30天,避免在闰年等情况下出现逻辑错误
  2. 禁止使用 time.Now() 来获取当前时间,获取的时间为系统默认时区,在Format时间时会出现异常
  3. 不要给前端、客户端返回字符串形式的时间类型,也不要接受它们的字符串形式的时间类型,在进行时间类型传输时一律使用秒级时间戳进行通信
  4. 必须使用 time.Duration代表某个时间段,禁止直接采用魔法数字来代表时间段。

推荐

  1. 建议使用 live_server 的 region 库来代替某些时间函数
  2. 对于以周的时间维度划分最好使用ISO周,否则在某周跨年时会出现异常,比如说需要判断某一天是今年的第几周
  3. 如果在某些配置文件中需要配置时间段的话,建议统一采用秒数,或者在程序、配置文件中明确时间单位。

制语句

强制

  1. 在高并发场景时,避免使用 “等于” 判断作为中断或者退出的条件。这是因为如果并发处理没有控制好的情况下,可能值会出现值被“击穿”的情况,最好使用大于、小于的区间判断来代替

推荐

  1. 表达异常的分支时,少用if-else语句,这种方式可以改为
BadGood
if condition { return err } else { // 这里写else的代码 }if condition { return err } // 这里写else的代码
  1. 尽量避免采用取反逻辑

SQL操作

强制

  1. SQL语句默认使用预编译并绑定变量
  2. 禁止拼接SQL语句,对于传入的参数用于order by或者表名等必须通过校验

推荐

  1. 最好使用 gorm.io 库,尽量避免使用自封装的库
  2. 在对sql进行加锁时,要注意锁的顺序,避免死锁
  3. 最好使用 deleted_at 来做软删除,除非是明确的不重要可删除的数据才真正删除

程序结构

强制

  1. 代码的每一层结构都必须有一个地方接受Context,可以放在函数中也可以放在结构体中,但是放在结构体中时务必保证每次使用结构体都初始化。
  2. 在引入一个新的代码库依赖时必须保证它的子依赖对现有的的仓库不会有影响。
  3. 禁止引入非稳定、未经过测试的代码库。
  4. 核心代码、公共common库应该做到测试全复盖

推荐

  1. 避免使用init函数。尽可能的使用显式调用
  2. 当函数存在可选项时,可以使用Option模式进行优化
  3. 尽量减少大括号的嵌套。这会严重影响代码的可读性,以及增加了不必要的复杂度

日志

强制

  1. 禁止使用 fmt、自行书写的logger库来打印日志。日志打印一定要使用 logurs 库。同时在生成logrus.Entry结构时需要携带上ctx参数。
  2. 对于敏感操作必须要打印一条日志代表玩家操作过。比如说玩家修改自己的密码。
  3. 强制使用日志分级。分为四个等级:debug、info、warn、error
  4. 正式环境禁止打印debug日志
  5. logrus强制使用 JSON Format

推荐

  1. 谨慎地记录日志。生产环境禁止输出 debug 日志;有选择地输出 info 日志
  2. 可以使用warn日志级别来记录用户输入参数错误的情况,如非必要,请不要在此场景打印 error 级别日志

服务

强制

  1. 用户请求传入的任何参数必须做有效性验证
  2. 用户输入的SQL参数严格使用参数绑定。防止sql注入、拼接sql字符串访问数据库。对于string类型的参数应该格外注意
  3. 对于暴露在外部的接口严格使用${serverName}/${type}/${version}/* 。其中${serverName} 为 项目名。${type} 限定三个类型:app代表暴露在app端的接口, rpc代表rpc接口,backend代表后台接口。${version} 代表接口版本。
  4. 暴露在外的接口要做玩家token验证。
  5. 隶属于用户个人的页面或者功能必须进行权限控制校验
  6. 用户敏感数据禁止直接展示,必须对数据进行脱敏

推荐

  1. 发贴、评论、发送即时消息等用户生成内容的场景需实现防刷、文本内容违禁词过滤等风控策略。
  2. 消息队列如果存在很大的吞吐量且允许部分消息丢失则最好使用Kafka。
  3. RabbitMQ适用于重要消息,在接入RabbitMQ时最好

本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!