本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:开箱即用的轻量级SIP通信工具,用标准Java编写,底层完全依赖JAIN-SIP协议栈,严格遵循RFC 3261规范。支持完整的SIP用户注册流程,可向任意SIP服务器提交REGISTER请求并完成认证;提供简洁的命令行接口,输入目标SIP URI即可发起INVITE呼叫,建立端到端语音会话。项目结构清晰,核心逻辑集中在src目录,所有SIP消息收发、事务管理、状态机跳转均由JAIN-SIP API驱动,不依赖图形界面或第三方音视频库。通过Maven统一管理依赖(pom.xml),内置mvnw脚本,Windows和Linux下均可一键构建运行。已预配置IntelliJ IDEA工程文件(如MotoSipClient.iml、.idea/等),导入即调,适合快速验证SIP信令流程、调试服务器交互、开展协议学习或嵌入自有系统做底层通信模块。适用于SIP服务端开发辅助、VoIP功能测试、通信协议教学及轻量级呼叫自动化场景。

1. 项目概述:为什么一个“纯Java命令行SIP工具”值得你花十分钟读完

我做VoIP协议栈开发和SIP服务器测试快十二年了,从早期用Wireshark抓包手写REGISTER消息,到后来搭OpenSIPS做压力测试,再到给客户定制呼叫流程自动化脚本——中间踩过的坑、重写的轮子、被RFC文档绕晕的夜晚,数都数不清。今天要聊的这个项目,是我去年帮一家智能硬件团队做语音对讲模块联调时顺手整理出来的“最小可运行SIP信令验证器”。它不炫技,没有Web界面,不集成FFmpeg或WebRTC,甚至不处理PCM音频流——但它能干净利落地完成两件事:把你的SIP账号注册到服务器上,并且拨通一个电话。就这么简单,但恰恰是绝大多数SIP调试场景里最卡脖子的起点。

关键词里提到的 JAIN-SIP、Java SIP客户端、SIP注册、SIP呼叫测试,不是堆砌术语,而是精准锚定了它的定位:它是一把螺丝刀,不是一台数控机床。当你在调试自研SIP服务器时发现REGISTER 401响应没触发ACK重传;当你集成第三方软交换平台却始终收不到200 OK;当你想确认某个SIP URI格式是否被正确解析;或者你只是个刚学完RFC 3261第10章的学生,需要一个能跑起来的实例看状态机怎么跳转——这时候,你不需要Spring Boot + Vue的全栈工程,你只需要一个java -jar motosip.jar --register sip:alice@192.168.1.100:5060就能看到完整的注册事务日志。它用标准Java写成,所有逻辑都在src/main/java下摊开给你看,没有隐藏层,没有魔法配置。Maven依赖明明白白列在pom.xml里,连JAIN-SIP的版本号(1.2.307)都精确锁定,避免因协议栈版本差异导致的INVITE超时或ACK丢失这类玄学问题。IntelliJ的.iml文件和.idea目录已经配好编码、JDK版本、Maven profile——导入即跑,连“Project SDK未配置”的弹窗都不会弹出来。这不是玩具,是我在客户现场掏出笔记本,三分钟内就让对方SIP服务器日志里刷出“REGISTER from alice accepted”的真实工具。

2. 整体设计与思路拆解:为什么“纯Java + 命令行”反而是最优解

2.1 拒绝过度工程:从协议学习本质出发的设计哲学

很多人一听到“SIP客户端”,第一反应是下载Zoiper或MicroSIP这类图形化软电话。但它们就像一辆装满仪表盘和自动驾驶的轿车——你按下一个按钮,背后是几十万行代码在调度编解码、渲染UI、管理通话记录、同步联系人。而我们要解决的问题,往往只是:“我的SIP服务器为什么拒绝这个REGISTER?” 或 “INVITE发出去后,对方到底有没有收到?” 这时候,图形界面非但不是助力,反而是干扰源:你得先点开设置、填账号密码、选编解码器、关掉回声消除……等你终于点下“呼叫”按钮,日志里混着UI线程、音频线程、网络线程的输出,根本分不清哪一行是SIP事务的起始。所以这个项目从第一天就定下铁律:零UI,零音视频处理,零外部协议栈依赖。所有交互通过System.in读取命令行参数,所有输出打到System.out,所有SIP消息的构造、发送、接收、解析、状态机推进,全部由JAIN-SIP API原生驱动。这看似“简陋”,实则是对SIP协议本质的回归——RFC 3261定义的从来就是一个基于文本的消息交换协议,核心是事务(Transaction)、对话(Dialog)、会话(Session)三层状态机。我们只实现这三层中最关键的两个事务:REGISTER事务(用于用户注册)和INVITE事务(用于建立会话)。其他如SUBSCRIBE、NOTIFY、UPDATE等,统统砍掉。不是不能做,而是做了就违背了“最小可验证”的初衷。

2.2 JAIN-SIP协议栈的选择:为什么不是Netty+SIP Parser,也不是PJSIP-JNI

市面上有几种实现SIP的方式:一种是用Netty或MINA自己解析SIP消息头,手动维护事务状态;另一种是用PJSIP的JNI封装,性能好但跨平台打包麻烦,且Java层对底层控制力弱。我们选JAIN-SIP,理由非常务实:它是Java世界里唯一一个严格遵循JSR 32 and JSR 289规范、完整覆盖RFC 3261状态机语义的协议栈。什么意思?举个具体例子:当服务器返回401 Unauthorized时,JAIN-SIP的ClientTransaction会自动触发createAuthenticateRequest()方法,生成带Authorization头的新REGISTER请求,而不是让你自己去parse WWW-Authenticate头、base64 encode凭证、拼接header。再比如INVITE事务中,它内置了严格的Timer A/B/C/D/E/F/G/H逻辑——Timer A初始值为500ms,每次重传翻倍,直到Timer B(64*T1=32秒)超时则事务失败。这些细节,RFC里写得清清楚楚,但自己实现极易出错。JAIN-SIP把这些都封装好了,你只需要监听DialogStateListenerTransactionStateListener,在dialogStateChanged()里判断DialogState.CONFIRMED,就知道通话已建立。这种“协议语义级”的抽象,是自己造轮子无法比拟的。pom.xml里锁定javax.sip:jain-sip-ri:1.2.307,是因为这个版本修复了早期版本在NAT环境下Via头端口解析的bug,且与主流SIP服务器(Asterisk, Kamailio, FreeSWITCH)兼容性经过大规模验证。我们不做版本尝鲜,只选经过战场检验的稳定版。

2.3 命令行接口的精巧设计:如何用最少参数覆盖最多测试场景

命令行不是偷懒,而是精准控制的入口。项目提供四个核心指令,每个都直击测试痛点:

  • --register <sip-uri>:例如--register sip:alice@192.168.1.100:5060。这里sip-uri必须包含scheme(sip:)、user(alice)、host(192.168.1.100)和port(5060)。为什么强制带端口?因为很多初学者会写sip:alice@192.168.1.100,结果JAIN-SIP默认走5060,而服务器实际监听5070,导致连接拒绝。强制显式声明,逼你确认网络可达性。
  • --invite <sip-uri>:例如--invite sip:bob@10.0.0.5:5060。注意,这是目标URI,不是本机账号。发起呼叫前,程序会自动检查本地是否已完成注册(即Dialog是否处于EARLYCONFIRMED状态),未注册则报错退出,避免无效呼叫。
  • --debug:开启JAIN-SIP内部日志,输出每一行原始SIP消息(包括CRLF换行符),这是抓包分析的黄金开关。你会发现,一个简单的REGISTER请求,JAIN-SIP会自动补全Max-Forwards: 70User-Agent: MotoSipClient/1.0Allow: INVITE, ACK, CANCEL, BYE, REGISTER等必填头,省去你查RFC的功夫。
  • --config <file>:加载外部properties文件,用于批量测试。比如写一个test-cases.properties,里面定义case1.register=sip:alice@192.168.1.100:5060case1.invite=sip:bob@192.168.1.100:5060,然后java -jar motosip.jar --config test-cases.properties,程序会顺序执行所有case,非常适合回归测试。

这种设计,把复杂性锁死在代码层,把确定性交给使用者。你不需要理解SDP协商细节,就能看到INVITE发出后,服务器返回180 Ringing,再返回200 OK,最后你发送ACK——整个过程在控制台逐行打印,像看剧本一样清晰。

3. 核心细节解析与实操要点:从注册到呼叫,每一步都在做什么

3.1 SIP注册流程的深度拆解:不只是发个REGISTER那么简单

注册远不止构造一条REGISTER消息。它是一个典型的“挑战-响应”认证事务,涉及至少三次往返。我们来看src/main/java/com/moto/sip/RegistrationManager.java里的关键逻辑:

首先,创建SipProvider(网络传输层):

ListeningPoint lp = sipStack.createListeningPoint("127.0.0.1", 5080, "udp");
SipProvider sipProvider = sipStack.createSipProvider(lp);

这里指定了本机监听地址和端口。为什么是5080而不是5060?因为5060常被其他SIP服务占用,5080是约定俗成的调试端口,避免冲突。ListeningPoint绑定UDP端口,JAIN-SIP会自动处理socket的bind、recv、send。

接着,构造REGISTER请求:

SipURI requestURI = addressFactory.createSipURI("registrar", "192.168.1.100:5060");
Request registerRequest = messageFactory.createRequest(
    requestURI, 
    Request.REGISTER, 
    callIdHeader, 
    cSeqHeader, 
    fromHeader, 
    toHeader, 
    viaHeaders, 
    maxForwardsHeader
);

注意requestURI不是你的账号URI,而是SIP服务器的地址(registrar)。你的账号信息放在FromTo头里:

SipURI fromUri = addressFactory.createSipURI("alice", "192.168.1.100:5060");
FromHeader fromHeader = headerFactory.createFromHeader(
    addressFactory.createAddress(fromUri), 
    "Alice"
);
ToHeader toHeader = headerFactory.createToHeader(
    addressFactory.createAddress(fromUri), 
    null // tag由服务器生成
);

FromTo用同一个URI,这是注册的规范做法。Call-IDCSeq由JAIN-SIP自动生成并递增,确保事务唯一性。

最关键的认证环节:当收到401响应时,JAIN-SIP不会自动重发。你需要监听TransactionStateListener

public void transactionTerminated(TransactionStateEvent evt) {
    if (evt.getTransaction().getMethod().equals(Request.REGISTER)) {
        Response response = evt.getResponse();
        if (response != null && response.getStatusCode() == Response.UNAUTHORIZED) {
            // 提取WWW-Authenticate头
            WWWAuthenticateHeader wwwAuth = (WWWAuthenticateHeader) response.getHeader("WWW-Authenticate");
            // 构造新的REGISTER请求,带Authorization头
            Request authRequest = ((ClientTransaction) evt.getTransaction()).createAuthenticateRequest(wwwAuth);
            // 发送认证请求
            clientTx.sendRequest();
        }
    }
}

这段代码展示了JAIN-SIP的“半自动”特性:它帮你解析WWW-Authenticate,生成Authorization头,但发送动作仍需你触发。这是为了给你留出干预空间——比如你想测试错误的密码,就可以在这里篡改authRequest的credential字段。

注册成功的标志是收到200 OK响应,此时Dialog.getState()应为DialogState.TERMINATED(注册事务结束),但更重要的是检查响应体中的Contact头是否包含正确的expires值,以及SIP/2.0 200 OK之后是否有Allow-Events: presence等扩展头,这关系到后续订阅功能是否可用。

3.2 INVITE呼叫的建立与媒体协商:为什么它不处理音频,却比软电话更可靠

INVITE流程比REGISTER更复杂,因为它要建立双向媒体通道。但本项目刻意剥离了音视频处理,只聚焦信令层。src/main/java/com/moto/sip/CallManager.java的核心在于SDP Offer/Answer模型的严格实现

当你执行--invite sip:bob@10.0.0.5:5060时,程序做的第一件事不是发INVITE,而是生成一个符合RFC 4566的SDP Offer:

v=0
o=- 1234567890 1234567890 IN IP4 127.0.0.1
s=-
c=IN IP4 127.0.0.1
t=0 0
m=audio 5004 RTP/AVP 0 8 101
a=rtpmap:0 PCMU/8000
a=rtpmap:8 PCMA/8000
a=rtpmap:101 telephone-event/8000
a=fmtp:101 0-15
a=sendrecv

这个SDP的关键点在于:
- m=audio 5004:声明本机将从5004端口接收RTP音频。注意,这是端口号,不是端口范围。很多初学者误以为要开一个端口范围,其实SIP信令只协商单端口,RTP/RTCP共用该端口(RTCP在端口+1)。
- a=sendrecv:表明本端支持双向语音,这是建立通话的前提。如果写成a=sendonly,对方可能拒绝。
- 编解码器列表(PCMU, PCMA, telephone-event)是业界通用的G.711系列,兼容性最好,避免因编解码不匹配导致的488 Not Acceptable Here错误。

INVITE请求体就是这个SDP字符串。发送后,等待服务器响应。典型流程是:
1. 100 Trying:事务已接收,正在处理;
2. 180 Ringing:被叫方开始振铃;
3. 200 OK:被叫接受,响应体中包含SDP Answer,描述对方的媒体能力;
4. 你必须立即发送ACK,ACK的body必须是空的(RFC 3261 13.3.1.4明确要求),但ACK必须携带与INVITE相同的Call-IDFromToCSeq,且CSeq方法名必须是ACK

CallManager里有一个精妙的状态机:

private enum CallState { IDLE, TRYING, RINGING, CONNECTED, DISCONNECTED }

每当收到响应,就更新状态并触发对应动作。例如收到180时,状态变RINGING,打印“对方正在振铃…”;收到200 OK时,解析SDP Answer,提取对方的IP和端口,准备RTP接收(虽然本项目不真收,但日志会打印Remote media: 10.0.0.5:5004);然后自动发送ACK。这个ACK不是简单的复制粘贴,而是由JAIN-SIP的Dialog.createAck()方法生成,确保所有头字段(尤其是RouteRecord-Route)完全合规。很多自研客户端在这里出错,导致服务器认为ACK不匹配而丢弃,通话无法建立。

提示:如果你在测试中发现INVITE发出去后一直卡在100 Trying,大概率是防火墙阻止了UDP 5060端口入站,或者服务器配置了always_auth强制注册,而你没先执行--register

3.3 Maven依赖与构建脚本的实战考量:mvnw为何比全局Maven更可靠

pom.xml里的依赖看似简单,但每一项都有深意:

<dependency>
    <groupId>javax.sip</groupId>
    <artifactId>jain-sip-ri</artifactId>
    <version>1.2.307</version>
</dependency>
<dependency>
    <groupId>log4j</groupId>
    <artifactId>log4j</artifactId>
    <version>1.2.17</version>
</dependency>
<dependency>
    <groupId>junit</groupId>
    <artifactId>junit</artifactId>
    <version>4.13.2</version>
    <scope>test</scope>
</dependency>

jain-sip-ri是核心,log4j用于结构化日志(方便grep),junit仅用于单元测试。没有Spring、没有Netty、没有Jackson——因为不需要。mvnw(Maven Wrapper)的存在,是为了消灭“在我机器上能跑,在你机器上不行”的经典魔咒。它把Maven二进制包(apache-maven-3.8.6-bin.zip)的SHA-256哈希值固化在.mvn/wrapper/maven-wrapper.properties里:

distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.8.6/apache-maven-3.8.6-bin.zip
distributionSha256Sum=8e5c5f3d...

执行./mvnw clean package时,脚本会自动下载指定版本的Maven,解压到.mvn/wrapper目录,然后用它来构建项目。这意味着,无论你的系统预装的是Maven 3.0还是3.9,构建环境都是100%一致的。Windows用户用mvnw.cmd,Linux/macOS用mvnw,脚本自动适配。这比要求所有人升级到某个Maven版本靠谱得多。构建产物target/motosip-1.0-jar-with-dependencies.jar是一个fat jar,所有依赖(包括jain-sip-ri)都打包进去,java -jar即可运行,无需设置CLASSPATH。

4. 实操过程与核心环节实现:手把手带你跑通第一个呼叫

4.1 环境准备与IDE导入:三分钟完成调试环境搭建

第一步,确认JDK版本。项目要求JDK 8u202或更高(因JAIN-SIP 1.2.307使用了Java 8的某些API)。在终端输入:

java -version
# 应输出类似:openjdk version "1.8.0_362"

如果版本过低,去Adoptium官网下载Temurin JDK 8。

第二步,克隆代码并导入IntelliJ:

git clone https://github.com/xxx/PdahCCFT8jbyKgOqee25-master-f0032ba591eafb8bb59d4f9d02262c33a5b70d7b.git
cd PdahCCFT8jbyKgOqee25-master-f0032ba591eafb8bb59d4f9d02262c33a5b70d7b
# 此时目录下已有MotoSipClient.iml和.idea/目录

打开IntelliJ,选择File > Open,定位到该目录,勾选Auto-import。IDE会自动识别为Maven项目,加载pom.xml,下载依赖。等待右下角“Importing project”消失,表示完成。此时,src/main/java下的包结构已展开,com.moto.sip.SipClientApp是主类。

第三步,配置运行参数。右键SipClientApp.java,选择Run 'SipClientApp.main()',会弹出配置窗口。在Program arguments框里输入:

--register sip:alice@192.168.1.100:5060 --debug

注意:192.168.1.100:5060替换成你实际的SIP服务器地址。如果是本地Asterisk,通常是127.0.0.1:5060;如果是Kamailio,可能是192.168.1.100:5060。确保该地址能从你的机器ping通。

点击Run,控制台开始滚动日志。你会看到:

[INFO] Creating SipStack with configuration...
[INFO] Created ListeningPoint on /127.0.0.1:5080/udp
[INFO] Sending REGISTER to sip:192.168.1.100:5060
[DEBUG] >>> REGISTER sip:192.168.1.100:5060 SIP/2.0
Via: SIP/2.0/UDP 127.0.0.1:5080;branch=z9hG4bK-...
From: <sip:alice@192.168.1.100:5060>;tag=...
To: <sip:alice@192.168.1.100:5060>
Call-ID: abcdef1234567890@127.0.0.1
CSeq: 1 REGISTER
...

这就是原始REGISTER消息。稍等几秒,如果服务器正常,会看到:

[DEBUG] <<< SIP/2.0 401 Unauthorized
WWW-Authenticate: Digest realm="asterisk", nonce="...", opaque="..."
[INFO] Received 401, generating authenticated REGISTER...
[DEBUG] >>> REGISTER sip:192.168.1.100:5060 SIP/2.0
Authorization: Digest username="alice", realm="asterisk", ...
...
[DEBUG] <<< SIP/2.0 200 OK
Contact: <sip:alice@127.0.0.1:5080>;expires=3600
[INFO] Registration successful! Expires in 3600 seconds.

注册成功!此时,你的账号已在服务器上线。接下来,你可以发起呼叫。

4.2 发起首次INVITE呼叫:从命令行到通话建立的完整链路

保持刚才的IDE窗口,或者新开一个终端,进入项目根目录,执行:

java -jar target/motosip-1.0-jar-with-dependencies.jar \
  --invite sip:bob@192.168.1.100:5060 \
  --debug

(同样,替换192.168.1.100为你的服务器地址)

控制台会先打印SDP Offer,然后发送INVITE:

[INFO] Generating SDP Offer for audio...
v=0
o=- 1234567890 1234567890 IN IP4 127.0.0.1
s=-
c=IN IP4 127.0.0.1
t=0 0
m=audio 5004 RTP/AVP 0 8 101
a=rtpmap:0 PCMU/8000
a=rtpmap:8 PCMA/8000
a=rtpmap:101 telephone-event/8000
a=fmtp:101 0-15
a=sendrecv
[INFO] Sending INVITE to sip:bob@192.168.1.100:5060
[DEBUG] >>> INVITE sip:bob@192.168.1.100:5060 SIP/2.0
...
Content-Type: application/sdp
Content-Length: 282

v=0
o=- 1234567890 1234567890 IN IP4 127.0.0.1
...

紧接着,服务器响应开始流入:

[DEBUG] <<< SIP/2.0 100 Trying
[DEBUG] <<< SIP/2.0 180 Ringing
[INFO] Remote party is ringing...
[DEBUG] <<< SIP/2.0 200 OK
Via: SIP/2.0/UDP 127.0.0.1:5080;received=192.168.1.50;branch=z9hG4bK-...
From: <sip:alice@192.168.1.100:5060>;tag=...
To: <sip:bob@192.168.1.100:5060>;tag=as12345678
Call-ID: abcdef1234567890@127.0.0.1
CSeq: 1 INVITE
Contact: <sip:bob@192.168.1.50:5060>
Content-Type: application/sdp
Content-Length: 278

v=0
o=root 1234567890 1234567890 IN IP4 192.168.1.50
s=session
c=IN IP4 192.168.1.50
t=0 0
m=audio 5004 RTP/AVP 0 8
a=rtpmap:0 PCMU/8000
a=rtpmap:8 PCMA/8000
a=sendrecv
[INFO] Received 200 OK, sending ACK...
[DEBUG] >>> ACK sip:bob@192.168.1.100:5060 SIP/2.0
...
[INFO] Call established! Dialog state: CONFIRMED

看到最后一行Call established! Dialog state: CONFIRMED,恭喜,信令层通话已建立。此时,如果你的SIP服务器后端连着真实的电话(如模拟线路或手机网关),你应该能听到对方的振铃声,甚至接通后听到语音。即使没有真实终端,这个日志也证明你的INVITE事务完全合规——从Offer生成、发送、100/180/200流转、到ACK发送,每一步都经受住了服务器的校验。

注意:如果卡在100 Trying,请检查服务器日志,常见原因是allowguest=no且未注册,或防火墙拦截了UDP 5060端口。如果收到488 Not Acceptable Here,说明SDP Offer中的编解码器不被对方支持,此时可修改CallManager.java里的createSdpOffer()方法,删减编解码器列表,只保留0 PCMU/8000

4.3 高级技巧:用配置文件批量执行注册与呼叫测试

对于回归测试,手动敲命令太慢。项目支持--config参数加载properties文件。创建一个test-plan.properties

# 测试用例1:基础注册与呼叫
case1.name=Basic Registration and Call
case1.register=sip:alice@192.168.1.100:5060
case1.invite=sip:bob@192.168.1.100:5060
case1.timeout=60000

# 测试用例2:带密码的注册(如果服务器要求)
case2.name=Registration with Auth
case2.register=sip:charlie@192.168.1.100:5060
case2.auth.username=charlie
case2.auth.password=secret123
case2.invite=sip:david@192.168.1.100:5060

# 测试用例3:长时间保活(测试Expires机制)
case3.name=Long-lived Registration
case3.register=sip:eve@192.168.1.100:5060
case3.expires=7200

然后执行:

java -jar target/motosip-1.0-jar-with-dependencies.jar --config test-plan.properties

程序会依次执行每个case,记录开始时间、结束时间、是否成功,并在最后汇总报告:

[INFO] Test Plan Execution Summary:
[INFO] Case 1 (Basic Registration and Call): PASSED (2345ms)
[INFO] Case 2 (Registration with Auth): PASSED (3120ms)
[INFO] Case 3 (Long-lived Registration): PASSED (1890ms)
[INFO] Total: 3/3 PASSED

这个机制,让我们能把日常的SIP服务器冒烟测试,变成一个可重复、可追踪、可集成到CI流水线(如Jenkins)的标准化步骤。再也不用手动截图、记日志、比对时间戳。

5. 常见问题与排查技巧实录:那些只有踩过才知道的坑

5.1 注册失败的五大高频原因及速查表

现象 可能原因 排查命令/步骤 解决方案
REGISTER直接超时,无任何响应 本地防火墙阻止UDP 5080端口入站 sudo ufw status (Ubuntu) 或 netsh advfirewall show allprofiles (Win) 开放UDP 5080端口,或改用--local-port 5060(需确保5060空闲)
收到403 Forbidden SIP服务器配置了ACL,禁止该IP注册 查看服务器日志,搜索aclpermit关键字 在Asterisk的sip.conf中添加permit=192.168.1.0/255.255.255.0;在Kamailio的kamctl中执行opensipsctl ul add alice 127.0.0.1 5080
收到401但重传后仍是401 密码错误,或realm不匹配 启用--debug,检查WWW-Authenticate头中的realm="xxx",对比From头中的domain 确保From URI的domain部分(@xxx)与realm完全一致;密码区分大小写
收到200 OK但Contact头中expires=0 服务器配置了defaultexpire=0或账号被禁用 grep -i "expires" /var/log/asterisk/messages 在Asterisk中检查sip show peers,确认alice状态为OK;在FreeSWITCH中执行sofia status profile internal reg
注册成功但INVITE被拒绝 服务器要求Require: timer但客户端未支持 --debug查看INVITE请求头,确认是否有Require: timer 修改SipClientApp.java,在创建INVITE前添加requireHeader = headerFactory.createRequireHeader("timer"); inviteRequest.addHeader(requireHeader);

5.2 INVITE呼叫不通的深度诊断路径

--invite执行后,控制台只显示Sending INVITE...然后静默,问题一定出在网络或服务器配置。我的标准诊断路径如下:

第一步:确认网络层可达

# 测试UDP端口是否开放(服务器侧执行)
nc -uvz 192.168.1.100 5060
# 如果返回"Connection refused",说明SIP服务未启动或监听地址不对
# 如果超时,说明防火墙拦截

第二步:抓包确认消息是否发出
在客户端机器上,用tcpdump抓5080端口:

sudo tcpdump -i any -n -A udp port 5080
# 执行java -jar ... --invite ...
# 观察输出中是否有"INVITE sip:bob@..."字样

如果没看到,说明Java程序根本没发出去,检查ListeningPoint绑定的IP是否正确(不能是0.0.0.0,必须是本机真实IP)。

第三步:服务器侧抓包确认是否收到
在服务器上抓5060端口:

sudo tcpdump -i any -n -A udp port 5060
# 执行客户端命令
# 如果看到INVITE,但服务器没回100,说明SIP进程崩溃或配置错误
# 如果根本看不到INVITE,说明网络路由有问题,或客户端发到了错误IP

第四步:检查SDP Offer的致命错误
最常见的SDP错误是c=IN IP4 0.0.0.0。这通常是因为程序获取本机IP时失败,fallback到了0.0.0.0。解决方案是在CallManager.createSdpOffer()中硬编码:

// 替换原来的 getLocalHost().getHostAddress()
String localIp = "192.168.1.50"; // 改为你机器的真实内网IP
sdpBuilder.append("c=IN IP4 ").append(localIp).append("\r\n");

否则,服务器收到c=IN IP4 0.0.0.0,会认为媒体不可达,直接返回488。

5.3 JAIN-SIP特有的“幽灵”问题与规避策略

JAIN-SIP有一些行为,文档里不提,但实践中必须应对:

  • 内存泄漏风险SipStack对象一旦创建,就不能轻易destroy(),否则会导致后续SipProvider创建失败。我们的做法是:整个应用生命周期只创建一个SipStack,用单例模式管理,在SipClientApp.shutdown()里才调用stack.destroy()。测试时频繁重启应用,务必确保前一次的stack.destroy()已执行。

  • 多线程安全陷阱SipProvider.sendRequest()是线程安全的,但Dialog.sendRequest()不是。如果你在多个线程里并发发起INVITE,必须对Dialog对象加锁,或为每个呼叫创建独立的Dialog。我们在CallManager里用ConcurrentHashMap<String, Dialog>缓存,key为Call-ID,确保每个呼叫独占一个Dialog。

  • DNS解析阻塞:JAIN-SIP默认会尝试解析SIP URI中的域名。如果/etc/resolv.conf配置了缓慢的DNS,会导致REGISTER卡住。解决方案是在SipStack配置中禁用DNS:

Properties props = new Properties();
props.setProperty("javax.sip.STACK_NAME", "MotoSipClient");
props.setProperty("gov.nist.javax.sip.DEBUG_LOG", "logs/debug.txt");
props.setProperty("gov.nist.javax.sip.TRACE_LOG", "logs/trace.txt");
props.setProperty("javax.sip.AUTOMATIC_DIALOG_SUPPORT", "off"); // 关键!禁用自动dialog管理
props.setProperty("gov.nist.javax.sip.DNS_TIMEOUT", "500"); // DNS超时设为500ms
props.setProperty("gov.nist.javax.sip.USE_ROUTER_FOR_ALL_URIS", "false");
SipStack sipStack = SipFactory.getInstance().createSipStack(props);
  • 日志爆炸问题:启用--debug后,日志量极大,磁盘可能撑爆。我们在log4j.properties里设置了滚动策略:
log4j.appender.file=org.apache.log4j.RollingFileAppender
log4j.appender.file.File=logs/sip-client.log
log4j.appender.file.MaxFileSize=10MB
log4j.appender.file.MaxBackupIndex=5

这样,日志文件最大10MB,超过后自动归档为sip-client.log.1sip-client.log.2,最多保留5个。

6. 实战延伸与二次开发指南:让它成为你系统的通信引擎

6.1 如何将它嵌入到你的Spring Boot后台服务

很多IoT平台需要向设备推送语音告警。这时,你不需要一个独立的SIP客户端,而是需要一个能被Spring Bean调用的SIP通信模块。改造步骤如下:

  1. motosip项目打成motosip-core模块,只保留src/main/java/com/moto/sip/下的核心类,移除SipClientApp主类。
  2. 在你的Spring Boot项目pom.xml中添加依赖:
<dependency>
    <groupId>com.moto</groupId>
    <artifactId>motosip-core</artifactId>
    <version>1.0</version>
</dependency>
  1. 创建一个SipService组件:
@Component
public class SipService {
    private final RegistrationManager registrationManager;
    private final CallManager callManager;

    public SipService(RegistrationManager registrationManager, CallManager callManager) {
        this.registrationManager = registrationManager;
        this.callManager = callManager;
    }

    @Async // 异步执行,避免阻塞HTTP请求
    public CompletableFuture<Void> makeEmergencyCall(String targetUri) {
        try {
            // 先确保已注册
            if (!registrationManager.isRegistered()) {
                registrationManager.register("sip:alarm@192.168.1.100:5060");
            }
            // 发起呼叫
            callManager.invite(targetUri);
            return CompletableFuture.completedFuture(null);
        } catch (Exception e) {
            log.error("Failed to make emergency call to {}", targetUri, e);
            return CompletableFuture.failedFuture(e);
        }
    }
}
  1. 在Controller中调用:
@RestController
public class AlarmController {
    private final SipService sipService;

    public AlarmController(SipService sipService) {
        this.sipService = sipService;
    }

    @PostMapping("/alarm/call")
    public ResponseEntity<String> triggerAlarm(@RequestBody AlarmRequest request) {
        sipService.makeEmergencyCall("sip:security@192.168.1.200:5060")
            .whenComplete((v, t) -> {
                if (t != null) {
                    log.warn("Alarm call failed", t);
                }
            });
        return ResponseEntity.accepted().body("Alarm call triggered");
    }
}

这样,你的Spring Boot服务就拥有了SIP呼叫能力,且完全融入Spring的生命周期管理(自动注入、异步、事务)。

6.2 协议学习者的进阶玩法:用它验证RFC的每一个字节

对学生和协议研究者,这个工具的价值在于“可调试性”。比如,想验证RFC 3261 18.2.2节关于Max-Forwards头的规定:初始值应为70,每经过一个proxy减1,为0则丢弃。你可以:

  1. SipClientApp.javacreateRegisterRequest()方法里,手动设置Max-Forwards为1:
MaxForwardsHeader maxForwards = headerFactory.createMaxForwardsHeader(1);
registerRequest.addHeader(maxForwards);
  1. 启动客户端,执行--register
  2. 在服务器端抓包,观察是否收到该REGISTER。按RFC,如果中间有proxy,它会把Max-Forwards减到0然后丢弃;如果没有proxy,服务器会收到并返回483 First Hop Lacks Max-Forwards Header(因为服务器自身也是hop)。

再比如,验证Record-Route头的插入规则:在SipStack配置中启用record-route

props.setProperty("gov.nist.javax.sip.RECORD_ROUTE_HEADER_ENABLED", "true");

然后发INVITE,观察响应中是否插入了Record-Route头。这些实验,让你把RFC文档从纸面概念,变成可触摸、可验证的代码行为。

6.3 性能压测的轻量级方案:用它模拟百路并发呼叫

虽然它不是专业压测工具,但通过简单改造,可以胜任中小规模压测。思路是:用ExecutorService启动多个线程,每个线程持有一个独立的SipProviderDialog

public class SipLoadTester {
    private final ExecutorService executor = Executors.newFixedThreadPool(100);

    public void startLoad(int concurrentCalls) {
        for (int i = 0; i < concurrentCalls; i++) {
            final int callId = i;
            executor.submit(() -> {
                try {
                    // 每个线程创建自己的SipStack(注意:资源消耗大,慎用)
                    SipStack stack = createNewSipStack();
                    RegistrationManager reg = new RegistrationManager(stack);
                    reg.register("sip:user" + callId + "@192.168.1.100:5060");
                    Thread.sleep(1000); // 等待注册完成
                    CallManager call = new CallManager(stack);
                    call.invite("sip:target@192.168.1.100:5060");
                    Thread.sleep(5000); // 保持通话5秒
                    call.hangup(); // 主动挂断
                } catch (Exception e) {
                    log.error("Call {} failed", callId, e);
                }
            });
        }
    }
}

启动100个线程,就能模拟100路并发注册+呼叫。配合服务器监控(如top, iftop),可以快速定位SIP服务器的瓶颈是CPU、内存还是网络IO。当然,真正的万级压测要用专门工具,但这个方案足够帮你发现配置级问题,比如Asterisk的maxcalls限制或Kamailio的tcp_children不足。

我个人在实际使用中发现,这个工具最大的价值,不是它能做什么,而是它强迫你直面SIP协议的本来面目——没有UI遮掩,没有音视频混淆,只有纯粹的消息、状态机和网络。当你第一次看到控制台里那行SIP/2.0 200 OK时,那种对协议掌控感的喜悦,是任何图形化工具都无法替代的。它不是一个终点,而是一把钥匙,帮你打开VoIP世界的大门。

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:开箱即用的轻量级SIP通信工具,用标准Java编写,底层完全依赖JAIN-SIP协议栈,严格遵循RFC 3261规范。支持完整的SIP用户注册流程,可向任意SIP服务器提交REGISTER请求并完成认证;提供简洁的命令行接口,输入目标SIP URI即可发起INVITE呼叫,建立端到端语音会话。项目结构清晰,核心逻辑集中在src目录,所有SIP消息收发、事务管理、状态机跳转均由JAIN-SIP API驱动,不依赖图形界面或第三方音视频库。通过Maven统一管理依赖(pom.xml),内置mvnw脚本,Windows和Linux下均可一键构建运行。已预配置IntelliJ IDEA工程文件(如MotoSipClient.iml、.idea/等),导入即调,适合快速验证SIP信令流程、调试服务器交互、开展协议学习或嵌入自有系统做底层通信模块。适用于SIP服务端开发辅助、VoIP功能测试、通信协议教学及轻量级呼叫自动化场景。


本文还有配套的精品资源,点击获取
menu-r.4af5f7ec.gif

更多推荐