从RTSP到浏览器播放:视频监控平台四组件链路搭建指南
简介面向需要将摄像头实时画面接入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 \ --restartunless-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 把摄像头地址“喂”给 ZLMediaKitZLMediaKit 提供 HTTP APIaddStreamProxy是最常用的入驻口。假设摄像头是海康示例curl -X POST http://127.0.0.1:8080/index/api/addStreamProxy \ -d vhost__defaultVhost__applivestreamcam01urlrtsp://admin:pwd192.168.1.64:554/Streaming/Channels/101rtp_type0返回code0表示拉流注册成功。参数细节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__applivestreamtest1urlrtsp://wowzaec2demo.streamlock.net/live/bigbuckbunny.mp4rtp_type0这个地址是公网公开的 RTSP 测试流能帮你把链路问题和设备问题拆开。拉流注册成功后ZLMediaKit 才会去主动连接摄像头所以接口返回code0不代表画面已经就绪通常需要 2~5 秒握手。批量接入时就是循环调用这个接口几百路摄像头注意并发服务端建议做成异步任务队列不要把几百个请求一次性全塞给 ZLM。拉流注册成功后你会得到三种播放地址HLShttp://服务器IP:8080/live/cam01/hls.m3u8HTTP-FLVhttp://服务器IP:8080/live/cam01/cam01.flvRTSP 回放rtsp://服务器IP:554/live/cam01HLS 地址手机浏览器可以直接播HTTP-FLV 需要前端引 flv.js桌面体验延迟更低。海康取流路径有讲究主码流是/Streaming/Channels/101H.264或201H.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_type0保持一致。也可以用 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] enable1 on_playhttp://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 MapString, Object onPlay(RequestBody MapString, Object body) { String params String.valueOf(body.getOrDefault(params, )); // params 形如 expire1730000000signxxxx if (PlayUrlService.verify(params)) { return Map.of(code, 0); // 允许播放 } return Map.of(code, 1); // 拒绝播放 } }逻辑说明params是播放 URL 里?后面的参数由 SpringBoot 生成播放地址时注入这里解析并校验签名。Map.of是 JDK9 的写法如果你的项目锁在 JDK8改成 HashMap。校验通过返回code0。注意这里不要用简单的字符串包含判断签名必须做时效校验否则 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?expire1730000000signxxxx return String.format(http://%s:8080/%s/%s/%s.flv?expire%dsign%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_proxyZLMediaKit 上的流代理记录包括 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-FLVflv.js延迟比 HLS 低但需要前端引入播放库。协议延迟量级浏览器兼容前端依赖典型场景HLSm3u83~8 秒手机原生支持Chrome 需 hls.jshls.js多端兼容、展示型预览HTTP-FLV1~3 秒需 flv.jsflv.js低延迟预览、轮播WebRTC200~800msChrome/Edge 原生信令与 STUN 处理大屏、联动控制这里要泼一盆冷水WebRTC 延迟低但要打通信令和 UDP 端口在复杂网络里排查成本明显高于前两种。如果需求只是“能看、不卡”优先 HLS如果明确要“跟现场同步、做远程操作”再考虑 WebRTC。别一上来就把三个协议全接上代码翻倍收益却未必有。4.2 用 hls.js 播 m3u8最小 Demo 与关键参数Vue 项目里装hls.js在组件里定义一个videomounted 时加载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返回code0但 HLS 地址一直 404ZLM 日志里不断重连。原因这个接口返回成功只代表“拉流任务注册成功”不代表摄像头已经连通。RTSP 地址写错、账号密码错误、摄像头端口没开都会在回调日志里体现。海康的取流路径有约定主码流是/Streaming/Channels/101H.264或/Streaming/Channels/201H.265子码流是102/202。很多人只记得101换到子码流或 H.265 机型就摸不着头脑。小米、萤石的取流地址格式又不一样统一做法是在设备端确认。解决先用 VLC 或ffprobe直接验证摄像头地址本身能通排除设备问题后再去查 ZLM。ffprobe -rtsp_transport tcp rtsp://admin:pwdip:554/Streaming/Channels/101能正常返回流信息再往 ZLM 里加。这一步能省掉 80% 的排查时间。5.2 网页上一直转圈VLC 却秒开现象VLC 打开同一路流秒出画面网页端 video 黑屏或一直 loading。原因协议栈不同。VLC 是完整的播放器支持 RTSP 直连浏览器要的是 HTTP 协议输出。你如果在网页里直接写rtsp://地址浏览器根本不具备 RTSP 会话能力理所当然黑屏。另一个隐蔽原因是前端地址是m3u8但 ZLM 的 HLS 切片还没生成完第一次加载只拿到空的索引。解决确认前端地址走的是 ZLM 的 HTTP 输出不是原始 rtspHLS 刚接入时等 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_type0强制 TCP 拉流再配合抓包确认重传率网络侧的问题基本都能定位。6. Geoserver 的联动姿势让摄像头回到地图上6.1 先说它解决什么问题看标题的人会困惑RTSP 是视频流Geoserver 是 GIS 服务这俩怎么凑一块。实际上 Geoserver 在这条链路里解决的不是“视频能不能播”而是“摄像头在哪、画面属于哪个空间位置”。常见的做法是摄像头点位经纬度存入 PostGISGeoserver 发布 WMS/WMTS 底图或摄像头点位图层前端用 Leaflet 或 Mapbox 加载地图点击点位弹窗嵌入第 4 章做好的播放器。Geoserver 的 WMS 请求可以配合 authkey 做地图访问控制视频播放的权限仍然归 SpringBoot 管两者各守一段。6.2 点位上叠加视频播放器的最小联动在 Vue 的 Leaflet 组件里点位弹窗和视频播放是两个独立职责const popup L.popup().setLatLng([lat, lng]) .setContent(video idcam-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 弹窗每一步都有明确的验证节点第一步能出画面第二步能出点位第三步才是点和画面的联动。这个方法帮我避开了很多次“什么都写了却不知道问题在哪”的困境。希望帮到你。本文还有配套的精品资源点击获取