SIP信令收发:从“一设备一连接”到“单实例信令服务”

5 阅读5分钟

信令收发:从“一设备一连接”到“单实例信令服务”

在很多项目里,经常看到这样一种“反模式”: 每个设备连接信令服务器时,单独起一个 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” 是对资源和架构的双重浪费,也会让后续的限流、日志、监控、灰度发布、回放排障变得极其困难。