服务与生命周期

一个部署单元就是一个 verticle.Service。三个方法定义了它的边界, 其余全部由 Run 驱动:进程怎么起、依赖怎么来、怎么停。

三个方法

package verticle

type Service interface {
    Name() string                              // 配置段 key、日志与指标前缀
    Setup(ctx context.Context, env *Env) error // 解码服务段、装配 domain
    BuildRouter(s *httpx.Server) error         // 注册路由
}

完整实现见 internal/app/service.go:

func (s *DemoService) Name() string { return "demo" }

func (s *DemoService) Setup(ctx context.Context, env *verticle.Env) error {
    if err := env.DecodeService(&s.cfg); err != nil {   // 解码 demo 段
        return err
    }
    deps := helloworld.Deps{Greeting: s.cfg.Greeting}
    if db, ok := env.DB(); ok { deps.DB = db }          // 可选依赖:逗号-ok
    if rc, ok := env.Redis(); ok { deps.Redis = rc }
    s.hello = helloworld.New(deps)

    env.Block("consumer", s.consume)                    // 后台任务随进程生命周期
    return nil
}

func (s *DemoService) BuildRouter(srv *httpx.Server) error {
    h := helloworld.NewHandler(s.hello)
    srv.Handle("GET", "/api/v1/hello/{name}", h.Greet)
    srv.Handle("GET", "/api/v1/hello/stats/{name}", h.Stats)
    return nil
}

Env:基础设施的唯一入口

业务拿不到 *sql.DB 或 redis 客户端本身,只能经访问器取。同一件事有两个 访问器,选哪个就表达了这段代码对依赖的期望:

访问器 行为 什么时候用
RequireDB() 未配置时返回错误,启动失败 没有这个依赖服务就没意义(例如 DAO 层)
DB() 返回 (*db.DB, bool) 依赖可选,没有就走降级路径

同样的两套访问器覆盖 Redis / Memcache / Kafka。

为什么不是 Dev / Prod 两套装配 依赖是否存在是部署事实,不是环境名。本地开发想省掉 Kafka,就删掉那一段配置—— 代码不用改,也不会因为断言而炸。

可选接口:按需实现

服务可以实现这些接口中的任意子集,Run 会探测:

接口 作用
ConfigWatcher OnServiceConfigChange(raw map[string]any):配置文件变化时收到 本服务段的新内容。
ReadyChecker Ready(ctx) error:/readyz 的判定,返回错误即 503。
GRPCRegistrar RegisterGRPC(*grpc.Server):只有实现了才会监听 grpc.addr。
RPCXRegistrar RegisterRPCX(*rpcx.Server):同上,对应 rpcx.addr。

后台任务与清理栈

两类"服务活着就得一直跑"的东西有专门的位置,不用自己管 goroutine 与信号:

// Block:阻塞式后台任务。ctx 取消即返回,Run 会等它退出。
env.Block("cron", s.sched.Run)
env.Block("consumer", func(ctx context.Context) error {
    return kafka.RunConsumer(ctx, cfg, group, topics, s.handle)
})

// AddCloser:清理函数,逆序执行(后注册的先跑)。
env.AddCloser(func() { _ = s.producer.Close() })
Block 必须响应 ctx 退出时框架会等每个 Block 返回。一个忽略 ctx 的 Block 会让优雅退出卡住, 最后只能被超时强杀。写循环时永远 select 在 ctx.Done() 上。

退出顺序

sequenceDiagram
    autonumber
    participant K as K8s / 终端
    participant V as verticle.Run
    participant B as Block 任务
    participant H as HTTP server
    participant I as 基础设施
    K->>V: SIGTERM / ctx 取消
    V->>B: 取消 ctx
    B-->>V: 任务返回(定时任务停、消费者收尾)
    V->>H: Shutdown(ctx)
    H-->>V: 在途请求排空完成
    V->>V: flush 链路追踪
    V->>I: 逆序执行 AddCloser,关连接池
    V-->>K: Run 返回 nil
				
先停后台任务再排空 HTTP:否则排空期间定时任务还在产生新工作。 基础设施最后关:排空中的请求还要用它。

配置热更

实现 ConfigWatcher 即可。注意它收到的是服务段的原始 map, 通常需要解码成一个新的配置结构再原子替换:

func (s *Service) OnServiceConfigChange(raw map[string]any) {
    var cfg lifecycleConfig
    if err := config.ParseMap(raw, &cfg); err != nil {
        obs.Warn(context.Background(), "reload rejected", "err", err)
        return   // 新配置不合法就保持旧配置,不要半更新
    }
    s.mu.Lock()
    s.cfg = cfg
    s.mu.Unlock()
}
热更不重建设施 热更只更新服务段配置,不重建 db / redis 连接池——连接池的生命周期与配置的生命周期 本来就不同。要改连接参数请滚动重启。

这条回调和文件 watch 是同一条:服务段来自本地文件还是 Nacos 配置中心(nacos: 段)没有区别,都是 ConfigWatcher 收到原始 map。区别只在框架段——它启动时建好,远端变化只告警不应用。

就绪与存活

/healthz 是存活探针,不查任何依赖;/readyz 会 ping 已配置的 基础设施并调用服务的 ReadyChecker。把"依赖还没就绪"和"进程还活着" 分开,才不会在依赖抖动时被 K8s 反复重启:

func (s *Service) Ready(context.Context) error {
    if time.Since(s.started) < warmup {
        return errs.Unavailable(50301, "warming up")   // → /readyz 返回 503
    }
    return nil
}

相关示例