简介:JGB28181 是一套基于 Java 实现的 GB28181 平台源码,面向希望理解国标视频监控信令交互与流媒体接入的开发者、学生及小型项目团队。压缩包共 49 个文件、约 64KB,其中 43 个 Java 源文件构成核心实现,另含 2 个 XML、1 个 properties 配置、1 个 YML、1 个 Markdown 说明及 .gitignore,体量轻便、目录清晰,适合按模块阅读。功能描述显示其覆盖注册、恢复、目录查询以及 TCP 被动/UDP 实时视频流,可帮助读者梳理 GB28181 平台的基本实现脉络与配置项组织方式。已有 4241 人学习或下载,适合作为国标平台入门与二次开发参考,从中积累信令流程、代码结构和联调排错思路。
1. Java 也能扛住 GB28181:这套平台解决的是接入和转发两件事
手里有一台网络摄像机,网页上点开能看画面,但要把它接进自己的业务系统,绕不开 GB28181。不少团队第一反应是去翻 C++ 的 SIP 栈,觉得用 Java 做信令处理这件事有点玄学,怕扛不住并发、怕 UDP 丢包。JGB28181 这个项目反着来:整套信令面用 Java 写,Spring Boot 起服务,Netty 收 UDP/TCP 报文,设备注册、心跳保活、目录查询、实时点播、录像回放、云台控制这些流程全部在代码里摊开,媒体流则交给独立的流媒体服务去收 PS、转 FLV 和 HLS。它适合两类人:一类是现有业务系统本来就是 Java 栈、不想为了接几路摄像头再养一套 C++ 服务的团队;另一类是想把 28181 的交互链路真正看懂的人——信令怎么进、怎么出、状态存在哪,这里不是黑匣子。
2. 架构与目录:SIP 信令、媒体转发、业务接口三层怎么切
拿到这套代码,先别急着跑,花十分钟把模块边界看清楚,后面排错能省一半时间。它的分层思路很朴素:信令归信令,媒体归媒体,业务归业务,三块之间只通过少数几个接口通信。这样切的好处是,你改业务逻辑的时候不会碰坏 SIP 事务状态机,换流媒体服务的时候也不用动信令代码。
2.1 模块划分与目录结构
整个工程按 Maven 多模块组织,最外层的 pom 只管版本和依赖收敛,真正的代码按职责拆成六块。目录大致长这样:
jgb28181/ ├── jgb28181-signal/ # SIP 信令层:UDP/TCP 收发、事务、对话管理 │ └── src/main/java/.../signal/ │ ├── SipServer.java # Netty 启动,绑定 5060,注册编解码器 │ ├── RegisterHandler.java # REGISTER 与 Digest 认证 │ ├── MessageHandler.java # MESSAGE:心跳、目录、报警、设备信息 │ └── InviteHandler.java # INVITE/BYE:实时点播与回放 ├── jgb28181-protocol/ # MANSCDP XML 编解码 │ └── src/main/java/.../cmd/ │ ├── CatalogCmd.java # 目录查询与响应实体 │ ├── KeepaliveCmd.java │ └── DeviceControlCmd.java # 云台、远程重启 ├── jgb28181-media/ # 流媒体对接、SSRC 生成、RTP 端口池 │ └── src/main/java/.../media/ │ ├── RtpPortPool.java # 30000-30500 端口分配与回收 │ └── MediaClient.java # 调用流媒体服务的 openRtpServer 等接口 ├── jgb28181-biz/ # 设备、通道、录像计划、告警 ├── jgb28181-api/ # 对前端和第三方暴露的 REST 接口 └── jgb28181-admin/ # 管理台静态资源与页面这个划分里最关键的一条线是signal和media之间不直接依赖。信令层只知道「这次会话需要一个收流地址」,地址从哪来、谁来收,它不关心。常见的扩展方式是定义一个MediaAllocator接口,把media模块的实现注入进去,换成别的流媒体实现时只改这一处。
提示:如果你只是想快速验证能不能跑通,先只启动
jgb28181-signal和jgb28181-api两个模块,媒体部分用一个固定的收流地址顶上,等信令链路通了再接流媒体服务。这样排错范围小很多。
2.2 端口与配置:把边界在配置里定死
28181 的坑有一半和端口有关。设备的配置页面里要填平台 IP、平台端口、本地 SIP 端口、注册密码、域 ID、设备 ID,这些值和平台侧必须一一对应,错一位就是注册失败或者在线但点播不通。我在项目里习惯把端口关系整理成一张表,写在配置文件的注释里,改之前先对一遍:
| 用途 | 协议 | 默认端口 | 说明 |
|---|---|---|---|
| SIP 信令 | UDP/TCP | 5060 | 注册、心跳、点播指令都走这里 |
| RTP 收流 | UDP | 30000-30500 | 每路会话占一个,防火墙要整段放行 |
| HTTP-FLV | TCP | 8080 | 给浏览器播放器拉流 |
| HLS | TCP | 8080 | m3u8 切片,延迟比 FLV 高 |
| REST 接口 | TCP | 9000 | 业务系统调用 |
对应的application.yml里,我一般把这几组值集中成三个段落,而不是散落在各处:
sip: platform-id: 34020000002000000001 # 平台自身 SIP 编码,20 位 realm: 3402000000 # 域 ID,前 10 位 host: 0.0.0.0 port: 5060 transport: UDP # 部分设备只支持 UDP password: your-password # 与设备注册密码一致 media: rtp-port-start: 30000 rtp-port-end: 30500 media-host: 10.0.0.10 # SDP 里填的收流地址,必须是设备可达的 IP api-base: http://10.0.0.11:8080 # 流媒体服务的接口地址这里有个特别容易翻车的地方:media-host不能用127.0.0.1。设备收到 INVITE 后,会照着 SDP 里c=行的地址往那个 IP 推流,你填回环地址,流就推到设备自己身上了,平台这边什么都收不到,日志里一片安静。我在两套环境里都踩过这个坑,后来干脆在启动时加了一段校验,检测到127.0.0.1或者localhost就直接拒绝启动。
2.3 设备状态为什么放 Redis 而不是数据库
设备数量和心跳频率决定了在线状态不能落 MySQL。假设 2000 台设备、心跳周期 60 秒,平均每秒就是 33 次更新,加上注册、目录同步,如果每次都写库再读库查在线,数据库连接池很快就顶不住。这个项目用 Redis 存运行态,MySQL 只存相对静态的通道目录和录像计划。
Key 设计很直接:
device:online:{deviceId} # String,值为最后心跳时间戳,TTL 180 秒 device:contact:{deviceId} # Hash,存设备实际来源 IP:Port device:channel:{deviceId} # Set,通道 ID 列表 stream:session:{sessionId} # Hash,存 SSRC、RTP 端口、流地址、创建时间心跳续期这段代码是整条链路里调用最频繁的方法,写法上要抠一点:
// 心跳续期:TTL 设成心跳周期的 3 倍,容忍设备连丢两个包 public void refresh(String deviceId, String remoteAddr) { String key = "device:online:" + deviceId; String last = redis.opsForValue().get(key); // 第一次心跳或从离线转在线,才触发上线事件,避免每次都推消息 if (last == null) { applicationEventPublisher.publishEvent(new DeviceOnlineEvent(deviceId)); } redis.opsForValue().set(key, String.valueOf(System.currentTimeMillis()), Duration.ofSeconds(180)); redis.opsForHash().put("device:contact:" + deviceId, "addr", remoteAddr); } // 判断在线,只认 Redis 的 key 是否存在,不查 MySQL public boolean isOnline(String deviceId) { return Boolean.TRUE.equals(redis.hasKey("device:online:" + deviceId)); }逻辑说明:用 TTL 做过期判断,比另起一个定时任务扫描快照表要稳。定时扫描在设备量大的时候会出现「扫描间隔内刚掉线又重连,结果被误判成离线」的抖动,而且扫描任务本身还会和心跳写入抢 CPU。参数上,Duration.ofSeconds(180)这个值不能拍脑袋定,它应该等于设备心跳周期乘以 3,国标里默认心跳是 60 秒,所以 180 是安全值;如果你的设备配的是 30 秒心跳,这个值降到 90 更合适,否则设备真掉线了平台要等三分钟才发现。
3. 设备接入链路:从 REGISTER 到 INVITE 的完整时序
信令这块是整个项目最值得读的部分,因为所有状态都在代码里。我按设备从上电到出画面的顺序拆,一段一段对着看。
3.1 REGISTER 与 Digest 认证:设备反复重连的根因
设备的注册流程是两段式的。第一次 REGISTER 不带认证头,平台回 401 并附带一个 nonce;设备拿 nonce 和密码算摘要,再发一次带Authorization的 REGISTER,平台校验通过才回 200 OK。很多「设备一直重连」的问题,根子就在这个 nonce 上。
// 第一段:没有 Authorization 头,直接回 401 挑战 if (req.getHeader(AuthorizationHeader.NAME) == null) { Response resp = messageFactory.createResponse(Response.UNAUTHORIZED, req); WWWAuthenticateHeader www = headerFactory.createWWWAuthenticateHeader("Digest"); www.setRealm(config.getRealm()); www.setNonce(nonceService.issue(deviceId)); // 每个设备独立 nonce,带过期时间 www.setAlgorithm("MD5"); resp.addHeader(www); // 注意:401 响应里不要再加 Contact 和 Expires,部分设备解析会失败 sipSender.send(resp); return; } // 第二段:校验摘要。nonce 过期要回 401 并换新 nonce,不能直接 403 AuthResult result = digestVerifier.verify(req, devicePassword(deviceId)); if (!result.isOk()) { sipSender.send(buildUnauthorized(req, result.isNonceExpired())); return; } // 认证通过,记录 Contact 里的真实地址,回 200 deviceRegistry.bindContact(deviceId, req.getHeader(ContactHeader.NAME).getAddress()); sipSender.send(messageFactory.createResponse(Response.OK, req));逻辑说明:nonceService.issue()生成的是一个带时间戳的随机串,校验时既比对摘要也校验时效。参数上,Contact 头里带的地址才是设备真实可达的地址,后面平台发 INVITE 必须往这个地址发,而不是往数据包里看到的源地址发——两者在 NAT 环境下经常不一致,这一点在排错章节还会再提。
3.2 Keepalive 与 Catalog:在线状态和通道目录怎么落库
设备注册成功后,每 60 秒发一条 MESSAGE,消息体是 MANSCDP 格式的 XML。平台侧的 MESSAGE 处理是一个分发器,靠CmdType分流:
// 消息体是一段 XML,用 XPath 取 CmdType 最省事,不用为每种命令建一个类 Document doc = saxReader.read(new ByteArrayInputStream(body)); String cmdType = doc.valueOf("/Query/CmdType"); switch (cmdType) { case "Keepalive": deviceRegistry.refresh(deviceId, remoteAddr); replyOk(req); // 必须回 200,不回设备会认为平台挂了 break; case "Catalog": List<Channel> list = parseItems(doc); int sumNum = Integer.parseInt(doc.valueOf("/Response/SumNum")); int sn = Integer.parseInt(doc.valueOf("/Response/Sn")); catalogAccumulator.append(deviceId, sn, sumNum, list); replyOk(req); break; case "Alarm": alarmService.save(deviceId, parseAlarm(doc)); replyOk(req); break; default: replyOk(req); // 未知命令也回 200,别让设备卡住重发 }逻辑说明:Catalog 的响应是分包的,这一点新手最容易漏。设备通道多的时候,一个 Response 塞不下,会拆成多条 MESSAGE 陆续发回来,每条带自己的Sn序号,SumNum是通道总数。正确的做法是累加收到的条数,等累计数量等于SumNum才开始做全量替换,否则你会看到目录一会儿多一会儿少,通道莫名其妙消失。catalogAccumulator里我用一个Map<deviceId, List<Channel>>做暂存,配一个 5 秒的清理定时器,防止设备中途掉线导致内存里的半截数据一直留着。
3.3 INVITE 与 SDP 协商:y 行和 f 行到底谁说了算
点播是平台主动发 INVITE,SDP 里塞进去的关键信息决定了设备往哪推、怎么推:
// 平台侧构造 INVITE 的 SDP,重点是 c 行、y 行、f 行 String sdp = String.join("\r\n", "v=0", "o=" + platformId + " 0 0 IN IP4 " + mediaIp, "s=Play", // 实时点播是 Play,回放是 Playback "c=IN IP4 " + mediaIp, // 设备往这个 IP 推流 "t=0 0", "m=video " + rtpPort + " RTP/AVP 96", // 收流端口,必须在端口池里抢到 "a=recvonly", "a=rtpmap:96 PS/90000", // 负载类型 96 固定对应 PS "y=" + ssrc, // 10 位十进制,设备回流的 SSRC "f=v/2/5/25/1/4000a/1/8/1" // 音视频参数描述 );参数说明:y=这行是 SSRC,必须是 10 位十进制字符串,常见的生成方式是「域 ID 后 5 位 + 5 位自增序号」,比如0100000001。这个值设备会原样用在它发出的 RTP 包头上,平台收到流之后靠它匹配会话。f=行描述的是媒体能力,格式是v/编码/分辨率/帧率/码率类型/码率/音频编码/音频采样位/音频采样率,2代表 H.264,5代表 1080P,25是帧率,1是变码率。这一行写错了设备可能直接回 488 拒掉,也可能接受了但推上来的是低分辨率,画面糊得你以为是自己解码有问题。
设备回 200 OK 之后,SDP 里会带回它确认的 SSRC,平台要把这个值和本地生成的值比对一下。不一致的情况不多但有,一旦不一致,后面收流就会找不到会话。
4. 媒体面:PS over RTP 收上来之后怎么变成能播的流
信令通了只是开始,真正决定体验的是媒体面。这一块我建议一开始就想清楚边界,别中途改架构。
4.1 为什么媒体面不自研解 PS
28181 的设备推上来的是 PS 封装,走 RTP 传输。PS 流的结构是00 00 01 BA开头的 pack header,里面套着00 00 01 E0的视频 PES 和00 00 01 C0的音频 PES。理论上可以自己解:剥 pack、取 PES、按 PTS/DTS 拼帧、把 H.264 的00 00 00 01起始码前面的 NALU 提出来、缓存 SPS/PPS、再封成 FLV 的 tag 推出去。
但我自己的血泪经验是别碰。这套东西写出来不难,写稳很难——跨 RTP 包的 PES 重组、丢包时的帧丢弃策略、音频和视频时间戳对齐、关键帧请求,每一个都是一个独立的小坑。信令部分总共几千行,媒体解封装部分想写稳,工作量不比信令小。
所以这个项目的做法是:信令自己写,媒体交给独立的流媒体服务。平台在 INVITE 里把收流地址指过去,设备把 PS 流推到流媒体服务,服务负责解 PS、转 FLV 和 HLS、按需拉流。平台只维护会话和 SSRC 的映射关系。
// 点播时向流媒体服务申请一个收流端口,拿到端口和流 ID MediaSession session = mediaClient.openRtpServer(streamId, 0, 1); // 把返回的端口写进 SDP 的 m 行,IP 用配置里的 media-host String sdp = buildSdp(session.getPort(), ssrc); sipClient.sendInvite(deviceId, channelId, sdp);4.2 收流与 SSRC 校验
端口分配这块多实例部署时容易出问题。如果平台起两个节点,各自内存里维护一份端口池,很容易两路会话抢到同一个端口,表现为后一路起流把前一路顶掉。这个项目用 Redis 的setIfAbsent抢端口:
// 端口池用 Redis 抢,避免多实例并发分配到同一个端口 public int alloc(String sessionId) { for (int p = portStart; p <= portEnd; p++) { Boolean ok = redis.opsForValue() .setIfAbsent("rtp:port:" + p, sessionId, Duration.ofMinutes(30)); if (Boolean.TRUE.equals(ok)) { return p; } } throw new IllegalStateException("RTP 端口耗尽,检查是否有会话没有释放"); }逻辑说明:Duration.ofMinutes(30)是兜底过期时间,防止平台进程崩溃导致端口永远被占。正常路径是 BYE 之后主动delete这个 key,过期只是保险。参数上,端口段长度要和你的并发路数匹配,30000-30500 是 501 个端口,如果单机并发要跑 300 路以上,就得扩到 31000 起再开一段,同时在防火墙上把整段放行——只放行单个端口是最常见的低级错误。
收到流之后要做 SSRC 匹配。流媒体服务回调里会带上 SSRC,平台拿它去stream:session:*里反查是哪个会话,对上了才建立播放地址,对不上就是「流收到了但不知道是谁的」,日志里表现为一堆孤立的 RTP 包,播放器永远转圈。
4.3 按需拉流与停流回收
设备侧没有「一直推流」这个概念,只有收到 INVITE 才推,收到 BYE 就停。所以会话的生命周期管理很重要。用户刷新页面、切走标签页,播放器断开是瞬间的事,如果立刻发 BYE,用户再点回来又要重新走一遍 INVITE,体感就是每次都要等两三秒。
// 延迟关流:播放器断开后先等一会,宽限期内有人再看就直接复用 public void onPlayerClosed(String streamId) { scheduler.schedule(() -> { if (playerRegistry.viewerCount(streamId) == 0) { MediaSession session = sessionStore.get(streamId); sipClient.sendBye(session.getCallId()); // 通知设备停推 rtpPortPool.release(session.getPort()); // 释放端口 sessionStore.remove(streamId); } }, 30, TimeUnit.SECONDS); }逻辑说明:30 秒这个宽限期是我调出来的值,短了用户体验差,长了设备白推流浪费带宽。并发路数多的时候还要考虑上限——如果 30 秒内有大量用户同时断开又重连,会话数会短暂翻倍,端口池要有余量。
5. 避坑与排查:五条踩过的记录
这一节按「现象 → 原因 → 解决」写,都是真金白银换来的。
设备显示在线,但点播一直超时
现象:管理台上设备是绿色在线,点预览转圈十几秒后提示超时,平台侧日志显示 INVITE 已发出,没有响应。
原因:平台往设备的注册源地址发 INVITE,但设备其实在 NAT 后面,源地址是私网转换后的地址,不可达;另外有些设备的 Contact 头里填的是内网 IP,平台如果按源 IP 发也同样打不通。
解决:优先用 REGISTER 里 Contact 头的地址作为目标地址,同时保存源地址做备用;发 INVITE 前先探测一下端口可达性,不通就快速失败,不要等 SIP 事务超时的 32 秒。日志里把实际发送的目标 IP 和端口打出来,这一条能省掉大量猜测。
INVITE 发出后设备回 488 或者干脆不理
现象:平台发 INVITE,设备回 488 Not Acceptable Here,或者直接没有响应。
原因:八成是 SDP 格式问题。常见的是y=行 SSRC 不是 10 位,或者m=行的负载类型不是 96,或者f=行的段数不对。还有一种隐蔽情况是s=行写错,实时点播必须是Play,写成别的设备直接拒。
解决:把 SDP 打印出来逐字对照国标示例,最好抓到一台能正常工作的设备的交互报文做比对。y=行用固定 10 位十进制,不足位补零。
画面能出来,但几秒到几十秒就断
现象:点播后能播,但播一会儿画面卡住然后断流,重新点又能播。
原因:两种可能。一是流媒体服务的按需拉流配置里「无人观看自动关流」的时间设得太短,播放器的首帧还没渲染完就被判定为空闲;二是 RTP 端口段只放行了起始的几个端口,后面的端口被防火墙拦掉。
解决:把自动关流时间调到 60 秒以上,并在平台侧配合前面说的宽限期逻辑;防火墙按整段放行,验证方法是连续点十几路不同通道,看是不是固定在某几路失败。
录像回放的时间对不上
现象:请求回放某个时间段,设备返回的录像却是另一个时间段的内容,或者直接返回空。
原因:t=行的时间格式是起始/结束,秒级时间戳,但设备用的是自己的本地时间,平台如果按 UTC 算就会整体偏移几个小时。
解决:统一按设备时区算时间戳,配置里单独留一个时区项,别用服务器默认时区。排查时先把请求的t=行转成人能看的时间打日志,一眼就能发现问题。
目录只收到第一页,通道少了一大半
现象:设备有 32 个通道,平台只显示了前 10 个。
原因:Catalog 响应分多条 MESSAGE 返回,代码里每条都当成全量覆盖了,后一条把前一条的结果冲掉。
解决:按Sn累加,累计数量等于SumNum才做全量替换,中途用暂存区存着。同时给暂存区加个 5 秒清理任务,防止设备掉线后脏数据残留。
注意:排查这类问题最有效的办法不是看平台日志,而是抓包。SIP 是文本协议,Wireshark 里直接能看到 XML 内容,一分钟就能定位是平台发错了还是设备回错了。抓包过滤用
sip || rtp,别只抓一个。
6. 二次开发:把设备抽象成接口,再留一层事件钩子
这套代码要接进自己的业务,最常见的诉求是「设备上线了我要做点什么」「有报警了我要推到消息中心」「点播前我要做权限校验」。这三件事都不该改信令层的代码,正确做法是在jgb28181-biz里留扩展点。
我一般会定义一组钩子接口,用 Spring 收集所有实现,事件来了遍历调用:
public interface DeviceEventHandler { default void onOnline(String deviceId) {} default void onOffline(String deviceId) {} default void onAlarm(String deviceId, AlarmInfo info) {} default void onCatalogChanged(String deviceId, List<Channel> channels) {} } // 分发时用 Spring 注入的 List,新增业务只要加一个 @Component 实现 @Component public class DeviceEventDispatcher { private final List<DeviceEventHandler> handlers; public DeviceEventDispatcher(List<DeviceEventHandler> handlers) { this.handlers = handlers; } public void fireOnline(String deviceId) { for (DeviceEventHandler h : handlers) { try { h.onOnline(deviceId); } catch (Exception e) { log.error("设备上线钩子执行失败, deviceId={}", deviceId, e); // 单个钩子异常不能影响信令线程 } } } }这段代码里最要紧的是那个 try-catch。钩子里跑的是业务代码,可能查库、可能发 HTTP 请求,任何一个抛异常,如果没兜住就会顺着调用栈冒到 Netty 的 handler 里,轻则这条消息处理失败,重则把信令线程带崩——表现就是「平台用着用着就不响应了」,非常难查。
扩展点整理成表更清楚:
| 扩展点 | 代码位置 | 典型改法 |
|---|---|---|
| 设备上下线 | DeviceEventHandler.onOnline/onOffline | 推送消息、更新资产台账 |
| 报警处理 | DeviceEventHandler.onAlarm | 落库、联动抓图、推送到消息中心 |
| 通道变更 | DeviceEventHandler.onCatalogChanged | 增量同步到业务表 |
| 点播鉴权 | jgb28181-api的播放接口前置 | 校验用户对该通道的权限 |
| 媒体服务替换 | MediaAllocator接口实现 | 换流媒体服务只改这一个实现类 |
验证改造有没有生效,我的习惯是先写一个只打日志的空实现,跑一遍注册和点播,确认钩子被触发、顺序符合预期,再往里填业务逻辑。另外建议给每个钩子加一个可配置的开关,出问题能单独关掉某个钩子而不影响整体接入。
从那以后我每次改这类信令项目,都会先把 SDP 和 MANSCDP 报文抓下来存成一组回归样本,改完跑一遍比对,因为这类协议问题从来不是编译期能发现的,全靠运行时对拍。希望帮到你。
本文还有配套的精品资源,点击获取