[译] 标准化的包布局(Standard Package Layout)

通常来讲是使用 Vendoring 做为包管理工具。在 Go 社区已经能够看到一些重要的问题,可是有一个问题在社区中不多被说起,即应用的包布局。前端

我曾经参与编写过的每个 Go 应用对这个问题彷佛都有不一样的答案, 我该如何组织个人代码? 。一些应用会把全部的东西都放到一个包里,而其它应用则会选择按照类型或模块来组织代码。若是没有一个适用于整个团队的策略,你将发现代码会散布在你应用不一样包里面。对于 Go 应用程序包布局的设计咱们须要一个更好的标准。android

我提议有一个更好的方式。经过遵循一些简单的规则咱们就能够解耦咱们的代码,使之更易于测试而且可使咱们的项目有一致的结构,在深刻探讨这个方式以前,让咱们来看下目前人们组织项目一些最多见的方式。ios


更新:我收到了不少关于这种方式很是棒的反馈,其中最多的是想要看到一个使用这种方式构建的应用。因而我已经开始从新写一系列文章记录使用这种包布局方式来构建应用,叫作 Building WTF Dial.git

常见的有缺陷的方式

如今彷佛有几种通用的 Go 应用组织方式,它们都有各自的缺陷。github

方法 #1: 单个包

把你全部的代码都扔进一个包,对于一个小的应用来讲这样就能够很好的工做。它消除了产生循环依赖问题的可能,由于在你的应用代码中并无任何依赖。golang

我曾经看到过使用这种方式构建超过 10K 行代码的应用 SLOC。可是一旦代码量超过这个数量,定位和独立你的代码将会变得很是困难。spring

方法 #2: Rails 风格布局

另外一种组织你代码的方式是根据它的功能类型。好比说,把全部你的 处理器,控制器,模型代码都分别放在独立的包中。我以前看到不少前 Rails 开发者(包括我本身)都使用这种方式来组织代码。sql

可是使用这种方式有两个问题。首先你的命名将会变得糟糕透顶,你最终会获得相似 controller.UserController 这样的命名,在这种命名中你重复了包名和类型名。对于命名,我是一个有执念的人。我相信当你在去除无用代码时名称是你最好的文档。好的名称也是高质量代码的表明,当其余人读代码时老是最早注意到这个。数据库

更大的问题在于循环依赖。你不一样的功能类型也许须要互相引用对方。只有当你维护单向依赖关系时,这个应用才可以工做,可是在不少时候维护单向依赖并不简单。后端

方法 #3:根据模块组织代码

这个方式相似于前面的 Rails 风格布局,可是咱们是使用模块来组织代码而不是功能。好比说,你或许会有一个 user 包和一个 account 包。

咱们发现使用这种方式也会遇到以前一样的问题。咱们最后也会遇到像 users.User. 这样可怕的命名。若是咱们的 accounts.Controller 须要和 users.Controller 进行交互,那么咱们一样会遇到相同的循环依赖问题,反之亦然。

一个更好的方式

我在项目使用的包组织策略涉及到如下4个简单的原则:

  1. Root 包是用于域类型的
  2. 经过依赖关系来组织子包
  3. 使用一个共享的 mock 子包
  4. Main 包将依赖关系联系到一块儿

这些规则帮助隔离咱们的包而且在整个应用中定义了一个清晰的领域语言。让咱们来看看这些规则在实践中是如何使用的。

#1. Root 包是用于域类型的

你的应用有一种用于描述数据和进程是如何交互的逻辑层面的高级语言。这就是你的域。若是你有一个电子商务应用,那你的域就会涉及到客户,帐户,信用卡支付,以及存货等内容。若是你的应用是 Facebook,你的域就会是用户,点赞以及用户间的关系。这些是不依赖于你基础技术的东西。

我把个人域类型放在 root 保存。这个包只包含了简单的数据类型,好比说包含用户信息的 User 结构或者是获取和保存用户数据的 UserService 接口。

这个 root 包会像如下这样:

package myapp

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
}
复制代码

这使你的 root 包变的很是简单。你也能够在这个包里放包含执行操做的类型,可是它们应该只依赖于其它的域类型。好比说,你能够在这个包加一个按期轮询 UserService 的类型。可是,它不该该调用外部服务或者将数据保存到数据库。这些是实现细节。

root 包不该该依赖于你应用中的其它任何包

#2. 经过依赖关系来组织子包

若是你的 root 包并不容许有外部依赖,那么咱们就必须把这些依赖放到子包里。在这种包布局的方式中,子包就至关于你域和实现之间的适配器。

好比说,你的 UserService 多是由 PostgreSQL 数据库提供支持。你能够在应用中引入一个叫作 postgres 的子包用来提供 postgres.UserService 的实现。

package postgres

import (
	"database/sql"

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

// UserService represents a PostgreSQL implementation of myapp.UserService.
type UserService struct {
	DB *sql.DB
}

// User returns a user for a given 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
}

// implement remaining myapp.UserService interface...
复制代码

这样就隔离了咱们对 PostgreSQL 的依赖关系,从而简化了测试,并为咱们未来迁移到其它数据库提供了一种简单的方法。若是你打算支持像 BoltDB 这种数据库的实现,就能够把它看做是一个可插拔体系结构。

这也为你实现层级提供了一种方式。好比说你想要在 Postgresql 前面加一个内存缓存 LRU cache。你能够添加一个 UserCache 类型来包装你的 Postgresql 实现。

package myapp

// UserCache wraps a UserService to provide an in-memory cache.
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 是一个用于读取字节的域类型,它的实现是经过组织依赖关系 tar.Readergzip.Readermultipart.Reader 来实现的。在标准库中也能够看到层级方式,常常能够看到 os.Filebufio.Readergzip.Readertar.Reader 这样一个个层级封装。

依赖之间的依赖

依赖关系并非孤立的。你能够把 User 数据保存在 Postgresql 中,而把金融交易数据保存在像 Stripe 这样的第三方服务。在这种状况下咱们用一个逻辑上的域类型来封装对 Stripe 的依赖,让咱们把它叫作 TransactionService

经过把咱们的 TransactionService 添加到 UserService ,咱们解耦了咱们的两个依赖。

type UserService struct {
        DB *sql.DB
        TransactionService myapp.TransactionService
}
复制代码

如今咱们的依赖只经过共有的领域语言交流。这意味着咱们能够把 Postgresql 切换为 MySQL 或者把 Strip 切换为另外一个支付的内部处理器而不用担忧影响到其它的依赖。

不要只对第三方的依赖添加这个限制

这听起来虽然有点奇怪,可是我也使用这种方式来隔离对标准库的依赖关系。例如 net/http 包只是另外一种依赖。咱们能够经过在应用中包含一个 http 子包来隔离对它的依赖。

有一个名称与它所包装依赖相同的包看起来彷佛很奇怪,可是这只是内部实现。除非你容许你应用的其它部分使用 net/http ,不然在你的应用中就不会有命名冲突。复制 http 名称的好处在于它要求你把全部 HTTP 相关代码都隔离到 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) {
        // handle request
}
复制代码

如今,你的 http.Handler 就像是一个在域和 HTTP 协议以前的适配器。

#3. 使用一个共享的 mock 子包

由于咱们的依赖经过域接口已经和其它的依赖隔离了,因此咱们可使用这些链接点来注入模拟实现。

这里有几个像 GoMock 的模拟库来帮你生成模拟数据,可是我我的更喜欢本身写。我发现许多的模拟工具都过于复杂了。

我使用的模拟很是简单。好比说,一个对 UserService 的模拟就像下面这样:

package mock

import "github.com/benbjohnson/myapp"

// UserService represents a mock implementation of myapp.UserService.
type UserService struct {
        UserFn      func(id int) (*myapp.User, error)
        UserInvoked bool

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

        // additional function implementations...
}

// User invokes the mock implementation and marks the function as invoked.
func (s *UserService) User(id int) (*myapp.User, error) {
        s.UserInvoked = true
        return s.UserFn(id)
}

// additional functions: Users(), CreateUser(), DeleteUser()
复制代码

这个模拟让我能够注入函数到任何使用 myapp.UserService 的接口来验证参数,返回预期的数据或者注入失败。

假设咱们想测试咱们上面构建的 http.Handler

package http_test

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

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

func TestHandler(t *testing.T) {
	// Inject our mock into our handler.
	var us mock.UserService
	var h Handler
	h.UserService = &us

	// Mock our User() call.
	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
	}

	// Invoke the handler.
	w := httptest.NewRecorder()
	r, _ := http.NewRequest("GET", "/users/100", nil)
	h.ServeHTTP(w, r)
	
	// Validate mock.
	if !us.UserInvoked {
		t.Fatal("expected User() to be invoked")
	}
}
复制代码

咱们的模拟彻底隔离了咱们的单元测试,让咱们只测试 HTTP 协议的处理。

#4. Main 包将依赖关系联系到一块儿

当全部这些依赖包独立维护时,你可能想知道如何把它们聚合到一块儿。这就是 main 包的工做。

Main 包布局

一个应用可能会产生多个二进制文件, 因此咱们使用 Go 的惯例把咱们的 main 包做为 cmd 包的子目录。 好比,咱们的项目中可能有一个 myapp 服务二进制文件,还有一个用于在终端管理服务 的 myappctl 客户端二进制文件。咱们的包将像这样布局:

myapp/
    cmd/
        myapp/
            main.go
        myappctl/
            main.go
复制代码

在编译时注入依赖

"依赖注入"这个词已经成了一个很差的说法,它让人联想到 Spring 冗长的XML文件。然而,这个术语所表明的真正含义只是要把依赖关系传递给咱们的对象,而不是要求对象构建或者找到这个依赖关系自己。

main 包中咱们能够选择哪些依赖注入到哪些对象中。由于 main 包只是简单的链接了各部分,因此 main 中的代码每每是比较小和琐碎的。

package main

import (
	"log"
	"os"
	
	"github.com/benbjohnson/myapp"
	"github.com/benbjohnson/myapp/postgres"
	"github.com/benbjohnson/myapp/http"
)

func main() {
	// Connect to database.
	db, err := postgres.Open(os.Getenv("DB"))
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()

	// Create services.
	us := &postgres.UserService{DB: db}

	// Attach to HTTP handler.
	var h http.Handler
	h.UserService = us
	
	// start http server...
}
复制代码

注意到你的 main 包也是一个适配器很重要。他把全部终端链接到你的域。

结论

应用设计是一个难题。尽管作出了这么多的设计决策,若是没有一套坚实的原则来指导,那你的问题只会变的更糟。咱们已经列举了 Go 应用布局设计的几种方式,而且咱们也看到了不少它们的缺陷。

我相信从依赖关系的角度来看待设计会使代码组织的更简单,更加容易理解。首先咱们设计咱们的领域语言,而后咱们隔离咱们的依赖关系,以后介绍了使用 mock 来隔离咱们的测试,最后咱们把全部东西都在 main 包中绑了起来。

能够在下一个你设计的应用中考虑下这些原则。若是有您有任何问题或者想讨论这个设计,请在 Twitter 上 @benbjohnson与我联系,或者在Gopher slack 查找 benbjohnson 来找到我。


掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 AndroidiOS前端后端区块链产品设计人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划官方微博知乎专栏

相关文章
相关标签/搜索