译 | Packages as layers, not groups


  1. 理解循环依赖
  2. 偷师标准库
  3. 将层次应用于应用程序开发
    1. 原始的方法
    2. 隔离您的业务领域
    3. 通过抽象去除依赖
    4. 重新组包
  4. 结论

四年前,我写了一篇名为 《标准包布局》 的文章试图阐述:包布局。即使对高级 Go 开发人员来说,这也是最困难的话题之一。然而,大多数开发人员还在艰难地将代码组织到目录结构中,相应的目录结构随着应用程序缓慢增长。

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

然而,包组织引起问题的频率,让切换到 Go 的开发人员吃惊。为什么 Go 包与其他语言如此不同?因为其不是 分组(group),而是 层次(layer)

理解循环依赖

Go 的包与其他语言中的分组,主要区别在于 Go 不允许循环依赖。包 A 可以依赖于包 B,但是包 B 不能依赖于包 A。

5A25FB3F-B5FC-4A20-94D5-74BA54CF69DA.svg

对开发人员而言,当两个包共享公共代码,该限制会带来问题。通常有两种解决方案:将两个包合并成一个包,或者引入第三个包。

744E11AC-B068-430F-9827-54940085D941.svg+xml

然而,拆分成越来越多的包只是把问题推迟到未来。最终,仍会得到一大堆乱七八糟的包,缺少真正的结构。

偷师标准库

当你使用 Go 编程需要指引时,查看标准库是最有效的窍门之一。没有代码是完美的,但 Go 标准库封装了语言创造者的许多理念。

例如,net/http 包构建在 net 包的抽象之上,而 net 包又构建在 io 层的抽象之上。假设 net 包需要以某种方式依赖 net/http 是没有意义的,以上包结构行之有效。

2EAF705B-4584-4128-AF1B-B6CDD8E1181B.svg+xml

虽然在标准库中行之有效,但很难延续到应用程序开发。

将层次应用于应用程序开发

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

在此应用程序中,有两个逻辑层:

  1. SQLite 数据库
  2. HTTP 服务

我们为它们各创建一个包 —— sqlite & http 。许多人拒绝将包命名成与标准库包相同的名称。这是一个站得住脚的说法,你也可以将其命名为 wtfhttp。然而,HTTP 包完全封装了 net/HTTP 包,因此在同一文件中不会同时使用两者。给每个包加前缀既乏味又难看,所以我没有这么做。

原始的方法

一种构造应用程序的方法是将数据类型(如 UserDial)和函数(如 FindUser(), CreateDial() )放到 sqlite 中。http 包可以直接依赖它:

1BBCD044-BC5D-4449-A13B-ABF05AA79576.svg+xml

这是一个不错的方法,它适用于简单的应用程序。不过,最终会遇到一些问题。

首先,我们的数据类型被命名为 sqlite.User 以及 sqlite.Dial。两个数据类型属于我们的应用程序,而不是 SQLite,如此命名很奇怪。

第二,HTTP 层现在只能提供来自 SQLite 的数据。如果需要在中间添加一个缓存层,会发生什么?或者如何支持其他类型的数据存储,比如 Postgres,或者甚至存储为JSON到磁盘上?

最后,需要为每次 HTTP 测试运行一个 SQLite 数据库,因为没有抽象层来 mock 它。我通常支持尽可能多地进行端到端测试,但是一些用例适合在较高层次引入单元测试。一旦引入了云服务,你不希望在每次测试调用都运行它的情况下,尤其正确。

隔离您的业务领域

第一点可以改变的是,将 业务领域 移动到自己的包中。也可以称之为“应用领域”。它是特定于应用程序的数据类型 —— 例如,User, Dial(在 WTF Dial 的例子中)。

为此,我使用根包(wtf)实现该意图,因为它已经很简便地以应用程序命名,而且根包是新开发人员打开代码库时首先看到的地方。类型现在更好的命名为 wtf.User 以及 wtf.Dial

804113F0-9C8B-4363-BC04-8E9113A84CA7.svg+xml

可以看到 wtf.Dial 类型的一个示例:

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。

为了解决该问题,我们为业务域中的服务操作创建接口。服务通常是Create/Read/Update/Delete(CRUD),但可以扩展到其他操作。

// 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 包之类的替代实现。

E1B5DA68-F665-4427-A355-9CF8A7DB32A4.svg+xml

重新组包

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

38EFCB27-8A0D-4DE8-86CA-BAD89EAF1030.svg+xml

对我们的小型 web 应用程序 WTF Dial 而言,这可能过于炫技了,但随着代码库的增长,会变得越来越重要。

结论

包是 Go 中一个强大的工具,但是如果你把它看作分组而不是层次的话,它会让你感到无尽的沮丧,理解应用程序的逻辑层之后,可以提取业务域的数据类型和接口约定,并将它们移动到根包,作为所有子包的通用域语言。随着时间的推移,定义领域语言对于应用程序的增长至关重要。

有问题或意见,请在 WTF Dial GitHub 讨论板 上创建新帖。

原文:https://www.gobeyond.dev/packages-as-layers/

本文作者:cyningsun
本文地址https://www.cyningsun.com/03-03-2021/packages-as-layers-not-groups-cn.html
版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-ND 3.0 CN 许可协议。转载请注明出处!

# Golang