1. 背景
我在阅读别人的代码时,一般会带着问题从 main 函数开始读,慢慢地梳理出一条调用链路,但是阅读时很容易迷失在代码中,因为会在文件之间来回跳转。此时会对源代码的组织方式比较好奇,写代码时也有类似的问题:
- 应该从1个包开始,逐渐提取出其它的包?
- 哪些代码需要组成单独的包?
- 包之间应该有什么样的依赖关系?
Go 语言并没有对文件结构做任何限制,所以这些问题我们可以自己做决定。
在确定文件结构之前,需要确定好的结构应该符合什么特点:
- 命名清晰:目录名称要能清晰地表达出目录实现的功能
- 功能明确:目录实现的功能应该是明确的,在整个项目目录中有很高的辨识度,当新增功能时,要能够非常清楚地知道把这个功能放在哪个目录下
- 可扩展性:每个目录下存放了同类的功能,在项目变大时应该可以放更多同类功能
- 一致性:项目的不同部分应该采用一致的方式,以便在对项目还不熟悉时就能判断出应该用哪一种方式
代码是关于业务逻辑最新的"文档",良好的代码结构可以让我们查找代码。然而,好的结构是实践出来的。
下面以"啤酒评论服务"为例,展示一些代码组织方式。
scss
啤酒评论服务
1 用户(user)可以添加一种啤酒(beer)
2 用户可以添加一个关于啤酒的评论(review)
3 用户可以列出所有啤酒
4 用户可以列出给定啤酒的所有评论
5 提供一个存储数据的选项:可以存储在内存(memory)或者JSON文件中
6 能够添加示例数据
2. 扁平结构
plain
flat
├── data.go
├── handlers.go
├── main.go
├── model.go
├── storage.go
├── storage_json.go
└── storage_mem.go
扁平结构是一个很好的开始,它的结构简单,易于理解,我们很容易猜出每个文件的功能,比如 handlers.go
是 HTTP 处理程序。这对于小型应用和库很有用。
由于只有一个包,所以不会产生循环依赖。但也导致所有变量都是全局变量,没办法把东西分开,比如 model.go
可以随意访问和修改 handler.go
中的内容。还有一个缺点无法根据文件结构判断整个项目的功能,因为实现其它功能的代码结构可能是完全一样的。
3. 按照技术功能分组
软件系统的功能可以分为业务功能和技术功能,业务功能是与业务相关的操作,技术功能是与技术相关的操作。这里指的是技术功能。把代码按照技术功能分组,典型的方式是三层架构:
- 用户界面层:负责提供用户界面
- 业务逻辑层:负责实现业务逻辑
- 数据访问层:包括外部依赖,比如数据库访问
我们可以根据代码属于哪一层对代码进行分组。目录结构如下:
go
layered/
├── data.go
├── handlers
│ ├── beers.go
│ └── reviews.go
├── main.go
├── models
│ ├── beer.go
│ ├── review.go
│ └── storage.go
└── storage
├── json.go
└── memory.go
这个结构把用户界面层和业务逻辑层放到一起,然后按照业务功能拆分出处理 beer 和 review 的文件。模型定义和存储功能分别使用 models 和 storage 文件夹。
此方式的优点是很容易分析出想找的代码在哪里,另外会把相关的东西放到同一个包中,避免产生全局变量。
缺点也有很多:
- 需要在不同层之间共享的变量没有合适的地方存放,比如配置信息、常量数据、通用数据模型等,models 是需要在业务逻辑和数据访问层共享的,所以只能抽象出单独的包。
- 随着项目的迭代,对 model 的需求会越来越多,需要通过一个文件完成一个 model 的所有功能会导致 models 中的字段和函数越来越多。不同的场景需要模型的不同字段,在阅读代码时很难判断什么场景需要什么字段。
- 业务逻辑直接调用存储函数,导致业务逻辑和数据访问耦合度较高,测试也不方便。
4. 按照业务功能对代码分组
这种方式对应的是领域驱动设计(DDD),DDD 的核心原则是将业务领域的概念和规则视为软件系统的核心,将业务领域划分为独立的有界上下文,降低业务的理解难度,它虽然不是关于如何组织文件的,但是按照业务功能对代码分组是它的一种常见做法。
有界上下文可以看做语义上的边界,不同领域的不同实体中可能有不同的属性,所以在代码中重复定义 model 是有必要的。例如,在销售场景中,"用户"可能有交货时间或者采购成本等属性,而在售后服务场景中的用户可能会有其它属性,比如响应时间和工单数量。在划分界限之后,如果要为用户添加一个属性,可以在不影响其它上下文的情况下添加。
一个领域通常会包含以下内容,这里只列举一部分:
- 语言:与业务有关的概念和术语,避免一个概念用多个相似的词描述而带来混乱。
- 模型,举例两个例子:
- 实体(entity):领域中的持久化对象,通常具有ID
- 值对象(value object):不可变对象,通常是对象的属性,比如价格
- 服务:无状态操作,提供通用功能的服务
- 仓库(storage):提供存储数据的接口
- 事件:领域中发生的重要事件
下面是一个把"啤酒评论服务"根据 DDD 分组的例子。
go
domain_example
├── service
│ ├── adding
│ │ ├── endpoint.go
│ │ └── service.go
│ ├── listing
│ │ ├── endpoint.go
│ │ └── service.go
│ └── reviewing
│ ├── endpoint.go
│ └── service.go
├── main.go
├── model
│ ├── beers
│ │ ├── beer.go
│ │ └── sample_beers.go
│ └── reviews
│ ├── review.go
│ └── sample_reviews.go
└── storage
├── json.go
├── memory.go
└── type.go
在 domain 中提供了 3 个 service,分别是 adding
添加啤酒,listing
列出啤酒和评论,reviewing
添加评论,每个 service 中包含了 http handler 和业务逻辑。
beers
和 reviews
是两个 model
,提供了查询和保存接口,具体的存储功能由 storage
实现。 它的优点和缺点与按照技术功能分组类似,新增的优点是,可以从文件名看出代码提供了哪些服务。
5. 六边形架构
将按技术功能分组和按业务分组结合起来可以达到更好地效果,六边形架构就是这样一种结构。
六边形架构可以将业务逻辑与外部依赖解耦,看起来像图中的六边形一样,被分成了好几层。领域层负责处理业务逻辑;应用层负责协调和调用业务逻辑,包括接收用户请求、处理异常等;框架层负责与外部依赖的交互,外部依赖包括数据库、网络服务等。
三层架构也是分层结构,但与六边形架构有区别,三层架构是上层依赖下层,从表现层到业务逻辑层,再到数据访问层(输出)。从写代码的角度是上层 import 和调用下层。而六边形架构的把输入和输出放在相同的位置,并且依赖只从外部指向内部,即只会出现外层 import 内层的情况。如果 domain 要调用外层,就需要通过接口实现,这利用了接口的重要用途即依赖反转。我们可以在每个边界都设置接口,外层通过实现接口满足内层的需要,让内层以抽象的方式调用外层。
下面是采用六边形架构的代码结构。
go
domain_hex
├── cmd
│ ├── beer-server
│ │ └── main.go
│ └── sample-data
│ ├── main.go
│ ├── sample-data
│ ├── sample_beers.go
│ └── sample_reviews.go
└── pkg
├── domain
│ ├── adding
│ │ ├── beer.go
│ │ └── service.go
│ ├── listing
│ │ ├── beer.go
│ │ ├── review.go
│ │ └── service.go
│ └── reviewing
│ ├── review.go
│ └── service.go
├── http
│ └── rest
│ └── handler.go
└── storage
├── json
│ ├── beer.go
│ ├── repository.go
│ └── review.go
└── memory
├── beer.go
├── repository.go
└── review.go
cmd
文件夹用来存储二进制文件,pkg
存放实际的 go 的代码,现在可以提供多个 main 函数。 pkg
下有 domain
, http
, storage
三个文件夹。在 domain
中存放核心业务逻辑。共有 3 个 domain
,提供添加啤酒、添加评论和获取数据的功能,每个 domain
下都有自己的模型和服务。现在,不同 domain
和 storage
中可以有同一个 model,但可以有不同的定义。
另一个改变是,现在由 http 包调用 domain 中的服务,storage
包实现 domain 中定义的存储接口,http
负责初始化 domain 服务,并且在初始化 domain 服务的时候将 storage 作为接口实现传进去了。
尽管 DDD 尝试将业务划分成独立的领域,但是 domain 之间有时是要会共享一些东西的,我们经常会遇到 utils、base、common 等用来存储各种实用函数,可以分为三种情况处理:
- 第一种情况是,直接复制到使用它的包中,因为 a little duplication is far cheaper than the wrong abstraction;
- 第二种情况是,确实要共享,那就拆分成多个包,用一个语义明确的名字而不是通用的名字;
- 第三种如果两个包之间确实有大量东西要共享,那就需要考虑合并成一个包,放到不同文件里。