把多个服务塞进一个 repo:12-Factor 基准代码原则在真实项目里的边界
订单、支付、通知三个服务最早放在同一个仓库里。为了省掉一次 HTTP 调用,通知服务直接 import 了订单模块的内部 service 包。当时的收益是少写一个客户端、少维护一份 DTO。半年后想给通知单独扩容时发现:构建产物里带着订单的整条依赖树;改一个订单内部函数签名会同时编译失败三个服务;打 tag 时也说不清这个 tag 属于谁。
原则:一份基准代码,多份部署
12-Factor 应用用版本控制系统管理,Git、Mercurial、Subversion 都算。一份用来跟踪代码所有修订版本的数据库被称作代码库(code repository, code repo, repo)。
在 SVN 这类集中式系统里,基准代码指控制系统中的这一份代码库;在 Git 这类分布式系统里,基准代码指最上游的那份代码库。
基准代码和应用之间保持一一对应:
- 一旦有多个基准代码,就不能称为一个应用,而是一个分布式系统。分布式系统中的每一个组件都是一个应用,每个应用可以分别使用 12-Factor 开发。
- 多个应用共享一份基准代码有悖于 12-Factor 原则。解决方案是把共享代码拆成独立的类库,用依赖管理策略加载。
每个应用只对应一份基准代码,但可以同时存在多份部署。生产环境、一个或多个预发布环境、每个开发者本机的实例,都各算一份部署。所有部署的基准代码相同,版本可以不同——开发者的提交没同步到预发布,预发布的提交没同步到生产,它们仍是同一个应用的不同部署。
来源:https://12factor.net/zh_cn/codebase (The Twelve-Factor App,Adam Wiggins)
monorepo 本身不违规,违规的是没有部署边界
一个 repo 装一个应用、附带构建脚本和文档,这不违反原则。违反的是把多个可以独立部署的应用塞进同一份基准代码,并让它们互相引用内部实现。
判断依据不复杂:这个单元能不能单独打 tag、单独构建、单独回滚。如果做不到,那它名义上是一个 repo,实际是一个分布式系统被压在了一个 codebase 里。
共享能力的正确出口是拆成独立依赖,按语义化版本发布,走内部制品库:
// package.json
"dependencies": {
"@corp/order-sdk": "^2.3.1"
}
com.corp
order-sdk
2.3.1
// go.mod
require github.com/corp/order-sdk v2.3.1
复制粘贴共享代码的问题是版本漂移:一个 bug 要改 N 处,改完还不确定哪几个服务跟上了。直接 import 同仓库另一个应用的内部实现,问题更隐蔽——编译期看不出耦合,等到要拆服务、要单独发布时才暴露。内部库的代价是要维护发布流程和版本升级,但这个代价换的是每个应用能独立演进。
用 tag / commit 作为部署触发点
一份代码库、多份部署,意味着部署动作必须能定位到具体的版本:
git tag -a v2.3.1 -m "release 2.3.1"
git push origin v2.3.1
GitLab CI 用 CI_COMMIT_TAG 判断当前流水线是否由 tag 触发:
deploy:
stage: deploy
script:
- ./deploy.sh
rules:
- if: '$CI_COMMIT_TAG'
GitHub Actions 直接监听 tag push:
on:
push:
tags:
- 'v*'
几个容易踩的点:
- tag 可以被强推覆盖(
git push -f origin v2.3.1),要审计就记录 tag 指向的 commit SHA,而不是只记 tag 名。 CI_COMMIT_TAG只在 tag 流水线里有值,分支流水线中是空字符串,脚本里判空,否则deploy.sh会拿到空版本号。- 同一份代码库对应多份部署,环境差异应该走参数或配置,不要写进 tag 名,否则预发布和生产会各自漂出一套 tag 规则。
- 如果 repo 里确实有多个应用,tag 加前缀区分:
order-v2.3.1、notify-v1.4.0,否则 CI 无法判断该部署哪一个。
边界
真正难切的是那种「只共享几个常量或协议定义」的场景。为几十行代码单开一个库,版本升级成本可能高于收益;留在原仓库又会让边界变模糊。这条线在哪,取决于团队规模和发布频率,没有通用答案。