Golang 标准包布局[译]

1,705 阅读9分钟
原文链接: www.jianshu.com

在Go社区中, 包管理和泛型看上去被当做一个很大的问题, 但有另一个很少被提及的问题——项目结构。

每一个我参与的Go程序都看似对这个问题有自己的答案, 那我应该如何组织我的Go代码? 有些人把所有代码放到一个包中, 有些人用类型或者模块归组代码.如果你们的团队没有使用一个好的策略, 你会发现代码混乱分散在不同的包中。 所以我们需要一个更加标准的Go程序设计。

我提倡一种更好的方法. 通过以下的几种简单的规则, 我们解耦我们的代码, 并便于测试, 同时给我们的项目带来一种始终如一的结构。

常见但有瑕疵的方式

方法一: 整块的包布局

把所有代码丢到一个包下确实可以在小程序中工作的很好. 避免了出现循环依赖的问题, 因为在你的应用程序中, 没有互相依赖的情况。

我目睹过这种方式在一万行代码内良好的工作, 但只要超过这个级别, 它将变得非常难于定位和分离代码。

方法二: Rails 风格

另一种方式是将代码按照功能类型来归组。比如, 把所有Handlers 放在同一个包, 所有Controller放在另外一个包, 所有Models也放到单独的包中。 我见过很多之前做Rails的开发者使用这种方式(包括我自己)。

但这种方式有两个问题。 第一, 你的命名格则会是糟糕的。 你的type命名会像controller.UserController 这样重复你的包名。 我倾向于保持良好的命名规范。我坚信当你在修改老代码时命名是最好的文档。 同时命名也是代码质量的一种表现——这是其他人阅读代码时首先知觉的事

但最大的问题是循环依赖。 不同的功能类型可能需要互相引用。 这种方式只适合单向依赖但大多数程序没那么简单。

方法三: 根据模块归组

这种方式与Rails风格结构类似除了是按照模块归组而不是按照功能. 比如, 你可能有一个user包和一个account 包。

我们会发现这种方式有一样的问题。 最终我们的命名又变得像users.User一样糟糕。 如果accounts.Controller和users.Controller需要相互调用,我们同样也有循环依赖的问题。

一种更好的方式

我用在项目中的包策略包含四个信条:

  1. Root package is for domain types (根包作为领域类型)
  2. Group subpackages by dependency (根据依赖将子包归组)
  3. Use a shared mock subpackage (使用共享的模拟子包)
  4. Main package ties together dependencies (main包将所有依赖关联起来)

这些规则有助于我们的包解耦, 它定义一个清晰的领域语言。 让我们看一下这几个规则的实践。

1. Root package is for domain types (根包作为领域类型)

你的程序有一个有逻辑且高层级语言描述数据和进程是如何交互的, 这是你的领域模型。如果你有一个电子商务应用, 那么你的领域包含一些像客户,账户,收费信用卡, 和库存处理。如果你是Facebook 那么你的领域是用户,赞和各种关联. 这是一些不依赖你的技术的东西。

我把领域类型放在我的根目录。 这个包只包含简单的数据类型, 如一个User struct 用户放用户数据或者一个UserService interface 来获取保存用户数据。

type User struct {
    ID      int
    Name    string
    Address Address
}
type UserService interface {
    User(id int) (*User, error)
    Users() ([]*User, error)
    CreateUser(u *User) error
    DeleteUser(id int) error
}

这使你的根包非常简单。 你还可以把具体的执行操作放在里面, 但仅当它们仅依赖于其他域类型的时候。 比如, 你可以有个定时轮训你的User Service的类型。 但是, 它不应该调用外部服务或者数据。这是一个操作细节。

在你的程序中根包不应该依赖于任何其他包

2. Group subpackages by dependency (根据依赖将子包归组)

如果你的根包不允许有外部依赖, 那么我们必须把这些依赖放到子包里面。 用这种包布局方式, 子包以一个桥接你的领域模型和实现的适配器而存在。

比如, 你的UserService可能依赖PostgreSQL。 你可以在你的程序中创建一个postgres 子包来提供一个postgres.UserService 实现:

package postgres

import (
    "database/sql"

    "github.com/benbjohnson/myapp"
    _ "github.com/lib/pq"
)

// UserService 代表一个myapp.UserService的PostgreSQL实现 
type UserService struct {
    DB *sql.DB
}

// User方法 根据一个给定的id返回一个用户
func (s *UserService) User(id int) (*myapp.User, error) {
    var u myapp.User
    row := db.QueryRow(`SELECT id, name FROM users WHERE id = $1`, id)
    if row.Scan(&u.ID, &u.Name); err != nil {
        return nil, err
    }
    return &u, nil
}
// 实现myapp.UserService剩余的接口

这种方式解耦了我们的PostgreSQL依赖, 简化了测试,同时提供了一种简单的方式以便未来迁移到另一种数据库。 它可以作为一个可插拔的架构如果你决定支持其他数据库实现比如BoltDB

这种方式还给了你一种方式来实现分层。可能你想要把数据贮存在内存中, 将LRU cache 前置于PostgreSQL。你可以增加一个实现UserService接口的UserCache, 来包装你的PostgreSQL实现:

package myapp

// UserCache 封装了UserService, 提供了内存缓存功能
type UserCache struct {
        cache   map[int]*User
        service UserService
}

// NewUserCache returns a new read-through cache for service.
func NewUserCache(service UserService) *UserCache {
        return &UserCache{
                cache: make(map[int]*User),
                service: service,
        }
}
// User returns a user for a given id.
// Returns the cached instance if available.
func (c *UserCache) User(id int) (*User, error) {
// Check the local cache first.
        if u := c.cache[id]]; u != nil {
                return u, nil
        }

// Otherwise fetch from the underlying service.
        u, err := c.service.User(id)
        if err != nil {
            return nil, err
        } else if u != nil {
            c.cache[id] = u
        }
        return u, err
}

在标准库中也使用了这种方式. io.Reader 是一个用来读取bytes的领域类型, 它的实现是按照依赖分组——tar.Reader, gzip.Reader, multipart.Reader. 这些也可以被分层. 另外常见的还有os.Filebufio.Reader封装, bufio.Reader 又被gzip.Reader封装, gzip.Reader 又被tar.Reader封装.

相互依赖处理

你的依赖不会孤立存在. 你可能把User 数据存储在PostgreSQL中, 但你的财务交易数据存储在像Stripe之类的第三方. 在这种情况下, 我们用一个逻辑上的领域类型来封装我们的Stripe依赖—我们叫它TransactionService.

通过增加TransactionService到UserService来解耦了我们的两个依赖:

type UserService struct{
        DB *sql.DB
        TransactionService   myapp.TransactionService
}

现在我们的依赖通过通用的领域语言来交流. 这意味着我们可以在不影响其他依赖的情况下, 替换PostgreSQL到MySQL或者替换Stripe到其他支付平台

这个规则不仅限于第三方依赖

这个听上去很奇怪, 但我也使用了以上同样方式解耦了对标准库的依赖. 比如, net/http 包是一个依赖项. 我们也可以使用一个包含http实现的子包将它解耦.

你可能会觉得很奇怪, 如果存在一个名字和它的依赖一样的包, 但是这是有意义的. 这种方式并不会让你的程序中存在命名冲突, 除非你允许 net/http被直接使用于其他地方.

package http

import (
    "net/http"

    "github.com/benbjohnson/myapp"
)

type Handler struct {
    UserService myapp.UserService
}

func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    // 处理请求
}

现在你的http.Handler 在连接了你的领域模型和HTTP协议.

3. Use a shared mock subpackage (使用共享的模拟子包)

因为我们的依赖通过领域接口互相独立, 所以我们可以用这些连接点来注入mock实现.

市面上有一些mock库比如GoMock, 它会为你生成mocks, 但我个人更加倾向于自己写. 因为我发现很多mocking工具都过于复杂.

我使用的mock方式很简单. 比如, 一个UserService mock 如下:

package mock

import "github.com/benbjohnson/myapp"

// UserService 代表一个myapp.UserService.的 mock实现 
type UserService struct {
    UserFn      func(id int) (*myapp.User, error)
    UserInvoked bool

    UsersFn     func() ([]*myapp.User, error)
    UsersInvoked bool

    // additional function implementations...
}

// User调用mock实现, 并标记这个方法为已调用
func (s *UserService) User(id int) (*myapp.User, error) {
    s.UserInvoked = true
    return s.UserFn(id)
}

// 其他函数Users(), CreateUser(), DeleteUser()

这个mock把函数注入任何使用了myapp.UserService接口来验证参数, 输出期望的数据或者注入错误.

我们测试下我们刚才使用的http.Handler:

package http_test

import (
    "testing"
    "net/http"
    "net/http/httptest"

    "github.com/benbjohnson/myapp/mock"
)

func TestHandler(t *testing.T) {
    // 将Mock注入其他Handler
    var us mock.UserService
    var h Handler
    h.UserService = &us

    // Mock我们的User()调用
    us.UserFn = func(id int) (*myapp.User, error) {
        if id != 100 {
            t.Fatalf("unexpected id: %d", id)
        }
        return &myapp.User{ID: 100, Name: "susy"}, nil
    }

    //调用其他Handler
    w := httptest.NewRecorder()
    r, _ := http.NewRequest("GET", "/users/100", nil)
    h.ServeHTTP(w, r)

    // 验证Mock
    if !us.UserInvoked {
        t.Fatal("expected User() to be invoked")
    }
}

我们的Mock让我们完全解耦了我们的单元测试, 只处理HTTP协议

4. Main package ties together dependencies (main包将所有依赖关联起来)

由于所有这些依赖包都是在独立的, 你可能想知道它们是如何聚集在一起的。这就是Main包的工作.

Main包布局

一个应用程序可能产生多个二进制文件, 所以我们将使用 Go 约定将我们的主包作为 cmd 包的子目录. 比如, 我们的项目可能有一个myapp服务的二进制文件和一个myappctl 客户端二进制文件从终端管理服务. 我们会像这样布局我们的Main包:

myapp/
    cmd/
         myapp/
              main.go
         myappctl/
              main.go
在编译时注入依赖

"依赖注入" 这个词受到不好的斥责. 它唤起了冗长的 Spring XML 文件的思想. 但是, 所有的这些实际上都意味着我们要将依赖项传递给对象, 而不是引用对象或查找依赖项本身.

主程序包是要选择将哪些依赖项注入到哪些对象中. 因为主包只是简单地将片段连接起来, 所以它往往是相当小且琐碎的代码:

package main

import (
    "log"
    "os"

    "github.com/benbjohnson/myapp"
    "github.com/benbjohnson/myapp/postgres"
    "github.com/benbjohnson/myapp/http"
)

func main() {
    // 连接数据库
    db, err := postgres.Open(os.Getenv("DB"))
    if err != nil {
        log.Fatal(err)
    }

    defer db.Close()

    // 创建Services
    us := &postgres.UserService{DB: db}

    // 关联Http Handler
    var h http.Handler
    h.UserService = us

    // 启动服务
}

同样重要的是要注意, 你的Main包也是一个适配器。它将终端连接到您的领域模型.

总结

应用程序设计是一个难题。有这么多的设计决策, 但如果没有Solid原则来指导你的问题的话会做得更糟。我们已经研究了几种当前 Go 应用程序设计的方法, 我们已经看到了他们的许多缺点。

我相信从依赖关系的角度来看, 设计使代码组织更简单, 更容易理解。首先, 我们设计我们的领域语言。然后我们解耦我们的依赖。接下来, 我们引入 mock 来隔离我们的测试。最后, 我们用Main包连接所有的东西。

原文连接 : medium.com/@benbjohnso…