[翻译]Packages as layers, not groups
四年前,我写了一篇名为”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的示例应用程序,因此您可以阅读介绍文章以了解更多信息。
在这个应用中,我们有两个逻辑层:
- 一个 SQLite 数据库
- 一个 HTTP 服务
我们创建了两个包 sqlite
和http
。 许多人任务程序包命名与标准库程序包相同的名称是不规范的。 这是一个正确的批评,您可以将其命名为wtfhttp
,但是,我们的http
包完全封装了net/http
包,因此我们永远不会在同一个文件中同时使用它们。 同时,我发现给每个程序包加前缀既乏味又丑陋,所以我不这样做。
常见的包组织方法
一种包组织方法的方法是在sqlite
包中包含我们的数据类型(例如,User
, Dial
等)和功能(例如,FindUser()
,CreateDial()
等)。 我们的http
包可能直接依赖于它:
这不是一个坏方法,它适用于简单的应用程序。 但是这里存在一些问题。
首先,我们的数据类型命名为sqlite.User
和sqlite.Dial
。 这看起来不符合逻辑,因为我们的数据类型属于我们的应用程序,而不是SQLite
。
其次,我们的HTTP
层现在只能提供来自SQLite
的数据。 如果我们需要在两者之间添加缓存层会怎样? 还是我们如何支持其他数据库,例如Postgres
,甚至以JSON
形式存储在磁盘上?
最后,由于没有抽象层,因此我们需要为每个HTTP
测试运行一个SQLite
数据库,而不能使用mock
生成数据。 我通常会尽可能地支持端到端测试(类似于系统测试),但是在高层中引入单元测试也是非常有效和重要的。而且我们不希望每次测试都需要搭建SQLite
组件。
隔离您的业务模型(business domain)
第一步需要做的是将我们的业务模型迁移到自己的软件包中。 这也可以称为”application domain(应用程序模型)”。 它是应用程序中专用的数据类型,例如WTF Dial
中的 User
, Dial
。
在这里,我使用了软件包(wtf
)保存我们的业务逻辑。因为它是我们应用程序的名称,并且它是新开发人员打开代码库时首先要看的地方。 其中的数据类型现在更恰当地命名为wtf.User
和wtf.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讨论版,使用它可以进行更方便的交流。