📹 第六章 SOAP及gSOAP实战
📄 一句话概括

SOAP 就是「HTTP 体里装一个信封」,信封三层:Envelope、Header、Body。 一次调用就是 Body 里的一个元素,而错误不走 HTTP 状态码,走信封里的 Fault。

📝 本节和前面几节的关系

前三节铺垫完了:RPC 是目标,HTTP 是传输,XML 是格式,WSDL 是说明书。这一节把它们拼起来。 ONVIF 的所有接口都是 SOAP,这一节是第七章的地基。

一、信封三层

SOAP 报文只有一个根元素 Envelope,它下面最多两个子元素。

层 标签 必需吗 放什么
信封 Envelope 必需 声明命名空间,包住一切
头 Header 可选 鉴权、超时、事务号这类「不属于业务」的信息
体 Body 必需 真正的方法调用或返回

Header 是 ONVIF 鉴权的地方(08.01.05):用户名密码不是放在 URL 里, 是放在 Header 的 Security 元素里,而且要按 WS-Security 的规矩算摘要。 这一层看不懂,鉴权就无从下手。 Body 里只能有一个子元素(一次调用一件事)。 想批量调十个接口?要么发十条报文,要么用规范里定义的批量操作。

二、一次调用 = 一个元素

这是 SOAP 最反直觉的地方:没有 method、没有 params、没有 call。

其他 RPC SOAP
{"method": "add", "params": [1, 2]} <tds:Add><A>1</A><B>2</B></tds:Add>
方法名是字符串 方法名是元素名
参数是数组 参数是子元素

所以「调用一个方法」在这里就是「发一个 XML 元素」, 参数就是它的子元素,返回值就是响应元素里的子元素。 这个设计的好处是「一切皆文档」:参数结构复杂到嵌套十层也没问题,XML 天生能表达树。 坏处是啰嗦——一个 GetDeviceInformation 要写五个标签。

三、返回值怎么取:必须带命名空间

Demo 里那句 find("{ns}Body") 是全篇的关键。 只写 find("Body") 返回 None,然后 None.find(...) 直接崩。

写法 结果 为什么
root.find("Body") None 没有命名空间的 Body 不存在
root.find("{http://…}Body") 节点 Clark 记法才对得上

这是 SOAP 解析的第一大 bug 来源, 而且报错信息通常很难看(AttributeError: 'NoneType' object has no attribute 'find'), 看不出是命名空间的问题。 记住一条:凡是 SOAP 里取节点,一律带 Clark 记法,哪怕是 Body 这种「看起来不会重名」的。

四、Fault:错误藏在信封里

HTTP 那一层只会告诉你「报文送到了没」,不会告诉你「调用成功了没」。 SOAP 的业务错误全在 Body 里的 Fault 元素。

字段 本例的值 含义
Code s:Sender 发送方的错(参数错、方法不存在)
Reason Action not supported 人读的原因
(对端) s:Receiver 接收方的错(设备内部故障)

Sender 和 Receiver 的区分极其实用:看到 Sender 就改自己的代码(方法名拼错、 参数缺了),看到 Receiver 就去看设备日志或换固件。 这个判断能省掉大量扯皮。 所以客户端代码必须做两件事:① 检查 HTTP 状态码;② 检查 Body 里是不是 Fault。 只做第一件,就会把「调用失败」当成「调用成功」。

五、1.1 和 1.2 的差别

项 SOAP 1.1 SOAP 1.2
信封命名空间 …/soap/envelope/ …/2003/05/soap-envelope
指定操作 SOAPAction 头 Content-Type 的 action 参数
Content-Type text/xml application/soap+xml
Fault 结构 faultcode/faultstring 子元素 Code/Reason 子元素

表里那两个空间名,完整写法是 http://schemas.xmlsoap.org/soap/envelope/ 和 http://www.w3.org/2003/05/soap-envelope。 第二行和第四行在实战里最要紧。 ONVIF 用的是 1.2,所以: ① Content-Type 别写成 text/xml;② Fault 的字段名是 Code/Reason。 但很多设备「两个都认」——这是为了让老客户端也能用。 所以抓包时看到 1.1 格式的报文不要奇怪,可能是客户端发的,设备也接了。

六、动手 Demo:发一个真的 SOAP 请求,拿回真的响应

服务端是本机起的迷你服务,但它遵守真的 SOAP 1.2 格式:认 GetDeviceInformation 就回设备信息, 不认就回 Fault。 客户端做三件事:拼信封、发 HTTP、解响应(注意带命名空间)。

# ① 拼信封:三层就完了
xml = ('<?xml version="1.0"?>'
       '<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope" '
       'xmlns:tds="http://www.onvif.org/ver10/device/wsdl">'
       '<s:Body><tds:GetDeviceInformation/></s:Body></s:Envelope>')

# ② 发出去(Content-Type 必须是 1.2 的)
r = _http_post(port, "/onvif/device_service",
                [("Content-Type", "application/soap+xml; charset=utf-8")], xml)

# ③ 解响应:先看是不是 Fault,再取值
root = ET.fromstring(r.split("\r\n\r\n", 1)[1])
body = root.find("{http://www.w3.org/2003/05/soap-envelope}Body")
node = list(body)[0]
if node.tag.endswith("Fault"):
    print("调用失败")
else:
    for ch in node:
        print(ch.tag.split("}")[1], ch.text)

实跑输出:

--------------------------------------------------------------------
发一个真的 SOAP 请求,拿回真的响应
--------------------------------------------------------------------
**到这一节才真正「用 SOAP 说一句话」。**
  服务端是本机起的迷你服务,**但它遵守的是真的 SOAP 1.2 格式。**

**请求体(这就是 SOAP 的全部内容):**

  `<s:Envelope>` → `<s:Body>` → `<tds:GetDeviceInformation/>`

  **三层就完了**:信封、体、方法。「调用一个方法」在这里就是「发一个元素」。

**响应解析:**

  根标签:`{http://www.w3.org/2003/05/soap-envelope}Envelope`
  体里第一个子元素:`{http://www.onvif.org/ver10/device/wsdl}GetDeviceInformationResponse`

  字段                  值
  ------------------------------------------------
  Manufacturer          ACME
  Model                 IPC-2000
  FirmwareVersion       5.6.1

  **注意取值时要带命名空间**(`{…}Body`),**只写 `Body` 是取不到的**。
  **这是解析 SOAP 最常见的错。**

**再发一个设备不认识的方法,看它怎么报错:**

  项            值
  --------------------------------------------
  元素名        Fault
  Code          s:Sender
  Reason        Action not supported

  **SOAP 的错误不走 HTTP 状态码**,走 `Fault` 元素。
  **HTTP 照样回 200,错误藏在信封里**——所以只查状态码是不够的。
  **这一条在排查 ONVIF 时特别重要**:`200 OK` 不代表成功,**必须把 Body 解开看有没有 Fault。**

取字段时用了 ch.tag.split("}")[1],这一步是把 Clark 记法里的命名空间去掉,只留本地名。 如果不去掉,打印出来每个字段名前面都挂着 40 个字符的 URI。 list(body)[0] 取的是 Body 的第一个子元素, 在 SOAP 里它就是要调用的那一个操作(或它的响应)——因为 Body 只允许一个子元素,这样取是合法的。 注意「再发一个不认识的方法」那一段: 服务端回的 HTTP 状态码仍然是 200,但 Body 里是 Fault。 这就是「200 OK 不代表成功」的实物证据。

30 秒实验

  1. 把 find 里的 Clark 记法改成 "Body",看报什么错(会是 NoneType 的错,不是「找不到」)。
  2. 把 Content-Type 改成 text/xml,看迷你服务端还认不认(本机这个不校验,真设备会 415)。
  3. 把请求里的操作名改成 GetSystemDateAndTime,看服务端怎么回 Fault。

常见误区

  1. 以为 HTTP 200 就是调用成功。 SOAP 的业务错误在 Fault 里,HTTP 层照样回 200。
  2. 以为取节点可以不写命名空间。 实测 find("Body") 返回 None, 然后崩在一个看起来毫不相关的 AttributeError 上。
  3. 以为 Fault 的 Sender/Receiver 只是文字描述。 Sender 是客户端的错、Receiver 是设备的错,这个区分决定你该改代码还是换固件。
自测三问(点开对答案) 1. SOAP 信封的三层是什么?哪一层能省? —— `Envelope`、`Header`、`Body`。 **`Header` 可以省**,但 ONVIF 鉴权就放在 `Header` 里,所以实际项目基本都带。 2. 一次 SOAP 调用在报文里长什么样? —— **就是 `Body` 里的一个元素**。 方法名是元素名,参数是子元素,**没有 `method`/`params` 这样的字段。** 3. `200 OK` 但调用失败,怎么发现? —— **解 `Body`,看第一个子元素是不是 `Fault`。**是的话读 `Code` 和 `Reason`。 **只查 HTTP 状态码永远发现不了。**

下一节收尾:把 SOAP 放进「Web Service」这个大概念里,和 REST 做一次字节级别的对比。

相关笔记