信令收发:从“一设备一连接”到“单实例信令服务”
在很多项目里,经常看到这样一种“反模式”: 每个设备连接信令服务器时,单独起一个 TCP/UDP 监听 + 独立 goroutine
结果是:
- 监听端口被无限“复制”,资源浪费甚至冲突
- 难以统一鉴权、限流、日志、监控
- 扩展新信令类型或加一层中间件时只能到处改
而VSS项目的设计:整个系统只有一组 SIP(TCP/UDP)监听,所有设备的所有信令都通过统一的 handler/分发逻辑处理,再用 goroutine 处理每个请求。
本文Skeyevss项目core/app/sev/vss的实现作为参考,分享:
从创建连接 → 接收信令 → 解析 → 分发 → 响应 的完整链路设计。
一、反模式:为每个设备“新起一个信令服务器”
很多人会这么写:
func StartServerForDevice(dev Device) {
go func() {
ln, _ := net.Listen("tcp", ":5060") // 每个设备占一个
for {
conn, _ := ln.Accept()
go handleDeviceConn(dev, conn)
}
}()
}
常见问题:
- 端口占用/冲突:多个 listener 绑定同一个端口,要么失败,要么变成“以为的多实例,实际上只有一个在工作”。
- 资源浪费:每个设备都有独立 accept loop、独立管理逻辑。
- 无法集中治理:你没法写一个统一的日志/黑名单/限流,因为入口被拆成了很多份。
- 正确的做法是:一个信令服务器实例 + 多个 handler + 每请求/每会话 goroutine,这一点在 VSS 的实现里非常清晰。
二、VSS 的正确姿势:单实例 SIP 服务器 + Handler 分发
2.1 只创建一次 TCP/UDP 服务端
在 VSS 的 SipServer 中,只针对 GBS/GBC 各自的端口创建一次 gosip.Server,不随设备数量而重复创建:
type SipServer struct {
networkType string
svcCtx *types.ServiceContext
}
func NewSipSev(svcCtx *types.ServiceContext) *SipServer {
return &SipServer{svcCtx: svcCtx}
}
func (s *SipServer) SipGbsServer(networkType SipNetworkType, handlers types.HType) {
s.svcCtx.InitFetchDataState.Wait()
var (
sipSvr = gosip.NewServer(gosip.ServerConfig{Host: s.svcCtx.Config.InternalIp}, nil, nil, NewLogger())
addr = fmt.Sprintf("%s:%d", s.svcCtx.Config.Host, s.svcCtx.Config.Sip.Port)
)
for method, h := range handlers {
_ = sipSvr.OnRequest(method, h)
}
_ = sipSvr.Listen(string(networkType), addr) // 这里仅 Listen 一次
if networkType == SipTCP { s.svcCtx.GBSTCPSev = &sipSvr } else { s.svcCtx.GBSUDPSev = &sipSvr }
}
要点:
- gosip.NewServer 只创建一次,并通过 OnRequest 注册多个 SIP 方法的入口。
- Listen 之后,这个 server 会接收所有设备的 REGISTER/INVITE/MESSAGE/ACK 等请求,不会因设备数量增加而新增 listener。
- GBC(级联)同理,只是端口不同。
这就是 “单实例信令服务器” 的核心:每种协议/端口组合只创建一个 listener,做到 “横向唯一”。
2.2 Handler 只负责“方法 + 命令”分发
在 gbs_sip 中,VSS 把各种 SIP 方法的入口统一注册到 types.HType(一个 map[Method]Handler):
func RegisterHandlers(svcCtx *types.ServiceContext) types.HType {
return types.HType{
sip.REGISTER: func(req sip.Request, tx sip.ServerTransaction) {
sip2.DO("GBS", svcCtx, req, tx, nil, new(gbssip.RegisterLogic))
},
sip.INVITE: func(req sip.Request, tx sip.ServerTransaction) {
sip2.DO("GBS", svcCtx, req, tx, nil, new(gbssip.InviteLogic))
},
sip.ACK: func(req sip.Request, tx sip.ServerTransaction) {
sip2.DO("GBS", svcCtx, req, tx, nil, new(gbssip.ACKLogic))
},
sip.BYE: func(req sip.Request, tx sip.ServerTransaction) {
sip2.DO("GBS", svcCtx, req, tx, nil, new(gbssip.ByeLogic))
},
sip.MESSAGE: func(req sip.Request, tx sip.ServerTransaction) {
// 先解析 MESSAGE,再按 CmdType 分发
},
}
}
关键思想:
- 方法分发(REGISTER/INVITE/BYE/MESSAGE…)由 gosip + HType 完成;
- 命令分发(MESSAGE 里的 CmdType:Keepalive/Catalog/DeviceInfo…)在 MESSAGE handler 里再按 CmdType 二次分发。
MESSAGE 内部的 CmdType 分发:
sip.MESSAGE: func(req sip.Request, tx sip.ServerTransaction) {
data, _ := sip2.NewParser[types.MessageReceiveBase]().ToData(req)
cmdType := data.GetCmdType()
switch strings.ToLower(cmdType) {
case strings.ToLower(types.MessageCMDTypeKeepalive):
sip2.DO("GBS", svcCtx, req, tx, data, new(gbssip.KeepaliveLogic))
case strings.ToLower(types.MessageCMDTypeCatalog):
sip2.DO("GBS", svcCtx, req, tx, data, new(gbssip.CatLogLogic))
// ... 其他 CmdType
default:
svcCtx.SipLog <- &types.SipLogItem{Content: strings.TrimSuffix(req.String(), "\n")}
}
}
这里对应我说的“只需要创建一次,然后通过 handle 分发”
- 第一级 handle:按 SIP 方法(REGISTER/INVITE/MESSAGE…)分发
- 第二级 handle:按业务命令(例如 MESSAGE.CmdType)分发
三、统一的处理管线:从接收、解析到响应(而不是“每个设备一套逻辑”)
真正让系统稳定的,是 VSS 的这条统一处理管线 sip.DO:
func DO[T types.SipReceiveHandleLogic[T]](
Type string,
svcCtx *types.ServiceContext,
req sip.Request,
tx sip.ServerTransaction,
data *types.MessageReceiveBase,
logic T,
) {
var h = &handler[T]{svcCtx: svcCtx, req: req, tx: tx, logic: logic, data: data, sType: Type}
h.run()
}
func (h handler[T]) run() {
// 1. 解析 SIP Request -> 内部 Request 结构
parsedReq, err := ParseToRequest(h.req)
if err != nil {
_ = h.respond(sip.NewResponseFromRequest("", h.req, http.StatusBadRequest, "Request parse failed", ""))
return
}
// 2. 写入 SIP 日志(异步)
var content = strings.TrimSuffix(h.req.String(), "\n")
h.svcCtx.SipLog <- &types.SipLogItem{Content: content, Type: types.BroadcastTypeSipReceive}
// 3. 根据配置做一些 guard(如 ban ip)
var setting = rule.NewConfig(h.svcCtx.Config, h.svcCtx.Setting).Conv()
if functions.Contains(parsedReq.ID, strings.Split(setting.Content().BanIp, "\n")) {
_ = h.respond(sip.NewResponseFromRequest("", h.req, types.StatusForbidden, "Forbidden", ""))
return
}
// 4. 包一层超时 ctx
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(h.svcCtx.Config.Timeout)*time.Millisecond)
defer cancel()
// 5. 交给具体逻辑处理
var res = h.logic.New(ctx, h.svcCtx, parsedReq, h.tx).DO()
// 6. 根据 res 统一响应(401/403/400/200)
// 包括 BeforeResponse 等 hook
// ...
}
这条管线把所有设备的逻辑统一起来了:
- 任何设备的任何 SIP 请求都会经过:
- 同一套解析(ParseToRequest)
- 同一个日志出口(SipLog channel)
- 同一处超时控制
- 同一处响应格式/错误码映射
- 具体的业务差异只体现在 logic.New(...).DO() 这一层(不同 Logic 实现)
对比“每个设备一个 goroutine + 手写处理解析”,VSS 的设计明显更可控。
四、请求级 goroutine:每个请求独立、不阻塞全局
- 接收侧(SIP handler):gosip 内部会为每个 transaction 做处理,你在 handler 里只做 parse+转发,
无需额外再起 goroutine(除非逻辑特别重)。 - 发送侧(SendLogic):对每一条从 channel 取出的任务,都起一个 goroutine 去执行实际的发包逻辑:
for {
select {
case v := <-l.svcCtx.SipSendCatalog:
go func(v *types.Request) {
if err := l.catalog(v); err != nil { /* log */ }
}(v)
case v := <-l.svcCtx.SipSendVideoLiveInvite:
go func(v *types.SipVideoLiveInviteMessage) {
if err := l.VideoLiveInvite(v); err != nil { /* log */ }
}(v)
// ... 其他 case ...
}
}
这样做的好处:
- SendLogic 本身是单 goroutine,只负责从 channel 取任务,逻辑简单。
- 每个任务(例如一次 Invite 或一次 Catalog)在独立 goroutine 里执行,互不阻塞。
- 可以很容易加上节流/限并发。
五、如何实现同样的模式
下面给一个简化版的实现路线,以便在项目中复用:
5.1 定义 ServiceContext 和信令总线
type ServiceContext struct {
Config Config
// 各类 client,如 DB、Redis、媒体服务 client 等
// 发送总线
SendInvite chan *InviteReq
SendBye chan *ByeReq
// 心跳/Catalog 定时任务
CatalogLoop chan *CatalogJob
CatalogJobMap *xmap.XMap[string, *CatalogJob]
// 状态
AckMap *xmap.XMap[string, *AckState]
StreamExists *set.CSet[string]
}
5.2 创建单例信令服务器
func StartSipServer(svcCtx *ServiceContext) {
var srv = gosip.NewServer(gosip.ServerConfig{Host: svcCtx.Config.SipHost}, nil, nil, logger)
var handlers = RegisterHandlers(svcCtx) // map[Method]Handler
for m, h := range handlers {
_ = srv.OnRequest(m, h)
}
_ = srv.Listen("udp", fmt.Sprintf("%s:%d", svcCtx.Config.SipHost, svcCtx.Config.SipPort))
}
5.3 统一入口 + 按方法/命令分发
- REGISTER/INVITE/BYE 直接 sip.DO 对应 Logic。
- MESSAGE 先 parse CmdType,再 switch 到 Keepalive/Catalog 等 Logic。
5.4 发送端统一在 SendProc 中消费 channel
func (p *SendProc) Run() {
for {
select {
case inv := <-p.svcCtx.SendInvite:
go p.handleInvite(inv)
case bye := <-p.svcCtx.SendBye:
go p.handleBye(bye)
}
}
}
六、总结:正确的信令服务器姿势
正确思路:
监听器只创建一次
处理逻辑根据方法/命令分发
每个请求(或会话)用 goroutine 承载
共享状态集中在 ServiceContext
收发链路有统一的入口和出口。
相反,“每个设备独立创建 TCP/UDP 监听 + 独立 server” 是对资源和架构的双重浪费,也会让后续的限流、日志、监控、灰度发布、回放排障变得极其困难。