因为MiBeeNVR,决定接 onvif-go 自己管理并重构

一切始于一个 401

前段时间我在给自己的开源项目 MiBeeNvr 做 ONVIF 相机接入。这是个纯 Go 的轻量 NVR,跑在香蕉派上,接海康、大华、IPC 和几台自制的 ESP32 相机。ONVIF 这层我没打算自己从 SOAP 开始造轮子------市面上现成的 Go 库里,onvif-go 是完成度最高的那个:设备发现、Profile 枚举、取流地址、PTZ、事件订阅、成像参数,甚至还有一个虚拟相机服务端用来做测试。文档全,例子多,我几乎没犹豫就选了它。

然后海康相机给了我一个下马威。

一台海康设备,凭据明明是对的,所有请求一律 sender not authorized。抓包、对账、翻协议文档,折腾了一个晚上,最后发现是相机时钟和 NVR 差了几分钟 ------WS-Security 的 UsernameToken 摘要把 Created 时间戳也算进了哈希,相机按自己的时钟校验重放窗口,差得多了就直接拒绝。这基本是海康系的老毛病,各家 SDK 都有自己的补法。

补法其实不复杂:先无鉴权调一次 GetSystemDateAndTime,算出偏差,再用"相机的现在"去生成摘要。我把修法做在了自己这边。后来又撞上一个:相机换 IP 之后,GetCapabilities 返回的 XAddr 还是旧地址------相机漫游到新地址后自己广播的还是老 IP,每次服务调用都打去一个不存在的机器。也修了。

而上游仓库的提交节奏早就慢了下来,issue 区是安静的。这两个补丁就只能挂在自己的 fork 里。

fork 不是终点,是债务的开始

对一个跑在生产上的项目来说,"等上游"不是一个选项。我先走了标准路线:fork 到组织账号,打两个 tag(v1.1.6、v1.1.7),NVR 的 go.mod 里挂一条 replace 指过去:

go 复制代码
replace github.com/0x524a/onvif-go => github.com/Mi-Bee-Studio/onvif-go v1.1.7

能跑,但别扭。每次社区有人问"你这个 fork 改了什么",我都得写一小作文;fork 和上游的 diff 越攒越多,总有一天会合不动;而 replace 这种东西,用过的都懂------它是个一直悬在那里的技术债。

今年八月我想通了一件事:这个库对我的项目来说是基础设施,原作者显然已经没有精力维护了,那不如正经接手 。原作者把仓库转给了我,MIT 协议,干净利落。紧接着我做了一件不算客气但很诚实的事------把它彻底改成了我想要的样子,发布了 v1.2.0。

(致谢还是要在的:这个库的地基是原作者打的,MIT 允许我这么干,README 和 LICENSE 里都留着致谢。开源世界的接力本来就是这么运转的。)

v1.2.0:既然要动,就动到位

接手之前我把整个库读了一遍,顺手做了个体检。数字不太好看:

  • 87 个 Go 文件、4.2 万行代码,239 个方法全部挂在一个 *Client------Device、Media、PTZ、Imaging、Events、DeviceIO 混作一团
  • 一个 3851 行的 media.go,83 个方法,130 个请求/响应结构体声明在函数体内部------没法复用、没法在测试里构造、服务端想引用一下都够不着
  • CI 里有 6 个一直红着的测试(是的,带着失败测试发布的)
  • 一堆历史遗留:误提交的二进制文件、40 个文件的重复数据目录、过时的 workflow
  • 依赖树里拖着一整套 RTSP 库,而库本身一行都没用到它

所以 v1.2.0 不是"换个 module 路径",而是一次完整的重造:

1. module 路径改为 github.com/mickeyzzc/onvif-go,replace 时代结束。

NVR 的 go.mod 里那行 replace 删掉了,直接 require。fork 的历史 tag(v1.0.0~v1.1.7)原样保留,谁在用旧版本都不受影响。

2. API 重构成服务门面。

go 复制代码
client, _ := onvif.NewClient("http://192.168.1.100/onvif/device_service",
    onvif.WithCredentials("admin", "pass"))
_ = client.Initialize(ctx)

// 以前:239 个方法平铺在 Client 上
// 现在:按 ONVIF 的服务域分而治之
info, _ := client.Device().GetDeviceInformation(ctx)
profiles, _ := client.Media().GetProfiles(ctx)
uri, _ := client.Media().GetStreamURI(ctx, profiles[0].Token)
_ = client.PTZ().ContinuousMove(ctx, profiles[0].Token, speed, timeout)
_ = client.Events().CreatePullPointSubscription(ctx, ...)
go 复制代码
// 设备发现是独立的包
devices, _ := discovery.Discover(ctx, 5*time.Second)

3. 巨文件拆掉,类型提升。

media.go 拆成 profiles / stream / encoder / audio / osd 五个文件,130 个函数内类型全部提升为包级------现在你可以在自己的测试里直接构造这些结构体了。

4. 工程化对齐我 NVR 项目的标准。

Go 1.26、gofumpt + goimports、golangci-lint v2 二十多个 linter 清零告警 、CI 三件套(lint / test / build)全绿、main 受保护分支。顺手修了那 6 个失败测试------其中一个还牵出一个真 bug:StorageUri 的 XML 标签大小写和 ONVIF 规范不一致,导致存储配置的 URI 永远解析为空。

5. 零第三方依赖。

唯一那个只被命令行工具用到的 RTSP 依赖被摘掉了。现在整个 module 的 go.mod 长这样:

复制代码
module github.com/mickeyzzc/onvif-go

go 1.26

就这两行。对一个基础库来说,这比什么都体面。

这个库能干什么

既然是宣传文,认真列一下能力面。ONVIF Profile S 覆盖度是这个库的强项:

能力 说明
设备发现 WS-Discovery 组播主动探测、网络接口选择(跨子网单播探测、被动 Hello 监听在路线图上)
媒体服务 Profile 枚举/创建、RTSP/HTTP 取流地址、快照地址、编码参数读写、OSD、多播控制
PTZ 连续/绝对/相对移动、预置位、状态查询
事件 PullPoint 订阅、消息拉取、续订(托管式自动轮询/续订在路线图上)
成像 曝光/白平衡/亮度/对比度/聚焦,含参数范围查询
设备管理 信息、能力、网络配置、用户管理、存储、证书、WiFi
虚拟相机服务端 自带一个 ONVIF 相机模拟器,不需要真机就能跑集成测试
抓包回归测试 testdata 里存着 Axis/Bosch/Reolink 等真机的 SOAP 抓包,作为回归夹具回放

#mermaid-svg-hQdyaq4BQWDyJqa4{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-hQdyaq4BQWDyJqa4 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-hQdyaq4BQWDyJqa4 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-hQdyaq4BQWDyJqa4 .error-icon{fill:#552222;}#mermaid-svg-hQdyaq4BQWDyJqa4 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-hQdyaq4BQWDyJqa4 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-hQdyaq4BQWDyJqa4 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-hQdyaq4BQWDyJqa4 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-hQdyaq4BQWDyJqa4 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-hQdyaq4BQWDyJqa4 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-hQdyaq4BQWDyJqa4 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-hQdyaq4BQWDyJqa4 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-hQdyaq4BQWDyJqa4 .marker.cross{stroke:#333333;}#mermaid-svg-hQdyaq4BQWDyJqa4 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-hQdyaq4BQWDyJqa4 p{margin:0;}#mermaid-svg-hQdyaq4BQWDyJqa4 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-hQdyaq4BQWDyJqa4 .cluster-label text{fill:#333;}#mermaid-svg-hQdyaq4BQWDyJqa4 .cluster-label span{color:#333;}#mermaid-svg-hQdyaq4BQWDyJqa4 .cluster-label span p{background-color:transparent;}#mermaid-svg-hQdyaq4BQWDyJqa4 .label text,#mermaid-svg-hQdyaq4BQWDyJqa4 span{fill:#333;color:#333;}#mermaid-svg-hQdyaq4BQWDyJqa4 .node rect,#mermaid-svg-hQdyaq4BQWDyJqa4 .node circle,#mermaid-svg-hQdyaq4BQWDyJqa4 .node ellipse,#mermaid-svg-hQdyaq4BQWDyJqa4 .node polygon,#mermaid-svg-hQdyaq4BQWDyJqa4 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-hQdyaq4BQWDyJqa4 .rough-node .label text,#mermaid-svg-hQdyaq4BQWDyJqa4 .node .label text,#mermaid-svg-hQdyaq4BQWDyJqa4 .image-shape .label,#mermaid-svg-hQdyaq4BQWDyJqa4 .icon-shape .label{text-anchor:middle;}#mermaid-svg-hQdyaq4BQWDyJqa4 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-hQdyaq4BQWDyJqa4 .rough-node .label,#mermaid-svg-hQdyaq4BQWDyJqa4 .node .label,#mermaid-svg-hQdyaq4BQWDyJqa4 .image-shape .label,#mermaid-svg-hQdyaq4BQWDyJqa4 .icon-shape .label{text-align:center;}#mermaid-svg-hQdyaq4BQWDyJqa4 .node.clickable{cursor:pointer;}#mermaid-svg-hQdyaq4BQWDyJqa4 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-hQdyaq4BQWDyJqa4 .arrowheadPath{fill:#333333;}#mermaid-svg-hQdyaq4BQWDyJqa4 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-hQdyaq4BQWDyJqa4 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-hQdyaq4BQWDyJqa4 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-hQdyaq4BQWDyJqa4 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-hQdyaq4BQWDyJqa4 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-hQdyaq4BQWDyJqa4 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-hQdyaq4BQWDyJqa4 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-hQdyaq4BQWDyJqa4 .cluster text{fill:#333;}#mermaid-svg-hQdyaq4BQWDyJqa4 .cluster span{color:#333;}#mermaid-svg-hQdyaq4BQWDyJqa4 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-hQdyaq4BQWDyJqa4 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-hQdyaq4BQWDyJqa4 rect.text{fill:none;stroke-width:0;}#mermaid-svg-hQdyaq4BQWDyJqa4 .icon-shape,#mermaid-svg-hQdyaq4BQWDyJqa4 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-hQdyaq4BQWDyJqa4 .icon-shape p,#mermaid-svg-hQdyaq4BQWDyJqa4 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-hQdyaq4BQWDyJqa4 .icon-shape .label rect,#mermaid-svg-hQdyaq4BQWDyJqa4 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-hQdyaq4BQWDyJqa4 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-hQdyaq4BQWDyJqa4 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-hQdyaq4BQWDyJqa4 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} onvif-go
你的应用
组播/单播
模拟
业务代码
Client 连接/凭据/时钟偏差
Device
Media
PTZ
Events
Imaging
DeviceIO
Security
discovery 发现
server 虚拟相机
internal/soap --- WS-Security digest
ONVIF 相机

(mermaid 在掘金和 GitHub 上直接渲染;知乎不认的话用 images/03-arch.png 截图版。)

接手之后修的几个"实战 bug"

这几个都是我在真实相机上撞出来的,比跑一万次 demo 都涨知识:

时钟偏差认证(海康) 就是开头那个 401。现在库里有 SetClockSkew,配合 GetSystemDateAndTime 测出的偏差生成摘要,海康的时钟漂移不再炸认证。后续计划做成 WithAutoClockSkew() 一个选项自动搞定。

陈旧 XAddr 相机换 IP 后,GetCapabilities 广播的还是旧地址,所有服务调用打向虚空。现在客户端会拿"我是从哪个地址够到你的"做基准,改写所有对不上的 XAddr。

僵尸相机入侵 有一天我的 NVR 里多了两台名叫 "ONVIF Camera" 的相机,地址长着 http://某主机名:5000/wsd/... 的样子------是局域网里的 Windows 主机广播 WS-Discovery Hello 被当成了相机入库,凭据为空、永远连不上,还会在 health 里报 error。修完发现主动发现路径早就有非 ONVIF 过滤,被动监听路径漏了------这类"两条路只有一条设卡"的 bug,不跑生产真发现不了。

StorageUri 大小写 上面提过的解析 bug,ONVIF 规范里一水的 Uri(StreamUri、SnapshotUri、StorageUri),字段名写成 StorageURI 就永远匹配不上。一个 xml tag 的事,但没人在意它就永远不会好。

路线图都排好了

接手时我把 NVR 这两年在 ONVIF 上踩的坑全部整理成了 issue(#1#12),按优先级排了 P0-P2:

  • P0:鉴权策略梯队(Digest/PasswordText/Basic/None------有些相机在不同服务上接受的鉴权方式还不一样)、GetStreamURI 的 StreamSetup 参数化、部分设备返回空 URI 的解析修复、跨子网单播探测
  • P1:托管式 PullPoint 订阅(自动轮询/续订)、主/子码流 Profile 选择助手、时钟偏差自动测量、被动发现结果过滤
  • P2:SetNetworkInterfaces 补齐、能力缓存、并发安全契约文档化

每一个都带着真实设备的复现背景和验收标准,不是许愿池。欢迎来提需求、报设备兼容性问题,或者直接交 PR------这个库现在是活的

相关推荐
格林威43 分钟前
C# 相机Burst模式图像采集:使用相机内存配合OpenCvSharp和Halcon实现短时间的高速采集的方法
开发语言·人工智能·数码相机·计算机视觉·c#·视觉检测·工业相机
志栋智能1 小时前
大模型驱动运维:重构故障根因分析的“思考”逻辑
运维·服务器·重构·自动化
ttwuai6 小时前
Go 后台管理系统组件库文档怎么写,才不会坑后续维护?
开发语言·javascript·golang
长江后浪博客7 小时前
黑色滴灌带三角结构视觉检测光源设计方案——200mm高角度环光 + 低角度条形暗场补光
人工智能·数码相机·视觉检测·机器视觉·光源设计·暗场补光·环形光
2601_962066237 小时前
golang超详细基础入门教程_golang教程
前端·python·golang
深念Y8 小时前
视频平台架构重构:从微服务到云原生
服务器·微服务·云原生·重构·架构·音视频·短视频
互联网中的一颗神经元21 小时前
10. Large 对象分配:直接路径
开发语言·golang
microrain1 天前
从关系库硬扛到专业时序引擎:SagooIoT时序数据存储的架构取舍
物联网·golang·开源·sagooiot
Vect__1 天前
channel 底层剖析
golang