《100 Go Mistakes and How to Avoid Them》之代码架构

2023-05-20 ⏳9.1分钟(3.6千字)

2.1 意外的代码阴影

关于变量作用域,在Go中,变量名可以在局部代码块中被重定义,这样就会造成variable shadowing

我简化了下例子:

var a int

if true {
    a,err := geta()
    if err != nil {
        return err
    }
} else {
    a,err := geta2()
    if err != nil {
        return err
    }
}

fmt.Println(a)

这样的情况就会造成a没有被赋值,因为被赋值的a是内部的变量而不是外部的。我们可以改成

var a int
var err error

if true {
    a,err = geta()
    if err != nil {
        return err
    }
}else{
    // same logic
}

fmt.Println(a)

或者

var a int

if true {
    b,err := geta()
    if err != nil {
        return err
    }
    a = b
}else{
    // same logic
}

fmt.Println(a)

2.2 非必要的缩进代码

代码的质量取决于很多标准,比如命名,统一的格式化等等。有一种可读性的标准就是缩进级别。比如以下代码:

func join(s1, s2 string, max int) (string, error) {
    if s1 == "" {
        return "", errors.New("s1 is empty")
    } else {
        if s2 == "" {
            return "", errors.New("s2 is empty")
        } else {
            concat, err := concatenate(s1, s2)
            if err != nil {
                return "", err
            } else {
                if len(concat) > max {
                    return concat[:max], nil
                } else {
                    return concat, nil
                }
            }
        }
    }
}
func concatenate(s1 string, s2 string) (string, error) {
    // ...
}

以上的代码逻辑正确,但是缩进太长了不利于阅读,所以我们可以改为:

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
}

再比如:

if s != "" {
       // ...
   } else {
       return errors.New("empty string")
}

// 更优解
if s == "" {
    return errors.New("empty string")
}
// ...

2.3 错误使用init函数

我们有时会错误的使用Go的init函数。 init函数用来初始化一个应用的状态,没有入参和出参,当package被初始化时,所有的常量和变量处理完之后,init函数会被执行。不同package之间存在依赖,被依赖的会先执行。同package下,会根据首字母顺序执行。我们也可以在一个package定义多个init函数,执行不同的逻辑块。我们不应该依赖这个执行顺序,不然在rename之后会出问题。

同时,文章也举了一个使用不当的例子,大家可以看看这样会有什么问题。

var db *sql.DB
func init() {
    dataSourceName :=os.Getenv("MYSQL_DATA_SOURCE_NAME")
    d, err := sql.Open("mysql", dataSourceName)
    if err != nil {
        log.Panic(err)
    }
    err = d.Ping()
    if err != nil {
        log.Panic(err)
    } 
    db = d 
}

这样的实现有3个大问题:

  1. 错误信息无法return,只能panic,导致程序退出

  2. 如果我们添加test文件,init函数就会执行,我们并不一定需要。

  3. db链接池是一个全局变量,任何内部函数都可以改变这个变量,测试用例会因为这个全局变量互相影响。

2.4 过度使用getters以及setters

在编程中,数据封装往往是为了隐藏对象的内部状态。Getter和Setter就是提供修改/获取内部对象字段的导出函数。在Go语言中,并没有明确的限制必须要通过getters and setters去获取数据。比如time标准库中有:

timer := time.NewTimer(time.Second)
<-timer.C

你可以直接修改C变量。

只有在getters and setters能为你提供好处时再考虑,比如:

  1. 后续会添加新功能(比如,返回了一个计算之后的值,或者包装了其他信息)

  2. 隐藏了内部实现,能让我们使用起来更灵活

  3. 在运行时阶段会让调试更方便。

总而言之,不要让我们的代码被getters and setters吞没。找到封装与效率之间的平衡。Go is simple.

2.5 Interface污染

Interface是Go设计组织代码的基石之一。而Interface污染则代表了代码中充斥着无用的抽象,难以理解。本章我们先重新了解下Go的Interface,再去讨论如何什么时候应该使用,避免过度使用。

2.5.1 概念

Interface提供了一种方式去描述对象的行为。我们可以创建出抽象然后不同的对象去实现。在Go中,interface于其他语言不同,它是隐式的,不需要使用关键词implements去标记对象X实现了接口Y。我们可以拿标准库中的例子:io.Readerio.Writer

type Reader interface {
    Read(p []byte) (n int, err error)
}

type Writer interface {
    Write(p []byte) (n int, err error)
}

它们两者就有很基础的抽象:

假设我们需要从某个文件中读内容写到另一个文件中,我们可以:

func copySourceToDest(source io.Reader, dest io.Writer) error { 
    // ...
}

当我们再次想要写到一个自己的io.Writer中然后写入数据库时,上述的函数同样可以做到。并且,在编写测试用例的时候也会很简单,我们可以直接使用string包和bytes包创建实现:

func TestCopySourceToDest(t *testing.T) {
    const input = "foo"
    source := strings.NewReader(input)
    dest := bytes.NewBuffer(make([]byte, 0))

    err := copySourceToDest(source, dest)
    if err != nil {
        t.FailNow()
    }
    got := dest.String()
    if got != input {
        t.Errorf("expected: %s, got: %s", input, got)
    }
}

当我们设计interface的时候,我们可以注意一个点:

The bigger the interface, the weaker the abstraction.

interface越大意味着抽象能力越弱,io.Reader 和 io.Writer为什么有那么强的抽象能力,是因为他们没法更简单了。当你想要组合他们的能力时就出现了io.ReadWriter.

type ReadWriter interface {
    Reader
    Writer 
}

2.5.2 什么时候使用interface

当我们要创建interface的时候,我们可以考虑三种情况,会给我们带来价值:

  1. 通用的行为

  2. 解藕

  3. 约束行为

通用行为我们可以用sort库举例,排序一个集合可以被总结为3个方法:

type Interface interface {
    Len() int
    Less(i, j int) bool
    Swap(i, j int)
}

当我们使用排序时,我们无需知道内部的实现是快排还是插入排序。内部的排序行为已经被抽象了。同时,sort包也能基于sort.Interface去完成一些其他功能,比如集合是否已排序:

func IsSorted(data Interface) bool {
    n := data.Len()
    for i := n - 1; i > 0; i-- {
        if data.Less(i, i-1) {
            return false
        }
    }
    return true 
}

解藕也是使用interface很重要的一个原因:

type CustomerService struct {
    store mysql.Store
}
func (cs CustomerService) CreateNewCustomer(id string) error { 
    customer := Customer{id: id}
    return cs.store.StoreCustomer(customer)
}

这样我们就能:

约束行为的使用场景可能有点反直觉,我们通过一个例子来看看:

type IntConfig struct {
    // ...
}
func (c *IntConfig) Get() int {
    // Retrieve configuration
}
func (c *IntConfig) Set(value int) {
    // Update configuration
}

我们收到一个IntConfig存储了一些配置项,但是我们不需要更新它,所以我们就抽象一个interface:

type intConfigGetter interface {
    Get() int
}

使用的时候:

type Foo struct {
    threshold intConfigGetter
}
func NewFoo(threshold intConfigGetter) Foo {
    return Foo{threshold: threshold}
}
func (f Foo) Bar()  {
    threshold := f.threshold.Get()
    // ...
}

这样我们就限制了只读的行为。

2.5.3 interface污染

Don’t design with interfaces, discover them.文章中提出,我们不应该在一开始就创造interface然后认为以后会需要,我们应该在编码的时候发现这里应该用interface然后再去使用。其实核心就是不要去过度设计,当真正使用上的时候再去抽象。如果你不清楚使用interface是否能让你的代码变得更好,那就保持sample。

2.6 生产端的interface

Go开发者经常会错误的理解一个问题:Interface应该存在在哪里?我们先理解两个概念:

在Go中,很多开发者会使用生产端的Interface,但实际上往往不应该那么用。下面举个例子,我们创建一个包去存取数据。同时,我们也想要在这个包下面去设置interface:

package store

type CustomerStorage interface {
    StoreCustomer(customer Customer) error
    GetCustomer(id string) (Customer, error) 
    UpdateCustomer(customer Customer) error 
    GetAllCustomers() ([]Customer, error) 
    GetCustomersWithoutContract() ([]Customer, error) GetCustomersWithNegativeBalance() ([]Customer, error)
}

但这并不是最佳实践。在Go中,interface是非常隐式的。在大部分场景下,我们应该遵循:abstractions should be discovered, not created.这意味着,生产端不应该强制设置抽象,相反,应该是客户端决定这里是否需要抽象。就上述的例子,也许有的客户端只关心GetAllCustomers,那么这个客户端可以自己生成一个interface:

package client

type customersGetter interface {
    GetAllCustomers() ([]store.Customer, error)
}

也有一些例外,比如在标准库中。encoding包定义了一些inteface,被子包encoding/jsonencoding/binary实现。因为在标准库中,Go开发者明确的知道这是有价值的,并且在当下就有使用场景。

2.7 返回interface

当设计函数签名时,我们可以返回interface也可以返回具体的实现。就以上面存取数据为例:我们的store包是具体的内存存储实现,client包则定义了一组实现,需要去完成数据的存取。如果store返回interface,就会产生循环依赖。我们可以认定一个准则. Be conservative in what you do, be liberal in what you accept from others. 我们应该:

这一点我们也可以从标准库中找到一些例子:

func LimitReader(r Reader, n int64) Reader {
    return &LimitedReader{r, n}
}

2.8 any没有表达任何信息

在Go中,一个interface类型没有包含任何方法称之为空interface,interface{}.在Go1.18中,any别名了空interface.所以,第一步我们应该用any替代掉原来的interface{}. 在很多场景中,any被认为过于笼统。

func main() {
    var i any
    // An int
    i = 42
    //A string
    i = "foo"
     //A struct
    i = struct {
        s string
    }{
        s: "bar",
    }
    // A function
    i = f
    _=i 
}
func f() {}

我们使用any,丢失了类型信息,我们可能需要使用到类型断言。举个例子:

package store
type Customer struct{
    // Some fields
}
type Contract struct{
    // Some fields
}
type Store struct{}
func (s *Store) Get(id string) (any, error) {
    // ...
}
func (s *Store) Set(id string, v any) error {
    // ...
}

上述的例子编译没什么问题,当你看到方法签名时,你并不能知道他在做什么,因为我们使用了any,它缺乏表达能力。后续维护者使用,大概率还要去阅读代码才能理解。不如直接:

func (s *Store) GetContract(id string) (Contract, error) { 
    // ...
}
func (s *Store) SetContract(id string, contract Contract) error { 
    // ...
}
func (s *Store) GetCustomer(id string) (Customer, error) { 
    // ...
}
func (s *Store) SetCustomer(id string, customer Customer) error { 
    // ...
}

在什么场景下any有用呢?第一个例子就是encoding/json包的Marshal函数,它本身就实现了对所有类型的解析:

func Marshal(v any) ([]byte, error) {
    // ...
}

另一个例子就是database/sql,本身就可以是任何类型:

func (c *Conn) QueryContext(ctx context.Context, query string, args ...any) (*Rows, error) {
    // ... 
}

2.9 混淆什么时候去用泛型

Go1.18增加了泛型的概念。有时候我们会困惑什么时候该用什么时候不该用。

2.9.1 概念

当我们有一个获取map[string]int的keys的函数:

 func getKeys(m map[string]int) []string {
    var keys []string
    for k := range m {
        keys = append(keys, k)
    }
    return keys 
}

我们后续又需要获取map[int]string的keys的时候,怎么办?再创建一个函数?这时候泛型就可以出马了:

func getKeys[K comparable, V any](m map[K]V) []K {
    var keys []K
    for k := range m {
        keys = append(keys, k)
    }
    return keys 
}

key使用comparable是因为Go中map的key不能是any,比如:

var m map[[]byte]int

这样就会报错:invalid map key type []byte

我们也可以自己设定类型:

type customConstraint interface {
    ~int | ~string
}

func getKeys[K customConstraint,V any](m map[K]V) []K {
    // Same implementation
}

2.9.2 常见的使用方式以及错误使用方式

在几种场景下,推荐使用泛型:

什么场景下不适用呢:

2.10 没有意识到内嵌类型可能出现的问题

Go在结构体中提供了内嵌类型的方式,会出现一些不在预期内的情况。

type Foo struct {
    Bar
}
type Bar struct {
    Baz int
}

Foo.Bar就是一种内嵌类型,允许Foo直接调用Bar的方法。我们下面看一种错误的内嵌使用方式,我们想要在内存中并发操作map:

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

因为sync.Mutex是内嵌类型,我们可以使用LockUnlock方法,但是这两个方法也同样是导出方法,可以被外部调用。而锁在大部分场景下都是去控制一些内部逻辑,这样就会给调用方产生负担。所以我们应该改为:

type InMem struct {
    mu sync.Mutex
    m map[string]int
}

2.11 没有使用functional options pattern

当我们设计API的时候经常会遇到一个问题:怎么去处理一些可选的配置项?效率的解决这个问题就可以让我们的API使用起来更舒适。举个例子,当我们需要设计一个函数去创建一个HTTP服务时,起初需要两个入参:地址以及端口,我们提供了以下方式:

func NewServer(addr string, port int) (*http.Server, error) { 
    // ...
}

一开始都很方便,渐渐的,客户端开始抱怨说怎么无法设置超时时间。这时候我们就会发现:当我们想要增加一个参数时,客户端就无法兼容原来的调用NewServer方式了。那我们怎么去设计一个API友好型的函数呢?比较通用的方式就是把所有的配置项设置成一个struct,然后只接收一个struct参数。而我们这一章要讲的则是另一种方式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
    }
}

使用的时候则:

server, err := httplib.NewServer("localhost",
        httplib.WithPort(8080),
        httplib.WithTimeout(time.Second))

这样的方式就能很好的控制可选项。我们也可以看看一些go标准库比如gRPC是如何使用这种方式的。

2.12 项目不分层

Go语言维护者在项目架构上没有太强势的公约。有一种布局方式在这几年愈演愈烈:project-layout. 除非你的项目足够小,或者你的组织已经形成了自己的标准,那么你可以考虑下project-layout:具体项目细节可以进项目的Readme查看。

值得注意的是,在2021,Russ Cox(Go的核心维护者之一)批评了这个布局。所以,我们需要记住,项目的架构没有定式。因此,请就一种布局达成一致,以使组织中的事物保持一致,以便开发人员不会浪费时间在一个个库中切换。

2.13 创建工具包

在本章我们讨论一个常见的坏实践:创建一些很共用的包名,比如:utils,common,base. 我们通过一个例子来说明这个问题。比如生成一个集合的包:

package util

func NewStringSet(...string) map[string]struct{} {
    // ...
}
func SortStringSet(map[string]struct{}) []string {
    // ...
}

调用的时候为:

set := util.NewStringSet("c", "a", "b")
fmt.Println(util.SortStringSet(set))

这种情况就会发现,util毫无意义。我们可以改为有意义的包名比如stringset

package stringset

func New(...string) map[string]struct{} { ... }
func Sort(map[string]struct{}) []string { ... }

这样我们就能去除掉后缀StringSet了。使用起来:

set := stringset.New("c", "a", "b")
fmt.Println(stringset.Sort(set))

2.14 忽略包名冲突

包名冲突会发生在申明变量的时候变量名和包名冲突,导致包无法再次使用,例如:

package redis

type Client struct { ... }
func NewClient() *Client { ... }
func (c *Client) Get(key string) (string, error) { ... }

redis := redis.NewClient()
v, err := redis.Get("foo")

我们要么在申明的时候就注意这一点:

redisClient := redis.NewClient()
v, err := redisClient.Get("foo")

要么可以选择别名包名

import redisapi "mylib/redis"

// ...
redis := redisapi.NewClient()
v, err := redis.Get("foo")

另外需要注意的是,我们也要避免变量名和内置函数的冲突,比如:

copy := copyFile(src, dst)

这样的话copy函数就无法再调用了。

2.15 没有代码注释

注释是代码很重要的一部分。在Go中,我们需要遵守一些规则: 首先,任何的导出元素必须有注释,无论是结构体,实现,函数还是什么。惯例就是注释是以导出元素名开头的。比如:

// Customer is a customer representation.
type Customer struct{}

// ID returns the customer identifier.
func (c Customer) ID() string { return "" }

我们需要注意,当我们注释函数或者方法时,需要强调这个函数做了什么而并不是如何做的。而且注释内容应该提供足够的信心可以让使用者不用看代码就能理解怎么使用这个函数。

当一个导出元素被废弃时,我们可以使用// Deprecated。这样用户使用的时候会报Warning.

当需要注释一个变量或者常量时,我们需要关注两方面:它的目的以及它的内容.比如:

// DefaultPermission is the default permission used by the store engine. 
const DefaultPermission = 0o644 // Need read and write accesses.

为了让维护者可以理解一个package的范围,我们需要给每个包加上注释:

// Package math provides basic constants and mathematical functions. //
// This package does not guarantee bit-identical results
// across architectures.
package math

2.16 不使用静态分析器

静态分析器是一个自动工具可以去分析代码错误,本章的意义不是在于给一分明确的列表需要哪些静态分析,而是我们需要理解静态分析对于Go项目的意义。我们通过下述一个例子,使用vet,Go工具集中的标准静态分析,找到shadow错误并避免。

package main

import "fmt"

func main() {
    i := 0 
    if true {
        i := 1
        fmt.Println(i)
    }
        fmt.Println(i)
    }

vet是Go自带的,我们先安装shadow分析。

$ go install golang.org/x/tools/go/analysis/passes/shadow/cmd/shadow
$ go vet -vettool=$(which shadow)
./main.go:8:3:
  declaration of "i" shadows declaration at line 6

我们可以通过golangci-lint,提供了很多有用的静态分析器以及格式化工具。