EvenChan's Ops.

编写Go的一些建议

字数统计: 1.5k阅读时长: 6 min
2020/06/10

显式与隐式

  Go 语言社区对于显式的初始化、方法调用非常推崇,类似 Spring Boot 和 Rails 的框架其实都广泛地采纳了『约定优于配置』的中心思想,简化了开发者和工程师的工作量

init

  在这里先以一个非常常见的函数 init 为例,介绍 Go 语言社区对显式调用的推崇;相信很多人都在一些 package 中阅读过这样的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var grpcClient *grpc.Client

func init() {
var err error
grpcClient, err = grpc.Dial(...)
if err != nil {
panic(err)
}
}

func GetPost(postID int64) (*Post, error) {
post, err := grpcClient.FindPost(context.Background(), &pb.FindPostRequest{PostID: postID})
if err != nil {
return nil, err
}

return post, nil
}

  这种代码虽然能够通过编译并且正常工作,然而这里的 init 函数其实隐式地初始化了 grpc 的连接资源,如果另一个 package 依赖了当前的包,那么引入这个依赖的工程师可能会在遇到错误时非常困惑,因为在 init 函数中做这种资源的初始化是非常耗时并且容易出现问题的

  一种更加合理的做法其实是这样的,首先定义一个新的 Client 结构体以及一个用于初始化结构的 NewClient 函数,这个函数接收了一个 grpc 连接作为入参返回一个用于获取 Post 资源的客户端,GetPost 成为了这个结构体的方法,每当我们调用 client.GetPost 时都会用到结构体中保存的 grpc 连接:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// post/client.go
type Client struct {
grpcClient *grpc.ClientConn
}

func NewClient(grpcClient *grpcClientConn) Client {
return &Client{
grpcClient: grpcClient,
}
}

func (c *Client) GetPost(postID int64) (*Post, error) {
post, err := c.grpcClient.FindPost(context.Background(), &pb.FindPostRequest{PostID: postID})
if err != nil {
return nil, err
}

return post, nil
}

  初始化 grpc 连接的代码应该放到 main 函数或者 main 函数调用的其他函数中执行,如果我们在 main 函数中显式的初始化这种依赖,对于其他的工程师来说就非常易于理解,我们从 main 函数开始就能梳理出程序启动的整个过程:

1
2
3
4
5
6
7
8
9
10
// cmd/grpc/main.go
func main() {
grpcClient, err := grpc.Dial(...)
if err != nil {
panic(err)
}

postClient := post.NewClient(grpcClient)
// ...
}

  各个模块之间会构成一种树形的结构和依赖关系,上层的模块会持有下层模块中的接口或者结构体,不会存在孤立的、不被引用的对象

  当然这并不是说我们一定不能使用 init 函数,作为 Go 语言赋予开发者的能力,因为它能在包被引入时隐式地执行了一些代码,所以我们更应该慎重地使用它们

  一些框架会在 init 中判断是否满足使用的前置条件,但是对于很多的 Web 或者 API 服务来说,大量使用 init 往往意味着代码质量的下降以及不合理的设计

1
2
3
4
5
6
7
8
9
10
11
12
13
func init() {
if user == "" {
log.Fatal("$USER not set")
}
if home == "" {
home = "/home/" + user
}
if gopath == "" {
gopath = home + "/go"
}
// gopath may be overridden by --gopath flag on command line.
flag.StringVar(&gopath, "gopath", gopath, "override default GOPATH")
}

  上述代码其实是 Effective Go 在介绍 init 方法使用是展示的实例代码,这是一个比较合理地 init 函数使用示例,我们不应该在 init 中做过重的初始化逻辑,而是做一些简单、轻量的前置条件判断

面向接口

  接口的作用其实就是为不同层级的模块提供了一个定义好的中间层,上游不再需要依赖下游的具体实现,充分地对上下游进行了解耦

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package post

var client *grpc.ClientConn

func init() {
var err error
client, err = grpc.Dial(...)
if err != nil {
panic(err)
}
}

func ListPosts() ([]*Post, error) {
posts, err := client.ListPosts(...)
if err != nil {
return []*Post{}, err
}

return posts, nil
}

  上述代码其实就不是一个设计良好的代码,它不仅在 init 函数中隐式地初始化了 grpc 连接这种全局变量,而且没有将 ListPosts 通过接口的方式暴露出去,这会让依赖 ListPosts 的上层模块难以测试

  我们可以使用下面的代码改写原有的逻辑,使得同样地逻辑变得更容易测试和维护:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package post

type Service interface {
ListPosts() ([]*Post, error)
}

type service struct {
conn *grpc.ClientConn
}

func NewService(conn *grpc.ClientConn) Service {
return &service{
conn: conn,
}
}

func (s *service) ListPosts() ([]*Post, error) {
posts, err := s.conn.ListPosts(...)
if err != nil {
return []*Post{}, err
}

return posts, nil
}
  1. 通过接口 Service 暴露对外的 ListPosts 方法
  2. 使用 NewService 函数初始化 Service 接口的实现并通过私有的结构体 service 持有 grpc 连接
  3. ListPosts 不再依赖全局变量,而是依赖结构体 service 持有的连接

  当我们使用这种方式重构代码之后,就可以在 main 函数中显式的初始化 grpc 连接、创建 Service 接口的实现并调用 ListPosts 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package main

import ...

func main() {
conn, err = grpc.Dial(...)
if err != nil {
panic(err)
}

svc := post.NewService(conn)
posts, err := svc.ListPosts()
if err != nil {
panic(err)
}

fmt.Println(posts)
}

  这种使用接口组织代码的方式在 Go 语言中非常常见,我们应该在代码中尽可能地使用这种思想和模式对外提供功能:

  1. 使用大写的 Service 对外暴露方法
  2. 使用小写的 service 实现接口中定义的方法
  3. 通过 NewService 函数初始化 Service 接口

总结

  1. 显示与隐式:尽可能地消灭项目中的 init 函数,保证显式地进行方法的调用以及错误的处理
  2. 面向接口:面向接口是 Go 语言鼓励的开发方式,也能够为我们写单元测试提供方便,我们应该遵循固定的模式对外提供功能
  3. 使用大写的 Service 对外暴露方法
  4. 使用小写的 service 实现接口中定义的方法
  5. 通过 func NewService(...) (Service, error) 函数初始化 Service 接口
CATALOG
  1. 1. 显式与隐式
    1. 1.1. init
  2. 2. 面向接口
  3. 3. 总结