架构总览

两条主线:纵向是 verticle.Run 的启动序列,横向是 cmd → internal → pkg/core 的依赖方向。前者决定服务怎么活起来, 后者决定代码往哪放。

分层与依赖方向

flowchart TD
    subgraph bin["可执行文件"]
        CMD["cmd/<service>
main 只做一件事:verticle.Run"] end subgraph internal["internal/ — 业务代码"] APP["app/
实现 verticle.Service
装配 domain"] DOM["domain/<name>/
handler.go · service.go · dao.go"] end subgraph core["pkg/core/ — 基础设施(唯一入口)"] V["verticle · app"] H["httpx · errs · obs · config"] D["db · redis · memcache · kafka"] R["auth · ratelimit · breaker · retry
idempotency · cache · cron"] end DEPS["第三方驱动
pgx · go-redis · franz-go
rpcx · grpc"] CMD --> APP APP --> DOM APP --> V DOM --> H DOM --> D DOM --> R D -.-> DEPS classDef guard fill:#fdf6f0,stroke:#c8641f,color:#2b2722 class core guard
业务代码(internal/)只能经 pkg/core/* 触碰第三方驱动: 实线是允许的调用方向,虚线只存在于 pkg/core 内部。 这条规则由 depguard 强制,不靠 review 提醒。

每个目录的职责

路径 职责
cmd/<service>/ 一个二进制一个部署单元;main 只调 verticle.Run。
internal/app/ 实现 verticle.Service,把基础设施句柄装配进 domain。
internal/domain/<name>/ 一个业务域三件套:handler.go(HTTP 边界)、 service.go(业务)、dao.go(数据)。
pkg/core/* 基础设施。业务只准从这里进(depguard 强制),第三方驱动被关在里面。
configs/ 框架段 + 每服务一段,段 key 等于 Service.Name()。
migrations/ 按文件名序执行,只追加不修改已有文件。
examples/<feature>/ 每个能力一个可运行示例,离线可跑、带测试——所以 CI 能验证它们。
skills/ 给 AI 的接入规范,与 AGENTS.md 一起充当"生成器"。

启动序列

verticle.Run 的每一步都是 fail-fast:任何一步返回错误,启动就停在那里, 不会带着半初始化状态开始服务。

flowchart TD
    A["config.LoadMap(path)"] --> B["框架段 ParseMap + 服务段
按 Service.Name() 取"] B --> C["obs.Init
日志先行,后面每步都有日志"] C --> D{"trace 段配了吗"} D -->|配了| D1["obs.InitTracing
OTLP exporter"] D -->|没配| D2["no-op,零开销"] D1 --> E["未知框架键告警
typo 不再静默"] D2 --> E E --> F["feature.Configure
灌入 feature: 段"] F --> G{"各基础设施段"} G --> G1["db · redis · memcache · kafka · auth
配了才建,未配为 nil"] G1 --> H["svc.Setup(ctx, env)
解码服务段、装配 domain"] H --> I["httpx.New + svc.BuildRouter
/healthz /readyz /metrics /version"] I --> J{"实现了 Registrar 吗"} J -->|GRPCRegistrar| J1["监听 grpc.addr"] J -->|RPCXRegistrar| J2["监听 rpcx.addr"] J -->|都没有| J3["告警且不监听
避免空端口被探活误判"] J1 --> K["运行"] J2 --> K J3 --> K K --> L["信号 / ctx 取消"] L --> M["优雅退出:停 Block → 停 HTTP 排空
→ flush trace → 关基础设施"] classDef decision fill:#f5f2ee,stroke:#8a8378,color:#2b2722 class D,G,J decision
注意第 3 步:日志在一切之前初始化,所以后面任何一步失败都有结构化日志可看。 另一个容易忽略的是 gRPC/rpcx 的监听条件——只配了地址但服务没实现对应接口时, 它不会监听端口,因为一个空端口会让 K8s 探活误判为健康。

优雅退出的顺序

// 顺序(app 包保证):
// 1. 停止 Block 里注册的后台任务(定时任务、消费者循环)
// 2. 关闭 HTTP server,排空在途请求
// 3. flush 链路追踪的 span
// 4. 逆序执行 AddCloser 注册的清理函数,最后关基础设施连接

env.Block("cron", s.sched.Run)                    // 随 ctx 取消而停
env.AddCloser(func() { flushMetrics() })          // 逆序执行,后注册的先跑

顺序是有理由的:后台任务先停,才不会在 HTTP 排空期间继续产生新工作; 基础设施最后关,才能在排空前把请求真正做完。

请求的生命周期

HTTP 侧的每一层也是显式的。两个中间件槽的区别值得记住: Use 走封套(能返回 errs 短路,不碰 ResponseWriter),UseOuter 在 mux 外层 (能改响应头、覆盖 /healthz 与 HandleRaw)。

sequenceDiagram
    autonumber
    participant C as 客户端
    participant G as guard
panic 恢复 · 体积限制 participant S as SecureHeaders
默认安全响应头 participant O as UseOuter 中间件
CORS · gzip · RealIP participant M as mux participant W as wrap
trace id · 超时 · 封套 participant MW as Use 中间件
认证 · 限流 · 幂等 participant H as Handler C->>G: 请求 G->>S: 放行 S->>O: 安全头已就位 O->>M: 路由匹配 M->>W: 命中注册的 handler W->>W: 开 server span,定 X-Request-ID W->>MW: ctx(带 trace id + 超时) MW->>H: ctx + *http.Request H-->>MW: (any, error) MW-->>W: 结果 W->>W: 渲染封套 · 记访问日志与指标 W-->>C: 响应
超时的 ctx 与连接的 ctx 是分离的:handler 的 ctx 由 context.WithoutCancel 派生。这样客户端断连不会被报成服务端超时, 而 handler 还能把副作用(以及幂等结果)完整写下去。

一致性契约

四条约定,前两条被 lint 强制,后两条被测试与例子的存在形态保证。

约定 强制方式
业务代码不 import 驱动库 depguard internal-no-drivers / examples-no-drivers
业务代码不 import Web 框架 depguard no-web-frameworks
handler 签名统一 类型系统——srv.Handle 只接受 HandlerFunc
每个能力有可运行的离线示例 + 测试 make ci 会跑 ./examples/...,示例坏了 CI 就红

下一步