在Go社区中, 包管理和泛型看上去被当做一个很大的问题, 但有另一个很少被提及的问题——项目结构。
每一个我参与的Go程序都看似对这个问题有自己的答案, 那我应该如何组织我的Go代码? 有些人把所有代码放到一个包中, 有些人用类型或者模块归组代码.如果你们的团队没有使用一个好的策略, 你会发现代码混乱分散在不同的包中。 所以我们需要一个更加标准的Go程序设计。
我提倡一种更好的方法. 通过以下的几种简单的规则, 我们解耦我们的代码, 并便于测试, 同时给我们的项目带来一种始终如一的结构。
常见但有瑕疵的方式
方法一: 整块的包布局
把所有代码丢到一个包下确实可以在小程序中工作的很好. 避免了出现循环依赖的问题, 因为在你的应用程序中, 没有互相依赖的情况。
我目睹过这种方式在一万行代码内良好的工作, 但只要超过这个级别, 它将变得非常难于定位和分离代码。
方法二: Rails 风格
另一种方式是将代码按照功能类型来归组。比如, 把所有Handlers 放在同一个包, 所有Controller放在另外一个包, 所有Models也放到单独的包中。 我见过很多之前做Rails的开发者使用这种方式(包括我自己)。
但这种方式有两个问题。 第一, 你的命名格则会是糟糕的。 你的type命名会像controller.UserController 这样重复你的包名。 我倾向于保持良好的命名规范。我坚信当你在修改老代码时命名是最好的文档。 同时命名也是代码质量的一种表现——这是其他人阅读代码时首先知觉的事
但最大的问题是循环依赖。 不同的功能类型可能需要互相引用。 这种方式只适合单向依赖但大多数程序没那么简单。
方法三: 根据模块归组
这种方式与Rails风格结构类似除了是按照模块归组而不是按照功能. 比如, 你可能有一个user包和一个account 包。
我们会发现这种方式有一样的问题。 最终我们的命名又变得像users.User一样糟糕。 如果accounts.Controller和users.Controller需要相互调用,我们同样也有循环依赖的问题。
一种更好的方式
我用在项目中的包策略包含四个信条:
- Root package is for domain types (根包作为领域类型)
- Group subpackages by dependency (根据依赖将子包归组)
- Use a shared mock subpackage (使用共享的模拟子包)
- Main package ties together dependencies (main包将所有依赖关联起来)
这些规则有助于我们的包解耦, 它定义一个清晰的领域语言。 让我们看一下这几个规则的实践。
1. Root package is for domain types (根包作为领域类型)
你的程序有一个有逻辑且高层级语言描述数据和进程是如何交互的, 这是你的领域模型。如果你有一个电子商务应用, 那么你的领域包含一些像客户,账户,收费信用卡, 和库存处理。如果你是Facebook 那么你的领域是用户,赞和各种关联. 这是一些不依赖你的技术的东西。
我把领域类型放在我的根目录。 这个包只包含简单的数据类型, 如一个User struct 用户放用户数据或者一个UserService interface 来获取保存用户数据。
1 | 复制代码type User struct { |
这使你的根包非常简单。 你还可以把具体的执行操作放在里面, 但仅当它们仅依赖于其他域类型的时候。 比如, 你可以有个定时轮训你的User Service的类型。 但是, 它不应该调用外部服务或者数据。这是一个操作细节。
在你的程序中根包不应该依赖于任何其他包
2. Group subpackages by dependency (根据依赖将子包归组)
如果你的根包不允许有外部依赖, 那么我们必须把这些依赖放到子包里面。 用这种包布局方式, 子包以一个桥接你的领域模型和实现的适配器而存在。
比如, 你的UserService可能依赖PostgreSQL。 你可以在你的程序中创建一个postgres 子包来提供一个postgres.UserService 实现:
1 | 复制代码package postgres |
这种方式解耦了我们的PostgreSQL依赖, 简化了测试,同时提供了一种简单的方式以便未来迁移到另一种数据库。 它可以作为一个可插拔的架构如果你决定支持其他数据库实现比如BoltDB
这种方式还给了你一种方式来实现分层。可能你想要把数据贮存在内存中, 将LRU cache 前置于PostgreSQL。你可以增加一个实现UserService接口的UserCache, 来包装你的PostgreSQL实现:
1 | 复制代码package myapp |
在标准库中也使用了这种方式. io.Reader 是一个用来读取bytes的领域类型, 它的实现是按照依赖分组——tar.Reader, gzip.Reader,
multipart.Reader. 这些也可以被分层. 另外常见的还有os.File 被bufio.Reader封装, bufio.Reader 又被gzip.Reader封装,
gzip.Reader 又被tar.Reader封装.
相互依赖处理
你的依赖不会孤立存在. 你可能把User 数据存储在PostgreSQL中, 但你的财务交易数据存储在像Stripe之类的第三方. 在这种情况下, 我们用一个逻辑上的领域类型来封装我们的Stripe依赖—我们叫它TransactionService.
通过增加TransactionService到UserService来解耦了我们的两个依赖:
1 | 复制代码type UserService struct{ |
现在我们的依赖通过通用的领域语言来交流. 这意味着我们可以在不影响其他依赖的情况下, 替换PostgreSQL到MySQL或者替换Stripe到其他支付平台
这个规则不仅限于第三方依赖
这个听上去很奇怪, 但我也使用了以上同样方式解耦了对标准库的依赖. 比如, net/http 包是一个依赖项. 我们也可以使用一个包含http实现的子包将它解耦.
你可能会觉得很奇怪, 如果存在一个名字和它的依赖一样的包, 但是这是有意义的. 这种方式并不会让你的程序中存在命名冲突, 除非你允许 net/http被直接使用于其他地方.
1 | 复制代码package http |
现在你的http.Handler 在连接了你的领域模型和HTTP协议.
3. Use a shared mock subpackage (使用共享的模拟子包)
因为我们的依赖通过领域接口互相独立, 所以我们可以用这些连接点来注入mock实现.
市面上有一些mock库比如GoMock, 它会为你生成mocks, 但我个人更加倾向于自己写. 因为我发现很多mocking工具都过于复杂.
我使用的mock方式很简单. 比如, 一个UserService mock 如下:
1 | 复制代码package mock |
这个mock把函数注入任何使用了myapp.UserService接口来验证参数, 输出期望的数据或者注入错误.
我们测试下我们刚才使用的http.Handler:
1 | 复制代码package http_test |
我们的Mock让我们完全解耦了我们的单元测试, 只处理HTTP协议
4. Main package ties together dependencies (main包将所有依赖关联起来)
由于所有这些依赖包都是在独立的, 你可能想知道它们是如何聚集在一起的。这就是Main包的工作.
Main包布局
一个应用程序可能产生多个二进制文件, 所以我们将使用 Go 约定将我们的主包作为 cmd 包的子目录. 比如, 我们的项目可能有一个myapp服务的二进制文件和一个myappctl 客户端二进制文件从终端管理服务. 我们会像这样布局我们的Main包:
1 | 复制代码myapp/ |
在编译时注入依赖
“依赖注入” 这个词受到不好的斥责. 它唤起了冗长的 Spring XML 文件的思想. 但是, 所有的这些实际上都意味着我们要将依赖项传递给对象, 而不是引用对象或查找依赖项本身.
主程序包是要选择将哪些依赖项注入到哪些对象中. 因为主包只是简单地将片段连接起来, 所以它往往是相当小且琐碎的代码:
1 | 复制代码package main |
同样重要的是要注意, 你的Main包也是一个适配器。它将终端连接到您的领域模型.
总结
应用程序设计是一个难题。有这么多的设计决策, 但如果没有Solid原则来指导你的问题的话会做得更糟。我们已经研究了几种当前 Go 应用程序设计的方法, 我们已经看到了他们的许多缺点。
我相信从依赖关系的角度来看, 设计使代码组织更简单, 更容易理解。首先, 我们设计我们的领域语言。然后我们解耦我们的依赖。接下来, 我们引入 mock 来隔离我们的测试。最后, 我们用Main包连接所有的东西。
原文连接 : medium.com/@benbjohnso…
本文转载自: 掘金