[翻译]Packages as layers, not groups

原文由 Ben Johnson 发表在 gobeyond,推荐观看原文文章链接

四年前,我写了一篇名为”Standard Package Layout“的文章,试图解决即使是高级Go开发人员最困难的主题之一:package layout(go项目中包的组织)。 但是,大多数开发人员仍在努力用目录的方式组织管理代码。

几乎所有的编程语言都有一种将相关功能分组在一起的机制。 Ruby有gems,Java有packages。 这些语言没有将代码代码分组的标准约定,因为老实说,这并不重要。 一切都取决于个人喜好。

但是,转到Go的开发人员发现他们需要经常关心代码包的组织。 为什么Go软件包与其他语言如此不同? 这是因为它们不是groups(组),而是layers(层)。

理解循环引用

Go软件包和其他语言之间的主要区别在于Go不允许循环依赖。 程序包A可以依赖程序包B,但是程序包B不能再依赖程序包A。软件包依赖关系只能有一个方向。

因为这个限制,开发人员在两个程序包中共享通用代码时带来了问题。 通常有两种解决方案:将两个程序包合并为一个程序包或引入第三个程序包。

但是,拆分成越来越多的程序包只会使问题继续越扩越大。 最终,相互中将会有大量的软件包,导致结构更加混乱。

标准库的做法

进行Go编程时,最有用的技巧之一是在需要指导时查看标准库。 没有代码是完美的,但是Go标准库包含了Go语言创建者许多思想的具体实践。

例如,net/http包建立在net包的抽象之上,而net包又建立在其下面的io层的抽象之上。 这种包结构之所以有效,是因为该开发方式中下层的net不需要依赖上层的net/http包,因此避免了循环引用。

虽然这在标准库中很好实践,但是很难在实际业务项目开发中进行实践。

应用开发中使用层

我们将看一个名为WTF Dial的示例应用程序,因此您可以阅读介绍文章以了解更多信息。

在这个应用中,我们有两个逻辑层:

  1. 一个 SQLite 数据库
  2. 一个 HTTP 服务

我们创建了两个包 sqlitehttp。 许多人任务程序包命名与标准库程序包相同的名称是不规范的。 这是一个正确的批评,您可以将其命名为wtfhttp,但是,我们的http包完全封装了net/http包,因此我们永远不会在同一个文件中同时使用它们。 同时,我发现给每个程序包加前缀既乏味又丑陋,所以我不这样做。

常见的包组织方法

一种包组织方法的方法是在sqlite包中包含我们的数据类型(例如,User, Dial等)和功能(例如,FindUser()CreateDial()等)。 我们的http包可能直接依赖于它:

这不是一个坏方法,它适用于简单的应用程序。 但是这里存在一些问题。

首先,我们的数据类型命名为sqlite.Usersqlite.Dial。 这看起来不符合逻辑,因为我们的数据类型属于我们的应用程序,而不是SQLite

其次,我们的HTTP层现在只能提供来自SQLite的数据。 如果我们需要在两者之间添加缓存层会怎样? 还是我们如何支持其他数据库,例如Postgres,甚至以JSON形式存储在磁盘上?

最后,由于没有抽象层,因此我们需要为每个HTTP测试运行一个SQLite数据库,而不能使用mock生成数据。 我通常会尽可能地支持端到端测试(类似于系统测试),但是在高层中引入单元测试也是非常有效和重要的。而且我们不希望每次测试都需要搭建SQLite组件。

隔离您的业务模型(business domain)

第一步需要做的是将我们的业务模型迁移到自己的软件包中。 这也可以称为”application domain(应用程序模型)”。 它是应用程序中专用的数据类型,例如WTF Dial 中的 User, Dial

在这里,我使用了软件包(wtf)保存我们的业务逻辑。因为它是我们应用程序的名称,并且它是新开发人员打开代码库时首先要看的地方。 其中的数据类型现在更恰当地命名为wtf.Userwtf.Dial

下面是wtf.Dial类型的一个例子:

// dial.go#L14-50
type Dial struct {
	ID int `json:"id"`

	// Owner of the dial. Only the owner may delete the dial.
	UserID int   `json:"userID"`
	User   *User `json:"user"`

	// Human-readable name of the dial.
	Name string `json:"name"`

	// Code used to share the dial with other users.
	// It allows the creation of a shareable link without
	// explicitly inviting users.
	InviteCode string `json:"inviteCode,omitempty"`

	// Aggregate WTF level for the dial.
	Value int `json:"value"`

	// Timestamps for dial creation & last update.
	CreatedAt time.Time `json:"createdAt"`
	UpdatedAt time.Time `json:"updatedAt"`

	// List of associated members and their contributing WTF level.
	// This is only set when returning a single dial.
	Memberships []*DialMembership `json:"memberships,omitempty"`
}

在此代码中,没有引用任何实现细节,仅是原始数据类型和time.Time。 为了方便起见,添加了JSON标签。

通过接口删除依赖项

我们的应用程序结构现在看起来更好,但是HTTP依赖SQLite仍然很奇怪。 我们的HTTP服务器希望从基础数据存储中获取数据,它并不特别关心是否为 SQLite

为了解决这个问题,我们为业务模型域中的服务创建接口。 这些服务通常是创建/读取/更新/删除(CRUD),但可以扩展到其他操作。

// dial.go#L81-L122
// DialService represents a service for managing dials.
type DialService interface {
	// Retrieves a single dial by ID along with associated memberships. Only
	// the dial owner & members can see a dial. Returns ENOTFOUND if dial does
	// not exist or user does not have permission to view it.
	FindDialByID(ctx context.Context, id int) (*Dial, error)

	// Retrieves a list of dials based on a filter. Only returns dials that
	// the user owns or is a member of. Also returns a count of total matching
	// dials which may different from the number of returned dials if the
	// "Limit" field is set.
	FindDials(ctx context.Context, filter DialFilter) ([]*Dial, int, error)

	// Creates a new dial and assigns the current user as the owner.
	// The owner will automatically be added as a member of the new dial.
	CreateDial(ctx context.Context, dial *Dial) error

	// Updates an existing dial by ID. Only the dial owner can update a dial.
	// Returns the new dial state even if there was an error during update.
	//
	// Returns ENOTFOUND if dial does not exist. Returns EUNAUTHORIZED if user
	// is not the dial owner.
	UpdateDial(ctx context.Context, id int, upd DialUpdate) (*Dial, error)

	// Permanently removes a dial by ID. Only the dial owner may delete a dial.
	// Returns ENOTFOUND if dial does not exist. Returns EUNAUTHORIZED if user
	// is not the dial owner.
	DeleteDial(ctx context.Context, id int) error
}

现在,我们的业务模型包(wtf)不仅指定了数据结构,而且还指定了接口规范,以说明我们的各层如何相互通信。 这使我们的程序包层次结构变得平坦,因此所有程序包现在都依赖于业务模型程序包。 这使我们可以打破包之间的直接依赖关系,并引入替代实现,例如mock

重新包装packages

Repackaging packages

打破软件包之间的依赖关系使我们可以灵活地使用代码。 对于我们的应用程序二进制文件wtfd,我们仍然希望http依赖于sqlite(请参阅wtf/main.go),但是对于测试,我们可以更改http依赖于我们的新mock包(请参阅http/server_test.go):

对于我们的小型Web应用程序WTF Dial来说,这可能是过度设计,但是随着我们代码库的增长,它变得越来越重要。

结论

包是Go中强大的工具,但如果将它们视为组而不是图层,则是导致很多困惑。了解了应用程序的逻辑层之后,您可以提取业务域的数据类型和接口协定,并将它们移至应用程序模型包中,以用作所有子包的通用域语言。 随着时间的推移,对于扩展应用程序,定义此模型包至关重要。

如果您对本文有问题,评论,或建议,请访问GitHub Discussion board,这是一个Github讨论版,使用它可以进行更方便的交流。

not found!