译文 以层的方式而不是组的方式进行包管理

astaxie · 2021年02月17日 · 最后由 astaxie 回复于 2021年02月19日 · 278 次阅读
本帖已被设为精华帖!

以层的方式而不是组的方式进行包管理

四年之前,我写了一篇包管理标准的文章,试图解决即使是高级 Go 开发者也觉得困难的话题之一:包管理。然而,大多数的开发者依旧挣扎于使用文件夹结构的代码组织方式,而在这种方式下,随着应用的发展,目录结构也会慢慢增长而日趋复杂。

几乎所有的编程语言都有一个将功能集合的机制。Ruby 有 gems,Java 有包管理。而那些没有组织代码标准约定的语言,老实说,是不关心代码组织的方式。所以这就完全依赖个人选择了。

然而,转换到 Go 语言的开发者会经常发现,他们的包管理方式常常会反噬。为何 Go 的包与其他语言如此不同?这是因为不是组的关系——而是层的关系。

理解循环依赖

Go 的包和其他语言组合方式的一个主要区别是 Go 不支持循环依赖。A 包可以依赖 B 包,那么之后 B 包就无法再依赖 A 包。

包的依赖关系只是单向的。

对于开发者来说,当他们在两个包中共享通用的代码时,这个限制就会导致问题。一般来说有两个解决方法:要么将两个包合二为一,要么引入第三个包。

但是,不断分裂更多的包只能掩盖问题。最后,只剩大量的包而没有真实的结构。

借鉴标准库

在 Go 编程需要指导时,最有效的方法之一是阅读标准库。没有完美的代码,但 Go 的标准库中封装了大量这门语言创造者们的理想代码。

比如,net/http包构建在net包的抽象基础之上,而net包则是构建在下面io层的抽象之上。想象一下net包在某种情况下依赖net/http包是多么的荒谬,你就会发现这种包结构非常合理。

虽然这种方式在标准库中有效,但很难在应用开发中使用。

在应用开发中使用层的方式

接下来我们看下一个叫做WTF Dial的示例应用,你可以阅读介绍文章进一步了解详情。

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

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

我们分别为这两个逻辑创建包——sqlite & http。很多人在对包的命名与标准库的包一样时会有些犹豫。这个想法是对的,你可以把它命名为wtfhttp,但是我们的 HTTP 包将完全封装net/http,而且我们不会在同个文件中同时使用这两个包。我觉得在每个包名字前都加前缀的做法很无聊也很丑,所以我不会这么干。

直接的方法

设置应用结构的一个方法是把数据类型(如UserDial)和函数(如FindUser()CreateDial())放在sqlite中。http包将直接依赖于它:

这个方法不错,对于简单的应用也有效。但最后我们还是会遇到一些问题。首先,我们的数据类型命名是sqlite.Usersqlite.Dial。这听起来有些奇怪,因为数据类型应该属于应用——而不是 SQLite。

其次,HTTP 层现在就只能托管 SQLite 的数据。如果我们想在中间增加缓存层怎么办?或者我们如何支持其他类型的数据存储方式,比如 Postgres 或直接在磁盘上存储的 JSON 文件?

最后,因为没有抽象层来模拟 SQLite 数据库,在每次 HTTP 测试时我们将不得不运行 SQLite 服务。一般来说,我支持尽可能多地进行端到端的测试,但在更高层引入单元测试的做法也有适用的场景。尤其是在引入云服务的场景下,你肯定不想每次测试调用时都运行云服务。

隔离业务领域

修改的第一步是将业务领域移动到单独的包内。这也叫做 “应用领域”。它表示的是应用特定的数据类型——比如在 WTF Dial 中的User, Dial

在这个例子中我使用根包(wtf),因为它和应用名字一样,而且是新开发者打开代码仓库时最先看到的地方。类型的命名使用更加合适的wtf.Userwtf.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"`
}

dial.go#L14-50

在上面的代码中,没有任何实现细节的引用——只有基础类型和time.Time。为了方便,也添加来 JSON 标签。

通过抽象服务移除依赖

我们的应用结构看起来好了一些,但 HTTP 依赖 SQLite 这点依旧有些奇怪。HTTP 服务器会从一个潜在的数据库里获取数据——不会特别关心它是 SQLite 还是别的。

要修复这个问题,我们可以在业务领域创建服务的接口。这些服务是典型的创建、读取、更新、删除(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
}

dial.go#L81-L122

现在我们的领域包(wtf)不仅设置了数据结构,也设置了层间交互的接口约定。这让包结构变得扁平,这样所有的包都可以依赖这个领域包。这让我们打破包间的直接依赖,并可以引入另一种实现比如mock包。

重新封包

将包间依赖打破,增加了我们使用代码的灵活性。对于应用的二进制文件,wtfd,我们依旧想让http依赖sqlite( 参见[wtf/main.go](https://github.com/benbjohnson/wtf/blob/main/cmd/wtfd/main.go#L180-L205)),但对于测试我们可以让http依赖新的mock包(参见[http/server_test.go](https://github.com/benbjohnson/wtf/blob/main/http/server_test.go#L22-L59)):

在这个小 web 应用 WTF Dial 上这么使用有些杀鸡用牛刀,但随着代码仓库的不断增加,这么处理会越来越重要。

结论

在 Go 语言中,包是一个强有力的工具,但如果你把它们按组而不是按层来管理,就会陷入无穷的沮丧中。在理解来应用的逻辑层后,应用领域的数据类型和接口约定可以抽取出来,放到根包中作为一个对所有子包都通用的领域语言。对于不断发展的应用有必要定义这种领域语言。

有问题或者评论?欢迎在WTF Dial GitHub 讨论版留言。

更多原创文章干货分享,请关注公众号
  • 加微信实战群请加微信(注明:实战群):gocnio
astaxie 将本帖设为了精华贴 02月17日 13:19

非常棒,不过上面的链接有点问题。我写了一篇包管理标准的文章,

谢谢,已经修复了

文章中的好几个外链有问题,应该都没有指定 http

songjiayang 回复

是的,发现是所有的 link 都少了一个/

需要 登录 后方可回复, 如果你还没有账号请点击这里 注册