在传统的 Go 项目开发中,一个代码仓库通常只包含一个 Go 模块 (Go Module),这自然形成了多仓库 (polyrepo) 的组织模式。但你是否想过,如何在一个仓库里管理多个 Go 项目?这听起来简单,但要构建一个能顺畅运行的多模块单体仓库 (monorepo),确实需要掌握一些特定的技巧。
本文将演示如何在 Go 中成功构建一个单体仓库,确保其中的每个模块都能独立、高效地管理自身的构建、测试与发布周期。
为什么要使用单体仓库?
是否在 Go 项目中使用单体仓库,很大程度上取决于团队规模和个人偏好。我个人经验是,当一个小型团队共同维护多个紧密相关的软件项目时,单体仓库模式会显得格外有吸引力。它能简化协作和代码共享。当然,在大型组织中,多个团队独立运作,多仓库结构或许更能赋予团队自主权。不过也有例外,像 Google、Facebook 这样的大型科技公司,都曾成功管理过规模极其庞大的单体仓库。
无论你偏好哪种方式,单体仓库在项目开发中确实存在一些公认的优缺点。了解这些,有助于你做出更适合自己团队的决定。
优点
- 跨项目变更更容易集成:你可以在一次提交中修改多个项目,确保相关变更的原子性和一致性。
- 代码审查集中化:所有代码变更都发生在同一个地方,团队负责的代码范围一目了然,便于统一评审标准。
- 易于共享知识和代码:库、工具和代码风格可以轻松地在项目间共享与复用,有助于保持整体的一致性。
缺点
- 构建工具更复杂:需要引入更精细的构建系统来有效管理多个模块及其依赖。
- 容易意外耦合:不同组件可能会因为处在同一仓库而被不必要地紧密耦合,违背模块化设计原则。
- 组件自主性降低:开发者可能无法完全自由地选择技术栈或实现方式,需要遵循仓库的全局约定。
在接下来的内容中,我们将探讨如何构建一个 Go 单体仓库,并尽量规避这些潜在缺点,实现良好的工程实践。
Go 中的单体仓库结构是怎样的?
一个典型的单体仓库可能包含多个组件,例如独立的应用程序和共享的内部库。在 Go 中,我们可以很自然地将这些组件组织为独立的 Go 模块。
让我们设想一个简单的场景:一个包含两个后端微服务和一个共享库的单体仓库。其目录结构可能如下所示,其中包含了三个独立的 Go 模块:
├── libs
│ └── hello
│ ├── go.mod
│ └── hello.go
└── services
├── one
│ ├── go.mod
│ └── main.go
└── two
├── go.mod
└── main.go
为了简化示例,我们将在 services/one 和 services/two 中各创建一个简单的 REST 接口。这两个微服务都将使用 libs/hello 目录下的共享 “Hello World” 库。
💡 注意:遵循良好的微服务设计原则,我们应尽量保持服务间的松耦合。在真实场景中,这些服务可能拥有不同的业务边界。在我们的单体仓库中,服务代码是内部隔离的,而共享代码则通过 libs 目录中的包进行显式且受控的共享。
服务一(Service One)
services/one/main.go
package main
import (
"net/http"
"github.com/earthly/earthly/examples/go-monorepo/libs/hello"
"github.com/labstack/echo/v4"
)
func main() {
e := echo.New()
e.GET("/one/hello", func(c echo.Context) error {
return c.String(http.StatusOK, hello.Greet("World"))
})
_ = e.Start(":8080")
}
服务二(Service Two)
services/two/main.go
package main
import (
"net/http"
"github.com/earthly/earthly/examples/go-monorepo/libs/hello"
"github.com/labstack/echo/v4"
)
func main() {
e := echo.New()
e.GET("/two/hello", func(c echo.Context) error {
return c.String(http.StatusOK, hello.Greet("Friend"))
})
_ = e.Start(":8080")
}
Hello 共享库
libs/hello/hello.go
package hello
import "fmt"
func Greet(audience string) string {
return fmt.Sprintf("Hello, %s!", audience)
}
如何在单体仓库中导入本地 Go 模块?
观察上面的代码,你会发现服务一和服务二都导入了 libs/hello 这个模块。通常,Go 模块的导入路径指向一个远程仓库(如 GitHub),Go 工具链会通过网络去获取它。
但在我们的单体仓库中,我们希望进行本地导入。这可以通过在 go.mod 文件中使用 replace 指令来实现。它告诉 Go 工具链:“当需要这个模块时,请直接使用我指定的本地路径版本,而不要去网络上下载。”
使用 replace 策略有几个明显的好处:
- 开发迭代更快:你可以同时修改库和消费该库的服务,无需等待库发布新版本。
- 变更可集中提交:库的修改和依赖它的服务的修改可以放在同一个提交或 Pull Request 中,保证一致性。
- 即时验证:库的任何更新都能立即被依赖它的服务使用并进行测试验证。
示例:replace 的用法
services/one/go.mod
module github.com/earthly/earthly/examples/go-monorepo/services/one
go 1.17
require (
github.com/earthly/earthly/examples/go-monorepo/libs/hello v0.0.0
github.com/labstack/echo/v4 v4.6.3
)
replace github.com/earthly/earthly/examples/go-monorepo/libs/hello v0.0.0 => ../../libs/hello
通过这种方式配置,我们就可以成功地在本地编译服务一、服务二,并运行它们的单元测试。
⚠️ VSCode 用户注意:默认情况下,Visual Studio Code 的 Go 插件 gopls 在打开整个 Go 单体仓库时可能会报错。你可以通过在项目根目录或工作区的 .vscode/settings.json 中添加以下配置来解决:
{
"gopls": {
"experimentalWorkspaceModule": true
}
}
Go 单体仓库的构建工具
现在我们的单体仓库在本地已经可以运行了。下一步通常是配置构建工具,用于容器化微服务、运行单元测试或集成测试,并作为持续集成 (CI) 流程的一部分。
Earthly 是一个非常适用于此任务的工具。它允许每个服务或库独立定义自己的构建、测试流程,并能智能地利用缓存——只有发生变化的组件才会被重新构建。
我们可以在每个服务和库的目录中添加一个 Earthfile,然后在仓库根目录添加一个“父级” Earthfile 作为协调器,来调用下层具体的构建目标。这种模式在管理 Monorepo 时非常高效。
Hello 共享库的 Earthfile
libs/hello/Earthfile
VERSION 0.6
deps:
FROM golang:1.17-alpine
WORKDIR /libs/hello
COPY go.mod go.sum ./
RUN go mod download
artifact:
FROM +deps
COPY hello.go .
SAVE ARTIFACT .
unit-test:
FROM +artifact
COPY hello_test.go .
RUN go test
服务一的 Earthfile
services/one/Earthfile
VERSION 0.6
deps:
FROM golang:1.17-alpine
WORKDIR /services/one
COPY ../../libs/hello+artifact/* /libs/hello
COPY go.mod go.sum ./
RUN go mod download
compile:
FROM +deps
COPY main.go .
RUN go build -o service-one main.go
unit-test:
FROM +compile
COPY main_test.go .
RUN CGO_ENABLED=0 go test
docker:
FROM +compile
ENTRYPOINT ["./service-one"]
SAVE IMAGE service-one:latest
💡 服务二的 Earthfile 与此结构类似,此处不再赘述。
根目录的父级 Earthfile
/Earthfile
VERSION 0.6
all-unit-test:
BUILD ./libs/hello+unit-test
BUILD ./services/one+unit-test
BUILD ./services/two+unit-test
all-docker:
BUILD ./services/one+docker
BUILD ./services/two+docker
现在,你只需在命令行运行:
earthly +all-docker
即可构建整个单体仓库中所有服务的 Docker 镜像。
同样,运行:
earthly +all-unit-test
即可运行所有模块的单元测试。
单体仓库构建中的高效缓存
一个高效的单体仓库构建工具,核心能力之一就是避免重建未更改的组件,也不运行不必要的测试。Earthly 在本地开发环境中天然支持这一点,能极大提升开发效率。
在 CI 流水线中,我们同样希望利用缓存。但在 GitHub Actions 等平台上,每次构建都在一个全新的、隔离的环境中运行,Earthly 会丢失之前的本地缓存。这时,可以使用共享缓存(例如通过远程容器仓库或专门的缓存服务)来解决这个问题。
⚠️ 注意:设置共享缓存需要在每次 CI 运行时上传和下载缓存数据,因此会引入一定的网络开销。但对于计算密集型任务(如长时间运行的集成测试),缓存带来的性能提升通常非常显著。
单体仓库中的微服务发布与版本管理
在单体仓库中管理 Go 模块的另一个挑战是版本控制。通常,一个仓库对应一个模块,其版本通过 Git Tag 来管理。但在单体仓库中,我们需要更细致的策略。
在我们的示例中,共享库通过 replace 指令被本地导入,这意味着它们只在单体仓库内部被消费,消费者总是使用最新的代码。可以说,这些内部库的“发布”并不像对外部用户那么重要。
但对于最终要部署的微服务而言,版本管理则至关重要。微服务通常以容器镜像的形式部署,不同版本的镜像可能需要共存。我们也希望通过语义化版本 (Semantic Versioning) 向使用者清晰传达变更的范围。
我们主要有两种选择:
- 使用 Git Tag 为整个单体仓库打上一个统一的版本号。
- 为每个微服务单独管理其版本。
我更推荐第二种方法,因为为整个仓库打统一版本号可能会错误地暗示“所有服务都发生了变更”,而实际上可能只有其中一个服务被修改了。
如何为微服务单独版本化?
一种实用的方法是:在每个服务目录下维护一个版本文件(例如 .semver.yaml),然后在构建时读取该文件,并为生成的镜像打上相应的标签。
以下是一个在 Earthfile 中使用开源工具 semver-cli 的示例:
services/one/Earthfile (添加以下target)
release-tag:
FROM golang:1.17-alpine
RUN go install github.com/maykonlf/semver-cli/cmd/semver@v1.0.2
COPY .semver.yaml .
RUN semver get release > version
SAVE ARTIFACT version
release:
FROM +docker
COPY +release-tag/version .
ARG VERSION="$(cat version)"
SAVE IMAGE --push service-one:$VERSION
这样,每次执行发布构建时,都会根据 .semver.yaml 文件中的内容生成镜像标签,并可以推送到远程镜像仓库。
总结
在 Go 中构建和维护一个多模块的单体仓库是完全可行且高效的。其关键技术点在于合理使用 go.mod 文件中的 replace 指令来管理本地模块依赖。此外,选择一个合适的构建工具(如 Earthly)可以显著提升开发效率,并优化 CI/CD 流程的性能。
通过清晰的模块划分、独立的构建流程定义以及智能的缓存利用,你可以在享受单体仓库带来的协作与一致性优势的同时,有效控制其潜在的复杂性。希望这篇指南能为你实践 Go 单体仓库提供清晰的路径。如果你想深入探讨更多关于 后端架构 或工程化实践,欢迎来到 云栈社区 交流分享。
📚 延伸阅读:本文的示例代码来源于 Earthly 官方示例仓库,遵循 Apache-2.0 许可证。