前言

本文翻译自 Ben Johnson 的Standard Package Layout。俺在使用 Go 语言的过程中,经常去思考怎么设计项目结构和组织代码。Go 语言不允许循环引用,大部分的框架也没有预先定义好这些,不像其他语言有着比较成熟 MVC 的框架,比如 PHP 的 Laravel, 或者 Java 的 Spring,按照既有的结构往里面堆代码即可。在 Go 语言中我们需要根据需求自己设计项目结构,对于 Gopher 来说是个挑战。

pushing-cart (配图来自:gophers


供应商, 泛型。这些在 Go 社区中被视为大问题,但还有一个问题鲜有提及 —— 应用程序布局。

我曾经工作过的每个 Go 应用似乎对这个问题都有不同的答案,我该如何组织我的代码? 一些应用将所有东西都放到一个包中,而另一些则按类型或模块分组。如果在你的团队中没有应用一个好的策略,你会发现代码分散在应用的各个包中。我们需要一个更好的 Go 应用设计标准。

我建议一个更好的方法。通过遵循这几个简单的规则,我们可以解耦代码,使其更容易测试,并且带来一致的项目结构。在我们深入探讨前,让我们先看一些目前人们常用的项目结构。

常见有缺陷的方法

似乎有一些常见的 Go 应用程序组织方法,每种方法都有自己的缺陷。

方法 #1:单片封装

将你所有的代码放到一个包中,对小应用来说确实可以很好的工作。它消除了任何循环依赖的机会,因为在你的的应用中,没有依赖关系。

我曾见过使用这种方式达到10k代码量的应用。超过这个大小,导航代码和隔离代码将变得非常困难。

方法 #2 Rails 布局风格

另一个方法是将你的代码按功能分组。例如,将你的处理器放到一个包中,你的控制器放到另一个包中,模型也同样放到另一个包中。我经常从前 Rails 开发人员(包括我自己)那里看到这一点。

但用这个方法有两个问题。首先,你的名字很糟糕。。你最终会得到像 controller.UserController 这样的类型名称,你在你的类型名称中重复了你的包名称。我倾向于坚持命名。我相信当你在一片杂乱中写代码时,你的命名将是最好的文档。名称也被用作质量的代表 —— 这是人们在阅读代码时首先注意到的。

然而,最大的问题还是循环依赖。你不同的功能类型也可能相互引用。这只在你只有单向依赖时有效,但通常你的应用没有那么简单。

方法 #3: 按模块分组

这个方法和 Rails 布局风格相似,只是我们按模块而非功能分组。例如,你可能会有一个用户包和账户包。

在这个方法中我们发现了同样的问题。同样,我们最终会得到像 users.User 可怕的名称。,如果我们的 accounts.Controller 需要和 users.Controller 交互,我们同样会遇到循环依赖的问题,反之亦然。

更好的方法

我在项目中使用的包组织策略包含 4 个简单原则:

  1. 根包用于域类型
  2. 按依赖对子包分组
  3. 使用共享 mock 子包
  4. 主包将依赖联系在一起

这些规则有助于隔离我们的包并在整个应用程序中定义清晰的领域语言。让我们看看这些规则中的每一个在实践中是如何运作的。

#1. 根包用于域类型

你的应用程序具有描述数据和流程如何交互的逻辑高级语言。这是你的域。如果你有一个电子商务应用程序,你的域涉及客户、帐户、信用卡收费和处理库存等内容。如果你是 Facebook,那么你的域是用户、喜欢和关系。这是不依赖于你的底层技术的东西。

我将我的域类型放到根包下。这个包仅包含简单的数据类型,例如 User 结构体用于存放用户数据,或 UserService 接口用于获取或保存用户数据。

它看起来像这样:

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
}

这让你的根包非常简单。你还可以包含执行操作的类型,但前提是它们仅依赖于其他域类型。 例如,你可以有一个定期轮询您的 UserService 的类型。 但是,它不应调用外部服务或保存到数据库。 那是一个实现细节。

根包不应依赖于应用程序中的任何其他包!

#2. 按依赖对子包分组

如果你的根包不允许有外部依赖,我们就必须将这些依赖放到子包中。在这种包布局方法中,子包作为域和实现之间的适配器存在。

例如:你的 UserService 可能会依赖 PostgreSQL。

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 缓存。 你可以添加一个实现 UserService 的 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.Reader、gzip.Reader、multipart.Reader。 这些也可以分层。 通常会看到由 bufio.Reader 包装的 os.File 由 gzip.Reader 包装,而 gzip.Reader 由 tar.Reader 包装。

依赖关系之间的依赖关系

你的依赖不是孤立存在的。 你可以将用户数据存储在 PostgreSQL 中,但你的金融交易数据存在于 Stripe 等第三方服务中。 在这种情况下,我们用逻辑域类型包装我们的 Stripe 依赖项 —— 我们称之为 TransactionService。

通过将 TransactionService 添加到 UserService 中,我们解耦了两个依赖项:

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

现在,我们的依赖项仅通过我们的通用域语言进行通信。 这意味着我们可以将 PostgreSQL 换成 MySQL 或将 Stripe 换成另一个支付处理器,而不会影响其他依赖项。

不要将此限制为第三方依赖项

这听起来可能很奇怪,但我也用同样的方法隔离了我的标准库依赖项。 例如,net/http 包只是另一个依赖项。我们也可以通过在我们的应用程序中包含一个 http 子包来隔离它。

拥有一个与它所包装的依赖项同名的包可能看起来很奇怪,但是,这是故意的。 除非你允许在应用程序的其他部分使用 net/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. 主包将依赖联系在一起

由于所有这些依赖包都孤立地四处浮动,你可能想知道它们是如何组合在一起的。 这就是主包的工作。

主包布局

一个应用程序可能会生成多个二进制文件,因此我们将使用 Go 约定将我们的主包放置为 cmd 包的子目录。 例如,我们的项目可能有一个 myapp 服务器二进制文件,但也有一个 myappctl 客户端二进制文件,用于从终端管理服务器。 我们将像这样布局我们的主包:

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() {
	// 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...
}

同样重要的是要注意你的主包也是一个适配器。 它将终端连接到你的域。

结论

应用程序设计是一个难题。有太多的设计决策需要做出,如果没有一套可靠的原则来指导你,问题就会变得更糟。我们已经研究了几种当前的 Go 应用程序设计方法,并且我们已经看到了它们的许多缺陷。

我相信从依赖关系的角度进行设计可以使代码组织更简单,更容易推理。 首先我们设计我们的领域语言。 然后我们隔离我们的依赖关系。 接下来我们引入模拟来隔离我们的测试。 最后,我们在主包中将所有内容捆绑在一起。