260 lines
8.8 KiB
Go
260 lines
8.8 KiB
Go
// 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)
|
||
}
|