likes
comments
collection
share

如何组织 Go 代码文件

作者站长头像
站长
· 阅读数 13

1. 背景

我在阅读别人的代码时,一般会带着问题从 main 函数开始读,慢慢地梳理出一条调用链路,但是阅读时很容易迷失在代码中,因为会在文件之间来回跳转。此时会对源代码的组织方式比较好奇,写代码时也有类似的问题:

  • 应该从1个包开始,逐渐提取出其它的包?
  • 哪些代码需要组成单独的包?
  • 包之间应该有什么样的依赖关系?

Go 语言并没有对文件结构做任何限制,所以这些问题我们可以自己做决定。

在确定文件结构之前,需要确定好的结构应该符合什么特点:

  • 命名清晰:目录名称要能清晰地表达出目录实现的功能
  • 功能明确:目录实现的功能应该是明确的,在整个项目目录中有很高的辨识度,当新增功能时,要能够非常清楚地知道把这个功能放在哪个目录下
  • 可扩展性:每个目录下存放了同类的功能,在项目变大时应该可以放更多同类功能
  • 一致性:项目的不同部分应该采用一致的方式,以便在对项目还不熟悉时就能判断出应该用哪一种方式

代码是关于业务逻辑最新的“文档”,良好的代码结构可以让我们查找代码。然而,好的结构是实践出来的。

下面以“啤酒评论服务”为例,展示一些代码组织方式。

啤酒评论服务
1 用户(user)可以添加一种啤酒(beer)
2 用户可以添加一个关于啤酒的评论(review)
3 用户可以列出所有啤酒
4 用户可以列出给定啤酒的所有评论
5 提供一个存储数据的选项:可以存储在内存(memory)或者JSON文件中
6 能够添加示例数据

2. 扁平结构

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. 按照技术功能分组

软件系统的功能可以分为业务功能和技术功能,业务功能是与业务相关的操作,技术功能是与技术相关的操作。这里指的是技术功能。把代码按照技术功能分组,典型的方式是三层架构:

  • 用户界面层:负责提供用户界面
  • 业务逻辑层:负责实现业务逻辑
  • 数据访问层:包括外部依赖,比如数据库访问

我们可以根据代码属于哪一层对代码进行分组。目录结构如下:

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 分组的例子。

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 和业务逻辑。

beersreviews 是两个 model,提供了查询和保存接口,具体的存储功能由 storage 实现。 它的优点和缺点与按照技术功能分组类似,新增的优点是,可以从文件名看出代码提供了哪些服务。

5. 六边形架构

如何组织 Go 代码文件

将按技术功能分组和按业务分组结合起来可以达到更好地效果,六边形架构就是这样一种结构。

六边形架构可以将业务逻辑与外部依赖解耦,看起来像图中的六边形一样,被分成了好几层。领域层负责处理业务逻辑;应用层负责协调和调用业务逻辑,包括接收用户请求、处理异常等;框架层负责与外部依赖的交互,外部依赖包括数据库、网络服务等。

三层架构也是分层结构,但与六边形架构有区别,三层架构是上层依赖下层,从表现层到业务逻辑层,再到数据访问层(输出)。从写代码的角度是上层 import 和调用下层。而六边形架构的把输入和输出放在相同的位置,并且依赖只从外部指向内部,即只会出现外层 import 内层的情况。如果 domain 要调用外层,就需要通过接口实现,这利用了接口的重要用途即依赖反转。我们可以在每个边界都设置接口,外层通过实现接口满足内层的需要,让内层以抽象的方式调用外层。

下面是采用六边形架构的代码结构。

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 下都有自己的模型和服务。现在,不同 domainstorage 中可以有同一个 model,但可以有不同的定义。

另一个改变是,现在由 http 包调用 domain 中的服务,storage 包实现 domain 中定义的存储接口,http 负责初始化 domain 服务,并且在初始化 domain 服务的时候将 storage 作为接口实现传进去了。

尽管 DDD 尝试将业务划分成独立的领域,但是 domain 之间有时是要会共享一些东西的,我们经常会遇到 utils、base、common 等用来存储各种实用函数,可以分为三种情况处理:

  • 第一种情况是,直接复制到使用它的包中,因为 a little duplication is far cheaper than the wrong abstraction;
  • 第二种情况是,确实要共享,那就拆分成多个包,用一个语义明确的名字而不是通用的名字;
  • 第三种如果两个包之间确实有大量东西要共享,那就需要考虑合并成一个包,放到不同文件里。