GB28181协议基础理解

本文深入解析GB28181国家标准协议,从协议架构、SIP协议应用、SDP会话描述、RTP传输机制到设备注册全流程,帮助你彻底理解视频监控设备接入的底层原理。
目录
- [1. 为什么需要GB28181协议?](#1. 为什么需要GB28181协议?)
- [2. GB28181协议架构与分层](#2. GB28181协议架构与分层)
- [2.1 协议整体架构](#2.1 协议整体架构)
- [2.2 协议分层模型](#2.2 协议分层模型)
- [2.3 核心组件关系](#2.3 核心组件关系)
- [3. SIP协议在GB28181中的应用](#3. SIP协议在GB28181中的应用)
- [3.1 什么是SIP协议](#3.1 什么是SIP协议)
- [3.2 SIP消息结构](#3.2 SIP消息结构)
- [3.3 SIP在GB28181中的作用](#3.3 SIP在GB28181中的作用)
- [3.4 实现SIP服务端](#3.4 实现SIP服务端)
- [4. SDP会话描述协议解析](#4. SDP会话描述协议解析)
- [4.1 SDP协议概述](#4.1 SDP协议概述)
- [4.2 SDP字段详解](#4.2 SDP字段详解)
- [4.3 SDP解析实现](#4.3 SDP解析实现)
- [5. RTP/RTCP传输协议机制](#5. RTP/RTCP传输协议机制)
- [5.1 RTP协议原理](#5.1 RTP协议原理)
- [5.2 RTCP控制协议](#5.2 RTCP控制协议)
- [5.3 RTP接收器实现](#5.3 RTP接收器实现)
- [6. GB28181设备注册流程](#6. GB28181设备注册流程)
- [6.1 注册时序图](#6.1 注册时序图)
- [6.2 注册认证机制](#6.2 注册认证机制)
- [6.3 注册流程实现](#6.3 注册流程实现)
- [7. 常见坑点与最佳实践](#7. 常见坑点与最佳实践)
- [8. 面试高频题](#8. 面试高频题)
1. 为什么需要GB28181协议?
业务痛点
在智慧城市、平安城市等大规模视频监控项目中,经常会遇到这些问题:
多厂商设备接入难题:
- 海康、大华、宇视等不同厂商的设备协议各不相同
- 每个厂商都需要单独对接SDK,开发成本极高
- SDK通常是C/C++开发,跨平台困难,Web端几乎无法使用
- 厂商SDK版本升级频繁,维护成本巨大
实际案例:
某城市项目需要接入15个厂家的10000+摄像头,如果每个厂家单独对接:
15个厂家 × 平均30人/天开发 × 平均8000元/天 = 360万元
后期维护:15个厂家 × 2人/厂家 × 20000元/月 × 12月 = 720万元/年
使用GB28181统一接入:
统一协议开发:5人 × 60天 × 8000元 = 240万元
后期维护:2人 × 20000元/月 × 12月 = 48万元/年
节省成本:(360 + 720) - (240 + 48) = 792万元
GB28181协议的价值
GB28181(全称:公共安全视频监控联网系统信息传输、交换、控制技术要求)是国家强制标准,主要解决:
-
统一设备接入标准
- 所有支持GB28181的设备,使用相同的协议
- 一套代码对接所有厂家设备
- 大幅降低开发和维护成本
-
跨平台兼容性
- 基于标准网络协议(SIP/RTP/RTSP)
- 支持Java、Python、Go等多种语言实现
- Web端可通过WebRTC或HLS播放
-
扩展性强
- 支持设备注册、心跳保活、目录查询
- 支持实时视频、历史回放、云台控制
- 支持级联(上下级平台互联)
-
国家强制要求
- 公安、交通等行业强制使用
- 政府项目必须支持GB28181
2. GB28181协议架构与分层
2.1 协议整体架构
GB28181协议采用分层架构,各层职责明确:
┌──────────────────────────────────────────────────────────┐
│ 应用层 │
│ (目录查询、实时视频、历史回放、云台控制、报警订阅) │
└──────────────────────────────────────────────────────────┘
↓
┌──────────────────────────────────────────────────────────┐
│ 信令控制层 │
│ SIP协议 (设备注册、心跳保活、会话建立、状态查询) │
└──────────────────────────────────────────────────────────┘
↓
┌──────────────────────────────────────────────────────────┐
│ 会话描述层 │
│ SDP协议 (媒体参数协商、编码格式、传输地址) │
└──────────────────────────────────────────────────────────┘
↓
┌──────────────────────────────────────────────────────────┐
│ 媒体传输层 │
│ RTP/RTCP协议 (音视频数据传输、质量反馈) │
└──────────────────────────────────────────────────────────┘
↓
┌──────────────────────────────────────────────────────────┐
│ 网络层 │
│ TCP/UDP协议 │
└──────────────────────────────────────────────────────────┘
为什么要分层?
- 职责分离:每一层只关注自己的事情,信令归信令,媒体归媒体
- 可扩展性:想换传输协议(比如用RTMP),只改媒体层,不影响信令层
- 易维护:出了问题能快速定位是哪一层的问题
2.2 协议分层模型
第一层:信令控制层(SIP协议)
SIP(Session Initiation Protocol)会话发起协议,负责建立、修改、终止会话。
核心功能:
- 设备注册(REGISTER)
- 会话邀请(INVITE)
- 会话确认(ACK)
- 会话结束(BYE)
- 消息传递(MESSAGE)
为什么选择SIP而不是自定义协议?
- SIP是成熟的互联网标准(RFC 3261)
- 有大量开源库支持(JAIN-SIP、PjSIP、EXosip)
- 文本协议,易于调试和分析
- 支持NAT穿透、代理、认证等复杂场景
第二层:会话描述层(SDP协议)
SDP(Session Description Protocol)会话描述协议,用于描述会话的媒体参数。
核心作用:
- 描述媒体类型(音频/视频)
- 指定编码格式(H.264/H.265/AAC)
- 定义传输地址(IP和端口)
- 声明媒体属性(码率、分辨率)
为什么需要SDP?
- 设备能力千差万别,需要协商统一的媒体参数
- 动态分配端口,支持多路并发
- 双方确认参数后才开始传输,避免资源浪费
第三层:媒体传输层(RTP/RTCP协议)
RTP(Real-time Transport Protocol)实时传输协议,用于传输音视频数据。
RTP核心特性:
- 时间戳:用于音视频同步
- 序列号:检测丢包和乱序
- 载荷类型:标识编码格式
- 同步源:区分不同的媒体流
RTCP(RTP Control Protocol)控制协议:
- 监控传输质量(丢包率、抖动)
- 提供发送端反馈
- 用于会话控制和同步
为什么不直接传原始流?
- 需要时间戳同步
- 需要序列号检测丢包
- 需要标识载荷类型
- 需要质量反馈机制
2.3 核心组件关系
在实际项目中,GB28181的实现通常包含以下核心组件:
┌─────────────────────────────────────────────────────────────┐
│ 平台侧 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ SIP Server │◄────►│ 会话管理器 │ │
│ │ (信令处理) │ │ (Session Mgr) │ │
│ └──────────────┘ └──────────────┘ │
│ ↓ ↓ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ 消息处理器 │ │ 设备管理器 │ │
│ │ (Handler) │ │ (Device Mgr) │ │
│ └──────────────┘ └──────────────┘ │
│ ↓ ↓ │
│ ┌──────────────────────────────────┐ │
│ │ RTP接收器 │ │
│ │ (接收媒体流,转发到流媒体服务器) │ │
│ └──────────────────────────────────┘ │
│ ↓ │
│ ┌──────────────────────────────────┐ │
│ │ 流媒体服务器 │ │
│ │ (ZLMediaKit/SRS/Wowza) │ │
│ │ (转码、推流、HLS/FLV/WebRTC) │ │
│ └──────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
↑
SIP/RTP协议
↓
┌─────────────────────────────────────────────────────────────┐
│ 设备侧 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ SIP Client │ │ 媒体编码器 │ │
│ │ (设备端实现) │ │ (H.264/265) │ │
│ └──────────────┘ └──────────────┘ │
│ ↓ ↓ │
│ ┌──────────────────────────────────┐ │
│ │ RTP发送器 │ │
│ │ (发送媒体流) │ │
│ └──────────────────────────────────┘ │
│ ↓ │
│ ┌──────────────────────────────────┐ │
│ │ 摄像头硬件 │ │
│ │ (采集视频) │ │
│ └──────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
为什么需要这么多组件?
- SIP Server:处理信令,不关心媒体流
- 会话管理器:管理多路并发视频会话
- RTP接收器:专门处理媒体流,避免SIP Server性能瓶颈
- 流媒体服务器:负责转码、分发,让Web端能播放
3. SIP协议在GB28181中的应用
3.1 什么是SIP协议
SIP(Session Initiation Protocol)是一个应用层的信令协议,用于创建、修改和终止会话。
SIP的核心特点:
- 文本协议:类似HTTP,易于调试
- 请求-响应模型:Client发请求,Server回响应
- 无状态:每个请求都是独立的
- 可扩展:支持自定义头域和方法
SIP不负责媒体传输:SIP只管"打电话"(建立连接),不管"说什么"(传输内容)。
3.2 SIP消息结构
SIP请求消息格式:
REGISTER sip:10.10.10.10:5060 SIP/2.0 ← 请求行(方法 URI 版本)
Via: SIP/2.0/UDP 10.10.10.20:5060;branch=xxx ← Via头(路由信息)
From: <sip:34020000001320000001@10.10.10.20>;tag=xxx ← From头(发送方)
To: <sip:34020000001320000001@10.10.10.20> ← To头(接收方)
Call-ID: xxx ← Call-ID(会话标识)
CSeq: 1 REGISTER ← CSeq(序列号)
Contact: <sip:34020000001320000001@10.10.10.20:5060> ← Contact(联系地址)
Max-Forwards: 70 ← Max-Forwards(最大跳数)
Expires: 3600 ← Expires(过期时间)
Content-Length: 0 ← Content-Length(消息体长度)
← 空行分隔头部和消息体
← 消息体(可选)
SIP响应消息格式:
SIP/2.0 200 OK ← 状态行(版本 状态码 原因短语)
Via: SIP/2.0/UDP 10.10.10.20:5060;branch=xxx ← Via头(原样返回)
From: <sip:34020000001320000001@10.10.10.20>;tag=xxx ← From头(原样返回)
To: <sip:34020000001320000001@10.10.10.20>;tag=yyy ← To头(添加tag)
Call-ID: xxx ← Call-ID(原样返回)
CSeq: 1 REGISTER ← CSeq(原样返回)
Contact: <sip:34020000001320000001@10.10.10.20:5060>
Expires: 3600
Content-Length: 0
关键字段说明:
| 字段 | 作用 | 示例 |
|---|---|---|
| Via | 记录消息路径,响应时原路返回 | SIP/2.0/UDP 10.10.10.20:5060 |
| From | 发起者地址,tag用于标识会话 | <sip:user@domain>;tag=xxx |
| To | 接收者地址 | <sip:user@domain> |
| Call-ID | 全局唯一的会话标识 | xxx@10.10.10.20 |
| CSeq | 序列号+方法名,检测重传和乱序 | 1 REGISTER |
| Contact | 实际联系地址,可能与From不同 | <sip:user@ip:port> |
3.3 SIP在GB28181中的作用
GB28181使用SIP协议完成以下功能:
-
设备注册(REGISTER)
- 设备向平台注册,建立通信通道
- 平台返回认证挑战,设备携带凭证重新注册
- 注册成功后,设备进入在线状态
-
心跳保活(MESSAGE)
- 设备定期发送心跳消息,证明在线
- 平台检测心跳超时,标记设备离线
-
视频邀请(INVITE)
- 平台向设备发起视频点播请求
- 设备返回SDP,告知媒体参数
- 双方建立RTP会话,开始传输视频
-
会话结束(BYE)
- 平台或设备主动结束视频会话
- 释放资源,停止传输
-
目录查询(MESSAGE)
- 平台向设备查询通道列表
- 设备返回XML格式的通道信息
3.4 实现SIP服务端
下面通过实际代码演示如何实现一个GB28181的SIP服务端。
Maven依赖:
xml
<dependency>
<groupId>javax.sip</groupId>
<artifactId>jain-sip-ri</artifactId>
<version>1.3.0-91</version>
</dependency>
SIP配置类:
java
package com.video.platform.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* SIP协议配置
*
* 配置SIP服务端的IP、端口、域、认证等参数
*/
@Data
@Component
@ConfigurationProperties(prefix = "sip.server")
public class SipConfig {
/**
* SIP服务端IP地址
* 注意:需要配置为服务器的实际IP,不能用127.0.0.1
*/
private String ip = "10.10.10.10";
/**
* SIP服务端端口
* 默认5060,可根据实际情况调整
*/
private Integer port = 5060;
/**
* SIP域
* 通常设置为服务器IP或域名
*/
private String domain = "10.10.10.10";
/**
* SIP服务端ID
* 20位国标编码:中心编码(8位)+ 行业编码(2位)+ 类型编码(3位)+ 网络标识(7位)
*/
private String serverId = "34020000002000000001";
/**
* 传输协议:UDP/TCP
* GB28181通常使用UDP
*/
private String transport = "UDP";
/**
* 认证用户名
* 设备注册时使用
*/
private String username = "admin";
/**
* 认证密码
* 设备注册时使用
*/
private String password = "123456";
/**
* 心跳周期(秒)
* 设备需要在此周期内发送心跳
*/
private Integer keepaliveInterval = 60;
/**
* 心跳超时时间(秒)
* 超过此时间未收到心跳,标记设备离线
*/
private Integer keepaliveTimeout = 180;
/**
* 注册有效期(秒)
* 设备注册后的有效时间
*/
private Integer registerExpires = 3600;
}
SIP服务端启动类:
java
package com.video.platform.sip;
import gov.nist.javax.sip.SipStackExt;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import javax.sip.*;
import javax.sip.address.AddressFactory;
import javax.sip.header.HeaderFactory;
import javax.sip.message.MessageFactory;
import java.util.Properties;
/**
* SIP服务端启动器
*
* 基于JAIN-SIP实现GB28181的SIP服务端
* 负责监听SIP消息,分发给对应的处理器
*/
@Slf4j
@Component
public class SipServer implements CommandLineRunner {
@Autowired
private SipConfig sipConfig;
@Autowired
private SipMessageProcessor sipMessageProcessor;
private SipStack sipStack;
private SipProvider sipProvider;
private AddressFactory addressFactory;
private HeaderFactory headerFactory;
private MessageFactory messageFactory;
@Override
public void run(String... args) throws Exception {
log.info("======== SIP服务端启动 ========");
log.info("SIP服务端配置:");
log.info(" - IP: {}", sipConfig.getIp());
log.info(" - 端口: {}", sipConfig.getPort());
log.info(" - 域: {}", sipConfig.getDomain());
log.info(" - 服务端ID: {}", sipConfig.getServerId());
log.info(" - 传输协议: {}", sipConfig.getTransport());
// 创建SIP工厂
SipFactory sipFactory = SipFactory.getInstance();
sipFactory.setPathName("gov.nist");
// 配置SIP Stack
Properties properties = new Properties();
properties.setProperty("javax.sip.STACK_NAME", "GB28181-Platform");
properties.setProperty("javax.sip.IP_ADDRESS", sipConfig.getIp());
// 调试模式(生产环境关闭)
properties.setProperty("gov.nist.javax.sip.TRACE_LEVEL", "32");
properties.setProperty("gov.nist.javax.sip.SERVER_LOG", "sip_server.log");
properties.setProperty("gov.nist.javax.sip.DEBUG_LOG", "sip_debug.log");
// 创建SIP Stack
sipStack = sipFactory.createSipStack(properties);
// 创建工厂
headerFactory = sipFactory.createHeaderFactory();
addressFactory = sipFactory.createAddressFactory();
messageFactory = sipFactory.createMessageFactory();
// 创建监听点(ListeningPoint)
ListeningPoint listeningPoint = sipStack.createListeningPoint(
sipConfig.getIp(),
sipConfig.getPort(),
sipConfig.getTransport()
);
// 创建SIP Provider
sipProvider = sipStack.createSipProvider(listeningPoint);
// 添加消息监听器
sipProvider.addSipListener(sipMessageProcessor);
// 启动SIP Stack
((SipStackExt) sipStack).start();
log.info("======== SIP服务端启动成功 ========");
}
/**
* 获取SipProvider
* 用于发送SIP消息
*/
public SipProvider getSipProvider() {
return sipProvider;
}
/**
* 获取HeaderFactory
* 用于创建SIP头部
*/
public HeaderFactory getHeaderFactory() {
return headerFactory;
}
/**
* 获取AddressFactory
* 用于创建SIP地址
*/
public AddressFactory getAddressFactory() {
return addressFactory;
}
/**
* 获取MessageFactory
* 用于创建SIP消息
*/
public MessageFactory getMessageFactory() {
return messageFactory;
}
/**
* 关闭SIP服务端
*/
public void shutdown() {
if (sipProvider != null) {
sipProvider.removeSipListener(sipMessageProcessor);
try {
sipStack.deleteSipProvider(sipProvider);
} catch (Exception e) {
log.error("删除SIP Provider失败", e);
}
}
if (sipStack != null) {
sipStack.stop();
}
log.info("SIP服务端已关闭");
}
}
SIP消息处理器:
java
package com.video.platform.sip;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import javax.sip.*;
import javax.sip.message.Request;
import javax.sip.message.Response;
/**
* SIP消息处理器
*
* 实现SipListener接口,处理各种SIP事件
*/
@Slf4j
@Component
public class SipMessageProcessor implements SipListener {
@Autowired
private SipRequestHandler sipRequestHandler;
/**
* 处理SIP请求
*/
@Override
public void processRequest(RequestEvent requestEvent) {
try {
Request request = requestEvent.getRequest();
String method = request.getMethod();
log.info("收到SIP请求:{}", method);
// 分发到对应的处理器
switch (method) {
case Request.REGISTER:
sipRequestHandler.handleRegister(requestEvent);
break;
case Request.MESSAGE:
sipRequestHandler.handleMessage(requestEvent);
break;
case Request.INVITE:
sipRequestHandler.handleInvite(requestEvent);
break;
case Request.ACK:
sipRequestHandler.handleAck(requestEvent);
break;
case Request.BYE:
sipRequestHandler.handleBye(requestEvent);
break;
default:
log.warn("不支持的请求方法:{}", method);
sendResponse(requestEvent, Response.NOT_IMPLEMENTED);
}
} catch (Exception e) {
log.error("处理SIP请求异常", e);
}
}
/**
* 处理SIP响应
*/
@Override
public void processResponse(ResponseEvent responseEvent) {
try {
Response response = responseEvent.getResponse();
int statusCode = response.getStatusCode();
log.info("收到SIP响应:{} {}", statusCode, response.getReasonPhrase());
// 根据状态码处理不同的响应
// GB28181中,平台主要作为Server,很少处理响应
} catch (Exception e) {
log.error("处理SIP响应异常", e);
}
}
/**
* 超时事件
*/
@Override
public void processTimeout(TimeoutEvent timeoutEvent) {
log.warn("SIP消息超时");
}
/**
* IO异常事件
*/
@Override
public void processIOException(IOExceptionEvent exceptionEvent) {
log.error("SIP IO异常:{}", exceptionEvent.getHost());
}
/**
* 事务终止事件
*/
@Override
public void processTransactionTerminated(TransactionTerminatedEvent transactionTerminatedEvent) {
log.debug("SIP事务终止");
}
/**
* Dialog终止事件
*/
@Override
public void processDialogTerminated(DialogTerminatedEvent dialogTerminatedEvent) {
log.debug("SIP Dialog终止");
}
/**
* 发送响应
*/
private void sendResponse(RequestEvent requestEvent, int statusCode) {
try {
ServerTransaction serverTransaction = requestEvent.getServerTransaction();
if (serverTransaction == null) {
SipProvider sipProvider = (SipProvider) requestEvent.getSource();
serverTransaction = sipProvider.getNewServerTransaction(requestEvent.getRequest());
}
Response response = requestEvent.getDialog().createResponse(statusCode);
serverTransaction.sendResponse(response);
} catch (Exception e) {
log.error("发送SIP响应失败", e);
}
}
}
4. SDP会话描述协议解析
4.1 SDP协议概述
SDP(Session Description Protocol)是一个文本协议,用于描述多媒体会话的参数。
为什么需要SDP?
在建立RTP会话之前,双方需要知道:
- 对方支持哪些编码格式?(H.264? H.265? AAC?)
- 使用哪个IP地址和端口接收数据?
- 码率、分辨率是多少?
- 音视频如何同步?
SDP就是用来交换这些信息的"协议说明书"。
SDP在GB28181中的使用场景:
- 设备返回SDP,告知平台媒体参数
- 平台发送SDP,告知设备推流地址
4.2 SDP字段详解
典型的GB28181 SDP示例:
v=0 ← 版本号
o=34020000001320000001 0 0 IN IP4 10.10.10.20 ← 会话所有者
s=Play ← 会话名称
c=IN IP4 10.10.10.20 ← 连接信息
t=0 0 ← 时间描述
m=video 15060 RTP/AVP 96 98 97 ← 媒体描述
a=rtpmap:96 PS/90000 ← 载荷96:PS流
a=rtpmap:98 H264/90000 ← 载荷98:H.264
a=rtpmap:97 H265/90000 ← 载荷97:H.265
a=recvonly ← 接收方向
a=setup:passive ← 连接角色
y=0100000001 ← SSRC(自定义字段)
字段详解:
| 字段 | 含义 | 示例 | 说明 |
|---|---|---|---|
| v= | 版本 | v=0 |
SDP版本,目前固定为0 |
| o= | 会话所有者 | o=username session_id version network_type address_type address |
标识会话创建者 |
| s= | 会话名称 | s=Play |
会话的描述性名称 |
| c= | 连接信息 | c=IN IP4 10.10.10.20 |
媒体流的目标地址 |
| t= | 时间描述 | t=0 0 |
会话的起始和结束时间,0 0表示永久 |
| m= | 媒体描述 | m=video 15060 RTP/AVP 96 |
媒体类型、端口、协议、载荷类型 |
| a=rtpmap | 载荷映射 | a=rtpmap:96 PS/90000 |
载荷类型对应的编码格式和时钟频率 |
| a=recvonly | 接收方向 | a=recvonly |
只接收不发送 |
| a=sendonly | 发送方向 | a=sendonly |
只发送不接收 |
| a=sendrecv | 双向 | a=sendrecv |
既发送又接收 |
| y= | SSRC | y=0100000001 |
GB28181自定义字段,标识同步源 |
关键点解释:
1. 媒体描述(m=)
m=video 15060 RTP/AVP 96 98 97
│ │ │ └─ 载荷类型列表(可以有多个,表示支持多种编码)
│ │ └─ 传输协议(RTP/AVP表示RTP over UDP)
│ └─ 端口号(RTP接收端口)
└─ 媒体类型(video/audio)
2. 载荷映射(a=rtpmap)
a=rtpmap:96 PS/90000
│ │ └─ 时钟频率(Hz),视频通常是90000
│ └─ 编码格式(PS/H264/H265/AAC)
└─ 载荷类型(对应m=行中的数字)
PS流(载荷96)是什么?
- PS(Program Stream)是MPEG-2的一种封装格式
- GB28181强制要求使用PS流封装
- PS流可以包含多路音视频,适合录像回放
- 需要解析PS头,提取H.264/H.265裸流
4.3 SDP解析实现
SDP解析器:
java
package com.video.platform.sip.sdp;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* SDP解析器
*
* 解析SIP消息中的SDP部分,提取媒体参数
*/
@Slf4j
public class SdpParser {
/**
* 解析SDP
*
* @param sdpContent SDP内容
* @return SDP信息对象
*/
public static SdpInfo parse(String sdpContent) {
if (sdpContent == null || sdpContent.trim().isEmpty()) {
throw new IllegalArgumentException("SDP内容为空");
}
SdpInfo sdpInfo = new SdpInfo();
String[] lines = sdpContent.split("\r\n|\n");
MediaInfo currentMedia = null;
for (String line : lines) {
if (line.trim().isEmpty()) {
continue;
}
// SDP格式:<type>=<value>
if (line.length() < 2 || line.charAt(1) != '=') {
log.warn("非法的SDP行:{}", line);
continue;
}
char type = line.charAt(0);
String value = line.substring(2);
switch (type) {
case 'v': // 版本
sdpInfo.setVersion(value);
break;
case 'o': // 会话所有者
parseOrigin(value, sdpInfo);
break;
case 's': // 会话名称
sdpInfo.setSessionName(value);
break;
case 'c': // 连接信息
parseConnection(value, sdpInfo);
break;
case 't': // 时间描述
parseTime(value, sdpInfo);
break;
case 'm': // 媒体描述
currentMedia = parseMedia(value);
if (currentMedia != null) {
sdpInfo.getMediaList().add(currentMedia);
}
break;
case 'a': // 属性
if (currentMedia != null) {
parseAttribute(value, currentMedia);
}
break;
case 'y': // SSRC(GB28181自定义)
if (currentMedia != null) {
currentMedia.setSsrc(value);
}
break;
default:
log.debug("未处理的SDP类型:{}", type);
}
}
return sdpInfo;
}
/**
* 解析会话所有者(o=)
* 格式:o=<username> <session id> <version> <network type> <address type> <address>
*/
private static void parseOrigin(String value, SdpInfo sdpInfo) {
String[] parts = value.split(" ");
if (parts.length >= 6) {
sdpInfo.setUsername(parts[0]);
sdpInfo.setSessionId(parts[1]);
sdpInfo.setSessionVersion(parts[2]);
sdpInfo.setAddressType(parts[4]);
sdpInfo.setAddress(parts[5]);
}
}
/**
* 解析连接信息(c=)
* 格式:c=<network type> <address type> <connection address>
*/
private static void parseConnection(String value, SdpInfo sdpInfo) {
String[] parts = value.split(" ");
if (parts.length >= 3) {
sdpInfo.setConnectionAddress(parts[2]);
}
}
/**
* 解析时间描述(t=)
* 格式:t=<start time> <stop time>
*/
private static void parseTime(String value, SdpInfo sdpInfo) {
String[] parts = value.split(" ");
if (parts.length >= 2) {
sdpInfo.setStartTime(parts[0]);
sdpInfo.setStopTime(parts[1]);
}
}
/**
* 解析媒体描述(m=)
* 格式:m=<media> <port> <protocol> <payload types>
*/
private static MediaInfo parseMedia(String value) {
String[] parts = value.split(" ");
if (parts.length < 4) {
log.warn("非法的媒体描述:{}", value);
return null;
}
MediaInfo media = new MediaInfo();
media.setMediaType(parts[0]); // video/audio
try {
media.setPort(Integer.parseInt(parts[1]));
} catch (NumberFormatException e) {
log.error("解析端口失败:{}", parts[1]);
return null;
}
media.setProtocol(parts[2]); // RTP/AVP
// 解析载荷类型列表
List<Integer> payloadTypes = new ArrayList<>();
for (int i = 3; i < parts.length; i++) {
try {
payloadTypes.add(Integer.parseInt(parts[i]));
} catch (NumberFormatException e) {
log.warn("解析载荷类型失败:{}", parts[i]);
}
}
media.setPayloadTypes(payloadTypes);
return media;
}
/**
* 解析属性(a=)
*/
private static void parseAttribute(String value, MediaInfo media) {
if (value.startsWith("rtpmap:")) {
// 解析载荷映射:rtpmap:<payload type> <encoding name>/<clock rate>
String mapValue = value.substring(7); // 去掉"rtpmap:"
String[] parts = mapValue.split(" ", 2);
if (parts.length == 2) {
try {
int payloadType = Integer.parseInt(parts[0]);
String[] codecParts = parts[1].split("/");
if (codecParts.length >= 2) {
String codecName = codecParts[0];
int clockRate = Integer.parseInt(codecParts[1]);
media.addPayloadMap(payloadType, codecName, clockRate);
}
} catch (NumberFormatException e) {
log.warn("解析rtpmap失败:{}", value);
}
}
} else if (value.equals("recvonly")) {
media.setDirection("recvonly");
} else if (value.equals("sendonly")) {
media.setDirection("sendonly");
} else if (value.equals("sendrecv")) {
media.setDirection("sendrecv");
} else if (value.startsWith("setup:")) {
media.setSetup(value.substring(6));
}
}
/**
* SDP信息
*/
@Data
public static class SdpInfo {
private String version; // v=
private String username; // o= 用户名
private String sessionId; // o= 会话ID
private String sessionVersion; // o= 会话版本
private String addressType; // o= 地址类型
private String address; // o= 地址
private String sessionName; // s=
private String connectionAddress; // c= 连接地址
private String startTime; // t= 开始时间
private String stopTime; // t= 结束时间
private List<MediaInfo> mediaList = new ArrayList<>(); // 媒体列表
/**
* 获取第一个视频媒体
*/
public MediaInfo getVideoMedia() {
return mediaList.stream()
.filter(m -> "video".equals(m.getMediaType()))
.findFirst()
.orElse(null);
}
/**
* 获取第一个音频媒体
*/
public MediaInfo getAudioMedia() {
return mediaList.stream()
.filter(m -> "audio".equals(m.getMediaType()))
.findFirst()
.orElse(null);
}
}
/**
* 媒体信息
*/
@Data
public static class MediaInfo {
private String mediaType; // video/audio
private Integer port; // 端口
private String protocol; // RTP/AVP
private List<Integer> payloadTypes = new ArrayList<>(); // 载荷类型列表
private Map<Integer, PayloadMap> payloadMaps = new HashMap<>(); // 载荷映射
private String direction; // recvonly/sendonly/sendrecv
private String setup; // active/passive
private String ssrc; // SSRC
/**
* 添加载荷映射
*/
public void addPayloadMap(int payloadType, String codecName, int clockRate) {
PayloadMap map = new PayloadMap();
map.setPayloadType(payloadType);
map.setCodecName(codecName);
map.setClockRate(clockRate);
payloadMaps.put(payloadType, map);
}
/**
* 获取第一个载荷映射
*/
public PayloadMap getFirstPayloadMap() {
return payloadTypes.isEmpty() ? null : payloadMaps.get(payloadTypes.get(0));
}
/**
* 判断是否支持PS流
*/
public boolean isSupportPS() {
return payloadMaps.values().stream()
.anyMatch(m -> "PS".equalsIgnoreCase(m.getCodecName()));
}
/**
* 判断是否支持H.264
*/
public boolean isSupportH264() {
return payloadMaps.values().stream()
.anyMatch(m -> "H264".equalsIgnoreCase(m.getCodecName()));
}
}
/**
* 载荷映射
*/
@Data
public static class PayloadMap {
private Integer payloadType; // 载荷类型
private String codecName; // 编码名称(PS/H264/H265/AAC)
private Integer clockRate; // 时钟频率
}
}
使用示例:
java
String sdpContent = "v=0\r\n" +
"o=34020000001320000001 0 0 IN IP4 10.10.10.20\r\n" +
"s=Play\r\n" +
"c=IN IP4 10.10.10.20\r\n" +
"t=0 0\r\n" +
"m=video 15060 RTP/AVP 96 98\r\n" +
"a=rtpmap:96 PS/90000\r\n" +
"a=rtpmap:98 H264/90000\r\n" +
"a=recvonly\r\n" +
"y=0100000001\r\n";
SdpParser.SdpInfo sdpInfo = SdpParser.parse(sdpContent);
// 获取视频媒体信息
SdpParser.MediaInfo video = sdpInfo.getVideoMedia();
System.out.println("视频端口:" + video.getPort()); // 15060
System.out.println("传输协议:" + video.getProtocol()); // RTP/AVP
System.out.println("支持PS流:" + video.isSupportPS()); // true
System.out.println("支持H.264:" + video.isSupportH264()); // true
System.out.println("SSRC:" + video.getSsrc()); // 0100000001
5. RTP/RTCP传输协议机制
5.1 RTP协议原理
RTP(Real-time Transport Protocol)是为实时传输音视频设计的传输层协议。
为什么要用RTP而不是直接用UDP?
UDP只负责传输数据包,不关心内容,而视频传输需要:
- 时间戳:音视频同步
- 序列号:检测丢包和乱序
- 载荷类型标识:区分编码格式
- 同步源标识:区分不同的媒体流
RTP在UDP的基础上增加了这些功能。
RTP包结构:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|V=2|P|X| CC |M| PT | sequence number | ← RTP固定头(12字节)
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| timestamp |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| synchronization source (SSRC) identifier |
+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+
| contributing source (CSRC) identifiers | ← 可选的CSRC列表
| .... |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| RTP Extension (可选) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| |
| Payload (媒体数据) |
| |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
字段说明:
| 字段 | 位数 | 说明 |
|---|---|---|
| V (Version) | 2 | 版本号,固定为2 |
| P (Padding) | 1 | 填充标志,用于对齐 |
| X (Extension) | 1 | 扩展头标志 |
| CC (CSRC Count) | 4 | CSRC数量(0-15) |
| M (Marker) | 1 | 标记位,视频帧结束标志 |
| PT (Payload Type) | 7 | 载荷类型(对应SDP中的载荷类型) |
| Sequence Number | 16 | 序列号,每发送一个包+1 |
| Timestamp | 32 | 时间戳,用于音视频同步 |
| SSRC | 32 | 同步源标识,唯一标识一个媒体流 |
关键字段详解:
1. 序列号(Sequence Number)
- 每发送一个RTP包,序列号+1
- 接收端通过序列号检测丢包和乱序
- 序列号从随机值开始,避免预测攻击
2. 时间戳(Timestamp)
- 表示RTP包中第一个字节的采样时刻
- 时钟频率由SDP中的clockRate指定(视频通常是90000Hz)
- 用于音视频同步和计算播放时间
示例:
假设时钟频率90000Hz,视频帧率30fps
则每帧的时间戳增量 = 90000 / 30 = 3000
第1帧:timestamp = 0
第2帧:timestamp = 3000
第3帧:timestamp = 6000
3. 载荷类型(Payload Type)
- 标识RTP包中的媒体格式
- 对应SDP中的载荷类型
- GB28181中:96=PS流,98=H.264,97=H.265
4. SSRC(同步源标识)
- 唯一标识一个RTP流
- 用于区分不同的媒体流(多路并发时)
- 随机生成,避免冲突
5. Marker位(M)
- 视频:标识帧的结束
- 音频:标识通话的开始
5.2 RTCP控制协议
RTCP(RTP Control Protocol)是RTP的配套协议,用于监控传输质量和会话控制。
RTCP的作用:
- 监控RTP传输质量(丢包率、抖动、延迟)
- 提供发送端反馈(让发送端调整码率)
- 传输会话参数(如CNAME)
RTCP包类型:
| 类型 | 名称 | 说明 |
|---|---|---|
| SR (200) | Sender Report | 发送端报告(发送统计) |
| RR (201) | Receiver Report | 接收端报告(接收统计) |
| SDES (202) | Source Description | 源描述(CNAME等) |
| BYE (203) | Goodbye | 会话结束通知 |
| APP (204) | Application | 应用自定义 |
RTCP端口规则:
- RTP端口为N,则RTCP端口为N+1
- 例如:RTP端口15060,RTCP端口15061
GB28181中的RTCP使用:
- 平台定期发送RR包,告知设备接收质量
- 设备可根据丢包率调整码率
- RTCP不是必须的,很多设备不发送RTCP
5.3 RTP接收器实现
下面实现一个RTP接收器,用于接收GB28181设备推送的PS流。
RTP包解析器:
java
package com.video.platform.rtp;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import java.nio.ByteBuffer;
/**
* RTP包解析器
*
* 解析RTP包头,提取媒体数据
*/
@Slf4j
public class RtpPacket {
// RTP固定头长度
private static final int RTP_HEADER_SIZE = 12;
private byte[] rawData; // 原始数据
private RtpHeader header; // RTP头
private byte[] payload; // 载荷数据
/**
* 从字节数组解析RTP包
*/
public static RtpPacket parse(byte[] data) {
if (data == null || data.length < RTP_HEADER_SIZE) {
throw new IllegalArgumentException("RTP包长度不足");
}
RtpPacket packet = new RtpPacket();
packet.rawData = data;
// 解析RTP头
packet.header = parseHeader(data);
// 提取载荷数据
int headerLength = RTP_HEADER_SIZE + packet.header.getCsrcCount() * 4;
if (packet.header.isExtension()) {
// 跳过扩展头(这里简化处理)
headerLength += 4;
}
int payloadLength = data.length - headerLength;
packet.payload = new byte[payloadLength];
System.arraycopy(data, headerLength, packet.payload, 0, payloadLength);
return packet;
}
/**
* 解析RTP头
*/
private static RtpHeader parseHeader(byte[] data) {
ByteBuffer buffer = ByteBuffer.wrap(data);
RtpHeader header = new RtpHeader();
// 第1字节:V(2bit) + P(1bit) + X(1bit) + CC(4bit)
byte byte0 = buffer.get();
header.setVersion((byte0 >> 6) & 0x03);
header.setPadding(((byte0 >> 5) & 0x01) == 1);
header.setExtension(((byte0 >> 4) & 0x01) == 1);
header.setCsrcCount(byte0 & 0x0F);
// 第2字节:M(1bit) + PT(7bit)
byte byte1 = buffer.get();
header.setMarker(((byte1 >> 7) & 0x01) == 1);
header.setPayloadType(byte1 & 0x7F);
// 第3-4字节:序列号
header.setSequenceNumber(buffer.getShort() & 0xFFFF);
// 第5-8字节:时间戳
header.setTimestamp(buffer.getInt() & 0xFFFFFFFFL);
// 第9-12字节:SSRC
header.setSsrc(buffer.getInt() & 0xFFFFFFFFL);
return header;
}
/**
* 获取RTP头
*/
public RtpHeader getHeader() {
return header;
}
/**
* 获取载荷数据
*/
public byte[] getPayload() {
return payload;
}
/**
* 获取原始数据
*/
public byte[] getRawData() {
return rawData;
}
/**
* RTP头信息
*/
@Data
public static class RtpHeader {
private int version; // 版本号(2bit)
private boolean padding; // 填充标志(1bit)
private boolean extension; // 扩展头标志(1bit)
private int csrcCount; // CSRC数量(4bit)
private boolean marker; // 标记位(1bit)
private int payloadType; // 载荷类型(7bit)
private int sequenceNumber; // 序列号(16bit)
private long timestamp; // 时间戳(32bit)
private long ssrc; // SSRC(32bit)
}
}
RTP接收器:
java
package com.video.platform.rtp;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.SocketException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* RTP接收器
*
* 接收RTP包,转发给对应的会话处理器
*/
@Slf4j
@Component
public class RtpReceiver {
// RTP端口范围
private static final int RTP_PORT_START = 30000;
private static final int RTP_PORT_END = 40000;
// 当前可用的RTP端口
private int currentRtpPort = RTP_PORT_START;
// 端口 -> 会话映射
private Map<Integer, RtpSession> portSessionMap = new ConcurrentHashMap<>();
// RTP接收线程池
private ExecutorService executorService = Executors.newCachedThreadPool();
/**
* 分配RTP端口
*
* 为新的视频会话分配可用的RTP端口
*
* @return RTP端口号
*/
public synchronized int allocatePort() {
// 循环查找可用端口
int port = currentRtpPort;
for (int i = 0; i < (RTP_PORT_END - RTP_PORT_START); i++) {
if (!portSessionMap.containsKey(port)) {
currentRtpPort = port + 2; // RTP端口是偶数,跳过RTCP端口
if (currentRtpPort >= RTP_PORT_END) {
currentRtpPort = RTP_PORT_START;
}
return port;
}
port += 2;
if (port >= RTP_PORT_END) {
port = RTP_PORT_START;
}
}
throw new RuntimeException("无可用的RTP端口");
}
/**
* 创建RTP会话
*
* @param ssrc 同步源标识
* @param port RTP端口
* @param handler RTP包处理器
* @return RTP会话
*/
public RtpSession createSession(String ssrc, int port, RtpPacketHandler handler) {
try {
// 创建UDP Socket
DatagramSocket rtpSocket = new DatagramSocket(port);
DatagramSocket rtcpSocket = new DatagramSocket(port + 1);
log.info("创建RTP会话:SSRC={}, RTP端口={}, RTCP端口={}",
ssrc, port, port + 1);
// 创建会话
RtpSession session = new RtpSession();
session.setSsrc(ssrc);
session.setRtpPort(port);
session.setRtcpPort(port + 1);
session.setRtpSocket(rtpSocket);
session.setRtcpSocket(rtcpSocket);
session.setHandler(handler);
session.setRunning(true);
// 加入映射
portSessionMap.put(port, session);
// 启动接收线程
startReceiving(session);
return session;
} catch (SocketException e) {
log.error("创建RTP会话失败:port={}", port, e);
throw new RuntimeException("创建RTP会话失败", e);
}
}
/**
* 启动RTP接收线程
*/
private void startReceiving(RtpSession session) {
// RTP接收线程
executorService.execute(() -> {
log.info("RTP接收线程启动:port={}", session.getRtpPort());
byte[] buffer = new byte[1500]; // MTU大小
DatagramPacket packet = new DatagramPacket(buffer, buffer.length);
while (session.isRunning()) {
try {
// 接收RTP包
session.getRtpSocket().receive(packet);
// 解析RTP包
byte[] data = new byte[packet.getLength()];
System.arraycopy(packet.getData(), 0, data, 0, packet.getLength());
RtpPacket rtpPacket = RtpPacket.parse(data);
// 更新统计
session.incrementPacketCount();
session.updateLastReceiveTime();
// 检测丢包
int seqNum = rtpPacket.getHeader().getSequenceNumber();
if (session.getLastSequenceNumber() != -1) {
int expected = (session.getLastSequenceNumber() + 1) & 0xFFFF;
if (seqNum != expected) {
log.warn("检测到丢包:期望={}, 实际={}, SSRC={}",
expected, seqNum, session.getSsrc());
}
}
session.setLastSequenceNumber(seqNum);
// 交给处理器处理
if (session.getHandler() != null) {
session.getHandler().handleRtpPacket(rtpPacket, session);
}
} catch (IOException e) {
if (session.isRunning()) {
log.error("接收RTP包异常:port={}", session.getRtpPort(), e);
}
}
}
log.info("RTP接收线程停止:port={}", session.getRtpPort());
});
// RTCP接收线程(可选)
executorService.execute(() -> {
log.info("RTCP接收线程启动:port={}", session.getRtcpPort());
byte[] buffer = new byte[1500];
DatagramPacket packet = new DatagramPacket(buffer, buffer.length);
while (session.isRunning()) {
try {
session.getRtcpSocket().receive(packet);
log.debug("收到RTCP包:length={}", packet.getLength());
// 这里可以解析RTCP包,获取传输质量信息
} catch (IOException e) {
if (session.isRunning()) {
log.error("接收RTCP包异常:port={}", session.getRtcpPort(), e);
}
}
}
log.info("RTCP接收线程停止:port={}", session.getRtcpPort());
});
}
/**
* 关闭RTP会话
*/
public void closeSession(String ssrc) {
RtpSession session = portSessionMap.values().stream()
.filter(s -> s.getSsrc().equals(ssrc))
.findFirst()
.orElse(null);
if (session != null) {
closeSession(session);
}
}
/**
* 关闭RTP会话
*/
private void closeSession(RtpSession session) {
log.info("关闭RTP会话:SSRC={}, port={}", session.getSsrc(), session.getRtpPort());
// 停止接收
session.setRunning(false);
// 关闭Socket
if (session.getRtpSocket() != null && !session.getRtpSocket().isClosed()) {
session.getRtpSocket().close();
}
if (session.getRtcpSocket() != null && !session.getRtcpSocket().isClosed()) {
session.getRtcpSocket().close();
}
// 移除映射
portSessionMap.remove(session.getRtpPort());
}
/**
* RTP包处理器接口
*/
public interface RtpPacketHandler {
void handleRtpPacket(RtpPacket packet, RtpSession session);
}
}
RTP会话:
java
package com.video.platform.rtp;
import lombok.Data;
import java.net.DatagramSocket;
/**
* RTP会话
*
* 表示一个视频流的RTP会话
*/
@Data
public class RtpSession {
private String ssrc; // 同步源标识
private int rtpPort; // RTP端口
private int rtcpPort; // RTCP端口
private DatagramSocket rtpSocket; // RTP Socket
private DatagramSocket rtcpSocket; // RTCP Socket
private RtpReceiver.RtpPacketHandler handler; // 包处理器
private boolean running; // 运行标志
// 统计信息
private long packetCount = 0; // 接收包数
private long byteCount = 0; // 接收字节数
private long lastReceiveTime; // 最后接收时间
private int lastSequenceNumber = -1; // 最后序列号
/**
* 增加包计数
*/
public void incrementPacketCount() {
packetCount++;
}
/**
* 更新最后接收时间
*/
public void updateLastReceiveTime() {
lastReceiveTime = System.currentTimeMillis();
}
/**
* 判断是否超时(30秒未收到数据)
*/
public boolean isTimeout() {
return System.currentTimeMillis() - lastReceiveTime > 30000;
}
}
6. GB28181设备注册流程
6.1 注册时序图
GB28181设备注册采用SIP REGISTER方法,流程如下:
设备端 平台端
│ │
│ ① REGISTER(不带认证) │
│ ──────────────────────────────────> │
│ │ 检查请求
│ │ 需要认证
│ ② 401 Unauthorized(带WWW-Authenticate)│
│ <────────────────────────────────── │
│ │
│ 计算Digest认证 │
│ response = MD5(...) │
│ │
│ ③ REGISTER(带Authorization) │
│ ──────────────────────────────────> │
│ │ 验证认证信息
│ │ 注册成功
│ ④ 200 OK │
│ <────────────────────────────────── │
│ │
│ 注册成功,进入在线状态 │
│ │ 设备在线
│ │
│ ⑤ MESSAGE(心跳) │
│ ──────────────────────────────────> │
│ │
│ ⑥ 200 OK │
│ <────────────────────────────────── │
│ │
│ 定期发送心跳... │
│ │
关键步骤:
- 第一次REGISTER:设备发送不带认证的注册请求
- 401响应:平台要求认证,返回401和nonce(随机数)
- 第二次REGISTER:设备计算Digest认证,携带Authorization头
- 200响应:平台验证通过,返回200 OK
- 心跳保活:设备定期发送MESSAGE心跳
6.2 注册认证机制
GB28181使用HTTP Digest认证(RFC 2617),而不是明文密码。
为什么要用Digest认证?
- 不传输明文密码,提高安全性
- 防止重放攻击(nonce是一次性的)
- 简单易实现,不需要HTTPS
Digest认证计算过程:
平台端(401响应):
WWW-Authenticate: Digest realm="平台域", nonce="随机数", algorithm=MD5
设备端(计算response):
① A1 = MD5(username:realm:password)
② A2 = MD5(method:uri)
③ response = MD5(A1:nonce:A2)
示例:
java
username = "34020000001320000001"
realm = "10.10.10.10"
password = "123456"
method = "REGISTER"
uri = "sip:10.10.10.10:5060"
nonce = "abc123def456"
A1 = MD5("34020000001320000001:10.10.10.10:123456")
= "e10adc3949ba59abbe56e057f20f883e"
A2 = MD5("REGISTER:sip:10.10.10.10:5060")
= "f5bb0c8de146c67b44babbf4e6584cc0"
response = MD5("e10adc3949ba59abbe56e057f20f883e:abc123def456:f5bb0c8de146c67b44babbf4e6584cc0")
= "6629fae49393a05397450978507c4ef1"
设备端发送:
Authorization: Digest username="34020000001320000001",
realm="10.10.10.10",
nonce="abc123def456",
uri="sip:10.10.10.10:5060",
response="6629fae49393a05397450978507c4ef1",
algorithm=MD5
平台端验证:
- 从数据库查询设备密码
- 使用相同算法计算response
- 比对计算结果和设备提交的response
- 一致则认证通过
6.3 注册流程实现
Digest认证工具类:
java
package com.video.platform.sip.auth;
import lombok.extern.slf4j.Slf4j;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.UUID;
/**
* Digest认证工具类
*
* 实现HTTP Digest认证算法(RFC 2617)
*/
@Slf4j
public class DigestAuthUtil {
/**
* 生成nonce(随机数)
* 用于防止重放攻击
*/
public static String generateNonce() {
return UUID.randomUUID().toString().replace("-", "");
}
/**
* 计算Digest认证的response
*
* @param username 用户名
* @param realm 域
* @param password 密码
* @param method 请求方法(REGISTER/INVITE等)
* @param uri 请求URI
* @param nonce 随机数
* @return response字符串
*/
public static String calculateResponse(
String username,
String realm,
String password,
String method,
String uri,
String nonce
) {
// A1 = MD5(username:realm:password)
String a1 = md5(username + ":" + realm + ":" + password);
// A2 = MD5(method:uri)
String a2 = md5(method + ":" + uri);
// response = MD5(A1:nonce:A2)
String response = md5(a1 + ":" + nonce + ":" + a2);
log.debug("Digest认证计算:");
log.debug(" username={}, realm={}", username, realm);
log.debug(" method={}, uri={}, nonce={}", method, uri, nonce);
log.debug(" A1={}", a1);
log.debug(" A2={}", a2);
log.debug(" response={}", response);
return response;
}
/**
* 验证Digest认证
*
* @param username 用户名
* @param realm 域
* @param password 密码(从数据库查询)
* @param method 请求方法
* @param uri 请求URI
* @param nonce 随机数
* @param clientResponse 客户端提交的response
* @return 是否验证通过
*/
public static boolean verifyResponse(
String username,
String realm,
String password,
String method,
String uri,
String nonce,
String clientResponse
) {
// 计算期望的response
String expectedResponse = calculateResponse(username, realm, password, method, uri, nonce);
// 比对
boolean result = expectedResponse.equals(clientResponse);
log.info("Digest认证验证:username={}, result={}", username, result);
return result;
}
/**
* MD5哈希
*/
private static String md5(String input) {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] bytes = md.digest(input.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
} catch (Exception e) {
throw new RuntimeException("MD5计算失败", e);
}
}
/**
* 解析Authorization头
*
* @param authorization Authorization头的值
* @return 解析结果
*/
public static AuthorizationInfo parseAuthorization(String authorization) {
if (authorization == null || !authorization.startsWith("Digest ")) {
return null;
}
AuthorizationInfo info = new AuthorizationInfo();
// 去掉"Digest "前缀
String params = authorization.substring(7);
// 解析参数
String[] pairs = params.split(",");
for (String pair : pairs) {
String[] kv = pair.trim().split("=", 2);
if (kv.length == 2) {
String key = kv[0].trim();
String value = kv[1].trim().replaceAll("\"", "");
switch (key) {
case "username":
info.setUsername(value);
break;
case "realm":
info.setRealm(value);
break;
case "nonce":
info.setNonce(value);
break;
case "uri":
info.setUri(value);
break;
case "response":
info.setResponse(value);
break;
case "algorithm":
info.setAlgorithm(value);
break;
}
}
}
return info;
}
/**
* Authorization信息
*/
public static class AuthorizationInfo {
private String username;
private String realm;
private String nonce;
private String uri;
private String response;
private String algorithm;
// Getter和Setter省略
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getRealm() { return realm; }
public void setRealm(String realm) { this.realm = realm; }
public String getNonce() { return nonce; }
public void setNonce(String nonce) { this.nonce = nonce; }
public String getUri() { return uri; }
public void setUri(String uri) { this.uri = uri; }
public String getResponse() { return response; }
public void setResponse(String response) { this.response = response; }
public String getAlgorithm() { return algorithm; }
public void setAlgorithm(String algorithm) { this.algorithm = algorithm; }
}
}
REGISTER请求处理器:
java
package com.video.platform.sip.handler;
import com.video.platform.config.SipConfig;
import com.video.platform.sip.SipServer;
import com.video.platform.sip.auth.DigestAuthUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import javax.sip.*;
import javax.sip.address.Address;
import javax.sip.header.*;
import javax.sip.message.Request;
import javax.sip.message.Response;
import java.text.ParseException;
/**
* REGISTER请求处理器
*
* 处理设备注册请求,实现Digest认证
*/
@Slf4j
@Component
public class RegisterRequestHandler {
@Autowired
private SipConfig sipConfig;
@Autowired
private SipServer sipServer;
/**
* 处理REGISTER请求
*/
public void handleRegister(RequestEvent requestEvent) {
try {
Request request = requestEvent.getRequest();
// 提取设备ID
FromHeader fromHeader = (FromHeader) request.getHeader(FromHeader.NAME);
Address fromAddress = fromHeader.getAddress();
String deviceId = fromAddress.getURI().toString();
// 提取纯设备ID:sip:34020000001320000001@10.10.10.20 -> 34020000001320000001
if (deviceId.contains("@")) {
deviceId = deviceId.substring(deviceId.indexOf(":") + 1, deviceId.indexOf("@"));
}
log.info("收到REGISTER请求:设备ID={}", deviceId);
// 检查Authorization头
AuthorizationHeader authHeader = (AuthorizationHeader) request.getHeader(AuthorizationHeader.NAME);
if (authHeader == null) {
// 第一次注册,没有认证信息,返回401要求认证
send401Response(requestEvent, deviceId);
} else {
// 第二次注册,验证认证信息
verifyAndRegister(requestEvent, deviceId, authHeader);
}
} catch (Exception e) {
log.error("处理REGISTER请求异常", e);
send500Response(requestEvent);
}
}
/**
* 发送401响应,要求认证
*/
private void send401Response(RequestEvent requestEvent, String deviceId)
throws ParseException, InvalidArgumentException, SipException {
log.info("发送401响应,要求设备认证:{}", deviceId);
// 生成nonce
String nonce = DigestAuthUtil.generateNonce();
// 创建401响应
Response response = sipServer.getMessageFactory().createResponse(
Response.UNAUTHORIZED,
requestEvent.getRequest()
);
// 添加WWW-Authenticate头
WWWAuthenticateHeader wwwAuthHeader = sipServer.getHeaderFactory().createWWWAuthenticateHeader("Digest");
wwwAuthHeader.setParameter("realm", sipConfig.getDomain());
wwwAuthHeader.setParameter("nonce", nonce);
wwwAuthHeader.setParameter("algorithm", "MD5");
response.addHeader(wwwAuthHeader);
// 发送响应
ServerTransaction serverTransaction = requestEvent.getServerTransaction();
if (serverTransaction == null) {
serverTransaction = sipServer.getSipProvider().getNewServerTransaction(requestEvent.getRequest());
}
serverTransaction.sendResponse(response);
log.info("401响应已发送:nonce={}", nonce);
}
/**
* 验证认证信息并注册
*/
private void verifyAndRegister(RequestEvent requestEvent, String deviceId, AuthorizationHeader authHeader)
throws Exception {
// 解析Authorization头
DigestAuthUtil.AuthorizationInfo authInfo = DigestAuthUtil.parseAuthorization(
authHeader.toString().substring("Authorization: ".length())
);
log.info("验证设备认证:设备ID={}, username={}", deviceId, authInfo.getUsername());
// 从数据库查询设备密码(这里简化为配置中的密码)
String password = sipConfig.getPassword();
// 验证认证
boolean verified = DigestAuthUtil.verifyResponse(
authInfo.getUsername(),
authInfo.getRealm(),
password,
"REGISTER",
authInfo.getUri(),
authInfo.getNonce(),
authInfo.getResponse()
);
if (verified) {
// 认证通过,发送200 OK
send200Response(requestEvent, deviceId);
// 更新设备状态为在线
updateDeviceStatus(deviceId, true);
} else {
// 认证失败,发送403 Forbidden
log.warn("设备认证失败:{}", deviceId);
send403Response(requestEvent);
}
}
/**
* 发送200 OK响应
*/
private void send200Response(RequestEvent requestEvent, String deviceId)
throws ParseException, InvalidArgumentException, SipException {
log.info("发送200 OK响应,注册成功:{}", deviceId);
// 创建200响应
Response response = sipServer.getMessageFactory().createResponse(
Response.OK,
requestEvent.getRequest()
);
// 添加Expires头
ExpiresHeader expiresHeader = sipServer.getHeaderFactory().createExpiresHeader(
sipConfig.getRegisterExpires()
);
response.addHeader(expiresHeader);
// 添加Date头
DateHeader dateHeader = sipServer.getHeaderFactory().createDateHeader(
new java.util.GregorianCalendar()
);
response.addHeader(dateHeader);
// 发送响应
ServerTransaction serverTransaction = requestEvent.getServerTransaction();
if (serverTransaction == null) {
serverTransaction = sipServer.getSipProvider().getNewServerTransaction(requestEvent.getRequest());
}
serverTransaction.sendResponse(response);
log.info("200 OK响应已发送,设备注册成功:{}", deviceId);
}
/**
* 发送403 Forbidden响应
*/
private void send403Response(RequestEvent requestEvent)
throws ParseException, InvalidArgumentException, SipException {
Response response = sipServer.getMessageFactory().createResponse(
Response.FORBIDDEN,
requestEvent.getRequest()
);
ServerTransaction serverTransaction = requestEvent.getServerTransaction();
if (serverTransaction == null) {
serverTransaction = sipServer.getSipProvider().getNewServerTransaction(requestEvent.getRequest());
}
serverTransaction.sendResponse(response);
}
/**
* 发送500响应
*/
private void send500Response(RequestEvent requestEvent) {
try {
Response response = sipServer.getMessageFactory().createResponse(
Response.SERVER_INTERNAL_ERROR,
requestEvent.getRequest()
);
ServerTransaction serverTransaction = requestEvent.getServerTransaction();
if (serverTransaction == null) {
serverTransaction = sipServer.getSipProvider().getNewServerTransaction(requestEvent.getRequest());
}
serverTransaction.sendResponse(response);
} catch (Exception e) {
log.error("发送500响应失败", e);
}
}
/**
* 更新设备状态
*/
private void updateDeviceStatus(String deviceId, boolean online) {
// 这里应该更新数据库中的设备状态
// 简化实现,只打印日志
log.info("更新设备状态:设备ID={}, 在线={}", deviceId, online);
// 实际项目中:
// deviceService.updateDeviceStatus(deviceId, online ? "在线" : "离线");
}
}
7. 常见坑点与最佳实践
7.1 常见坑点
坑点1:IP地址配置错误
java
// ❌ 错误:使用127.0.0.1
sipConfig.setIp("127.0.0.1"); // 设备无法连接
// ✅ 正确:使用服务器实际IP
sipConfig.setIp("10.10.10.10"); // 设备可以连接
为什么会有这个坑?
- 设备在外网,无法访问127.0.0.1
- SIP消息中的IP地址会被设备使用
- Via、Contact等头部都包含IP地址
坑点2:端口被占用
java
// 检查端口是否可用
private boolean isPortAvailable(int port) {
try (ServerSocket serverSocket = new ServerSocket(port)) {
return true;
} catch (IOException e) {
return false;
}
}
坑点3:RTP端口耗尽
java
// ❌ 错误:没有释放端口
public void stopVideo(String ssrc) {
rtpSession.close(); // 只关闭会话,没有回收端口
}
// ✅ 正确:及时回收端口
public void stopVideo(String ssrc) {
rtpSession.close();
rtpReceiver.releasePort(rtpSession.getPort()); // 回收端口
}
坑点4:PS流解析失败
java
// GB28181强制使用PS流封装
// 需要解析PS头,提取H.264/H.265裸流
// 很多开发者不知道PS是什么,导致解析失败
坑点5:时间戳计算错误
java
// ❌ 错误:使用系统时间
rtpPacket.setTimestamp(System.currentTimeMillis());
// ✅ 正确:使用相对时间戳(以时钟频率为单位)
// 假设时钟频率90000Hz,帧率30fps
long timestamp = frameIndex * (90000 / 30);
rtpPacket.setTimestamp(timestamp);
7.2 最佳实践
实践1:统一异常处理
java
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(SipException.class)
public ResponseEntity<String> handleSipException(SipException e) {
log.error("SIP异常", e);
return ResponseEntity.status(500).body("SIP协议错误");
}
}
实践2:连接池管理
java
// RTP端口池
@Component
public class RtpPortPool {
private Queue<Integer> availablePorts = new ConcurrentLinkedQueue<>();
@PostConstruct
public void init() {
// 初始化端口池(30000-40000)
for (int port = 30000; port < 40000; port += 2) {
availablePorts.offer(port);
}
}
public int borrowPort() {
Integer port = availablePorts.poll();
if (port == null) {
throw new RuntimeException("端口池耗尽");
}
return port;
}
public void returnPort(int port) {
availablePorts.offer(port);
}
}
实践3:超时检测
java
// 定时检测会话超时
@Scheduled(fixedRate = 10000) // 每10秒检测一次
public void checkTimeout() {
List<RtpSession> timeoutSessions = sessionManager.getAllSessions().stream()
.filter(RtpSession::isTimeout)
.collect(Collectors.toList());
for (RtpSession session : timeoutSessions) {
log.warn("会话超时,自动关闭:SSRC={}", session.getSsrc());
sessionManager.closeSession(session.getSsrc());
}
}
实践4:配置外部化
yaml
# application.yml
sip:
server:
ip: 10.10.10.10
port: 5060
domain: 10.10.10.10
server-id: 34020000002000000001
transport: UDP
username: admin
password: 123456
keepalive-interval: 60
keepalive-timeout: 180
register-expires: 3600
实践5:日志记录
java
// 记录关键操作
log.info("设备注册:设备ID={}, IP={}, 端口={}", deviceId, ip, port);
log.info("视频点播:设备ID={}, 通道ID={}, RTP端口={}", deviceId, channelId, rtpPort);
log.warn("心跳超时:设备ID={}, 最后心跳={}", deviceId, lastHeartbeat);
log.error("RTP接收异常:SSRC={}, 异常={}", ssrc, e.getMessage());
8. 面试高频题
Q1: GB28181协议是什么?为什么要用它?
参考答案:
GB28181(公共安全视频监控联网系统信息传输、交换、控制技术要求)是国家强制标准,用于视频监控设备的统一接入。
为什么要用GB28181:
-
统一标准
- 不同厂商设备使用相同协议
- 一套代码对接所有设备
- 大幅降低开发和维护成本
-
跨平台兼容
- 基于SIP/RTP等互联网标准协议
- 支持多种编程语言实现
- Web端可通过WebRTC播放
-
国家要求
- 公安、交通等行业强制使用
- 政府项目必须支持
-
扩展性强
- 支持设备注册、心跳、目录查询
- 支持实时视频、历史回放、云台控制
- 支持级联(上下级平台互联)
相比厂商SDK的优势:
- SDK通常很重,GB28181轻量级
- SDK绑定平台,GB28181通用
- SDK难以跨平台,GB28181易于实现
- SDK维护成本高,GB28181标准稳定
Q2: GB28181协议分为哪几层?各层的作用是什么?
参考答案:
GB28181协议采用分层架构,从下到上分为:
1. 网络层(TCP/UDP)
- 提供基础的网络传输能力
- GB28181通常使用UDP传输
2. 媒体传输层(RTP/RTCP)
- RTP:传输音视频数据
- RTCP:监控传输质量,提供反馈
- 包含时间戳、序列号、载荷类型等
3. 会话描述层(SDP)
- 描述媒体参数(编码格式、传输地址)
- 用于双方协商媒体能力
- 在SIP消息体中传输
4. 信令控制层(SIP)
- 处理设备注册、心跳保活
- 发起视频点播、历史回放
- 控制会话的建立、修改、终止
5. 应用层
- 目录查询、云台控制
- 报警订阅、录像检索
- 业务功能实现
分层的好处:
- 职责分离,易于维护
- 可扩展,可替换底层协议
- 易于定位问题
Q3: SIP协议在GB28181中起什么作用?请说明REGISTER的完整流程。
参考答案:
SIP的作用:
SIP(Session Initiation Protocol)会话发起协议,在GB28181中用于:
- 设备注册(REGISTER)
- 会话建立(INVITE)
- 心跳保活(MESSAGE)
- 会话结束(BYE)
- 目录查询(MESSAGE)
REGISTER注册流程:
1. 设备发送REGISTER请求(不带认证)
↓
2. 平台返回401 Unauthorized + WWW-Authenticate头
(包含realm、nonce、algorithm)
↓
3. 设备计算Digest认证:
A1 = MD5(username:realm:password)
A2 = MD5(method:uri)
response = MD5(A1:nonce:A2)
↓
4. 设备发送REGISTER请求(带Authorization头)
↓
5. 平台验证认证信息:
- 从数据库查询密码
- 使用相同算法计算response
- 比对结果
↓
6. 验证通过,返回200 OK
设备进入在线状态
为什么要用Digest认证?
- 不传输明文密码
- 防止重放攻击(nonce一次性)
- 简单易实现
Q4: RTP协议的作用是什么?RTP包头包含哪些重要字段?
参考答案:
RTP的作用:
RTP(Real-time Transport Protocol)实时传输协议,专为音视频传输设计,在UDP基础上提供:
- 时间戳:音视频同步
- 序列号:检测丢包和乱序
- 载荷类型:标识编码格式
- 同步源标识:区分不同媒体流
RTP包头重要字段:
| 字段 | 位数 | 作用 |
|---|---|---|
| 序列号 | 16bit | 检测丢包、乱序,每发送一个包+1 |
| 时间戳 | 32bit | 音视频同步,表示采样时刻 |
| 载荷类型 | 7bit | 标识编码格式(96=PS, 98=H.264) |
| SSRC | 32bit | 唯一标识一个RTP流 |
| Marker | 1bit | 标识帧结束(视频)或通话开始(音频) |
示例:
java
// 解析RTP包头
int version = (byte0 >> 6) & 0x03; // 版本号
boolean marker = ((byte1 >> 7) & 0x01) == 1; // Marker位
int payloadType = byte1 & 0x7F; // 载荷类型
int sequenceNumber = buffer.getShort(); // 序列号
long timestamp = buffer.getInt(); // 时间戳
long ssrc = buffer.getInt(); // SSRC
时间戳计算:
时钟频率:90000Hz
帧率:30fps
每帧时间戳增量 = 90000 / 30 = 3000
第1帧:timestamp = 0
第2帧:timestamp = 3000
第3帧:timestamp = 6000
Q5: SDP协议的作用是什么?如何解析SDP消息?
参考答案:
SDP的作用:
SDP(Session Description Protocol)会话描述协议,用于描述多媒体会话的参数,让双方能够:
- 协商编码格式(H.264/H.265/AAC)
- 确定传输地址(IP和端口)
- 声明媒体属性(码率、分辨率)
典型SDP示例:
v=0 ← 版本号
o=user 0 0 IN IP4 10.10.10.20 ← 会话所有者
s=Play ← 会话名称
c=IN IP4 10.10.10.20 ← 连接地址
t=0 0 ← 时间描述
m=video 15060 RTP/AVP 96 98 ← 媒体描述
a=rtpmap:96 PS/90000 ← 载荷96:PS流
a=rtpmap:98 H264/90000 ← 载荷98:H.264
a=recvonly ← 接收方向
y=0100000001 ← SSRC
关键字段:
- m=:媒体类型、端口、协议、载荷类型
- a=rtpmap:载荷类型到编码格式的映射
- c=:连接地址
- a=recvonly/sendonly/sendrecv:传输方向
解析实现:
java
public static SdpInfo parse(String sdpContent) {
String[] lines = sdpContent.split("\r\n|\n");
SdpInfo sdpInfo = new SdpInfo();
for (String line : lines) {
char type = line.charAt(0);
String value = line.substring(2);
switch (type) {
case 'm': // 媒体描述
MediaInfo media = parseMedia(value);
sdpInfo.addMedia(media);
break;
case 'a': // 属性
if (value.startsWith("rtpmap:")) {
parseRtpmap(value, currentMedia);
}
break;
// ... 其他字段
}
}
return sdpInfo;
}
Q6: GB28181中的PS流是什么?为什么要用PS流?
参考答案:
PS流(Program Stream):
- MPEG-2标准定义的一种封装格式
- 可以包含多路音视频流
- 适合录像回放和长时间传输
- GB28181强制使用PS流封装
为什么要用PS流?
-
统一标准
- 避免厂商各自定义封装格式
- 确保互操作性
-
多路复用
- 一个PS流可以包含音频+视频
- 适合录像回放
-
时间戳同步
- PS头包含时间戳信息
- 保证音视频同步
-
成熟稳定
- MPEG-2是成熟标准
- 有大量开源库支持
PS流处理:
java
// 接收RTP包(载荷是PS流)
byte[] psData = rtpPacket.getPayload();
// 解析PS头
PsHeader psHeader = PsParser.parseHeader(psData);
// 提取H.264/H.265裸流
byte[] h264Data = PsParser.extractH264(psData);
// 转发给流媒体服务器
mediaServer.pushH264Stream(h264Data);
常见坑点:
- 很多开发者不知道PS是什么
- 直接把PS流当H.264推流,导致播放失败
- 需要解析PS头,提取裸流
Q7: 如何实现GB28181的心跳保活机制?
参考答案:
心跳保活机制:
设备定期向平台发送MESSAGE心跳消息,平台检测心跳超时,标记设备离线。
实现要点:
1. 设备端发送心跳:
java
// 每60秒发送一次心跳
@Scheduled(fixedRate = 60000)
public void sendKeepalive() {
String keepaliveXml = "<?xml version=\"1.0\"?>" +
"<Notify>" +
"<CmdType>Keepalive</CmdType>" +
"<SN>12345</SN>" +
"<DeviceID>34020000001320000001</DeviceID>" +
"<Status>OK</Status>" +
"</Notify>";
sipClient.sendMessage(keepaliveXml);
}
2. 平台端接收心跳:
java
@Override
public void handleMessage(RequestEvent requestEvent) {
Request request = requestEvent.getRequest();
String body = new String(request.getRawContent());
// 解析XML
if (body.contains("<CmdType>Keepalive</CmdType>")) {
String deviceId = extractDeviceId(body);
// 更新最后心跳时间
deviceService.updateLastKeepalive(deviceId, new Date());
// 返回200 OK
send200Response(requestEvent);
}
}
3. 定时检测超时:
java
// 每30秒检测一次
@Scheduled(fixedRate = 30000)
public void checkKeepaliveTimeout() {
// 查询超过180秒未心跳的设备
List<Device> timeoutDevices = deviceService.selectTimeoutDevices(180);
for (Device device : timeoutDevices) {
log.warn("设备心跳超时:{}", device.getDeviceId());
// 标记离线
deviceService.updateStatus(device.getDeviceId(), "离线");
// 关闭相关会话
sessionManager.closeSessionsByDevice(device.getDeviceId());
}
}
4. 心跳恢复:
java
// 设备重新发送心跳
public void handleKeepalive(String deviceId) {
Device device = deviceService.getById(deviceId);
if ("离线".equals(device.getStatus())) {
log.info("设备心跳恢复:{}", deviceId);
deviceService.updateStatus(deviceId, "在线");
}
// 更新心跳时间
deviceService.updateLastKeepalive(deviceId, new Date());
}
配置建议:
- 心跳间隔:60秒
- 超时时间:180秒(3倍心跳间隔)
- 检测周期:30秒
Q8: GB28181如何实现视频点播?说明INVITE流程。
参考答案:
视频点播流程:
平台 设备
│ │
│ ① INVITE(带SDP) │
│ ──────────────────────────────────> │
│ │ 解析SDP
│ │ 准备推流
│ ② 200 OK(带SDP) │
│ <────────────────────────────────── │
│ │
│ 解析SDP,获取设备推流地址 │
│ │
│ ③ ACK │
│ ──────────────────────────────────> │
│ │
│ ④ RTP媒体流 │
│ <════════════════════════════════ │
│ │
│ 播放视频... │
│ │
│ ⑤ BYE(停止播放) │
│ ──────────────────────────────────> │
│ │ 停止推流
│ ⑥ 200 OK │
│ <────────────────────────────────── │
│ │
实现代码:
1. 平台发送INVITE:
java
public void startVideo(String deviceId, String channelId) {
// 分配RTP端口
int rtpPort = rtpReceiver.allocatePort();
// 构建平台SDP
String sdp = buildInviteSdp(rtpPort);
// 发送INVITE请求
Request invite = buildInviteRequest(deviceId, channelId, sdp);
sipProvider.sendRequest(invite);
// 创建RTP会话
String ssrc = generateSsrc(channelId);
rtpReceiver.createSession(ssrc, rtpPort, rtpPacket -> {
// 处理RTP包,转发给流媒体服务器
handleRtpPacket(rtpPacket);
});
}
private String buildInviteSdp(int rtpPort) {
return "v=0\r\n" +
"o=" + sipConfig.getServerId() + " 0 0 IN IP4 " + sipConfig.getIp() + "\r\n" +
"s=Play\r\n" +
"c=IN IP4 " + sipConfig.getIp() + "\r\n" +
"t=0 0\r\n" +
"m=video " + rtpPort + " RTP/AVP 96 98\r\n" +
"a=rtpmap:96 PS/90000\r\n" +
"a=rtpmap:98 H264/90000\r\n" +
"a=recvonly\r\n";
}
2. 设备返回200 OK:
java
// 设备解析INVITE中的SDP,获取平台RTP端口
SdpInfo platformSdp = SdpParser.parse(inviteBody);
String platformIp = platformSdp.getConnectionAddress();
int platformRtpPort = platformSdp.getVideoMedia().getPort();
// 构建设备SDP
String deviceSdp = "v=0\r\n" +
"o=" + deviceId + " 0 0 IN IP4 " + deviceIp + "\r\n" +
"s=Play\r\n" +
"c=IN IP4 " + deviceIp + "\r\n" +
"t=0 0\r\n" +
"m=video 15060 RTP/AVP 96\r\n" + // 设备推流端口
"a=rtpmap:96 PS/90000\r\n" +
"a=sendonly\r\n" +
"y=" + ssrc + "\r\n";
// 发送200 OK
Response ok = createResponse(200, invite, deviceSdp);
sendResponse(ok);
// 开始推流到平台
startPushStream(platformIp, platformRtpPort);
3. 平台发送ACK确认:
java
// 收到200 OK后,发送ACK
Request ack = dialog.createAck(cseq);
dialog.sendAck(ack);
// 开始接收RTP流
log.info("开始接收视频流:SSRC={}, 端口={}", ssrc, rtpPort);
4. 平台停止播放:
java
public void stopVideo(String ssrc) {
// 发送BYE请求
Request bye = dialog.createRequest(Request.BYE);
dialog.sendRequest(bye);
// 关闭RTP会话
rtpReceiver.closeSession(ssrc);
log.info("停止视频播放:SSRC={}", ssrc);
}
下一篇预告: 《GB28181设备注册实现》- 深入设备注册流程,掌握设备管理、状态维护、批量注册等核心技术。