简介:面向需要将摄像头实时画面接入Web端的安防监控、在线视频等场景,这套基于SpringBoot、Vue、ZLMediaKit与Geoserver的联调方案,实现了从摄像头RTSP流拉取到浏览器播放的完整链路,适合具备Java与前端基础、正在研究流媒体接入的开发者。压缩包共14个文件,后台由7个Java类构成,覆盖业务逻辑、Mapper与Controller层;前端有3个Vue组件处理视频展示与地图交互,另有SQL脚本完成数据库初始化,XML映射和JS文件补齐持久层与页面逻辑;附带编译后的Windows版ZLMediaKit安装包及运行报错常用DLL,可免去本地编译环节。整体仅14.14MB,结构紧凑,目录按代码、脚本、运行组件分区,便于按需取用。目前已有872人学习下载。整套资源将前后端代码、数据库脚本、流媒体运行组件集中整合,既可作为学习RTSP拉流与Web播放原理的示例,也能改造成实际项目的启动模板,帮助规避环境配置与依赖缺失等常见问题。
1. 从 RTSP 到浏览器:为什么这条链路要四个组件
做监控平台的人大概率都经历过同一个画面:把摄像头地址直接塞进<video>标签,浏览器一片白屏。原因不复杂,RTSP 靠 RTP 传输,需要持久的会话和动态端口,浏览器只认 HTTP 系协议;ZLMediaKit 在这条链路里就是那个把 rtsp 流翻译成 web 端能直接消费的中间层。于是标题里的四个组件各司其职:ZLMediaKit 负责拉流和转封装,SpringBoot 负责鉴权和播放地址下发,Vue 负责播放交互,Geoserver 负责让摄像头回到地图上。这条链路适合正在做视频监控平台、摄像头接入管理、GIS 视频融合的团队,是从零到一不容易走偏的组合。
2. 先跑通 ZLMediaKit:拉摄像头 rtsp 流的第一个落点
2.1 它到底替你做了什么
先解决“为什么必须有它”。摄像头的 RTSP 服务不是你网站的资产,它在一台可能不通公网的设备上,开启一个独占的会话,拉流方要主动去连接、鉴权、协商端口。如果你的网页端直接连摄像头,等于让浏览器承担 RTSP 客户端的工作,而浏览器做不到。ZLMediaKit 在这里做三件事:主动向摄像头发起 RTSP 会话(这一动作叫拉流);把拉回来的流重新封装成 HLS、HTTP-FLV、WebRTC 等浏览器能消费的格式;把这些流同时分发给多个观看端,谁来看都不必再跟摄像头重新建立会话。
对比一下替代方案:你可以直接用 FFmpeg 转码,但 FFmpeg 是进程模型,一路流一个进程,100 路摄像头就是 100 个常驻进程,内存和句柄消耗很快失控;你也可以自己用 GStreamer 拼管线,但那是给嵌入式设备和媒体处理专家准备的,调试成本偏高。ZLMediaKit 是常驻服务,切片、分发、鉴权都在配置和 API 里管理,用起来更像一个流媒体中间层,这也是它在监控场景里常见的原因。这一章的目标不是把流媒体服务器原理讲全,而是让你在 30 分钟内,把一个摄像头地址变成浏览器能打开的播放地址。
2.2 Docker 起服务和端口规划
常见做法是用 Docker 部署,省去编译依赖的体力活:
docker run -d \ --name zlmediakit \ --restart=unless-stopped \ -p 1935:1935 -p 554:554 \ -p 8080:80 -p 8554:8554 \ -p 10000:10000/udp \ -v /data/zlm/conf:/opt/media/conf \ -v /data/zlm/log:/opt/media/log \ zlmediakit/zlmediakit:master参数说明:1935是 RTMP 端口,554是 RTSP 端口,8080映射给你自己用,对应容器内的 Web/API 端口80,后面所有 HTTP 请求都走这个口;8554是 WebRTC 的端口,视频对外用 WebRTC 时端口不够会被抢占;10000/udp是 RTP 接收端口,拉取 RTSP 时 UDP 数据从这里进来。挂载目录把配置和日志落到宿主机,否则下次容器重建,你在 hook、鉴权里的改动全部丢失。如果摄像头在别的网段,554 端口要能被摄像头网段访问到,这一步不打通,后面所有画面都拉不回来。
2.3 把摄像头地址“喂”给 ZLMediaKit
ZLMediaKit 提供 HTTP API,addStreamProxy是最常用的入驻口。假设摄像头是海康,示例:
curl -X POST "http://127.0.0.1:8080/index/api/addStreamProxy" \ -d "vhost=__defaultVhost__&app=live&stream=cam01&url=rtsp://admin:pwd@192.168.1.64:554/Streaming/Channels/101&rtp_type=0"返回code=0表示拉流注册成功。参数细节:stream建议直接用摄像头点位编号,比如cam01,后面和数据库点位表一一对应,出问题时好排查;app是应用名,这个例子用live,它决定播放 URL 的第二段路径;rtp_type是拉流的传输方式,0表示强制走 TCP。TCP 拉流在多跳网络里容错性最高,摄像头不在同一个二层网络时,用 UDP 很容易出现花屏、断帧,所以前期排查阶段一律设成0。如果你手头还没有摄像头,先用公开测试流练手:
curl -X POST "http://127.0.0.1:8080/index/api/addStreamProxy" \ -d "vhost=__defaultVhost__&app=live&stream=test1&url=rtsp://wowzaec2demo.streamlock.net/live/bigbuckbunny.mp4&rtp_type=0"这个地址是公网公开的 RTSP 测试流,能帮你把链路问题和设备问题拆开。拉流注册成功后,ZLMediaKit 才会去主动连接摄像头,所以接口返回code=0不代表画面已经就绪,通常需要 2~5 秒握手。批量接入时就是循环调用这个接口,几百路摄像头注意并发,服务端建议做成异步任务队列,不要把几百个请求一次性全塞给 ZLM。
拉流注册成功后,你会得到三种播放地址:
- HLS:
http://服务器IP:8080/live/cam01/hls.m3u8 - HTTP-FLV:
http://服务器IP:8080/live/cam01/cam01.flv - RTSP 回放:
rtsp://服务器IP:554/live/cam01
HLS 地址手机浏览器可以直接播,HTTP-FLV 需要前端引 flv.js,桌面体验延迟更低。海康取流路径有讲究,主码流是/Streaming/Channels/101(H.264)或201(H.265),子码流是102/202。同一个摄像头,网页预览和算法分析建议分开接,后面第 5 章细说。
2.4 先不写前端,用工具验证画面
拉流注册后,用 VLC 或 ffplay 验证源没有问题:
ffplay -rtsp_transport tcp -i "rtsp://127.0.0.1:554/live/cam01" -autoexit -window_title "test"-rtsp_transport tcp是必须的,和前面的rtp_type=0保持一致。也可以用 curl 检查 HLS 地址是否已经生成:
curl -I "http://127.0.0.1:8080/live/cam01/hls.m3u8"第一次请求可能404,因为切片还没生成,等几秒再请求一次,返回200就说明转封装链路正常。这里有个细节:HLS 的 m3u8 文件是先生成索引再生成切片,200只代表索引可用,还要确认.ts分片能正常下载。这个验证步骤的意义在于:把“摄像头有没有问题”和“前端代码有没有问题”分成两件事,后面排查会轻松一半。这个入口对下游算法也很友好,直接把它当流源喂给 YOLO 推理服务,可以省掉每个算法单独对接摄像头的重复劳动。
提示:ZLM 日志默认在挂载出来的
/data/zlm/log目录下,排查拉流失败优先看 MediaServer 日志里的断连原因,比反复试 URL 快得多。
3. SpringBoot 接进来:鉴权、签名与播放地址下发
3.1 为什么播放地址不能直接给前端
先把 ZLMediaKit 的地址http://ip:8080/live/cam01/hls.m3u8发给前端,看起来链路最短,但实际项目里你很快会发现三个问题:一是这个地址是“裸”的,任何人拿到 URL 就能看摄像头,安防项目第一个不答应;二是摄像头本身在业务系统里属于设备资产,真正的 RTSP 地址、账号、密码不能下场到浏览器,否则等于把设备凭据公开;三是多业务方共用一台 ZLM 时,谁在看、看的是哪一路、要不要计量,你得有一个业务层去记账。
所以 SpringBoot 在这里不是凑数的,它是播放链路的编排层:验证请求者身份,按设备权限生成有时效的播放地址,再把 ZLMediaKit 的鉴权开关打开,让每一次播放都由它裁决。它不参与具体媒体数据传输,只是控制“谁能拿到地址”和“拿到地址后能播多久”,这个定位决定了它的代码量不会大,但重要性最高。
3.2 在 ZLMediaKit 里开鉴权:HTTP Hook 回调
ZLM 支持 HTTP Hook,播放请求到达时会回调你的 SpringBoot 接口,拿到返回结果再决定放行还是拒绝。先在 ZLM 的config.ini里打开:
[hook] enable=1 on_play=http://172.16.1.10:8080/zlm/hook/on_playon_play就是播放鉴权回调,ZLM 会把 vhost、app、stream、播放 URL 里的 query 参数(params)等字段 POST 过来。你的接口返回{"code":0}就放行,非 0 就拒绝。注意on_play的地址要填 SpringBoot 能被 ZLM 访问到的内网地址,别写localhost,两台机器各说各话。这一步做完,播放地址即使泄露,没有合法params也播不了。
还需要强调:加了 hook 不等于万事大吉,hook 回调本身是 HTTP 请求,要在 SpringBoot 侧校验请求来源 IP,只接受 ZLM 服务器的访问,否则伪造请求也能骗过鉴权。SpringBoot 侧实现:
@RestController @RequestMapping("/zlm/hook") public class ZlmHookController { @PostMapping("/on_play") public Map<String, Object> onPlay(@RequestBody Map<String, Object> body) { String params = String.valueOf(body.getOrDefault("params", "")); // params 形如 "expire=1730000000&sign=xxxx" if (PlayUrlService.verify(params)) { return Map.of("code", 0); // 允许播放 } return Map.of("code", 1); // 拒绝播放 } }逻辑说明:params是播放 URL 里?后面的参数,由 SpringBoot 生成播放地址时注入,这里解析并校验签名。Map.of是 JDK9+ 的写法,如果你的项目锁在 JDK8,改成 HashMap。校验通过返回code=0。注意这里不要用简单的字符串包含判断,签名必须做时效校验,否则 URL 可以无限复用。
3.3 生成带过期时间的播放地址
前端不直接拿设备地址,而是请求 SpringBoot 接口换取播放 URL。生成逻辑如下:
public static String buildPlayUrl(String host, String app, String stream, long expireSeconds) { long expire = System.currentTimeMillis() / 1000 + expireSeconds; String secretKey = "your-zlm-secret-change-me"; String sign = md5("expire=" + expire + "&secret=" + secretKey); // 输出 http://host:8080/live/cam01/cam01.flv?expire=1730000000&sign=xxxx return String.format("http://%s:8080/%s/%s/%s.flv?expire=%d&sign=%s", host, app, stream, stream, expire, sign); }参数说明:expireSeconds一般给 10 分钟到 2 小时,安防大屏场景建议 30 分钟,过期后前端重新请求一次即可。签名算法是“参数 + 密钥做 MD5”,密钥只存在 SpringBoot 的配置里,前端永远看不到。有人图省事把 secret 明文拼在 URL 里,那就等于没设防。另外注意:签名参数名要和 3.2 里 hook 校验的字段完全一致,两边不一致会导致 ZLM 认为播放非法,表现为地址刚生成时能播,过一会儿就断。
3.4 摄像头点位管理:数据库里存什么
到这一步,SpringBoot 的业务模型大致有三张表的雏形:
camera:摄像头基础信息,包括设备编号、名称、点位坐标、RTSP 地址、通道类型stream_proxy:ZLMediaKit 上的流代理记录,包括 app、stream、摄像机 ID、状态play_url:播放地址签发记录,包括摄像机 ID、签名参数、过期时间、操作人
这个划分并非必须,但有一个红线:rtsp_url只存在于camera表,SpringBoot 的接口返回给前端的 DTO 里不要包含这个字段,只返回 ZLM 播放地址和过期时间。用 Jackson 序列化时记得加@JsonIgnore,否则字段还是会被序列化出去。这样做的好处是:浏览器永远接触不到设备凭据;设备更换、地址修改只影响camera表;权限控制可以下沉到接口层,比如管理员能看全部点位,普通用户只能看自己绑定的几路。
3.5 SpringBoot 版本相关的两个坑
如果你是从旧工程改造,很容易遇到两个坑:一是javax.servlet和jakarta.servlet的切换,SpringBoot 3.x 用 jakarta 命名空间,旧代码全部要改 import,不想改就把 SpringBoot 钉在 2.7.x;二是 JDK 版本,SpringBoot 3 强制 JDK17,老代码用了反射、动态代理的要注意兼容性。这里不是让你追新版本,而是强调:不管用哪个版本,签名和 hook 这两段逻辑用标准 HTTP 和 JSON 实现,不依赖 SpringBoot 特有 API,将来版本升级成本最低。
4. Vue 播放端:m3u8、HTTP-FLV、WebRTC 怎么选
4.1 三种协议的延迟与兼容性对比
ZLM 已经把流转成了多种协议,Vue 端要做的只是挑一个合适的播放器接进来。挑协议的核心是延迟和兼容性的权衡,不是哪个新用哪个。正常项目里我一般按场景分:做视频墙、大屏巡看,延迟要求高,用 WebRTC;做网页端的实时监控预览,用 HLS 就够,m3u8 在手机 Safari 里原生能播;做低带宽下的轮询预览,用 HTTP-FLV(flv.js),延迟比 HLS 低但需要前端引入播放库。
| 协议 | 延迟量级 | 浏览器兼容 | 前端依赖 | 典型场景 |
|---|---|---|---|---|
| HLS(m3u8) | 3~8 秒 | 手机原生支持,Chrome 需 hls.js | hls.js | 多端兼容、展示型预览 |
| HTTP-FLV | 1~3 秒 | 需 flv.js | flv.js | 低延迟预览、轮播 |
| WebRTC | 200~800ms | Chrome/Edge 原生 | 信令与 STUN 处理 | 大屏、联动控制 |
这里要泼一盆冷水:WebRTC 延迟低,但要打通信令和 UDP 端口,在复杂网络里排查成本明显高于前两种。如果需求只是“能看、不卡”,优先 HLS;如果明确要“跟现场同步、做远程操作”,再考虑 WebRTC。别一上来就把三个协议全接上,代码翻倍,收益却未必有。
4.2 用 hls.js 播 m3u8:最小 Demo 与关键参数
Vue 项目里装hls.js,在组件里定义一个video,mounted 时加载:
import Hls from 'hls.js'; const video = this.$refs.video; if (Hls.isSupported()) { this.hls = new Hls({ liveSyncDurationCount: 3, maxLiveSyncPlaybackRate: 1.5 }); this.hls.loadSource(this.streamUrl); // streamUrl 是 SpringBoot 返回的 m3u8 地址 this.hls.attachMedia(video); this.hls.on(Hls.Events.ERROR, (e, data) => { if (data.fatal) this.handleFatal(); // 网络断了要重新拉 URL }); }逻辑说明:Hls.isSupported()判断当前浏览器是否支持 MSE,不支持就走原生video.src = m3u8作为降级;liveSyncDurationCount: 3表示播放器会尽量贴近直播末尾 3 个分片的距离,值越大越稳、延迟越大;maxLiveSyncPlaybackRate: 1.5允许播放器在追帧时短暂以 1.5 倍速播放,这个参数能明显减少直播画面越拖越久的问题。最大好处是免安装:不需要装任何插件,也不用 VLC,一个 npm 依赖就解决桌面端播放 m3u8 的问题。
注意:组件销毁时必须调用
this.hls.destroy(),否则播放器会一直请求切片,日志里出现大量 404 还找不到原因。如果你用的是 vue-router,离开页面也要销毁,切页面之后声音还在放,多半就是没销毁实例。
4.3 flv.js 的延迟调优与取舍
HTTP-FLV 在桌面端体验接近实时,flv.js 的接入代码并不复杂:
import flvjs from 'flv.js'; const flvPlayer = flvjs.createPlayer( { type: 'flv', isLive: true, url: this.streamUrl }, { enableStashBuffer: false, liveBufferLatencyChasing: true } ); flvPlayer.attachMediaElement(this.$refs.video); flvPlayer.load();参数说明:enableStashBuffer:false关闭内部缓冲,首帧渲染更快、延迟更低,但弱网容易断流;liveBufferLatencyChasing是追帧平滑的开关,开启后播放器会自动丢帧对齐直播末尾。两条经验:一是 flv.js 在 iOS 移动端兼容性差,移动端老老实实走 HLS;二是如果画面总是卡在半截,优先怀疑流类型不是 FLV 而是 HLS,后端给地址的时候把协议和播放器对应好,这种黑匣子问题多半出在协议不匹配。
4.4 播放地址过期了怎么办
前面签名地址设置了过期时间,前端要有意识地处理“过期续期”。常见做法是:拿到地址时记录expire时间戳,在过期前 2 分钟静默调用 SpringBoot 的续期接口换取新的 URL,播放器swap()或重新loadSource。别等到黑屏再换,黑屏之后的处理逻辑比续期复杂得多:要销毁重建、要处理卡在缓冲区的帧,还要照顾用户的心理预期。另一个实践细节:Vue 组件的beforeUnmount里统一做hls.destroy()和flvPlayer.destroy(),短视频平台踩过这个坑的人不少,多路画面叠加播放时尤其明显。
5. 避坑与排查:拉流到播放最容易翻车的五个点
5.1 摄像头 RTSP 地址写错了,接口却返回成功
现象:addStreamProxy返回code=0,但 HLS 地址一直 404,ZLM 日志里不断重连。
原因:这个接口返回成功只代表“拉流任务注册成功”,不代表摄像头已经连通。RTSP 地址写错、账号密码错误、摄像头端口没开,都会在回调日志里体现。海康的取流路径有约定:主码流是/Streaming/Channels/101(H.264)或/Streaming/Channels/201(H.265),子码流是102/202。很多人只记得101,换到子码流或 H.265 机型就摸不着头脑。小米、萤石的取流地址格式又不一样,统一做法是在设备端确认。
解决:先用 VLC 或ffprobe直接验证摄像头地址本身能通,排除设备问题后再去查 ZLM。
ffprobe -rtsp_transport tcp "rtsp://admin:pwd@ip:554/Streaming/Channels/101"能正常返回流信息,再往 ZLM 里加。这一步能省掉 80% 的排查时间。
5.2 网页上一直转圈,VLC 却秒开
现象:VLC 打开同一路流秒出画面,网页端 video 黑屏或一直 loading。
原因:协议栈不同。VLC 是完整的播放器,支持 RTSP 直连;浏览器要的是 HTTP 协议输出。你如果在网页里直接写rtsp://地址,浏览器根本不具备 RTSP 会话能力,理所当然黑屏。另一个隐蔽原因是:前端地址是m3u8,但 ZLM 的 HLS 切片还没生成完,第一次加载只拿到空的索引。
解决:确认前端地址走的是 ZLM 的 HTTP 输出,不是原始 rtsp;HLS 刚接入时等 5 秒再拉流;还不行就抓一次 m3u8 内容,里面 ts 分片列表为空时,问题在转封装侧,不在播放器。前端永远不要直接用rtsp://地址,浏览器没有 RTSP 会话栈,这条规则定下来,能少踩 90% 的坑。
5.3 ZLMediaKit 裸奔:任何人拿到地址就能看
现象:项目上线几天后,收到告警说流量异常,一查是有陌生 IP 在持续拉取摄像头画面。
原因:ZLM 默认没有鉴权,播放 URL 不校验任何人。只开了端口映射就往公网放,等于把摄像头直播挂到了公网上。ZLMediaKit 相关的漏洞报告,多数集中在 hook 鉴权绕过和默认 secret 未修改这两类问题上。
解决:三层都做。第一层,ZLM 开启 HTTP hook 鉴权,也就是第 3 章的on_play配置;第二层,把secret从默认值改掉,API 调用也需要签名;第三层,对外只开放必要的 HTTP 端口,RTSP/RTMP 端口不要直接暴露公网,用防火墙在前面挡一道。血泪经验:监控项目一上线就要当公网服务来防守,这类系统的漏洞往往不是功能性问题,而是鉴权边界问题。
5.4 主码流接进来了,网页却卡成幻灯片
现象:摄像头能看到画面,但多路同时预览时 CPU 飙升,画面掉帧。
原因:每一路的码流没有区分用途。主码流清晰度高、帧率高,适合存储和算法分析;网页预览通常是“看个大概”,用子码流就够了。全项目统一用主码流接入,浏览器解码压力直接拉满,几路 4K 同时播,任何机器都扛不住。
解决:接入时按用途分流:预览走子码流102,录像和算法分析走主码流101。如果摄像头画质高,还要在 ZLM 侧考虑是否转码,但转码有 CPU 成本,能改码流参数就不要转码。另外,前端播放器解码参数也要匹配,H.265 的流在 Chrome 上兼容性差,画面黑屏时先检查编码格式。
5.5 内网秒开,公网永远卡顿
现象:同一个播放地址,在内网测很流畅,放到公网访问就黑屏、起播慢、卡顿。
原因:公网链路和内网不是一回事。如果是 WebRTC 播放,UDP 端口段在防火墙上没放行,信令通了,媒体传不回来;如果是 HLS,切片文件太小、请求次数太多,公网往返延迟放大后,加载效率明显下降。这类问题排查到最后往往不是配置问题,而是链路中间设备的玄学,抓包看 TCP 重传率最快。
解决:公网环境优先用 HTTP-FLV 或 HLS,避免 WebRTC 的 UDP 依赖;hls.js 里适当调大liveSyncDurationCount,让播放器多缓存一点;在 ZLM 出口前加一层反向缓存,减轻公网频繁拉取切片造成的压力。保持rtp_type=0强制 TCP 拉流,再配合抓包确认重传率,网络侧的问题基本都能定位。
6. Geoserver 的联动姿势:让摄像头回到地图上
6.1 先说它解决什么问题
看标题的人会困惑:RTSP 是视频流,Geoserver 是 GIS 服务,这俩怎么凑一块。实际上 Geoserver 在这条链路里解决的不是“视频能不能播”,而是“摄像头在哪、画面属于哪个空间位置”。常见的做法是:摄像头点位经纬度存入 PostGIS,Geoserver 发布 WMS/WMTS 底图或摄像头点位图层,前端用 Leaflet 或 Mapbox 加载地图,点击点位弹窗嵌入第 4 章做好的播放器。Geoserver 的 WMS 请求可以配合 authkey 做地图访问控制,视频播放的权限仍然归 SpringBoot 管,两者各守一段。
6.2 点位上叠加视频播放器的最小联动
在 Vue 的 Leaflet 组件里,点位弹窗和视频播放是两个独立职责:
const popup = L.popup().setLatLng([lat, lng]) .setContent(`<video id="cam-pop" muted playsinline></video>`) .openOn(map); fetch('/api/play/url?deviceId=' + id) .then(res => res.json()) .then(data => attachHlsToVideo('#cam-pop', data.url));逻辑说明:弹窗先占位,再请求 SpringBoot 换取播放地址,最后把 hls.js 挂到 video 上。弹窗关闭时,要把播放器实例销毁,否则视频会在页面里继续拉流,叠加多个点位后声音会互相串。这块代码很短,却是 GIS 视频融合里最容易翻车的地方。另外,Geoserver 发布点位图层时,前端图层只显示 id、name、在线状态,不要把设备 RTSP 地址作为属性发布出去,这一点和第 3 章的防泄露原则一致。
6.3 我的收尾习惯
最后讲一个实际的项目习惯:把视频播放链路先跑通,再叠加 GIS。两条链路叠在一起排错,问题互相干扰,黑屏时你分不清是视频流断了还是地图图层覆盖了。我的做法是先在纯网页上把 hls.js 播放调好,再去接 Leaflet 弹窗,每一步都有明确的验证节点:第一步能出画面,第二步能出点位,第三步才是点和画面的联动。这个方法帮我避开了很多次“什么都写了却不知道问题在哪”的困境。希望帮到你。
本文还有配套的精品资源,点击获取