做VoIP的人一定绕不开SIP协议。不管你是做软电话、呼叫中心、还是对接语音网关,最终都会落到同一个问题:怎么用代码去收发那些REGISTER、INVITE、BYE请求。我在Go项目里为了这件事翻过不少轮子,最后稳定跑起来的方案,就是基于GoSIP生态的github.com/emiago/sipgo这个库。这篇文章从一个SIP Server的视角切入,讲清楚怎么用GoSIP把UAS(被叫侧)和UAC(主叫侧)两头都跑起来,顺便把收发请求时最容易踩的坑一起梳理掉。适合谁看?打算在Go服务里集成SIP能力、或者想自己写一个轻量SIP服务器的开发者,这篇应该能帮你节省至少两天的试错时间。
1. 项目整体设计与思路拆解
1.1 为什么坚持用Go写SIP服务
很多老牌SIP Stack都是用C/C++写的,比如pjsip、sofia-sip,性能确实强,但接入Go项目时总免不了CGO编译,一碰到交叉编译就头疼。Go这边虽然生态没有C那么成熟,但胜在部署简单、并发模型契合,而且纯Go的SIP库已经能覆盖绝大多数生产场景。
我做这个项目时选Go还有一个现实原因:整个服务的其他模块(REST API、数据库、WebSocket信令)都是Go写的,如果SIP部分用C库,那就要维护两套构建链,CI又得额外装一堆依赖。用纯Go的SIP库之后,一个二进制文件搞定所有事,部署直接扔到服务器上跑,省心太多了。
1.2 GoSIP库怎么选,我为什么用sipgo而不是其他库
Go生态里能用的SIP库不算多,主要看这几个:
github.com/asiortino/go-sip:比较早期的纯Go实现,消息解析和事务层都有,但维护频率低,很多年没大更新了,部分API设计偏底层,拿来学习可以,上生产要谨慎。github.com/cloudwebrtc/go-sip:这个库功能很全,还支持WebSocket传输,适合和浏览器端软电话打通,但依赖比较多,事务层状态机用起来稍重。github.com/emiago/sipgo:目前社区活跃度最高的纯Go SIP库,API设计干净,内建事务层,支持UDP/TCP/WS传输,官方还提供了UA、UAC、UAS、Server等高层封装。我最终选的就是这个。
选sipgo还有几个实际的考量。第一,它的事务层实现完整,UDP重传、定时器这些都有现成处理,不用自己造轮子。第二,它提供Server这个高层抽象,可以直接绑定端口监听请求,也提供UAC用来主动发请求,收发两边的API风格一致,学习成本低。第三,它本身不强制绑定某个传输层,同一份业务代码可以同时跑在UDP和TCP上,后面如果要接WebSocket,也不用推翻重来。
1.3 SIP在Server里到底是怎么流转的
SIP协议本身是文本协议,类似HTTP,但和HTTP最大的不同在于它是一个“会话初始化”协议,只管建立、修改、拆除多媒体会话,不负责传输媒体流本身。媒体流通常走RTP,由SDP协商出的端口承载。
一次典型的SIP通话流程是这样的:
- 主叫(UAC)发出INVITE请求,里面带着SDP,说明自己能接收媒体的IP和端口。
- 被叫(UAS)收到请求后,回100 Trying表示已经在处理,然后回180 Ringing表示开始振铃。
- 被叫接听后,回200 OK,同样附带自己的SDP。
- 主叫收到200 OK后,回复ACK确认,此时媒体通道建立,双方开始通话。
- 任一方挂机,发出BYE请求,对方回200 OK,整个会话结束。
这个流程里,Server端要做的事其实很明确:接收请求、解析消息、执行业务逻辑、返回对应的响应。GoSIP库帮你把Socket监听、事务匹配、消息解析都处理好了,你要做的就是注册业务处理器。
1.4 架构上的取舍:无状态转发还是有状态业务
在搭建SIP Server之前,先想清楚一个问题:你到底需要处理什么级别的业务?
如果是做一个简单的代理、负载均衡器,那可以“无状态”处理,转发完请求就完事。但如果你要做的是注册服务器、呼叫控制服务器,那必须“有状态”处理,因为你得记录用户是否注册、当前通话是否在振铃、超时之后要做什么。
GoSIP的Server模型天然适合有状态处理。每个到达的请求都会生成一个ServerTransaction,你可以持有这个事务对象,在业务逻辑执行完后主动回响应。如果只是简单地同步响应,也可以直接调用tx.Respond返回结果。实际项目里,我通常会在事务里挂一个业务ID,用Context把请求链路串起来,方便后续查日志。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析与实操要点
2.1 先把SIP消息结构吃透
SIP消息分两种:请求和响应。请求长这样:
code复制INVITE sip:100@192.168.1.10:5060 SIP/2.0
Via: SIP/2.0/UDP 192.168.1.5:5060;branch=z9hG4bK-12345
From: <sip:200@192.168.1.5>;tag=abc
To: <sip:100@192.168.1.10>
Call-ID: abcdef-12345@192.168.1.5
CSeq: 1 INVITE
Contact: <sip:200@192.168.1.5:5060>
Max-Forwards: 70
Content-Type: application/sdp
Content-Length: 123
响应长这样:
code复制SIP/2.0 180 Ringing
Via: SIP/2.0/UDP 192.168.1.5:5060;branch=z9hG4bK-12345
From: <sip:200@192.168.1.5>;tag=abc
To: <sip:100@192.168.1.10>;tag=def
Call-ID: abcdef-12345@192.168.1.5
CSeq: 1 INVITE
Contact: <sip:100@192.168.1.10:5060>
Content-Length: 0
这里有几个字段做通信时要格外注意。
branch参数是事务匹配的关键,同一事务的请求、响应、ACK都必须带着相同的branch。Via头里的地址和端口告诉对方响应该往哪里送,如果写错了,响应就会石沉大海。From和To里的tag参数是对话(Dialog)匹配的关键,首条请求的From必定有tag,被叫回响应时必须在To里补上自己的tag。Call-ID配合tag,唯一标识一个通话会话。CSeq用于保证请求顺序,每发一个新请求,序号必须递增。Contact则告诉对方后续请求应该发往哪个地址,这个在注册和话后信息交互时尤其重要。
2.2 Server端的核心对象:UAS、UAC、Transaction、Dialog
这几个概念我第一次接触时也绕了很久,用大白话解释一下。
- UAC:发起请求的一方。你作为客户端主动打电话、主动注册,你就是UAC。
- UAS:接收请求并响应的一方。你作为服务器等别人打电话进来、处理注册请求,你就是UAS。
- Transaction:一次请求从发出到收到最终响应(非1xx)的整个过程。在GoSIP里,
ServerTransaction就是服务端收到一个请求后,直到你调用Respond为止的这个生命周期。 - Dialog:由一系列Transaction组成的端到端会话关系。一次通话里,INVITE、ACK、BYE各属于不同Transaction,但它们在同一个Dialog里。Dialog靠Call-ID、From tag、To tag三元组来识别。
实际编码时,别把Transaction和Dialog搞混。Transaction是一次性的,处理完就销毁;Dialog是长存的,要一直跟踪到通话结束。GoSIP不会替你管理Dialog状态,需要自己在业务层维护。
2.3 绕不开的关键API:OnRequest、ReplyWith、Respond
sipgo的Server使用方式很接近Go标准库的net/http。核心就几个方法:
sipgo.NewServer(ua):创建一个SIP服务器实例,需要传入一个UserAgent。server.OnRequest(method, handler):注册某个SIP方法(如INVITE、REGISTER)的处理器。server.OnRequest(handler):不带方法参数时,处理所有请求,适合做全局日志、鉴权。tx.Respond(response):通过ServerTransaction把响应发回给请求方。
UA是sipgo里的顶层抽象,你可以把它理解成一台SIP终端设备,一个进程里可以同时创建多个UA。Server和UAC都挂在UA下面,分别处理被动和主动两类消息。
我习惯先在OnRequest里统一打印一下收到的消息内容,再分发给具体业务处理。这种方式在联调阶段特别有用,能第一时间确认对方发来的消息长什么样。
3. 实操过程与核心环节实现
3.1 环境准备与库安装
先准备好Go环境,我用的是Go 1.22,理论上Go 1.21以上的版本都能跑。用到的库是github.com/emiago/sipgo,安装命令:
bash复制go get github.com/emiago/sipgo
需要注意,sipgo的API在v0.20之后有过一些调整,建议锁定一个具体版本再开发。我写这篇文章时用的版本是v0.31.0,后续如果API有变化,以你自己的go.mod里锁定的版本为准。
另外准备一个SIP客户端用来联调。Windows上可以用X-lite、microSIP或Zoiper;Linux上可以用Linphone命令行版;如果你不想装图形界面,也可以用sipsak或sipp直接发SIP消息,后面我会演示。
3.2 5分钟搭建一个最小SIP Server
直接上代码,这个示例能处理REGISTER、INVITE、BYE、OPTIONS这四个最常用的方法:
go复制package main
import (
"context"
"fmt"
"log"
"net/http"
"time"
"github.com/emiago/sipgo"
"github.com/emiago/sipgo/sip"
)
func main() {
// 1. 创建UserAgent
ua, err := sipgo.NewUA()
if err != nil {
log.Fatal(err)
}
defer ua.Close()
// 2. 创建Server
server, err := sipgo.NewServer(ua)
if err != nil {
log.Fatal(err)
}
defer server.Close()
// 3. 注册各方法的处理器
server.OnRequest(sip.REGISTER, handleRegister)
server.OnRequest(sip.INVITE, handleInvite)
server.OnRequest(sip.BYE, handleBye)
server.OnRequest(sip.OPTIONS, handleOptions)
// 4. 启动监听,UDP 5060是SIP默认端口
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
log.Println("SIP Server started on udp :5060")
server.ListenAndServe(ctx, "udp", ":5060")
}()
// 让主goroutine一直运行
select {}
}
func handleRegister(req *sip.Request, tx sip.ServerTransaction) {
log.Printf("[REGISTER] From: %s, Contact: %s, Expires: %s",
req.From().Address.String(),
req.Contact().Address.String(),
req.GetHeader("Expires"))
// 这里可以写自己的用户存储逻辑,比如查DB、Redis
// 这里简单实现:只要收到就返回200 OK
resp := sip.NewResponseFromRequest(req, http.StatusOK, "OK", nil)
if err := tx.Respond(resp); err != nil {
log.Printf("respond register failed: %v", err)
}
}
func handleInvite(req *sip.Request, tx sip.ServerTransaction) {
log.Printf("[INVITE] From: %s, To: %s, CallID: %s",
req.From().Address.String(),
req.To().Address.String(),
req.CallID().Value())
// 先回100 Trying
trying := sip.NewResponseFromRequest(req, 100, "Trying", nil)
_ = tx.Respond(trying)
// 模拟振铃
ringing := sip.NewResponseFromRequest(req, 180, "Ringing", nil)
_ = tx.Respond(ringing)
// 模拟3秒后接听
time.Sleep(3 * time.Second)
// 这里可以填充SDP Body,做真正的媒体协商
okResp := sip.NewResponseFromRequest(req, 200, "OK", nil)
okResp.SetBody([]byte("v=0\r\no=- 123456 7890 IN IP4 192.168.1.10\r\ns=call\r\nc=IN IP4 192.168.1.10\r\nt=0 0\r\nm=audio 7078 RTP/AVP 0\r\na=rtpmap:0 PCMU/8000\r\n"))
okResp.SetHeader(&sip.ContentTypeHeader{Type: "application/sdp"})
if err := tx.Respond(okResp); err != nil {
log.Printf("respond invite failed: %v", err)
}
}
func handleBye(req *sip.Request, tx sip.ServerTransaction) {
log.Printf("[BYE] CallID: %s", req.CallID().Value())
resp := sip.NewResponseFromRequest(req, http.StatusOK, "OK", nil)
_ = tx.Respond(resp)
}
func handleOptions(req *sip.Request, tx sip.ServerTransaction) {
log.Printf("[OPTIONS] From: %s", req.From().Address.String())
resp := sip.NewResponseFromRequest(req, http.StatusOK, "OK", nil)
_ = tx.Respond(resp)
}
这段代码有几个细节值得说一下。
sip.NewResponseFromRequest会从原请求里复制Via、From、To、Call-ID、CSeq这些关键头域,保证响应能被对方的事务层正确匹配。在实际项目中不要手动拼响应,很容易漏头。SetBody设置SDP时,一定记得设置Content-Type: application/sdp,否则对方解析不了Body。很多新手在这里卡半天,最后的报错都是“没有媒体”或“SDP解析失败”。time.Sleep(3 * time.Second)模拟振铃只是演示。真实项目里千万不要在handler里直接sleep,因为SIP Server是按请求并发处理的,如果并发量上来,sleep会挂住大量goroutine,事务超时也是一堆问题。正确做法是把呼叫状态交给一个独立的呼叫管理器。
跑起来之后,用sipsak发一个OPTIONS请求测试:
bash复制sipsak -s sip:100@127.0.0.1:5060 -v
能看到服务器返回SIP/2.0 200 OK,说明Server已经能正常收发了。
3.3 主动发起SIP请求:用GoSIP当UAC
接收请求只是其中一半,很多时候你的服务也要主动向别的SIP服务器发请求,比如注册到上游、呼叫一个外部号码、或者转发呼叫。这时候需要用到sipgo.UAC。
下面这个例子演示如何主动发送一个INVITE请求,并等待最终响应:
go复制func sendInvite() error {
ua, err := sipgo.NewUA()
if err != nil {
return err
}
defer ua.Close()
uac, err := sipgo.NewUAC(ua)
if err != nil {
return err
}
// 构造目标URI
dest := sip.Uri{
User: "100",
Host: "192.168.1.20",
Port: 5060,
}
from := &sip.FromHeader{
Address: sip.Uri{User: "200", Host: "192.168.1.10"},
Params: sip.NewParams().Add("tag", sip.String{Str: randomTag()}),
}
to := &sip.ToHeader{
Address: dest,
}
callID := randomCallID()
req := sip.NewRequest(sip.INVITE, dest)
req.SetHeader(from)
req.SetHeader(to)
req.SetHeader(&sip.CallIDHeader{Value: callID})
req.SetHeader(&sip.CSeqHeader{
SeqNumber: 1,
Method: sip.INVITE,
})
// 发送请求并获取事务
clientTx, err := uac.SendRequest(req)
if err != nil {
return err
}
// 等待响应,超时设置为5秒
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
select {
case res := <-clientTx.Responses():
fmt.Printf("收到响应: %d %s\n", res.StatusCode(), res.Reason())
if res.StatusCode() == 200 {
// 这里需要回ACK,实际用uac.Ack这种辅助方法
// 简单起见,直接手动构造ACK
ack := sip.NewRequest(sip.ACK, dest)
ack.SetHeader(from)
ack.SetHeader(to)
ack.SetHeader(&sip.CallIDHeader{Value: callID})
ack.SetHeader(&sip.CSeqHeader{SeqNumber: 1, Method: sip.ACK})
_, err := uac.SendRequest(ack)
if err != nil {
return err
}
}
case <-ctx.Done():
return fmt.Errorf("等待响应超时")
}
return nil
}
func randomTag() string {
// 生成随机字符串
}
func randomCallID() string {
// 生成随机字符串
}
这里有几个容易犯错的地方。
From和To头的顺序不要搞反。sip.NewRequest只负责创建请求行,请求里的From、To、Call-ID、CSeq、Via都必须自己设置好。- Via头如果没设置,sipgo会自动生成一个,但自动生成的Host可能不对。如果服务器部署在NAT后面,建议手动指定Via的Host为公网地址,否则对方回响应时找不到你。
- 收到200 OK后必须回ACK。如果收不到ACK,对方会按事务超时重传200 OK。很多半吊子实现都漏了这一步,结果就是对方一直在重传,日志里刷屏。
- 真实场景里,
CSeq序号必须递增,不能每次都用1。同一个Dialog里的不同请求,CSeq必须+1。
3.4 实际联调:用X-lite注册并呼叫
Server代码跑起来以后,最直观的验证方式就是用真实SIP软电话去注册和拨打。
打开X-lite的账户设置,填上:
- 用户名:100
- 域名:127.0.0.1(如果你在本机跑)
- 密码:留空
然后启动X-lite。正常情况下,X-lite会发出一个REGISTER请求到你的Server,Server日志会打印出[REGISTER] From: ...,并返回200 OK。如果X-lite界面显示已注册成功,说明REGISTER流程是通的。
接着用另一个软电话(比如Zoiper)再注册一个号码200,从200拨给100。你会看到Server日志依次打印INVITE、100 Trying、180 Ringing、200 OK。此时X-lite会响铃,由于代码里写死了3秒后自动接听,实际上是模拟被叫自动接听。接通后媒体通道的建立需要看SDP,如果双方SDP协商到的IP和端口互相可达,就能听到声音了。
我实测的结果是,本机联调时一切正常,但跨服务器联调时经常出现一个现象:REGISTER和INVITE都能收到,但响应发不回去,或者对方半天收不到。这个问题基本都出在网络层面,后面的排查章节会详细讲。
4. 常见问题与排查技巧实录
4.1 响应发出去了,客户端却收不到
这个问题在我接触SIP的初期几乎天天遇到。请求能到服务端,说明网络可达,但响应回不去,最常见的原因有这几个。
第一,Via头里的地址不可达。如果你在公网服务器上跑GoSIP Server,但收到请求的Via里写的是内网IP,那么你的响应就会尝试发到内网地址,自然而然失败。解决办法是设置Received和rport参数,或者在Server层面对Via头做改写。
第二,防火墙拦截了UDP响应。SIP默认端口是UDP 5060,很多云安全组默认只放行TCP,UDP入方向没开。检测方法很简单,在服务器上执行tcpdump -i any udp port 5060,然后让客户端发一个请求,看响应包是否已经发出、是否收到网络层的ICMP不可达。
第三,软电话和Server不在同一个网段,又没有设置好NAT穿透。这个属于VoIP老问题,建议联调时先保证在同一内网,验证通了之后再上公网。
我在sipgo里通常这样做:
go复制server.OnRequest(func(req *sip.Request, tx sip.ServerTransaction) {
// 如果有rport,尝试从连接里拿到真实来源地址
via := req.Via()
if via != nil {
if rport := req.GetHeader("rport"); rport != "" {
// 设置Received参数为实际来源IP
via.Params.Add("received", sip.String{Str: req.Source().IP.String()})
}
}
})
这里的req.Source()是sipgo提供的来源地址接口,可以用来确保响应发回给真实来源。
4.2 事务超时和UDP重传问题
UDP是面向无连接的,SIP为了保证可靠,定义了应用层重传机制。如果UAS收到INVITE后,在Timer A(初始重传间隔,通常500毫秒)内没有返回任何可确认的响应,UAC会以指数退避的方式重传请求。
sipgo的事务层帮你处理了大部分重传逻辑,但有一个前提:你的业务handler要尽快返回至少一个响应。哪怕只是100 Trying,也要先回,否则对方的事务层会认为包丢了,持续重传。
有一次我调试一个复杂的呼叫流程,在handler里做数据库查询用了3秒,结果客户端那边的日志显示INVITE被重传了3次。后来我才意识到,UAS在解析完请求、事务层确认收到之后,就应该立即回100 Trying,然后异步去处理业务,等真正决策出结果后再回最终响应。sipgo的ServerTransaction支持异步响应,建议把所有耗时操作放到goroutine里,同时保存事务对象。
如果业务耗时特别长,比如超过30秒的呼叫排队场景,记得定期回180 Ringing或183 Session Progress,让UAC知道你还活着,不要傻等超时。
4.3 401/407鉴权怎么处理
真实项目里,REGISTER和INVITE通常都要做鉴权。sipgo不会替你自动处理401/407,需要自己写Digest鉴权逻辑。
收到401 Unauthorized后,UAC需要根据WWW-Authenticate头里的realm、nonce,用用户名密码计算出Authorization头,然后带着相同的Call-ID和递增的CSeq重发请求。sipgo的uac.SendRequest本身是多次性的,你可以先收401,处理完再发第二个请求,但要注意复用同一个clientTx还是新建事务。
我建议用一个简单方案:在UAC侧维护一个“预鉴权”函数,先不带Authorization发一次,等收到401后计算摘要,再带Authorization重发。核心计算逻辑如下:
go复制hash1 := md5(username + ":" + realm + ":" + password)
hash2 := md5(method + ":" + uri)
response := md5(hash1 + ":" + nonce + ":" + hash2)
然后在请求头里加:
code复制Authorization: Digest username="100", realm="asterisk", nonce="xxx", uri="sip:100@domain", response="xxx"
UAS侧收到带Authorization的请求后,校验response字段是否等于自己算出来的值,一致就返回200,否则返回403。生产环境建议用专业的SIP鉴权库,或者把鉴权逻辑放在网关层,业务层只信任已鉴权的请求。
4.4 常见错误速查表
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 请求能收到,但响应石沉大海 | Via头地址不可达;防火墙拦UDP响应 | 改写Via,设置Received/rport;检查安全组和iptables |
| 同一请求被重复收到 | UDP重传,UAS没及时回100 | 确保handler首先回100 Trying |
| 注册成功后,过几秒又显示离线 | 没有实现REGISTER的过期刷新,或返回的Expires不对 | 检查Expires头,实现刷新逻辑 |
| 呼叫建立后听不到声音 | SDP里的IP和端口为内网/错误;RTP端口被防火墙拦 | 跑通同一内网;用rtp.proxy或修改SDP为公网地址 |
| 收到INVITE但回200时客户端报“不匹配” | To头里缺tag,或Call-ID被改 | 用sip.NewResponseFromRequest生成响应,别手动拼 |
| OPTIONS请求返回408 | UAS没注册OPTIONS处理器 | 注册OnRequest(sip.OPTIONS, handler) |
4.5 几个实战调优心得
sipgo的Server默认是多goroutine并发处理请求的,但要注意别在handler里无脑创建goroutine。我见过同事把每个INVITE都扔到goroutine里处理,结果呼叫一多,goroutine数量直接飙到好几万,服务肉眼可见地卡顿。
更好的做法是:用一个有缓冲的channel把“新呼叫到达”这个事件丢给上层的呼叫管理器统一调度,handler只做解析和入队。这样能控制并发度,也方便做排队、黑名单、限流。
日志层面,每个请求务必记录Call-ID和branch,因为SIP调试基本都要靠这两个字段串联全链路。我自己习惯在Context里挂上Call-ID,所有子goroutine的日志都带上同一个标识,处理问题时能省一大半时间。
最后再分享一个技巧:如果同一个进程里既要跑Server又要跑UAC,务必分别为它们创建独立的UA实例,不要共用一个。sipgo文档里没有强调这点,但我实测遇到过并发调用SendRequest时偶发的事务冲突,拆成两个UA实例后问题就消失了。原因在于VoIP信令本来就是双向的,Server和UAC共用同一个事务层时,某些响应的来源判断会被搞混。分开之后,各自维护各自的事务表,逻辑更清晰,也好排查问题。
