Go 编码规范 | 青训营笔记

123 阅读6分钟

这是我参与「第三届青训营 -后端场」笔记创作活动的第3篇笔记。

Go 编码规范

编写的代码能够达到正确可靠、简洁清晰的目标可称之为高质量代码。

  • 各种边界条件是否考虑完备
  • 异常情况处理,稳定性保证
  • 易读易维护

原则

实际应用场景千变万化,各种语言的特性和语法各不相同,但高质量编程遵循的原则是相通的。

  • 简单性
    • 消除“多余的复杂性”,以简单清晰的逻辑编写代码
    • 不理解的代码无法修复改进
  • 可读性
    • 代码是写给人看的,而不是机器
    • 编写可维护代码的第一步是确保代码可读
  • 生产力
    • 团队整体工作效率非常重要

编码规范

代码格式

使用 gofmt 自动格式化代码,使用 goimports 自动增删依赖的包引用以及将依赖包按字母序排序并分类。

  • gofmt 是 Go 官方提供的工具,能自动格式化 Go 代码为官方统一风格
  • goimports 也是 Go 官方提供的工具,goimports = gofmt + 依赖包管理

注释

Good code has lots of comments, bad code requires lots of comments.

​ ——Dave Thomas and Andrew Hunt

注释应该做的事情:

  • 解释代码作用
  • 解释代码如何做的
  • 解释代码实现的原因
  • 解释代码什么情况会出问题
解释代码作用
  • 适合注释公共符号

    公共符号只是一些常量,变量,对外提供的一些函数等。

    // Open opens the named file for reading. If successful, methods on
    // the returned file can be used for reading; the associated file
    // descriptor has mode O_RDONLY.
    // If there is an error, it will be of type *PathError.
    func Open(name string) (*File, error) {
    	return OpenFile(name, O_RDONLY, 0)
    }
    

    如果函数比较简单或函数名已经解释了函数的作用,则不需要额外添加注释,如:

    // Returns true if the table cannot hold any more entries
    func IsTableFull() bool
    
解释代码如何做的
  • 适合注释实现过程

    这一段代码的作用是给 URL 加上 Referer 信息,但直接看代码对功能的描述可能不太明显,所以加上注释解释实现的功能

    // Add the Referer header from the most recent
    // request URL to the new one, if it's not https->http:
    if ref := refererForURL(reqs[len(reqs)-1].URL, req.URL); ref != "" {
        req.Header.Set("Referer", ref)
    }
    

    下面这个注释,调用了一个 process 的函数,但注释对理解 process 函数没有任何作用

    // Process every element in the list
    for e := range elements {
        process(e)
    }
    
解释代码实现的原因

适合解释代码的外部因素或提供额外的上下文。

下面这一段代码,如果没有这个注释,我们可能无法理解 shouldRedirect 为什么要等于 false

switch resp.StatusCode {
	// ...
	case 307, 308:
		redirectMethod = reqMethod
		shouldRedirect = true
		includeBody = true

		if ireq.GetBody == nil && ireq.outgoingLength() != 0 {
			// We had a request body, and 307/308 require
			// re-sending it, but GetBody is not defined. So just
			// return this response to the user instead of an
			// error, like we did in Go 1.7 and earlier.
			shouldRedirect = false
		}
	}
	// ...
}
解释代码什么情况会出错

适合解释代码的限制条件

下面代码解释了解析字符串时区的流程,并介绍了不合法的字符串会发生什么,怎么处理

// parseTimeZone parses a time zone string and returns its length. Time zones
// are human-generated and unpredictable. We can't do precise error checking.
// On the other hand, for a correct parse there must be a time zone at the
// beginning of the string, so it's almost always true that there's one
// there. We look at the beginning of the string for a run of upper-case letters.
// If there are more than 5, it's an error.
// If there are 4 or 5 and the last is a T, it's a time zone.
// If there are 3, it's a time zone.
// Otherwise, other than special cases, it's not a time zone.
// GMT is special because it can have an hour offset.
func parseTimeZone(value string) (length int, ok bool)
公共符号始终要注释
  • 包中声明的每个公共符号(变量、常量、函数以及结构)都要添加注释

    下面这个函数的注释,介绍了这个函数的功能以及怎么工作

    // ReadAll reads from r until an error or EOF and returns the data it read.
    // A successful call returns err == nil, not err == EOF. Because ReadAll is
    // defined to read from src until EOF, it does not treat an EOF from Read
    // as an error to be reported.
    func ReadAll(r Reader) ([]byte, error)
    
  • 任何既不明显也不简短的公共功能必须予以注释

  • 无论长度或复杂度程度如何,对库中的任何函数都必须进行注释

但也有例外:

  • 不需要注释实现接口的方法。例如下面的代码对解释函数的作用毫无意义,只是让我们去参考其他的地方。

    //Read implements the io.Reader interface
    func (r *FileReader) Read(buf []byte) (int, error)
    
  • 不要重复注释

    第 17 行的函数也是导出的,但紧跟着上面的结构体,上面的代码注释已经解释了这个函数的作用,在这个函数就不需要重复写了。

    // LimitReader returns a Reader that reads from r
    // but stops with EOF after n bytes.
    // The underlying implementation is a *LimitedReader.
    func LimitReader(r Reader, n int64) Reader { return &LimitedReader{r, n, nil} }
    
    // A LimitedReader reads from R but limits the amount of
    // data returned to just N bytes. Each call to Read
    // updates N to reflect the new amount remaining.
    // Read returns Err when N <= 0.
    // If Err is nil, it returns EOF instead.
    type LimitedReader struct {
    	R   Reader // underlying reader
    	N   int64  // max bytes remaining
    	Err error  // error to return on reaching the limit
    }
    
    func (l *LimitedReader) Read(p []byte) (n int, err error) {
    	if l.N <= 0 {
    		err := l.Err
    		if err == nil {
    			err = EOF
    		}
    		return 0, err
    	}
    	if int64(len(p)) > l.N {
    		p = p[0:l.N]
    	}
    	n, err = l.R.Read(p)
    	l.N -= int64(n)
    	return
    }
    

命名规范

Good naming is like a good joke. If you have to explain it, it’s not funny.

——Dave Cheney

variable
  • 简洁胜于冗长

  • 缩略词全大写,但当其位于变量开头且不需要导出时,使用全小写

    • ServeHTTP 而不是 ServeHttp
    • XMLHTTPRequest 或者 xmlHTTPRequest
  • 变量距离其被使用的地方越远,需要携带的上下文信息就越多

    全局变量在其名字中需要更多的上下文信息,使得在不同地方可以轻易辨认其含义

// God
for i := 0; i < len(s); i++ {
    // do something
}

// Bad
for index := 0; index < len(s); i++ {
    // do something
}

i 和 index 的作用范围仅限于 for 循环内部时,index 的额外冗长几乎没有增加对于程序的理解

对于函数内的参数:

// Good
func (c *Client) send(req *Request, deadline time.Time)

// Bad
func (c *Client) send(req *Request, t time.Time)

将 deadline 替换成 t 降低了变量名的信息量,t 常代指任何时间,deadline 指截止时间,有特殊意义。

function
  • 函数名不携带包名的上下文信息,因为包名和函数名总是成对出现的
  • 函数名要尽量简短
  • 当名为 foo 的包某个函数返回类型 Foo 时,可以省略类型信息而不导致歧义
  • 当名为 foo 的包某个函数返回类型 T 时(T 并不是 Foo),可以在函数名中加入类型信息

例子:http 包中创建服务的方法如何命名更好?

func Serve(l net.Listener, handler Handler) error
func ServeHTTP(l net.Listener, handler Handler) error

由于这个函数在 http 包中,调用时一般都是 http.xxx ,所以第一种显然是更好的。

package
  • 只有小写字母组成,不包含大写字母和下划线等字符
  • 简短并包含一定的上下文信息。eg:schema、task 等
  • 不要与标准库同名。

以及尽量满足:

  • 不使用常用变量名作为包名。eg:bufio 而不是 buf
  • 使用单数而不是复数。eg:encoding 而不是 encodings
  • 谨慎地使用缩写。eg:format 简写为 fmt

控制流程

线性原理,处理逻辑尽量走直线,避免复杂的嵌套分支,保证正常流程清晰。

  • 如果两个分支中都包含 return 语句,则可以去除冗余的 else

    // Bad
    if foo {
        return x
    } else {
        return nil
    }
    
    // Good
    if foo {
        return x
    } 
    return nil
    

    这样写的好处是保证了正常的情况只出现在 if 中,对于不正常的,都是返回 nil,同时可以在有其他情况时,直接在 if 后面添加 else 就行

  • 尽量保证正常代码路径为最小缩进

    优先处理错误情况/特殊情况,尽早返回或继续循环来减少嵌套。

    看下面这一段的代码:

    func OneFunc() error {
        err := doSomething()
        if err == nil {
            err := doAnotherThing()
            if err == nil {
                return nil // normal case
            }
            return err
        }
        return err
    }
    

    这段代码的正常流程的路径被嵌套在两个 if 条件中,而成功退出的条件是 return nil,需要仔细匹配大括号来找到。

    如果后续正常流程需要增加一步操作,调用新的函数,则会再增加一层嵌套。修正后:

    func OneFunc() error {
        if err := doSomething(); err != nil {
            return err
        }
        if err := doAnotherThing(); err != nil {
            return err
        }
        return nil
    }
    

    如果有错,直接返回就好,如果没错,执行正常的逻辑。

错误和异常处理

简单错误

简单错误是指仅出现一次的错误,且在其他地方不需要捕获该错误

对于简单的错误,优先使用 errors.New 来创建匿名变量直接表示,如果有格式化的需求,使用 fmt.Errorf

func defaultCheckRedirect(req *Request, via []*Request) error {
	if len(via) >= 10 {
		return errors.New("stopped after 10 redirects")
	}
	return nil
}
错误的 Wrap 和 Unwrap

错误的 Wrap 实际提供了一个 error 嵌套另一个 error 的能里,从而生成一个 error 的跟踪链

在 fmt.Errorf 中使用 %w 关键字来将一个错误关联至错误链中

list, _, err := c.GetBytes(cache.Subkey(a.actionID, "srcfiles"))
if err != nil {
    return fmt.Errorf("reading srcfiles list: %w", err)
}

Go1.13 在 errors 中新增了三个新 API 和一个新的 format 关键字,分别是 errors.Is、errors.As 、errors.Unwrap 以及 fmt.Errorf 的 %w。如果项目运行在小于 Go1.13 的版本中,导入 golang.org/x/xerrors 来使用。以下语法均已 Go1.13 作为标准。

错误判定

errors.Is 可以判定错误链上的所有错误是否包含特定的错误。

data, err = lockedfile.Read(targ)
if errors.Is(err, fs.ErrNotExist) {
    // Treat non-existent as empty, to bootstrap the "latest" file
    // the first time we connect to a given database.
    return []byte{}, nil
}
return data, err

errors.As 可以获取特定种类的错误。

if _, err := os.Open("non-existing"); err != nil {
    var pathError *fs.PathError
    if errors.As(err, &pathError) {
        fmt.Println("Failed at path:", pathError.Path)
    } else {
        fmt.Println(err)
    }
}
panic
  • 不建议在业务代码中使用 panic
  • 调用函数不包含 recover 会造成程序崩溃
  • 若问题可以被屏蔽或解决,建议使用 error 代替 panic
  • 在程序启动阶段发生不可逆转的错误时,可以在 init 或 main 函数中使用 panic
recover
  • recover 只能在被 defer 的函数中使用
  • 嵌套无法生效
  • 只在当前 goroutine 生效
  • defer 的语句是后进先出
func (s *ss) Token(skipSpace bool, f func(rune) bool) (tok []byte, err error) {
	defer func() {
		if e := recover(); e != nil {
			if se, ok := e.(scanError); ok {
				err = se.err
			} else {
				panic(e)
			}
		}
	}()
    // ...
}

如果需要更多的上下文信息,可以 recover 后在 log 中记录当前的调用栈

func (t *treeFS) Open(name string) (f fs.File, err error) {
	defer func() {
		if e := recover(); e != nil {
			f = nil
			err = fmt.Errorf("gitfs panic: %v\n%s", e, debug.Stack())
		}
	}()
    // ...
}
小结

error 尽可能提供简明的上下文信息链,方便定位问题。

panic 用于真正异常的情况。

recover 在当前的 goroutine 的被 defer 的函数中生效。