简介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/TCP5060注册、心跳、点播指令都走这里RTP 收流UDP30000-30500每路会话占一个防火墙要整段放行HTTP-FLVTCP8080给浏览器播放器拉流HLSTCP8080m3u8 切片延迟比 FLV 高REST 接口TCP9000业务系统调用对应的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: ListChannel 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里我用一个MapdeviceId, ListChannel做暂存配一个 5 秒的清理定时器防止设备中途掉线导致内存里的半截数据一直留着。3.3 INVITE 与 SDP 协商y 行和 f 行到底谁说了算点播是平台主动发 INVITESDP 里塞进去的关键信息决定了设备往哪推、怎么推// 平台侧构造 INVITE 的 SDP重点是 c 行、y 行、f 行 String sdp String.join(\r\n, v0, o platformId 0 0 IN IP4 mediaIp, sPlay, // 实时点播是 Play回放是 Playback cIN IP4 mediaIp, // 设备往这个 IP 推流 t0 0, mvideo rtpPort RTP/AVP 96, // 收流端口必须在端口池里抢到 arecvonly, artpmap:96 PS/90000, // 负载类型 96 固定对应 PS y ssrc, // 10 位十进制设备回流的 SSRC fv/2/5/25/1/4000a/1/8/1 // 音视频参数描述 );参数说明y这行是 SSRC必须是 10 位十进制字符串常见的生成方式是「域 ID 后 5 位 5 位自增序号」比如0100000001。这个值设备会原样用在它发出的 RTP 包头上平台收到流之后靠它匹配会话。f行描述的是媒体能力格式是v/编码/分辨率/帧率/码率类型/码率/音频编码/音频采样位/音频采样率2代表 H.2645代表 1080P25是帧率1是变码率。这一行写错了设备可能直接回 488 拒掉也可能接受了但推上来的是低分辨率画面糊得你以为是自己解码有问题。设备回 200 OK 之后SDP 里会带回它确认的 SSRC平台要把这个值和本地生成的值比对一下。不一致的情况不多但有一旦不一致后面收流就会找不到会话。4. 媒体面PS over RTP 收上来之后怎么变成能播的流信令通了只是开始真正决定体验的是媒体面。这一块我建议一开始就想清楚边界别中途改架构。4.1 为什么媒体面不自研解 PS28181 的设备推上来的是 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, ListChannel channels) {} } // 分发时用 Spring 注入的 List新增业务只要加一个 Component 实现 Component public class DeviceEventDispatcher { private final ListDeviceEventHandler handlers; public DeviceEventDispatcher(ListDeviceEventHandler 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 报文抓下来存成一组回归样本改完跑一遍比对因为这类协议问题从来不是编译期能发现的全靠运行时对拍。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?