起点:三个仓库,一个产品面
iOS app、Mainline CI/CD 平台、用户行为 analytics 管道一开始分在三个仓库里。它们服务同一个产品族,拆开的代价是常见那几样:跨仓改动要协调多个 PR、共享 Go helper 得 vendor 或复制一份、各仓 CI 各有各的构建约定。我们分两步合并——先是 Mainline 后端栈,三个月后是 analytics 管道——第二步更省事,因为约定已经就位。
先划边界:合的是代码,不是运行时
两次都守着同一条规则:把代码并进 monorepo,跟是否共享运行时资源,是两件事。 仓库是源码和构建配置的 SSOT;部署拓扑是另一个独立决策。
Mainline 落在 Server/mainline/,与 Server/aio-server/(Widget 管理)以及 Next.js 前端 Server/mainline-web/、Server/aio-server-web/ 并排。第一次合并的 commit 一次性把 2050 个文件搬进 Server/——其中 mainline 1165 个、aio-server 353 个,外加两个 web 应用和共享 libs——一把建起了整棵后端目录树。
analytics 服务在自己的 CLAUDE.md 里把边界写死:
部署资源隔离:与 AIO 业务后端(aio-server / mainline)零共享。
独立 VLAN、独立 VM、独立 PG / Redis / MQ。代码合入 AIO,运行时资源不共享。
于是 analytics 的五层管道(Collector → Redpanda → Worker → ClickHouse → Query → Dashboard)住在 Server/analytics-service/ 和 Server/analytics-web/,但跑在自己的基建上。迁移 commit 只合代码层,deploy 和 migrate job 先留成 TODO stub,等独立基建就位再接。源码同仓、运行时隔离,正是这个 trade-off 让合并不必绑上一次耦合部署。
依赖处理:一个 Go workspace,一个 pnpm workspace
Go module 靠根目录的 go.work 缝在一起。每次合并加一行:
go 1.25.5
use (
./Server/libs/go
./Server/libs/proto
./Server/aio-server
./Server/analytics-service
./Server/mainline
)
共享 Go 代码放在 Server/libs/go,每个服务用同一 module path 前缀引用——github.com/anthropics/mainline/libs/go/{logger,apperror,hmac,tracectx,jwt,apikey,ratelimit}。analytics 服务保留原 module path github.com/anthropics/mainline/services/analytics-service,内部 import 不必重写。
真正的摩擦在共享库。analytics 依赖了 AIO 没有的 helper(apikey、ratelimit),以及已有 helper 的略不同形态。我们没有 fork,而是对 libs/go 做增量扩展:新增 jwt.GenericManager(UUID 主键,与既有 user-system manager 并存)、hmac.SignSimple(无 timestamp)、apperror.NotFoundMessage。落点是新文件(jwt/generic.go 等)而非改既有文件,已有服务的行为一动不动。
Bazel 是唯一构建系统,第三方 Go 依赖走 Bzlmod。analytics 合并往 MODULE.bazel 里只加了管道需要的那几个:
+ "com_github_clickhouse_clickhouse_go_v2",
+ "com_github_pashagolub_pgxmock_v4",
+ "com_github_twmb_franz_go",
前端共享同一棵依赖树。analytics-web 进来时被瘦身——它原本独立的 recharts / date-fns / clsx 被去掉,换成纯 SVG sparkline,并加入根 pnpm-workspace.yaml,共享顶层 package.json 和 node_modules:
packages:
- "Server/mainline-web"
- "Server/aio-server-web"
- "Server/portal-web"
- "Server/analytics-web"
- "Server/libs/ts/*"
合并确实会打破一类东西:假设了仓库根的硬编码路径。Mainline 原本在 ./dev/mainline/llm.yaml 找 LLM 配置;monorepo 下这个路径不存在了,修复是改指到 ./DevOps/Development/Server/mainline/llm.yaml。凡是编码了仓库布局的路径常量,正是仓库变成子树时会偏移的地方,值得提前 grep 一遍。
端口隔离:对 worktree 名做哈希
一台机器上多个 worktree 各自都要跑整套后端栈——Postgres、Redis、ClickHouse、Redpanda、MinIO,加上服务本身。固定端口会在两个 worktree 同时 bazel run 的瞬间撞车。方案是从 worktree 名派生一个确定性 offset:
calculate_port_offset() {
local name="$1"
local hash=$(echo -n "$name" | md5sum | cut -c1-6) # macOS 上用 md5
echo $(( 16#$hash % 500 ))
}
取 MD5 的 6 位十六进制、对 500 取模,得到一个 [0, 499] 区间内、对给定 worktree 稳定的 offset。每个服务再从自己的 base 推具体端口。Mainline 把 offset 乘 10 摊开:
SERVER_PORT=$((8080 + offset * 10)) # ≤ 13070
WEB_PORT=$((3000 + offset * 10)) # ≤ 7990
GRPC_PORT=$((9090 + offset * 10)) # ≤ 14080
analytics 栈是后加的,所以它的端口区间得绕开 Mainline、aio-server、portal 已经占的范围。它把每个区间从 15000 起、步进 1000——舒服地高过其他几个的天花板;而区间间隔(1000)远大于最大 offset(499),区间之间不可能相互渗入:
POSTGRES_PORT=$((15000 + offset))
REDIS_PORT=$((16000 + offset))
CLICKHOUSE_HTTP_PORT=$((17000 + offset))
CLICKHOUSE_NATIVE_PORT=$((18000 + offset))
REDPANDA_KAFKA_PORT=$((19000 + offset))
# ... 直到 COLLECTOR_PORT=$((24000 + offset))
实践里有两个逃生口很关键。PORT_OFFSET 可以显式指定,用在 MD5 派生值恰好和某个无关进程撞上时。CI 则传 WORKTREE_NAME=ci-job-$CI_JOB_ID,让共享 runner 上的并发 job 像本地 worktree 一样拿到各自不同的 offset——docker compose 的 project name(analytics-e2e-${WORKTREE_NAME})也用它做 key,把各栈彻底隔开。
这套机制支撑的完整 E2E——起栈、migrate、seed、跑测试、拆栈——在一个 worktree 内报告 28/28 通过、约 15 秒,另有 15/15 Bazel 单测和 2/2 集成测试。
可迁移的约束
- 把「代码住这」和「它跑在哪」分开。 monorepo 里源码同仓与共享基建是两回事;运行时隔离要显式写出来(我们写进
CLAUDE.md),免得有人以为共享数据库也一起搬过来了。 - 共享库做增量扩展。 用新文件、新入口,让进来的服务复用公共代码,而不扰动已经依赖它的那些服务。
- 后来者绕开占位者。 一套栈最后并入时,它的端口区间必须避让已分配的;显式取一个远高于现有天花板的 base,比让所有人重排要省。
- grep 一遍仓库根假设。 配置搜索路径,以及任何编码了布局的常量,都会在仓库变成子树的那一刻失效。