244 lines
8.5 KiB
Go
244 lines
8.5 KiB
Go
// Package app 负责把 room-service 的领域服务、存储、路由目录和 gRPC 入口装配成进程。
|
||
package app
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"net"
|
||
"sync"
|
||
"time"
|
||
|
||
"google.golang.org/grpc"
|
||
"google.golang.org/grpc/credentials/insecure"
|
||
healthgrpc "google.golang.org/grpc/health/grpc_health_v1"
|
||
activityv1 "hyapp.local/api/proto/activity/v1"
|
||
roomv1 "hyapp.local/api/proto/room/v1"
|
||
"hyapp/pkg/grpchealth"
|
||
"hyapp/pkg/logx"
|
||
"hyapp/pkg/tencentim"
|
||
"hyapp/services/room-service/internal/config"
|
||
"hyapp/services/room-service/internal/healthcheck"
|
||
"hyapp/services/room-service/internal/integration"
|
||
roomservice "hyapp/services/room-service/internal/room/service"
|
||
"hyapp/services/room-service/internal/router"
|
||
mysqlstorage "hyapp/services/room-service/internal/storage/mysql"
|
||
grpcserver "hyapp/services/room-service/internal/transport/grpc"
|
||
)
|
||
|
||
// App 负责装配 room-service 运行时依赖。
|
||
type App struct {
|
||
// cfg 保留启动配置,便于后续扩展后台 worker 时复用。
|
||
cfg config.Config
|
||
// grpcServer 承载 RoomCommandService、RoomGuardService 和标准 gRPC health。
|
||
grpcServer *grpc.Server
|
||
// service 是 Room Cell 领域入口,后台 presence worker 复用它执行清理命令。
|
||
service *roomservice.Service
|
||
// listener 是 room-service 的唯一网络入口。
|
||
listener net.Listener
|
||
// walletConn 是 SendGift 同步扣费依赖。
|
||
walletConn *grpc.ClientConn
|
||
// activityConn 是 room outbox 事实投递到 activity-service 的 best-effort 通道。
|
||
activityConn *grpc.ClientConn
|
||
// repository 是 MySQL 持久化实现,保存 meta、command log、snapshot 和 outbox。
|
||
repository *mysqlstorage.Repository
|
||
// redisClose 关闭 Redis client,Redis directory 本身不拥有额外生命周期。
|
||
redisClose func() error
|
||
// health 汇总 gRPC、runtime、MySQL 和 Redis lease 探测状态。
|
||
health *healthcheck.State
|
||
// workerCtx 统一控制本节点后台 worker,关闭时必须先停止 worker 再释放 MySQL/Redis。
|
||
workerCtx context.Context
|
||
// workerCancel 停止 presence stale 和 outbox 补偿 worker。
|
||
workerCancel context.CancelFunc
|
||
// workerWG 等待后台 worker 当前事件或当前扫描批次安全退出。
|
||
workerWG sync.WaitGroup
|
||
// closeOnce 防止信号退出和 Serve 错误同时触发重复关闭。
|
||
closeOnce sync.Once
|
||
}
|
||
|
||
// New 初始化 room-service 应用。
|
||
func New(cfg config.Config) (*App, error) {
|
||
normalized, err := config.Normalize(cfg)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
cfg = normalized
|
||
|
||
// wallet-service 是 SendGift 主链路依赖,连接对象创建失败时不能启动。
|
||
walletConn, err := grpc.Dial(cfg.WalletServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
activityConn, err := grpc.Dial(cfg.ActivityServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||
if err != nil {
|
||
_ = walletConn.Close()
|
||
return nil, err
|
||
}
|
||
|
||
startupCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||
defer cancel()
|
||
|
||
// MySQL 是房间恢复的持久来源,启动阶段必须确认连接池可用。
|
||
repository, err := mysqlstorage.Open(startupCtx, cfg.MySQLDSN, cfg.MySQLMaxOpenConns, cfg.MySQLMaxIdleConns)
|
||
if err != nil {
|
||
_ = activityConn.Close()
|
||
_ = walletConn.Close()
|
||
return nil, err
|
||
}
|
||
|
||
if cfg.MySQLAutoMigrate {
|
||
// 本地和测试环境可自动建表,线上应通过显式迁移控制结构变更。
|
||
if err := repository.Migrate(startupCtx); err != nil {
|
||
_ = repository.Close()
|
||
_ = activityConn.Close()
|
||
_ = walletConn.Close()
|
||
return nil, err
|
||
}
|
||
}
|
||
|
||
// Redis directory 保存 room owner lease,是单房间单活的运行时仲裁来源。
|
||
redisClient, err := router.NewRedisClient(startupCtx, cfg.RedisAddr, cfg.RedisPassword, cfg.RedisDB)
|
||
if err != nil {
|
||
_ = repository.Close()
|
||
_ = activityConn.Close()
|
||
_ = walletConn.Close()
|
||
return nil, err
|
||
}
|
||
|
||
directory := router.NewRedisDirectory(redisClient)
|
||
roomPublisher := integration.NewNoopRoomEventPublisher()
|
||
outboxPublisher := integration.NewNoopOutboxPublisher()
|
||
if cfg.TencentIM.Enabled {
|
||
// 腾讯云 IM 替代自研 IM;room-service 只负责服务端 REST 群消息桥接。
|
||
tencentClient, err := tencentim.NewRESTClient(cfg.TencentIM.RESTConfig())
|
||
if err != nil {
|
||
_ = repository.Close()
|
||
_ = activityConn.Close()
|
||
_ = walletConn.Close()
|
||
_ = redisClient.Close()
|
||
return nil, err
|
||
}
|
||
tencentPublisher := integration.NewTencentIMPublisher(tencentClient)
|
||
roomPublisher = tencentPublisher
|
||
outboxPublisher = tencentPublisher
|
||
}
|
||
|
||
// 领域服务只依赖接口,App 层负责选择 MySQL/Redis/gRPC 具体实现。
|
||
svc := roomservice.New(roomservice.Config{
|
||
NodeID: cfg.NodeID,
|
||
LeaseTTL: cfg.LeaseTTL,
|
||
RankLimit: cfg.RankLimit,
|
||
SnapshotEveryN: cfg.SnapshotEveryN,
|
||
PresenceStaleAfter: cfg.PresenceStaleAfter,
|
||
MicPublishTimeout: cfg.MicPublishTimeout,
|
||
}, directory, repository, integration.NewGRPCWalletClient(walletConn), roomPublisher, outboxPublisher)
|
||
svc.SetEventPublisher(integration.NewActivityOutboxPublisher(activityv1.NewRoomEventConsumerServiceClient(activityConn), time.Second))
|
||
|
||
server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("room-service")))
|
||
// 命令服务处理房间状态变更,守卫服务给腾讯云 IM 回调或 gateway 做发言和进群校验。
|
||
roomv1.RegisterRoomCommandServiceServer(server, grpcserver.NewServer(svc))
|
||
roomv1.RegisterRoomGuardServiceServer(server, grpcserver.NewServer(svc))
|
||
roomv1.RegisterRoomQueryServiceServer(server, grpcserver.NewServer(svc))
|
||
healthState := healthcheck.NewState(cfg.NodeID, svc, repository, directory)
|
||
healthgrpc.RegisterHealthServer(server, grpchealth.NewServer(healthState))
|
||
|
||
// listener 在 New 阶段创建,可以把端口占用作为启动失败暴露。
|
||
listener, err := net.Listen("tcp", cfg.GRPCAddr)
|
||
if err != nil {
|
||
_ = activityConn.Close()
|
||
_ = walletConn.Close()
|
||
_ = repository.Close()
|
||
_ = redisClient.Close()
|
||
return nil, err
|
||
}
|
||
workerCtx, workerCancel := context.WithCancel(context.Background())
|
||
|
||
return &App{
|
||
cfg: cfg,
|
||
grpcServer: server,
|
||
service: svc,
|
||
listener: listener,
|
||
walletConn: walletConn,
|
||
activityConn: activityConn,
|
||
repository: repository,
|
||
redisClose: redisClient.Close,
|
||
health: healthState,
|
||
workerCtx: workerCtx,
|
||
workerCancel: workerCancel,
|
||
}, nil
|
||
}
|
||
|
||
// Run 启动 gRPC 服务。
|
||
func (a *App) Run() error {
|
||
// health 先标记 serving,再进入 Serve,ready 才能在监听开始后通过。
|
||
a.health.MarkGRPCServing()
|
||
defer func() {
|
||
if a.workerCancel != nil {
|
||
a.workerCancel()
|
||
}
|
||
a.workerWG.Wait()
|
||
}()
|
||
if a.cfg.PresenceStaleScanInterval > 0 {
|
||
// presence worker 只清理本节点已装载 Cell,命令仍走 Room Cell 持久化链路。
|
||
a.workerWG.Go(func() {
|
||
a.service.RunPresenceStaleWorker(a.workerCtx, a.cfg.PresenceStaleScanInterval)
|
||
})
|
||
}
|
||
if a.cfg.MicPublishScanInterval > 0 {
|
||
// MicUp 只代表业务占麦;后台 worker 负责释放未确认 RTC 发流的 pending_publish 麦位。
|
||
a.workerWG.Go(func() {
|
||
a.service.RunMicPublishTimeoutWorker(a.workerCtx, a.cfg.MicPublishScanInterval)
|
||
})
|
||
}
|
||
if a.cfg.OutboxWorker.Enabled {
|
||
a.workerWG.Go(func() {
|
||
a.service.RunOutboxWorker(a.workerCtx, roomservice.OutboxWorkerOptions{
|
||
PollInterval: a.cfg.OutboxWorker.PollInterval,
|
||
BatchSize: a.cfg.OutboxWorker.BatchSize,
|
||
PublishTimeout: a.cfg.OutboxWorker.PublishTimeout,
|
||
RetryStrategy: a.cfg.OutboxWorker.RetryStrategy,
|
||
})
|
||
})
|
||
}
|
||
|
||
err := a.grpcServer.Serve(a.listener)
|
||
a.health.MarkGRPCStopped()
|
||
if errors.Is(err, grpc.ErrServerStopped) {
|
||
// GracefulStop 触发的退出不是故障。
|
||
return nil
|
||
}
|
||
|
||
return err
|
||
}
|
||
|
||
// Close 关闭底层 gRPC 服务。
|
||
func (a *App) Close() {
|
||
a.closeOnce.Do(func() {
|
||
// 先进入 draining,避免下线中的节点继续接管房间 lease 或接收新命令。
|
||
a.health.MarkDraining()
|
||
if a.service != nil {
|
||
a.service.MarkDraining()
|
||
}
|
||
if a.workerCancel != nil {
|
||
// 先停后台 worker 并等待当前投递结果落库,再关闭 gRPC/MySQL/Redis。
|
||
a.workerCancel()
|
||
}
|
||
a.workerWG.Wait()
|
||
// GracefulStop 让已进入的 gRPC 请求自然结束。
|
||
a.grpcServer.GracefulStop()
|
||
if a.walletConn != nil {
|
||
_ = a.walletConn.Close()
|
||
}
|
||
if a.activityConn != nil {
|
||
_ = a.activityConn.Close()
|
||
}
|
||
if a.repository != nil {
|
||
// MySQL 连接池最后释放,保证 GracefulStop 期间仍可完成持久化。
|
||
_ = a.repository.Close()
|
||
}
|
||
if a.redisClose != nil {
|
||
// Redis client 关闭后本节点不再续约或接管房间。
|
||
_ = a.redisClose()
|
||
}
|
||
})
|
||
}
|