// Package app 负责把 im-service 的领域服务、传输层、健康检查和下游连接装配成一个进程。 package app import ( "context" "errors" "log" "net" "net/http" "sync" "time" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" healthgrpc "google.golang.org/grpc/health/grpc_health_v1" imv1 "hyapp/api/proto/im/v1" roomv1 "hyapp/api/proto/room/v1" "hyapp/services/im-service/internal/auth" "hyapp/services/im-service/internal/config" "hyapp/services/im-service/internal/healthcheck" imservice "hyapp/services/im-service/internal/service" grpcserver "hyapp/services/im-service/internal/transport/grpc" httptransport "hyapp/services/im-service/internal/transport/http" ) // grpcGuardClient 是 im-service 到 room-service Guard RPC 的薄适配器。 type grpcGuardClient struct { // client 是由 protobuf 生成的 room guard client,领域层只依赖 GuardClient 接口。 client roomv1.RoomGuardServiceClient } // CheckSpeakPermission 转发公屏发言守卫请求,im-service 不复制房间禁言和踢人规则。 func (c grpcGuardClient) CheckSpeakPermission(ctx context.Context, req *roomv1.CheckSpeakPermissionRequest) (*roomv1.CheckSpeakPermissionResponse, error) { return c.client.CheckSpeakPermission(ctx, req) } // VerifyRoomPresence 转发房间订阅守卫请求,确保 WS 频道订阅以 room-service presence 为准。 func (c grpcGuardClient) VerifyRoomPresence(ctx context.Context, req *roomv1.VerifyRoomPresenceRequest) (*roomv1.VerifyRoomPresenceResponse, error) { return c.client.VerifyRoomPresence(ctx, req) } // App 负责装配 im-service 运行时依赖。 type App struct { // cfg 保存 drain timeout 等生命周期配置,避免下线流程写死预算。 cfg config.Config // httpServer 承载 /ws 和 HTTP health endpoint;im-service 的外部实时入口只在这里暴露。 httpServer *http.Server // grpcServer 承载 room-service 调用的 RoomBridgeService 和标准 gRPC health。 grpcServer *grpc.Server // httpLn 是 HTTP/WebSocket listener,提前创建便于 New 阶段暴露端口占用错误。 httpLn net.Listener // grpcLn 是内部 gRPC listener,和 HTTP listener 独立失败、独立健康检查。 grpcLn net.Listener // roomConn 是到 room-service 的守卫查询连接,ready 检查依赖它确认房间权限链路可用。 roomConn *grpc.ClientConn // health 汇总 listener 状态、drain 状态和 room-service 连接状态。 health *healthcheck.State // wsHandler 持有真实 WS 连接注册表,优雅下线时负责发送 1012 close frame。 wsHandler *httptransport.WSHandler // shutdownOnce 保证第一次优雅下线流程只启动一次。 shutdownOnce sync.Once // roomConnCloseOnce 避免第二次信号强制关闭和优雅下线同时关闭同一 gRPC client。 roomConnCloseOnce sync.Once } // New 初始化 im-service 应用。 func New(cfg config.Config) (*App, error) { // room-service 是订阅和发言守卫的权威来源;连接失败不能退化为本地放行。 roomConn, err := grpc.Dial(cfg.RoomServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { return nil, err } // 领域服务持有真实 WS session、消息历史和 ACK 状态;App 层只负责注入依赖。 verifier := auth.NewHMACVerifier(cfg.JWTSecret) svc := imservice.New(imservice.Config{ NodeID: cfg.NodeID, RoomHistoryLimit: cfg.RoomHistoryLimit, }, verifier, grpcGuardClient{client: roomv1.NewRoomGuardServiceClient(roomConn)}) healthState := healthcheck.NewState(cfg.NodeID, svc, roomConn) // HTTP mux 同时承载健康检查和 WS;gateway-service 不终止 WebSocket。 mux := http.NewServeMux() healthHandler := healthcheck.NewHTTPHandler(healthState) wsHandler := httptransport.NewWSHandler(svc) mux.HandleFunc("/healthz/live", healthHandler.Live) mux.HandleFunc("/healthz/ready", healthHandler.Ready) mux.Handle("/ws", wsHandler) // HTTP 端口创建失败时释放已建立的 room-service 连接,避免 New 返回错误后遗留资源。 httpLn, err := net.Listen("tcp", cfg.HTTPAddr) if err != nil { _ = roomConn.Close() return nil, err } // gRPC 端口创建失败时同时释放 HTTP listener 和 room-service 连接,保持初始化原子性。 grpcLn, err := net.Listen("tcp", cfg.GRPCAddr) if err != nil { _ = httpLn.Close() _ = roomConn.Close() return nil, err } // RoomBridgeService 是 room-service 低时延广播入口;health 用于服务发现和本地探测。 grpcSrv := grpc.NewServer() imv1.RegisterRoomBridgeServiceServer(grpcSrv, grpcserver.NewServer(svc)) healthgrpc.RegisterHealthServer(grpcSrv, healthcheck.NewGRPCServer(healthState)) return &App{ cfg: cfg, httpServer: &http.Server{Handler: mux}, grpcServer: grpcSrv, httpLn: httpLn, grpcLn: grpcLn, roomConn: roomConn, health: healthState, wsHandler: wsHandler, }, nil } // Run 启动 HTTP 和 gRPC 入口。 func (a *App) Run() error { // 两个入口任意一个失败都要触发进程退出;继续半开放会导致路由和健康状态不一致。 errCh := make(chan error, 2) a.health.MarkHTTPServing() go func() { err := a.httpServer.Serve(a.httpLn) a.health.MarkHTTPStopped() if errors.Is(err, http.ErrServerClosed) { // 主动 Close 导致的停止不是故障,交给另一个入口完成退出汇合。 errCh <- nil return } errCh <- err }() a.health.MarkGRPCServing() go func() { err := a.grpcServer.Serve(a.grpcLn) a.health.MarkGRPCStopped() if errors.Is(err, grpc.ErrServerStopped) { // GracefulStop 导致的停止不是故障,避免正常 drain 被误报为崩溃。 errCh <- nil return } errCh <- err }() firstErr := <-errCh if firstErr != nil { // 第一个入口异常退出时主动关闭另一个入口,保证实例不会继续接部分流量。 a.ForceClose() return firstErr } secondErr := <-errCh if secondErr != nil { // 第二个入口异常通常来自并发关闭窗口,也统一触发 Close 以释放 roomConn。 a.ForceClose() return secondErr } return nil } // Shutdown 按 graceful-shutdown 文档顺序收敛 HTTP、WebSocket、gRPC 和下游连接。 func (a *App) Shutdown(ctx context.Context) error { start := time.Now() var shutdownErr error a.shutdownOnce.Do(func() { a.health.MarkDraining() a.wsHandler.StartDraining() httpCtx, httpCancel := timeoutChild(ctx, a.cfg.HTTPDrainTimeout) httpErr := a.httpServer.Shutdown(httpCtx) httpCancel() if httpErr != nil { // HTTP shutdown 超时后继续 WS/gRPC 收敛,最终由调用方决定是否强制退出。 shutdownErr = errors.Join(shutdownErr, httpErr) log.Printf("service=im-service node_id=%s state=draining resource=http_listener result=shutdown_error error=%q", a.cfg.NodeID, httpErr.Error()) } wsCtx, wsCancel := timeoutChild(ctx, a.cfg.WSDrainTimeout) remainingWS := a.wsHandler.Shutdown(wsCtx) wsCancel() if remainingWS > 0 { log.Printf("service=im-service node_id=%s state=draining resource=websocket result=forced active_ws_connections=%d", a.cfg.NodeID, remainingWS) } grpcCtx, grpcCancel := timeoutChild(ctx, a.cfg.GRPCDrainTimeout) grpcForced := a.drainGRPC(grpcCtx) grpcCancel() if grpcForced { log.Printf("service=im-service node_id=%s state=draining resource=grpc_bridge result=forced", a.cfg.NodeID) } a.closeRoomConn() log.Printf("service=im-service node_id=%s state=stopped ready=false active_ws_connections=%d shutdown_deadline_ms=%d duration_ms=%d result=shutdown_complete", a.cfg.NodeID, a.ActiveWSConnections(), a.cfg.ShutdownTimeout.Milliseconds(), time.Since(start).Milliseconds()) }) return shutdownErr } // ForceClose 立即关闭活动连接和 listener,用于第二次信号或优雅下线超时后的兜底。 func (a *App) ForceClose() int { a.health.MarkDraining() activeWS := a.wsHandler.ForceClose() a.grpcServer.Stop() _ = a.httpServer.Close() a.closeRoomConn() return activeWS } // ActiveWSConnections 返回本节点仍持有的 WS 连接数,用于信号日志和强制退出日志。 func (a *App) ActiveWSConnections() int { return a.wsHandler.ActiveConnections() } // Close 保留给测试或异常路径使用;正常信号路径应调用 Shutdown。 func (a *App) Close() error { a.ForceClose() return nil } func (a *App) drainGRPC(ctx context.Context) bool { done := make(chan struct{}) go func() { a.grpcServer.GracefulStop() close(done) }() select { case <-done: return false case <-ctx.Done(): a.grpcServer.Stop() return true } } func (a *App) closeRoomConn() { a.roomConnCloseOnce.Do(func() { if a.roomConn != nil { _ = a.roomConn.Close() } }) } func timeoutChild(parent context.Context, timeout time.Duration) (context.Context, context.CancelFunc) { if timeout <= 0 { // 非正数配置不额外截断父上下文,保留 shutdown_timeout 作为最终上限。 return context.WithCancel(parent) } return context.WithTimeout(parent, timeout) }