VSS 开发心得:ServiceContext 的设计与使用
ServiceContext(代码里写作 svcCtx)是 VSS 进程内的中心依赖容器:配置、RPC 客户端、Redis、各类 SIP 出站 channel、并发安全的 map/set 状态、以及 gosip 服务端句柄都挂在这里。理解它的边界和使用约定,比零散看单个 Logic 文件更容易把「HTTP / SIP 收 / SIP 发 / WS / SSE」串成一条线。
类型定义:core/app/sev/vss/internal/types/types.go → ServiceContext struct。
构造:core/app/sev/vss/internal/svc/service_context.go → NewServiceContext。
注入入口:main.go 中 svcCtx := svc.NewServiceContext(c),再传给 HTTP/SIP/WS/SSE 与各类 SipProc。
1. 设计目标:一个进程,一块共享状态
VSS 不是「每个请求 new 一套服务」,而是 单进程多协程:
- 收 SIP:gosip 在
OnRequest里回调,RegisterHandlers(svcCtx)闭包捕获svcCtx。 - 发 SIP:业务只往
SipSendXXXchannel 里投递;SendLogic统一消费。 - HTTP/WS:Handler 同样拿
svcCtx调 RPC 或往 channel 塞任务。
把共享资源收敛到 ServiceContext,好处是:
- 依赖显式:打开 struct 就知道能碰哪些客户端、哪些队列。
- 生命周期单一:随进程创建、随进程退出。
- 避免隐形全局变量:除个别包级工具外,业务逻辑以
svcCtx入参为主。
代价是:谁都能改 map、谁都能写 channel,需要团队纪律。
2. NewServiceContext
在NewServiceContext初始化了两类东西:
| 类别 | 代表字段 | 说明 |
|---|---|---|
| 不可变「配置 / 客户端」 | Config、RpcClients、RedisClient | 启动后一般只读;RPC 带拦截器(重试、keepalive、Api2DBRpc 鉴权等)。 |
| 可变「运行时状态」 | 各类 chan、xmap.XMap、set.CSet、Broadcast | 多 goroutine 读写:channel 协程约定、XMap/CSet 自身线程安全,普通 DictionaryMap 是原生 map(见 §3)。 |
另外:
Setting:先New零值,FetchDataLogic周期拉配置后覆盖/合并。DictionaryMap/OnvifDiscoverDevices/MediaServerRecords/DeviceOnlineState:由FetchDataLogic刷新;消费者读内存即可。GBSTCPSev/GBSUDPSev:在SipGbsServerListen成功后才赋值为 gosipServer指针;出站Send(TCP 事务)依赖其非空(gbs_send.go)。
3. 字段速查:先归类
3.1 SIP 出站总线(生产者 → SendLogic)
缓冲 channel,投递即意图,不要在热路径上阻塞过久:
SipSendCatalog、SipSendDeviceInfo、SipSendVideoLiveInvite、SipSendTalkInvite、SipSendBye、控制/预置位/录像/订阅/广播/对讲、SipLog 等。
习惯:HTTP 或收包 Logic 只 <- 投递;真正 gosip 发送在 SendLogic + GBSSender。
3.2 定时与状态机(channel + map)
SipCatalogLoop/SipCatalogLoopMap:注册上下线维护 Catalog 任务;SipHeartbeatLoop/SipHeartbeatLoopMap:过期与心跳超时。SetDeviceOnline/DeviceOnlineStateUpdateMap:设备/通道在线写 DB 的批量聚合入口(见设备在线状态文档)。AckRequestMap:INVITE 后 ACK/BYE 依赖的对话缓存(key 含设备、流名等)。
习惯:map 的 key 语义(设备 ID vs 通道 ID vs streamName)一旦混用,排障成本极高;新增 key 时写清注释并全局 grep 消费方。
3.3 并发安全注意点
DictionaryMap 是 map[string]*categories.Item[...](原生 map)。
FetchDataLogic.dictionaries 里是 l.svcCtx.DictionaryMap = maps 整体替换,读侧若在无锁环境下长期持有旧指针再读写,理论上仍有可见性习惯问题;工程上约定:业务只读 map,不在 HTTP 请求里写 DictionaryMap。若未来改为细粒度更新,需要 mutex 或原子替换策略 再评审。
xmap.XMap、set.CSet:仓库封装为并发安全结构,可放心在多协程 Get/Set(仍注意 业务逻辑上的覆盖与竞态,例如同一 key 被两处覆盖)。
3.4 流与 invite 防击穿的集合
InviteRequestState+InviteRequestLock:同一streamName并发 invite 控制。PubStreamExistsState:媒体侧流存在标记。FetchDeviceVideoState:录像信息拉取互斥场景。
4. 生命周期与 InitFetchDataState(容易忽略的细节)
ServiceContext 内有 InitFetchDataState sync.WaitGroup。
main里在启动SipProc前执行InitFetchDataState.Add(2),与FetchDataLogic中两条分支各Done()一次 配对(字典 / 设置链)。SendLogic、CatalogLoopLogic、HeartbeatOfflineLogic等 出站与定时任务在DO开头Wait():保证 首帧字典、Setting、媒体列表、在线快照、ONVIF 等尽量就绪 后再发信令或定时任务。
注意:sync.WaitGroup 在计数为 0 时,Wait() 会立即返回。
SipGbsServer 入口也有 InitFetchDataState.Wait():若在 Add(2) 之前 gosip 监听协程已跑到这里,则 不会阻塞——即 收 SIP 与「FetchData 完成」之间可能存在时间窗。实际业务是否在 Logic 内强依赖 Setting,决定在极端冷启动下是否会有短暂不一致。出站侧则普遍 显式 Wait。
开发心得:新增「必须在首包数据就绪后才能跑」的逻辑时,优先与现有 proc 一样 InitFetchDataState.Wait();若仅依赖本地 Config 即可,可不必 Wait,但要在代码审查时说清。
5. 使用约定
- 不要长期持有
svcCtx的子字段切片指针跨 goroutine 写:例如从OnvifDiscoverDevices切一片子 slice 再在后台改元素,除非保证生命周期与只读。 - 往 channel 投递考虑阻塞:缓冲区满会卡住 HTTP 或 SIP 收包线程;必要时应 select + 超时/丢弃策略(与
SipLog等思路一致)。 - 新增全局状态时:优先
xmap/set+ 明确 key;避免再增加无锁原生 map。 - TCP SIP 发送:确保
Listen已完成、GBSTCPSev已赋值,再依赖RequestWithContext/Send;单元测试里常需 mock 或跳过真发送。
6. 小结
ServiceContext= 配置 + RPC + Redis + SIP 队列 + 运行时 map/set + gosip Server 指针 的聚合体,是 VSS 的「小型服务定位器」。- 出站 SIP:统一 channel →
SendLogic;状态:注册 / 心跳 / ACK / 流 各管一类 map,key 语义要统一。 InitFetchDataState:协调 FetchData 与 proc,注意 WaitGroup 在 Add 前的 Wait 不阻塞 与冷启动时间窗。- 线程安全:
DictionaryMap原生 map 整体替换 仍为敏感点;其余优先用xmap/set。