服务与生命周期
一个部署单元就是一个 verticle.Service。三个方法定义了它的边界,
其余全部由 Run 驱动:进程怎么起、依赖怎么来、怎么停。
三个方法
package verticle
type Service interface {
Name() string // 配置段 key、日志与指标前缀
Setup(ctx context.Context, env *Env) error // 解码服务段、装配 domain
BuildRouter(s *httpx.Server) error // 注册路由
}
-
Name()决定去配置文件的哪一段取服务自己的配置, 同时作为日志与指标的 service 标签。框架段(http、db…) 与它无关。 -
Setup做装配:解码服务段、从env取基础设施句柄、 构造 domain。这里是唯一该出现"装配代码"的地方。 -
BuildRouter只注册路由,不写业务逻辑。
完整实现见 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
配置热更
实现 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
}
相关示例
- examples/lifecycle:Block、Closer、热更、就绪门。
- examples/nacos:服务段放进 Nacos,走同一条 ConfigWatcher 热更。
- examples/http:最小服务的路由注册。