不封装的代价不是行数多,是「XML 长什么样」这件事散落在每处调用里。 封装对了,八行变四行;封装错了,以后每改一次都要动所有调用方。
08.08.09 把命名空间表定下来了,这一节把「怎么用它」收进一个类。 08.03 到 08.07 那五个操作都是各自独立的例子,这一节要把它们串成一个能复用的小客户端。
一、不封装长什么样
取一个流地址,要写这么多:
inner = ('<trt:GetStreamUri><trt:StreamSetup>'
'<tt:Stream>RTP-Unicast</tt:Stream>'
'<tt:Transport><tt:Protocol>RTSP</tt:Protocol>'
'</tt:Transport></trt:StreamSetup>'
'<trt:ProfileToken>Profile_1</trt:ProfileToken>'
'</trt:GetStreamUri>')
a, b, n = S.call(port, "media", "GetStreamUri", inner)
uri = re.search(r"<tt:Uri>([^<]+)</tt:Uri>", b).group(1)
八行,只换来一个字符串。
如果客户端要调八个操作,这种样板就是八份,
而八份里每一份都有「命名空间写错、元素顺序写反」的机会。
注意最后一行那个正则:
它假设返回里只有一个 <tt:Uri>——GetStreamUri 的返回确实只有一个,但换个操作就不一定了。
二、封装之后
同样三件事,变成三行:
c = OnvifClient(xaddr)
print(c.info())
print(c.profiles())
print(c.stream_uri("Profile_1"))
第一行建对象时就带了地址,
后面每行只说「要什么」,不说「怎么要」——这是封装的判据:调用方代码里不该出现 XML。
第二行那个 info() 背后是两次调用(设备信息 + 能力集),但调用方不需要知道这个。
该合并的合并掉,是封装的价值之一。
第三行那个 profiles() 返回的是结构化的列表,
不是 XML 字符串——这样上层代码才能写 for p in profiles。
三、收益不在行数
八行变四行只是副作用,真正的收益是「三件事被关起来了」:
| 被关起来的东西 | 以前在哪 |
|---|---|
| 命名空间前缀 | 每处调用 |
| 元素顺序 | 每处调用 |
| 解析路径 | 每处调用 |
这三样正好是 ONVIF 最烦人的三点:前缀写错报 unbound、顺序写反设备不认、路径写错取到空值。 把它们关进类里之后, 上层代码只面对「方法名 + 参数 + 返回值」,也就是普通函数的样子。 这条界线值得写下来:封装的目的是把「协议细节」和「业务逻辑」隔开,不是把代码变短。
四、该包什么、不该包什么
这两列的分界决定了封装能不能长期活下去:
| 该包 | 不该包 |
|---|---|
| 拼信封、解析 XML | 重试与超时策略 |
| 把 token 当参数传 | 业务判断 |
| 把异常统一成一种 | 日志内容 |
右列那三项如果包进去,每改一次都要动封装层,而封装层一动,所有调用方都受影响。 这就是「封装过度」比「不封装」更难维护的原因:不封装至少改起来是局部的。 举例说重试: 取流地址失败了该重试三次还是立刻报错,取决于调用它的场景—— 预览界面要立刻报错,后台巡检可以重试。这种决策不能塞进封装层。 左列那三项则是纯协议细节:换一台设备、换一个 ONVIF 版本,它们要变, 而调用方不该跟着变。
五、一个最小可用的轮廓
封装层不需要大,五个方法就能覆盖第八章全部例子:
| 方法 | 背后是什么 |
|---|---|
info() |
设备信息 + 能力 |
profiles() |
GetProfiles |
stream_uri(t) |
GetStreamUri |
snapshot_uri(t) |
GetSnapshotUri |
set_res(w, h) |
改编码配置 |
第一行那个「+ 能力」是我合并的:单独调 GetCapabilities 的场景很少,
大多是为了知道「这台设备支不支持云台」,所以要一个合并后的结果。
第五行那个 set_res 返回什么值得想一下:08.07 已经证明设备会「静默夹边界」,
所以它应该返回「实际生效的分辨率」,而不是返回「成功」。
这条设计是从实测里长出来的:设 3000×2000 返回成功、回读是 1920×1080,
如果封装层只说「成功」,调用方会一直以为自己设对了。
六、动手 Demo:把八行样板压成四行
Demo 在本机搭的模拟设备上真调一遍,对比「裸调」和「封装后」两种写法。 输出里的四个值都是真跑出来的,包括那个抓拍地址上的端口号。
c = OnvifClient(xaddr)
i = c.info()
print("Manufacturer=%s,Model=%s,Firmware=%s"
% (i["Manufacturer"], i["Model"], i["FirmwareVersion"]))
for p in c.profiles():
print("Profile: %s(%dx%d)" % (p["token"], p["w"], p["h"]))
print("stream_uri →", c.stream_uri("Profile_1"))
print("snapshot_uri→", c.snapshot_uri("Profile_1"))
实跑输出:
--------------------------------------------------------------------
把八行样板压成四行
--------------------------------------------------------------------
**不封装的代码长什么样?**
**每个操作都要拼一遍信封、发一遍请求、解一遍 XML**:
inner = ('<trt:GetStreamUri><trt:StreamSetup>'
'<tt:Stream>RTP-Unicast</tt:Stream>'
'<tt:Transport><tt:Protocol>RTSP</tt:Protocol>'
'</tt:Transport></trt:StreamSetup>'
'<trt:ProfileToken>Profile_1</trt:ProfileToken>'
'</trt:GetStreamUri>')
a, b, n = S.call(port, "media", "GetStreamUri", inner)
uri = re.search(r"<tt:Uri>([^<]+)</tt:Uri>", b).group(1)
**8 行,只换来一个字符串。**
**如果客户端要调八个操作,这种样板就是八份。**
**封装之后同样三件事**:
c = OnvifClient(xaddr)
print(c.info())
print(c.profiles())
print(c.stream_uri("Profile_1"))
**真跑一次,看输出**:
info → Manufacturer=ACME,Model=IPC-2000,FirmwareVersion=5.6.1
profiles → MainStream(1920×1080)
stream_uri → `rtsp://127.0.0.1:554/onvif/profile1`
snapshot_uri → `http://127.0.0.1:52731/onvif/snapshot/profile1`
**8 行变 4 行**,但真正的收益不是行数:
**是把「XML 长什么样」这件事关进了类里面。**
**上层代码从此看不到命名空间、看不到元素顺序、看不到解析路径**——
**这正好是 ONVIF 最烦人的三件事。**
**封装层该包什么、不该包什么,有两条界线**:
该包 不该包
-------------------------------------------------------
拼信封、解析 XML **重试与超时策略**(要按场景调)
把 token 当参数传 **业务判断**(哪路流给谁看)
把异常统一成一种 **日志内容**(现场要看得懂)
**第二列那三项如果包进去,以后每改一次都要动封装层**,
**而封装层一动,所有调用方都受影响**——
**这就是「封装过度」比「不封装」更难维护的原因。**
输出最后两行的地址端口不一样:
一个 554、一个 52731——因为 RTSP 用标准端口,而抓拍走的是 HTTP 服务的临时端口。
这两个地址都是设备算出来告诉你的,客户端不能自己拼:
拼出来的 rtsp://ip:554/onvif/profile1 在你这台设备上对,换一台就 404。
profiles() 返回的是列表,
所以能直接 for 循环——这是「返回结构化数据」和「返回 XML 字符串」的差别。
注意 stream_uri 的参数是 Profile_1 这个 token:它必须从 profiles() 里拿,
不能写死在代码里(下一节会专门讲这件事的后果)。
30 秒实验
- 把
info()拆成两次单独调用,看代码长多少行。 - 给
set_res加一个「返回实际生效值」的设计,看调用方代码怎么变。 - 把重试逻辑塞进封装层,再想想「预览界面」和「后台巡检」两个场景能不能共用。
常见误区
- 以为封装的目的是让代码变短。 目的是把协议细节和业务逻辑隔开,行数只是副作用。
- 把重试和超时策略包进封装层。 这两个要按场景调,包进去之后每改一次都动所有调用方。
set_res只返回「成功」。 设备会静默夹边界,要返回实际生效的分辨率, 否则调用方一直以为自己设对了。
自测三问(点开对答案)
1. 封装的判据用一句话怎么说? —— **调用方代码里不该出现 XML。** 一旦上层开始拼信封或写 `re.search` 解报文,就说明界线破了。 2. 为什么重试策略不该包进封装层? —— **因为「重试几次」取决于调用场景**:预览要立刻报错、巡检可以重试。 包进去之后每改一次都要动封装层,牵动所有调用方。 3. `set_res` 为什么应该返回「实际生效的分辨率」? —— **因为设备会静默夹边界**:设 3000×2000 返回成功、回读是 1920×1080。 只说「成功」会让调用方误判。下一节把这八章的东西串成完整案例:发现、取信息、取配置、取流地址。
- ☐ (本节完,下一节 08.08.11 设备查找的案例代码)