四年前,我写了一篇名为 《标准包布局》 的文章试图阐述:包布局。即使对高级 Go 开发人员来说,这也是最困难的话题之一。然而,大多数开发人员还在艰难地将代码组织到目录结构中,相应的目录结构随着应用程序缓慢增长。
几乎所有编程语言都有一种机制,将相关功能组合在一起。Ruby 有 gems
,Java 有 package
。这些语言没有约定代码进行分组的标准,因为的确并不重要。最终一切都取决于个人喜好。
然而,包组织引起问题的频率,让切换到 Go 的开发人员吃惊。为什么 Go 包与其他语言如此不同?因为其不是 分组(group)
,而是 层次(layer)
。
理解循环依赖
Go 的包与其他语言中的分组,主要区别在于 Go 不允许循环依赖。包 A 可以依赖于包 B,但是包 B 不能依赖于包 A。
对开发人员而言,当两个包共享公共代码,该限制会带来问题。通常有两种解决方案:将两个包合并成一个包,或者引入第三个包。
然而,拆分成越来越多的包只是把问题推迟到未来。最终,仍会得到一大堆乱七八糟的包,缺少真正的结构。
偷师标准库
当你使用 Go 编程需要指引时,查看标准库是最有效的窍门之一。没有代码是完美的,但 Go 标准库封装了语言创造者的许多理念。
例如,net/http
包构建在 net
包的抽象之上,而 net
包又构建在 io
层的抽象之上。假设 net
包需要以某种方式依赖 net/http
是没有意义的,以上包结构行之有效。
虽然在标准库中行之有效,但很难延续到应用程序开发。
将层次应用于应用程序开发
我们将看到一个名为 WTF Dial
的示例应用程序,您可以阅读 介绍性文章 了解更多关于它的信息。
在此应用程序中,有两个逻辑层:
- SQLite 数据库
- HTTP 服务
我们为它们各创建一个包 —— sqlite
& http
。许多人拒绝将包命名成与标准库包相同的名称。这是一个站得住脚的说法,你也可以将其命名为 wtfhttp
。然而,HTTP 包完全封装了 net/HTTP
包,因此在同一文件中不会同时使用两者。给每个包加前缀既乏味又难看,所以我没有这么做。
原始的方法
一种构造应用程序的方法是将数据类型(如 User
、Dial
)和函数(如 FindUser()
, CreateDial()
)放到 sqlite
中。http
包可以直接依赖它:
这是一个不错的方法,它适用于简单的应用程序。不过,最终会遇到一些问题。
首先,我们的数据类型被命名为 sqlite.User
以及 sqlite.Dial
。两个数据类型属于我们的应用程序,而不是 SQLite
,如此命名很奇怪。
第二,HTTP 层现在只能提供来自 SQLite 的数据。如果需要在中间添加一个缓存层,会发生什么?或者如何支持其他类型的数据存储,比如 Postgres,或者甚至存储为JSON到磁盘上?
最后,需要为每次 HTTP 测试运行一个 SQLite 数据库,因为没有抽象层来 mock 它。我通常支持尽可能多地进行端到端测试,但是一些用例适合在较高层次引入单元测试。一旦引入了云服务,你不希望在每次测试调用都运行它的情况下,尤其正确。
隔离您的业务领域
第一点可以改变的是,将 业务领域 移动到自己的包中。也可以称之为“应用领域”。它是特定于应用程序的数据类型 —— 例如,User
, Dial
(在 WTF Dial 的例子中)。
为此,我使用根包(wtf
)实现该意图,因为它已经很简便地以应用程序命名,而且根包是新开发人员打开代码库时首先看到的地方。类型现在更好的命名为 wtf.User
以及 wtf.Dial
。
可以看到 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
包之类的替代实现。
重新组包
打破包之间的依赖关系可以让我们灵活地使用代码。对于二进制应用程序 wtfd
,我们仍然希望 http
依赖于 sqlite
(参见 wtf/main.go ),但是对于测试,我们可以将 http
更改为依赖于新的mock
包(参见http/server_test.go):
对我们的小型 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 许可协议。转载请注明出处!