首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >信令收发:从“一设备一连接”到“单实例信令服务”

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

原创
作者头像
Openskeye
发布2026-05-09 15:30:15
发布2026-05-09 15:30:15
780
举报

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

在很多项目里,经常看到这样一种“反模式”: **每个设备连接信令服务器时,单独起一个 TCP/UDP 监听 + 独立 goroutine**<br>

结果是:

- 监听端口被无限“复制”,资源浪费甚至冲突

- 难以统一鉴权、限流、日志、监控

- 扩展新信令类型或加一层中间件时只能到处改

而VSS项目的设计:整个系统只有一组 SIP(TCP/UDP)监听,所有设备的所有信令都通过统一的 handler/分发逻辑处理,再用 goroutine

处理每个请求。

本文Skeyevss项目`core/app/sev/vss`的实现作为参考,分享:<br>

从创建连接 → 接收信令 → 解析 → 分发 → 响应 的完整链路设计。

<br>

---

## 一、反模式:为每个设备“新起一个信令服务器”

很多人会这么写:

```

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 实现)<br>

对比“每个设备一个 goroutine + 手写处理解析”,VSS 的设计明显更可控。

## 四、请求级 goroutine:每个请求独立、不阻塞全局

- 接收侧(SIP handler):gosip 内部会为每个 transaction 做处理,你在 handler 里只做 parse+转发,<br>

无需额外再起 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)

}

}

}

```

## 六、总结:正确的信令服务器姿势

正确思路:

> 监听器只创建一次 <br>

> 处理逻辑根据方法/命令分发 <br>

> 每个请求(或会话)用 goroutine 承载 <br>

> 共享状态集中在 ServiceContext <br>

> 收发链路有统一的入口和出口。

相反,“每个设备独立创建 TCP/UDP 监听 + 独立 server” 是对资源和架构的双重浪费,也会让后续的限流、日志、监控、灰度发布、回放排障变得极其困难。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档