技术5/21/2026·8 min

把独立后端并入 iOS monorepo

如何把 Mainline CI/CD 平台和一条五层 analytics 管道并入 iOS monorepo:边界划在哪、Go 与 pnpm 依赖怎么共享,以及一套基于哈希的端口方案如何让多 worktree 互不撞车。

起点:三个仓库,一个产品面

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(apikeyratelimit),以及已有 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.jsonnode_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 一遍仓库根假设。 配置搜索路径,以及任何编码了布局的常量,都会在仓库变成子树的那一刻失效。