Go 的十个应用技巧(译文) | Go主题月

546

Fatih Arslan

2015年10月8日

image.png

在我过去几年用 Go 的过程中,有一些个人经验。我相信它们都应该很好地扩展。我的意思是:

  • 你开发的应用程序需求在敏捷环境中不断变化。你不想仅仅因为需要就在3-4个月后重构它的每一部分。新功能应该很容易添加。

  • 你开发的应用程序是由团队共同开发的,它应该可读性和易于维护。

  • 你开发的应用程序被很多人使用,会有 bug,应该很容易发现和快速修复。

随着时间的推移,我认识到这些事情对长期来说是很重要的。其中一些是次要的,但它们影响了很多事情。这些都是建议,试着调整一下,让我知道它是否适合你。请随意评论(●'◡'●)

1. 使用单一路径

多个 GOPATH 的弹性不好。GOPATH 本身本质上是高度独立的(通过导入路径)。拥有多个 GOPATH 可能会产生副作用,比如对给定的包使用不同的版本。你可能在一个地方更新了它,但在另一个地方却没有。尽管如此,我还没有遇到一个需要多个 GOPATH 的情况。使用单一 GOPATH,它将促进您的 Go 开发效率。

我需要澄清一件事情,很多人不同意这种说法。像 etcdcamlistore 这样的大型项目通过 godep 工具将依赖项冻结到一个文件夹中。这意味着这些项目只有一个 GOPATH 。他们只看到该文件夹中找到对应的版本。为每个项目使用不同的 GOPATH 只是一种过度的做法,除非你认为你的项目是大而重要的。如果你认为你的项目需要它自己的 GOPATH 文件夹,去创建一个,但是在那之前不要尝试使用多个 GOPATH 文件夹,它只会减慢你的速度。

2. 将select习惯用法包装到函数

如果有一种情况需要中断 for-select,那么就需要使用标签。例如:

func main() {

L:
    for {
        select {
        case <-time.After(time.Second):
            fmt.Println("hello")
        default:
            break L
        }
    }

    fmt.Println("ending")
}

正如你所看到的,你需要把 break 和标签结合起来使用。这是一种常见的用法,但我不喜欢。在我们的示例中,for 循环似乎很小,但通常它要大得多,跟踪中断状态很繁冗。

如果需要中断 for-select,我会将封装到一个函数中:

func main() {
    foo()
    fmt.Println("ending")
}

func foo() {
    for {
        select {
        case <-time.After(time.Second):
            fmt.Println("hello")
        default:
            return
        }
    }
}

这样做的好处是,您还可以返回一个错误(或任何其他值),举个栗子:

// blocking
if err := foo(); err != nil {
    // do something with the err
}

3. 使用带标记的文本进行结构初始化

这是一个未标记的文本示例:

type T struct {
    Foo string
    Bar int
}

func main() {
    t := T{"example", 123} // untagged literal
    fmt.Printf("t %+vn", t)
}

现在,如果您在 T 结构中添加一个新字段,您的代码将无法编译:

type T struct {
    Foo string
    Bar int
    Qux string
}

func main() {
    t := T{"example", 123} // doesn't compile
    fmt.Printf("t %+vn", t)
}

如果使用了 Go 标签,Go的兼容性规则(golang.org/doc/go1comp… Zone 的新字段时,情况尤其如此,请参见:golang.org/doc/go1.1#l… 现在回到我们的示例,始终使用标记文字:

type T struct {
    Foo string
    Bar int
    Qux string
}

func main() {
    t := T{Foo: "example", Bar: 123}
    fmt.Printf("t %+vn", t)
}

这可以很好地编译并且是弹性好。如果向 T 结构添加另一个字段,则无所谓。您的代码将始终编译,并保证被进一步的 Go 版本编译。运行 Go Vet 会捕获未标记的结构文本。

4. 将结构体初始化拆分为多行

如果有两个以上的字段,只需使用多行即可。它使您的代码更易于阅读,这意味着:

T{Foo: "example", Bar:someLongVariable, Qux:anotherLongVariable, B: forgetToAddThisToo}

用例:

T{
    Foo: "example",
    Bar: someLongVariable,
    Qux: anotherLongVariable,
    B: forgetToAddThisToo,
}

这有几个优点,

  • 更容易阅读
  • 使禁用/启用字段初始化更容易(只需注释掉或删除它们)
  • 添加另一个字段更容易(添加换行符)

5. 为整型常量值添加 String() 方法

如果要对自定义枚举使用带有 iota 的自定义整数类型,请始终添加 String() 方法。假设你有:

type State int

const (
    Running State = iota 
    Stopped
    Rebooting
    Terminated
)

如果您从这个类型创建一个新变量并打印它,您将得到一个整数(play.golang.org/p/V5VVFB05H…):

func main() {
    state := Running

    // print: "state 0"
    fmt.Println("state ", state)
}

在这里,0并不意味着什么,除非你再次查找常量变量。只需将 String() 方法添加到 state 类型中即可修复它(play.golang.org/p/ewMKl6K30…):

func (s State) String() string {
    switch s {
    case Running:
        return "Running"
    case Stopped:
        return "Stopped"
    case Rebooting:
        return "Rebooting"
    case Terminated:
        return "Terminated"
    default:
        return "Unknown"
    }
}

新的输出是:state:Running。正如你所看到的,它现在更具可读性。当你需要调试你的应用程序时,它会让你的生活变得更加轻松。您可以通过实现 MarshalJSON()UnmarshalJSON() 等方法来完成相同的操作..

最后一点,这一切现在都可以通过 Stringer 工具实现自动化:

godoc.org/golang.org/…

此工具使用 go generate 根据整数类型自动创建一个非常有效的字符串方法。

6. iota 从 a + 1 开始增量

在我们前面的例子中,我们碰到一些bug,我已经遇到过好几次了。假设您有一个新的结构类型,它还存储一个 state 字段:

type T struct {
    Name  string
    Port  int
    State State
}

现在,如果我们创建一个基于 T 的新变量并打印它,您会感到惊讶

(play.golang.org/p/LPG2RF3y3…) :

func main() {
    t := T{Name: "example", Port: 6666}

    // prints: "t {Name:example Port:6666 State:Running}"
    fmt.Printf("t %+vn", t)
}

你看到 bug 了吗?我们的 State 字段未初始化,默认情况下 Go 使用相应类型的零值。因为 State 是一个整数,所以它将是 0,0 意味着在我们的例子中基本上是运行的。

现在你怎么知道 State 是否真的初始化了呢?它真的处于运行模式吗?没有办法区分这一个,是一种导致未知和不可预测的bug。不管怎样,只要 ioat 从 + 1 开始(play.golang.org/p/VyAq-3OIt…):

const (
    Running State = iota + 1
    Stopped
    Rebooting
    Terminated
)

现在你的 T 在默认情况下只会打印 Unknown,对吗?😊 :

func main() {
    t := T{Name: "example", Port: 6666}

    // prints: "t {Name:example Port:6666 State:Unknown}"
    fmt.Printf("t %+vn", t)
}

但是让 ioat 从零值开始也是解决这个问题的另一种方法。例如,您可以引入一个名为 Unknown 的新状态并将其更改为:

const (
    Unknown State = iota 
    Running
    Stopped
    Rebooting
    Terminated
)

7. 返回回调函数

我见过很多这样的代码 (play.golang.org/p/8Rz1EJwFT…):

func bar() (string, error) {
    v, err := foo()
    if err != nil {
        return "", err
    }

    return v, nil
}

但是您可以执行以下操作:

func bar() (string, error) {
    return foo()
}

更简单、更易于阅读(当然,除非您希望记录中间值)。

8. 把 slice、map 等转换成自定义类型

再次将 slice 或 map 转换为自定义类型,使代码更易于维护。假设您有一个 Server 类型和一个现在假设您只想检索具有特定名称的服务器。让我们稍微更改一下ListServers()函数,并添加一个简单的过滤器支持:服务器列表的函数:

type Server struct {
    Name string
}

func ListServers() []Server {
    return []Server{
        {Name: "Server1"},
        {Name: "Server2"},
        {Name: "Foo1"},
        {Name: "Foo2"},
    }
}

现在假设您只想检索具有特定名称的 server 。让我们稍微更改一下 ListServers() 函数,并添加一个简单的过滤器支持:

// ListServers returns a list of servers. If name is given only servers that
// contains the name is returned. An empty name returns all servers.
func ListServers(name string) []Server {
    servers := []Server{
        {Name: "Server1"},
        {Name: "Server2"},
        {Name: "Foo1"},
        {Name: "Foo2"},
    }

    // return all servers
    if name == "" {
        return servers
    }

    // return only filtered servers
    filtered := make([]Server, 0)

    for _, server := range servers {
        if strings.Contains(server.Name, name) {
            filtered = append(filtered, server)
        }
    }

    return filtered
}

现在可以将其用于筛选名字含有 Foo 字符串的 server:

func main() {
    servers := ListServers("Foo")

    // prints: "servers [{Name:Foo1} {Name:Foo2}]"
    fmt.Printf("servers %+vn", servers)
}

如您所见,我们的 servers 现在已被过滤。然而,这并不能很好地扩展。如果要为 servers 引入另一个逻辑,该怎么办?像检查所有 servers 的运行状况,为每个 server 创建一个数据库记录,按另一个 server 筛选一个新字段等等…

让我们引入另一个名为 Servers 的新类型,并更改初始 ListServers() 以返回此新类型:

type Servers []Server

// ListServers returns a list of servers.
func ListServers() Servers {
    return []Server{
        {Name: "Server1"},
        {Name: "Server2"},
        {Name: "Foo1"},
        {Name: "Foo2"},
    }
}

现在我们要做的是,我们只需向 Servers 类型添加一个新的 Filter() 方法:

// Filter returns a list of servers that contains the given name. An
// empty name returns all servers.
func (s Servers) Filter(name string) Servers {
    filtered := make(Servers, 0)

    for _, server := range s {
        if strings.Contains(server.Name, name) {
            filtered = append(filtered, server)
        }

    }

    return filtered
}

现在让我们用 Foo 字符串筛选服务器:

func main() {
    servers := ListServers()
    servers = servers.Filter("Foo")
    fmt.Printf("servers %+vn", servers)
}

瞧!看看你的代码是怎么简化的?你想检查服务器是否正常?或者为每个服务器添加一个 DB 记录?没问题,只要添加这些新方法:

func (s Servers) Check() 
func (s Servers) AddRecord() 
func (s Servers) Len()
...

(哇,太棒了吧! - 注:这句话是翻译这篇文章时有感而发,非原作者文章内容。)

9. withContext 包装函数

有时,您会为每个函数重复执行一些操作,比如锁定/解锁、初始化新的局部上下文、准备初始变量等等..例如:

func foo() {
    mu.Lock()
    defer mu.Unlock()

    // foo related stuff
}

func bar() {
    mu.Lock()
    defer mu.Unlock()

    // bar related stuff
}

func qux() {
    mu.Lock()
    defer mu.Unlock()

    // qux related stuff
}

如果你想改变一件事,你需要去其他地方改变它们。如果它的共同任务是最好的事情是创建一个 withContext 函数。此函数将函数作为参数,并使用给定上下文调用它:

func withLockContext(fn func()) {
    mu.Lock
    defer mu.Unlock()

    fn()
}

然后只需封装初始函数:

func foo() {
    withLockContext(func() {
        // foo related stuff
    })
}

func bar() {
    withLockContext(func() {
        // bar related stuff
    })
}

func qux() {
    withLockContext(func() {
        // qux related stuff
    })
}

不要只考虑锁定上下文。这方面的最佳用例是 DB 连接或 DB 上下文。让我们稍微改变一下 withContext 函数:

func withDBContext(fn func(db DB)) error {
    // get a db connection from the connection pool
    dbConn := NewDB()

    return fn(dbConn)
}

正如您现在看到的,它获取一个数据库连接,将其传递给给定的函数并返回函数调用的错误。现在你要做的就是:

func foo() {
    withDBContext(func(db *DB) error {
        // foo related stuff
    })
}

func bar() {
    withDBContext(func(db *DB) error {
        // bar related stuff
    })
}

func qux() {
    withDBContext(func(db *DB) error {
        // qux related stuff
    })
}

你在想不同的场景,比如做一些初始化前的东西?没问题,只要将它们添加到 withDBContext 中就可以了。这也适用于测试。

这种方法的缺点是,它增加了缩进,使它更难阅读。再次寻求最简单的解决方案。

10. 为访问 map 添加 setter、getters

如果您大量使用 map 进行检索和添加,那么就给 map 添加 getter和setter。通过使用 getter和setter,您可以将逻辑封装到它们各自的函数中。这里最常见的错误是并发访问。假设你在一个 go routine 里有这个:

m["foo"] = bar

另一个是:

delete(m, "foo")

会发生什么?你们大多数人已经熟悉这样的竞争环境了。基本上,这是一个简单的竞争条件,因为默认情况下 mao 是非线程安全的。但您可以使用互斥量来轻松地保护它们:

mu.Lock() m["foo"] = "bar" mu.Unlock()

mu.Lock() delete(m, "foo") mu.Unlock()

假设您在其他地方使用此 map 。你会把互斥量弄得到处都是!但是,您可以完全通过使用 getter 和 setter 函数来避免这一点:

func Put(key, value string) {
    mu.Lock()
    m[key] = value
    mu.Unlock()
}




func Delete(key string) {
    mu.Lock()
    delete(m, key)
    mu.Unlock()
}

对这个过程的一个改进是使用一个接口。您可以完全隐藏实现。只需使用一个简单、定义良好的界面,并让包用户使用它们:

type Storage interface {
    Delete(key string)
    Get(key string) string
    Put(key, value string)
}

这只是一个例子,但你应该能感觉到,底层实现使用什么并不重要。重要的是使用本身和接口简化并解决了如果您公开内部数据结构将遇到的许多错误。

话虽如此,有时您可能需要一次锁定几个变量,这就有点过分了。所以要熟悉你开发的应用程序,只有在你有需要的时候才应用这个改进。

结论

抽象并不总是好的。有时候最简单的事情就是你已经在做的事情。话虽如此,不要试图让你的代码更聪明。Go 本质上是一种简单的语言,在大多数情况下,它只有一种方式做某事。它的力量来自于这种简单性,这也是为什么它在人类层面上能如此好地扩展的原因之一。

如果你真的需要,就使用这些技巧。例如,将 []Server 转换为 Servers 是另一种抽象,只有在有正当理由的情况下才这样做。但是一些技术,比如 iotas 从1开始,可以一直使用。同样,我们总是倾向于简单。

特别感谢 Cihangir Savas、Andrew Gerrand、Ben Johnson 和 Damian Gryski 的宝贵反馈和建议。

原文链接:arslan.io/2015/10/08/…