2026-04-25 01:18:30 +08:00

260 lines
8.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 endpointim-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 同时承载健康检查和 WSgateway-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)
}