☰
基于SpringBoot+Netty的海康摄像头ISUP接入与HLS播放方案
2026/10/3 17:58:55 网站建设 项目流程

在物联网安防项目里,“海康威视摄像头ISUP(原EHOME协议)”接入需求一直很硬核。做视频平台的团队最怕的不是数据库,不是前端,而是设备侧各种私有协议。今天这篇就直接讲透一件事:用SpringBoot + Java + Netty自建ISUP服务端,让海康摄像头主动注册进来,再配合ZLMediaKit把实时视频流转成HLS,最后Vue前端用hls.js在浏览器里直接播放。整个方案我落地过,不是SDK拉RTSP那种内网玩法,而是真正适合跨公网接入的ISUP主动注册模式,适合正在做安防平台、视频中台、物联网设备接入的同学参考。

1. 项目背景与整体方案拆解

1.1 ISUP(原EHOME协议)到底是什么

海康老设备上有个功能叫EHOME,新固件里统一改叫ISUP(Internet Serial Union Protocol),本质是一套基于TCP的私有协议。它的核心思想是让摄像头主动去连接平台服务器,而不是平台去探测设备。这个设计对跨网络场景特别友好:摄像头在客户现场,可能没有公网IP、在NAT后面、甚至4G拨号上网,平台不需要知道设备在哪,只要设备能访问到平台服务器就行。

很多人第一反应是用海康官方SDK拉RTSP流,但那套方案要求设备端和取流端网络互通,要么在同一局域网,要么做端口映射,整个部署链路非常脆弱。ISUP不一样,设备主动拨号到平台,注册、心跳、取流指令、媒体流回传都在这条TCP链路上跑,公网环境下反而稳定得多。

也正因为ISUP是私有协议,海康没有公开完整的协议文档(通常需要走官方渠道申请对接文档),所以不少团队被吓退。但实际拆解下来,核心就几块:设备注册、心跳保活、信令交互、媒体流接收。搞明白消息结构之后,用Netty完全能自己实现一个服务端。

1.2 技术选型:为什么是SpringBoot + Netty + ZLMediaKit + Vue

这个组合不是拍脑袋定的,是我对比过好几种方案后的实际选择。

  • SpringBoot:负责上层业务,比如设备管理、预览会话管理、鉴权、播放地址生成。做平台类项目,Spring的生态和团队熟悉度都是最优解。
  • Netty:ISUP是长连接TCP私有协议,Netty在高并发长连接场景下的性能和内存管理比手撸Socket强太多,社区成熟,解决粘包拆包也很方便。
  • ZLMediaKit:流媒体服务器。ISUP媒体流回传需要接收、转封装、分发,这层如果自己做,工程量非常大。ZLM支持RTP/RTSP/RTMP/HLS/FLV/WebRTC等多种协议,而且提供了REST控制API,非常适合做二次开发。
  • Vue + hls.js:前端要“免安装播放器”,HLS是最稳的方案,Safari原生支持,Chrome/Edge用hls.js兜底。虽然延迟比WebRTC高几秒,但胜在兼容性好,摄像机实时预览场景完全够用。

整体链路是:海康摄像头通过ISUP注册到Netty服务端,业务层下发预览指令后,设备开始回传视频流,流媒体服务器接收并转成HLS,浏览器端通过Vue组件绑定播放地址渲染画面。

1.3 整体链路与模块划分

模块技术载体职责
设备接入层Netty TCP服务(端口7660)接收ISUP注册、心跳、信令
业务管理层SpringBoot设备管理、预览会话、鉴权、播放地址生成
流媒体层ZLMediaKit(端口8090/1935/554)接收视频流、转封装、输出HLS/RTSP/RTMP
前端展示层Vue2/Vue3 + hls.js拉取HLS流并播放

提前说明一下,ISUP服务端和ZLM的集成度取决于你们项目的媒体流回传方式,我在下文会给出一种经过验证的媒体流转发思路。

2. 后端工程搭建与环境准备

2.1 SpringBoot基础工程初始化

项目用Maven构建,SpringBoot版本我建议用2.7.x或者3.x,按团队习惯选。如果项目还要对接MinIO做录像存储,注意SpringBoot 3对javax到jakarta的切换,避免依赖冲突。我这边以SpringBoot 2.7.18为例,Java 8/11都能跑。

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>io.netty</groupId> <artifactId>netty-all</artifactId> <version>4.1.100.Final</version> </dependency> <dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-all</artifactId> <version>5.8.25</version> </dependency> </dependencies>

用Hutool主要是图方便,HTTP调用ZLM控制API、JSON解析、缓存工具都很顺手。如果公司禁止引入第三方工具库,也可以用OkHttp加Jackson,效果一样。

2.2 关键依赖说明

Netty版本选4.1.x,稳定且内置的拆包器够用。ISUP协议的报文头里通常会包含消息总长度字段,所以最核心的就是LengthFieldBasedFrameDecoder。这里有个坑:ISUP的报文长度字段偏移量不是固定的,不同设备固件、不同协议版本可能不一样,千万别抄网上代码直接写死,一定要拿抓包数据确认偏移位置。

SpringBoot方面,除了web和Netty,基本不需要额外视频依赖。很多新手以为Java处理视频需要引入什么神秘的媒体库,其实真正干重活的是ZLMediaKit,后端只负责信令和控制。

2.3 ZLMediaKit流媒体服务部署

ZLM建议直接用Docker部署,省掉编译的麻烦。官方镜像或者社区镜像都行,只要暴露端口一致。

docker run -d \ --name zlm \ -p 1935:1935 \ -p 8080:80 \ -p 8443:443 \ -p 8554:554 \ -p 10000:10000/udp \ -p 10000:10000/tcp \ -p 8000:8000/udp \ -v /opt/zlm:/opt/media/bin/www \ registry.cn-hangzhou.aliyuncs.com/zlmediakit/zlmediakit:master

启动后修改config.ini里的api.secret,这是ZLM控制API的鉴权密钥,后端调用时需要带上。http.port默认80,映射到宿主机8080方便区分。

确认ZLM启动成功的方法很简单,浏览器直接访问后台HTTP API接口,例如http://127.0.0.1:8080/index/api/getServerConfig?secret=你的密钥,能返回JSON就说明服务正常。

3. ISUP协议接入与信令交互实现

3.1 ISUP报文结构快速认知

ISUP协议报文由消息头和消息体组成。消息头里一般包含设备标识、协议类型、消息类型、消息序列号、消息长度等字段。消息体根据消息类型不同而不同,比如注册报文里面会带设备型号、固件版本、通道数等信息。

具体字段和二进制格式必须以PDF协议文档为准,我这里只说通用认知。你在写解码器前,先抓一份设备注册的完整报文,用Wireshark或者tcpdump导出来,对照文档逐字节分析,比看十篇文章都管用。

抓包分析时优先确认这几个字段的位置:消息起始标识、消息总长度、消息类型、源设备ID、目标设备ID。只要这五个字段定位准确,后面的解码器基本不会跑偏。

3.2 Netty服务端核心实现

先定义ISUP服务端启动器,监听7660端口。这个端口是协议默认端口,可以在设备端配置平台上改,但建议保持默认,降低设备配置出错率。

@Slf4j @Component public class IsupServer implements ApplicationRunner { @Resource private IsupChannelInitializer isupChannelInitializer; @Override public void run(ApplicationArguments args) { EventLoopGroup boss = new NioEventLoopGroup(1); EventLoopGroup worker = new NioEventLoopGroup(); try { ServerBootstrap bootstrap = new ServerBootstrap(); bootstrap.group(boss, worker) .channel(NioServerSocketChannel.class) .option(ChannelOption.SO_BACKLOG, 1024) .childOption(ChannelOption.SO_KEEPALIVE, true) .childOption(ChannelOption.TCP_NODELAY, true) .childHandler(isupChannelInitializer); bootstrap.bind(7660).sync(); log.info("ISUP server started at port 7660"); } catch (Exception e) { log.error("ISUP server start failed", e); } } }

TCP_NODELAY一定要打开,ISUP是长连接实时信令,关闭Nagle算法可以减少小包延迟,对信令响应速度有帮助。

ChannelInitializer里加上拆包器、解码器和业务Handler,顺序很关键,不能乱。

public class IsupChannelInitializer extends ChannelInitializer<SocketChannel> { @Override protected void initChannel(SocketChannel ch) { ChannelPipeline pipeline = ch.pipeline(); // 长度字段偏移量和长度字段字节数按你们协议文档调整 pipeline.addLast(new LengthFieldBasedFrameDecoder(1024 * 1024, 20, 4, 0, 0)); pipeline.addLast(new IsupMessageDecoder()); pipeline.addLast(new IsupServerHandler()); pipeline.addLast(new IdleStateHandler(90, 0, 0, TimeUnit.SECONDS)); } }

IdleStateHandler用来做读超时判断,90秒内没收到设备心跳或任何数据就触发userEventTriggered,在这条通道的处理器里主动关闭连接。

3.3 设备注册、心跳与在线管理

设备注册是接入流程里的第一件大事。设备上线后会发一包注册报文,里面带着设备编码。服务端解析后要做三件事:把设备编码和Channel关联起来、回复注册成功、同时触发业务层设备上线回调。

@Slf4j @ChannelHandler.Sharable @Component public class IsupServerHandler extends SimpleChannelInboundHandler<IsupMessage> { @Resource private DeviceSessionManager deviceSessionManager; @Override protected void channelRead0(ChannelHandlerContext ctx, IsupMessage msg) { if (msg.getMsgType() == MsgType.LOGIN) { String deviceId = msg.getSourceId(); DeviceSession session = new DeviceSession(); session.setDeviceId(deviceId); session.setChannel(ctx.channel()); session.setRemoteAddress(ctx.channel().remoteAddress().toString()); deviceSessionManager.onDeviceOnline(session); IsupMessage ack = buildLoginAck(msg); ctx.writeAndFlush(ack); log.info("device online: {}", deviceId); } else if (msg.getMsgType() == MsgType.HEARTBEAT) { deviceSessionManager.refreshHeartbeat(msg.getSourceId()); ctx.writeAndFlush(buildHeartbeatAck(msg)); } else { eventPublisher.publishEvent(new IsupMessageEvent(ctx.channel(), msg)); } } @Override public void channelInactive(ChannelHandlerContext ctx) { deviceSessionManager.removeByChannel(ctx.channel()); ctx.close(); } @Override public void userEventTriggered(ChannelHandlerContext ctx, Object evt) { if (evt instanceof IdleStateEvent) { log.warn("device heartbeat timeout, close channel: {}", ctx.channel()); deviceSessionManager.removeByChannel(ctx.channel()); ctx.close(); } } }

设备Session管理我建议直接内存Map套AtomicInteger计数,如果设备量大或者需要多实例部署,再上Redis。这里可以顺带说一句:设备在线状态和通道状态最好分开存,通道可能独立离线,别一设备掉线就把所有通道状态清了。

3.4 实时预览指令的下发时机与报文组织

设备注册成功只是第一步,预览才是业务核心。前端点击“播放”按钮,后端收到请求后要做的事:先确认设备在线,再确认通道号和码流类型,然后通过ISUP链路向设备发送实时视音频请求信令。

预览指令里需要填写的关键参数一般包括:目标设备ID、通道号、码流类型(主码流/子码流)、平台接收媒体流的地址和端口。这里平台接收媒体流的地址,就是ZLM上为这个预览会话开的RTP接收端口。

public void startPreview(DeviceSession session, int channelNo, int streamType) { IsupMessage playReq = new IsupMessage(); playReq.setTargetId(session.getDeviceId()); playReq.setMsgType(MsgType.REAL_TIME_PLAY); playReq.setBody(buildPlayBody(session.getDeviceId(), channelNo, streamType)); session.getChannel().writeAndFlush(playReq); }

设备收到预览指令后,如果参数正确,就会向平台注册时上报的媒体接收地址回传PS流。这里最容易出问题的是媒体接收地址配置,很多设备在注册时上报的是内网地址,导致平台端收不到流,后面章节我会专门讲排查方法。

4. 视频媒体流处理与HLS输出

4.1 为什么需要ZLMediaKit这一层

有的同学可能会问,Netty已经把流收到了,直接用Java写个Socket服务向浏览器吐数据不行吗?理论上可以,但视频流不是普通字节流,它涉及到PS解封装、音视频解码、时间戳对齐、多路并发分发、转协议等一系列问题,自己从零做就是一个中型流媒体项目。ZLMediaKit把这些脏活累活都干了,我们只需要把ISUP回传的媒体流按它要求的方式喂进去,然后把播放地址交给前端。

更关键的是ZLM自带HLS切片能力,内部把RTP/PS流转换成HLS分片后,前端拿到m3u8索引文件,按列表拉ts分片播放,整个链路工程化程度很高。

4.2 把ISUP回传的PS流送入流媒体的实现思路

我这里采用的方案是:把ISUP回传的PS流按RTP封装推给ZLM。ZLM提供了控制API,可以动态开启一个RTP接收端口并关联到指定流ID。

大致流程:

  1. 后端调用ZLM的openRtpServer接口,参数传流ID和端口号(传0表示自动分配端口),ZLM返回一个实际端口号。
  2. Netty收到设备回传的PS流数据后,在内存里做一次RTP打包(把PS流塞进RTP载荷,加上RTP头),然后通过UDP发送到刚才ZLM分配的那个端口。
  3. ZLM收到RTP数据后,自动完成PS解封装和转封装,生成HLS分片。
  4. 播放地址直接拼接:http://zlm服务器IP:8080/{app}/{streamId}.hls.m3u8。

这里说一下我为什么不用FFmpeg中转。ISUP回传的媒体流是在TCP私有连接里透传的,FFmpeg无法直接读取这条私有连接,要么先把裸流重新转发成一个本地UDP/TCP端口,再用FFmpeg拉,这样中间又多了进程和临时端口,故障点多。直接RTP封装给ZLM,整条链路都在进程内,逻辑清晰、还少一层IO拷贝。

RTP打包的核心代码逻辑就是构造RTP头,然后按MTU大小切分PS流数据。如果设备回传PS流本身比较规整,甚至可以先不分包,直接一个RTP包塞一帧PS数据,简单粗暴也能跑。但遇到大帧时容易超过MTU导致丢包,所以建议还是按标准分片。

4.3 通道会话管理与播放地址生成

每个预览请求都要生成一个全局唯一的流ID,我习惯用deviceId_channelNo_streamType加上一个短雪花ID做后缀,看起来像这样:4201001234567890_1_main_820391。这样在ZLM后台排查流的时候,一眼就能看出是哪个设备的哪路通道。

SpringBoot业务层需要维护一个预览会话表,记录:

字段说明
streamId唯一流ID
deviceId设备编码
channelNo通道号
rtpPortZLM分配的RTP接收端口
playUrl生成的HLS播放地址
startTime预览开始时间

预览结束后,调用ZLM的closeRtpServer关闭端口,同时给设备下发停止预览指令。如果不关,设备会一直推流,浪费带宽也占ZLM资源。再强调一下,这个步骤最容易漏,很多线上事故就是预览会话泄漏导致ZLM端口耗尽。

5. Vue前端播放与组件封装

5.1 播放方案对比:HLS、FLV、WebRTC

前端播放视频流有很多方案,我列一下实际体验:

  • HLS(m3u8):兼容性最好,浏览器原生支持和hls.js兜底,延迟大概3到10秒,部署简单。适合预览场景,也是目前最主流的“免安装播放器”方案。
  • FLV(HTTP-FLV):需要flv.js库,延迟能压到1到3秒,但移动端H5兼容性一般,对网络丢包更敏感。
  • WebRTC:延迟最低(500毫秒内),但ZLM的WebRTC播放需要额外信令服务,前端联调工作量更大。

考虑到标题场景是浏览器Vue播放m3u8,而且大多数监控预览并不要求极低延迟,HLS是性价比最高的方案。

5.2 Vue视频播放器组件实现

新建一个LivePlayer.vue组件,封装hls.js的加载、播放和销毁逻辑。

<template> <video ref="videoEl" class="video-player" controls autoplay muted playsinline ></video> </template> <script> import Hls from 'hls.js'; export default { name: 'LivePlayer', props: { src: { type: String, required: true } }, data() { return { hls: null }; }, mounted() { this.initPlayer(); }, methods: { initPlayer() { const video = this.$refs.videoEl; if (!this.src) return; if (Hls.isSupported()) { this.hls = new Hls({ liveSyncDurationCount: 3, liveMaxLatencyDurationCount: 6 }); this.hls.loadSource(this.src); this.hls.attachMedia(video); this.hls.on(Hls.Events.ERROR, (event, data) => { if (data.fatal) { this.$emit('play-error', data); } }); } else if (video.canPlayType('application/vnd.apple.mpegurl')) { // Safari 原生 HLS 支持 video.src = this.src; video.play(); } } }, beforeDestroy() { if (this.hls) { this.hls.destroy(); this.hls = null; } } }; </script>

几个细节说下:muted属性必须加上,浏览器自动播放策略不允许带声音自动播放,不加的话画面会被浏览器拦截。playsinline属性是针对iOS Safari的,加了这个视频才能在页面内联播放,不会自动全屏。liveSyncDurationCount控制了HLS直播追帧的延迟,值越小延迟越低,但太小会导致频繁跳帧,3是比较平衡的配置。

5.3 断线重连与播放体验优化

摄像头设备经常掉线重连,HLS流也会因为设备网络波动短暂中断。前端做自动重连很有必要。

基本思路是监听video的error事件和hls.js的fatal事件,如果是网络错误就销毁当前实例,等2秒重新加载播放地址。注意重连之前最好让父组件重新向后端要一次播放地址,因为重新推流后streamId可能变了。

tryReconnect() { this.$emit('reconnect-request'); }

多路视频同时预览时,页面上可能挂十几个video标签,建议用v-if控制组件的挂载和卸载,切走就销毁,回来再重建,避免内存暴涨。hls.js实例非常占内存,每路直播在120MB到220MB内存之间,长期驻留很容易把前端页面拖垮。

6. 常见问题排查与避坑实录

6.1 设备侧注册问题

现象1:设备一直显示离线

优先检查设备的ISUP配置:服务器地址、端口、设备ID是否填对。很多设备默认服务器地址是web.hik-online.com这种,要改成你们平台的公网地址或内网地址。然后看平台侧日志,TCP连接是否建立,注册报文是否收到。

现象2:注册成功但心跳不稳定

检查设备网络环境,如果是4G网络,运营商NAT超时时间不同,心跳周期要设备端配置合理,一般90秒发一次,平台端读超时可以放大到120秒更稳妥。另外防火墙别把7660端口拦了,长连接端口一旦被断开,回包就全断了。

6.2 后端信令问题

现象3:Netty收到的报文解析乱码

大概率是拆包器长度字段位置写错了。ISUP协议报文头里长度字段的偏移是固定的,但不同文档版本可能不一致,老EHOME和新ISUP就有差异。老老实实抓包,把HEX数据对照文档一字节一字节比对。我踩过一次坑,把4字节长读成2字节,所有大报文全部解析失败,查了大半天。

现象4:注册成功但预览指令无响应

先确认设备通道号是否存在,主码流还是子码流用错了。有些设备子码流分辨率低但码率更低,适合公网传输。其次检查预览指令里媒体接收地址填的是不是平台能真正监听的地址,如果设备在公网外,千万别填127.0.0.1,得填平台服务器的公网IP或者映射到公网的网卡IP。

6.3 流媒体和前端播放问题

现象5:ZLM收不到ISUP回传的PS流

用ZLM的getAllSession接口查看RTP会话是否存在。如果会话存在但码率一直是0,问题出在设备没有把流发到这个端口,或者源端口不匹配。可以用tcpdump在ZLM服务器上抓一下UDP端口,判断数据有没有到达。

现象6:播放地址返回404

m3u8文件还没生成。ZLM生成HLS切片需要一点时间,通常收到码流后1到2秒可访问。如果一直404,检查流ID是否和ZLM里实际注册的流ID一致,不要拼错路径。

现象7:前端一直黑屏

先单独用浏览器打开m3u8地址测试,如果能播放说明后端和流媒体链路正常,问题在前端。优先查浏览器是否自动播放被拦截,加muted解决。另外hls.js版本别太旧,建议使用1.x版本,对HLS直播兼容性更好。

6.4 一条重要的安全提醒

视频流播放地址一定要加鉴权,不要直接对外开放。ZLM本身支持通过控制接口设置播放鉴权,或者你在SpringBoot层做一层代理,播放地址用带有效期的token换取。摄像机画面属于敏感数据,生产环境如果不做访问控制,后果谁都担不起。

7. 写在最后的经验与扩展想法

这个项目从设备注册到浏览器播放,整个链路走通差不多花了两周时间,最耗时的不是写代码,而是ISUP协议报文结构的确认和设备调试。我第一次对接时没有申请到完整协议文档,全靠抓包反推消息头,走了不少弯路。

如果你们项目进度紧,我的建议是:先向海康官方申请ISUP对接文档,同时找一台支持ISUP的实体设备进行抓包分析。前期把报文结构、设备ID规则、长度字段偏移确认清楚,后面Netty解码和信令交互写起来会非常快。千万别上来就写代码,协议都没搞清楚,写出来的解码器大概率不能用。

这个方案后续还可以扩展录像回放功能。ISUP协议本身支持文件回放指令,设备可以回传录像文件流,接入逻辑和实时预览类似。把收到的视频流在ZLM侧按需录像,然后用MinIO做分片存储,前端再做个时间轴,就是一个完整的视频云平台雏形了。

另外想多提一句,ISUP和GB28181虽然都是安防设备接入协议,但侧重点不同。GB28181是国家标准,大部分厂商设备都支持,适合多厂商混合场景;ISUP是海康私有协议,设备接入更轻量,但可移植性差。做平台型产品,最好两者都支持,但第一次做的话,先把ISUP打通,再啃GB28181就轻松很多。

如果你们公司有其他品牌的摄像头,这套架构也能平滑扩展,流媒体层不用动,只需要新增一个适配器把对应协议的信令翻译成统一的流会话管理就行。这也是为什么我把协议接入和流媒体转发拆得很干净,目的就是让模块之间能独立演进。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询