简介:WSDL-Parser 是一套面向 Swift 开发者的 WSDL 文件解析工具,用于从 XML 格式的 WSDL 描述中提取服务、端口、绑定、接口、操作及消息类型等元数据,帮助开发者快速理解并调用 Web 服务,降低手工处理 SOAP/XML 的复杂度。资源以 Swift 源码为主,共 19 个文件,包含 11 个 swift 核心实现、多个 plist 配置、storyboard 界面文件以及 xcodeproj 工程文件,整体压缩包仅 29KB,结构紧凑,适合在已有工程中集成或作为学习示例。已有 218 人学习下载。压缩包内附测试用例、操作控制器和类型控制器等模块,展示了从加载 WSDL 到解析、再到生成调用代码的完整流程;Parser、Schema、Endpoint、Operation 等类的划分清晰,便于二次封装与扩展。对于需要对接 SOAP 服务或亲手实现 WSDL 解析逻辑的开发者,这份轻量代码能提供可直接参考的解析骨架和命名空间处理思路,也可借此理解 XSD 类型与 Swift 类型的映射方式。
1. WSDL 文件解析的第一步:当 Swift 项目撞上 SOAP 接口
如果你跟我一样,在某一次维护旧系统的任务里被一个.wsdl文件拦住,就会发现 Swift 生态里关于 SOAP 服务接入的现代方案几乎空白。这个 WSDL-Parser 资源解决的就是把 WSDL 描述文件正确读进 Swift 内存,转成服务、操作、参数三层模型,再交给网络层去拼 SOAP 请求。真正动手前先分清目标:我们不解析 XML 的全部含义,只抽服务地址、绑定、消息名和操作名。适合手里握着一份可能历史十年以上的 WSDL、但必须从 Swift 侧调通的开发者,也适合想了解 XMLParser 事件流如何应对复杂文档的初学者。
2. WSDL 格式拆解与解析选型:从 types 到 binding 的建模顺序
WSDL 文档本质上是一份带强命名空间的 XML,但直接拿 XML 工具硬啃往往迷路,因为它把“抽象契约”和“具体传输绑定”混在同一套语法里。我在对接某些老系统时接到过一份很长的 WSDL,人工从中找 endpoint 就像在黑匣子里摸零件。后来总结出的经验是:先画段位图,再决定解析顺序,顺序不对后面很难收场。
2.1 WSDL 文档的五个段落:types、message、portType、binding、service
WSDL 1.1 规范把整个描述拆成五个顶层元素,解析器真正要抓的信息其实很集中,可以用下面这张表概括:
| 顶层元素 | 职责 | 解析重点 | 常见忽略项 |
|---|---|---|---|
| types | 定义消息里用的数据类型 | schema 里的 element、complexType 名称 | 内联的完整约束条件 |
| message | 描述一次调用的输入/输出参数 | part 的 name、element 或 type 引用 | 参数的具体取值规则 |
| portType | 定义一组抽象操作 | operation 的 name、input、output | 传输细节 |
| binding | 把抽象操作绑定到 SOAP 协议 | soap:operation 的 soapAction、style | HTTP 传输的细节 |
| service | 汇总可访问的端口 | port 的 binding 引用、soap:address 的 location | 多个 port 的分流规则 |
看到这张表你会明白,解析 WSDL 和解析普通 XML 的差别在于:信息是分散在不同段落里,靠名称互相引用的。举例来说,portType里的operation只告诉你有submitOrder这个动作,真正调用时要用的soapAction却放在binding段落;而最终要访问的地址又藏在service段落的soap:address里。如果只按 XML 树从上往下遍历,你很可能会存了很多不需要的 schema 细节,却漏掉最关键的 endpoint。
我建议的解析顺序是从外向里:先找service -> port -> soap:address拿到 endpoint,再回读 binding 拿到 soapAction,最后把 portType 里的 operation 名称和消息引用对齐。这样做的好处是,即使某个 WSDL 的 types 段落特别复杂,解析器也能在早期失败时保留下 endpoint 信息用于排错。
来看一个最小片段,注意命名空间与节点的关系:
<?xml version="1.0" encoding="utf-8"?> <wsdl:definitions xmlns:wsdl="http://schemas.xmlsoap.org/wsdl/" xmlns:soap="http://schemas.xmlsoap.org/wsdl/soap/" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:tns="http://www.example.com/order" targetNamespace="http://www.example.com/order"> <wsdl:service name="OrderService"> <wsdl:port name="OrderServicePort" binding="tns:OrderServiceBinding"> <soap:address location="https://api.example.com/order"/> </wsdl:port> </wsdl:service> </wsdl:definitions>这里有两个容易看走眼的地方:binding="tns:OrderServiceBinding"里的tns是文档自定义前缀,不一定每个文件都叫tns;soap:address里的soap前缀同样可以在另一个文件里被命名成soap12或别的名字。所以解析器内部一律用“命名空间 URI + 本地名”来识别节点,前缀只保留在原始属性值里,不参与匹配。
2.2 DOM、SAX、还是 XMLParser:Swift 环境下的选型依据
拿到 WSDL 后的第一反应很可能是直接解析整棵 XML 树。Swift 在 Apple 平台自带XMLDocument,属于 DOM 方案,可以对节点做随机访问,写起来直观;但在 Linux 环境或 Swift Package 里,XMLDocument的可用性有差异,需要引入FoundationXML,跨平台时不够统一。DOM 还会把整个文档载入内存,对于 WSDL 这种几十 KB 的文档虽然不至于压垮内存,但不利于做流式处理。
还有一种常见选择是 SAX 风格的事件驱动解析。Foundation 里的XMLParser就是这种模型,它不构建整棵树,而是在读取过程中不断抛出“开始节点 / 结束节点 / 找字符”的回调。WSDL 文档的结构层次分明,用事件驱动刚好可以在回调里维护一个上下文栈,每次收到开始节点就压栈、结束时弹栈,配合一个状态机就能把分散的信息收集起来。
第三方解析库(比如把 HTML 解析库拿来做 XML)我不是很推荐。WSDL 的命名空间规则比较严格,第三方容错式解析反而会把soap:Envelope这类前缀当成普通标签名,丢掉命名空间信息。除非项目里已经引入了一套 XML 库并想复用,否则纯 Foundation 方案依赖最少,也符合跨平台要求。
选型结论很明确:用XMLParser加一个XMLParserDelegate实现,辅以显式命名空间处理。真正需要小心的不是解析框架本身,而是事件回调里如何区分当前节点属于哪个层级——这就是下一章要讲的上下文栈。
3. 用 Swift 实现解析器核心:XMLParser 回调、命名空间与三层数据模型
这一章直接给出能跑的代码骨架。我把解析器拆成两层:数据模型层负责承载 WSDL 的关键信息,解析器层负责从 XML 事件流里提取并组装这些信息。实际开发中我也习惯先把模型定下来,再写回调,不然回调里东拼西凑的字段会越写越乱。
3.1 数据模型定义:从 XML 节点到 Swift 结构体的映射
先看模型定义:
import Foundation /// WSDL 根模型,对应 <definitions> struct WSDLDefinition { let targetNamespace: String var services: [WSDLService] = [] } /// 一个可调用的服务端点,对应 <service> + <port> struct WSDLService { let name: String let portName: String let bindingName: String var endpoint: URL? var operations: [WSDLOperation] = [] } /// 一个操作,对应 <operation> + <soap:operation> 的合并结果 struct WSDLOperation { let name: String var soapAction: String var style: WSDLStyle let inputName: String? let outputName: String? let parameterNamespace: String } enum WSDLStyle: String { case document case rpc }逻辑说明:WSDLDefinition对应根节点definitions,targetNamespace在构造时就会赋值,后续operation的parameterNamespace一般会复用它,因为绝大多数 WSDL 把数据元素定义在同一个命名空间下。WSDLService是service和port合并后的产物,bindingName保留的是原始字符串如tns:OrderServiceBinding,它只用来做关联标记,不参与节点匹配。
边界参数要注意:这份实现把“一个服务对应一个 endpoint”当成默认情况,是因为大部分内部系统就是这么暴露的。如果你遇到一个service下有多个port的 WSDL,建议把endpoint改成数组,或单独抽一个WSDLPort结构,否则最后一个port会覆盖前面的地址。
3.2 解析器主流程:XMLParser 配置与上下文栈
解析器主体代码如下:
final class WSDLParser: NSObject, XMLParserDelegate { private var elementStack: [String] = [] private var definition: WSDLDefinition? private var currentService: WSDLService? private var endpointAddress: String? private var operationsByName: [String: WSDLOperation] = [:] private let wsdlNamespace = "http://schemas.xmlsoap.org/wsdl/" private let soapBindingNamespace = "http://schemas.xmlsoap.org/wsdl/soap/" func parse(contentsOf url: URL) throws -> WSDLDefinition { resetInternalState() let data = try Data(contentsOf: url) let parser = XMLParser(data: data) parser.delegate = self parser.shouldProcessNamespaces = true parser.shouldReportNamespacePrefixes = false guard parser.parse() else { throw WSDLParserError.parseFailed(underlying: parser.parserError) } guard let result = definition else { throw WSDLParserError.noDefinitionsFound } return result } private func resetInternalState() { elementStack.removeAll() definition = nil currentService = nil endpointAddress = nil operationsByName.removeAll() } } enum WSDLParserError: Error { case parseFailed(underlying: Error?) case noDefinitionsFound }这里有两个关键参数:shouldProcessNamespaces = true让XMLParser帮我们解析前缀与命名空间 URI 的映射,之后回调里的namespaceURI才是可靠的;shouldReportNamespacePrefixes = false表示我们不需要前缀声明事件,因为解析逻辑只认 URI。resetInternalState()必须在parse入口无条件执行,这是防止后文多次解析时状态残留的基础。
有了主流程,再看事件回调的核心逻辑:
extension WSDLParser { func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String: String] = [:]) { elementStack.append(elementName) if namespaceURI == wsdlNamespace && elementName == "definitions" { definition = WSDLDefinition(targetNamespace: attributeDict["targetNamespace"] ?? "") } if namespaceURI == wsdlNamespace && elementName == "service" { currentService = WSDLService( name: attributeDict["name"] ?? "", portName: "", bindingName: "", endpoint: nil, operations: [] ) } if namespaceURI == wsdlNamespace && elementName == "port" { currentService?.portName = attributeDict["name"] ?? "" currentService?.bindingName = attributeDict["binding"] ?? "" } if namespaceURI == soapBindingNamespace && elementName == "address" { endpointAddress = attributeDict["location"] ?? "" } if namespaceURI == wsdlNamespace && elementName == "operation" { let opName = attributeDict["name"] ?? "" let operation = WSDLOperation( name: opName, soapAction: "", style: .document, inputName: nil, outputName: nil, parameterNamespace: definition?.targetNamespace ?? "" ) // 如果 binding 中已经补全了 soapAction,这里不要覆盖 if operationsByName[opName] == nil { operationsByName[opName] = operation } currentService?.operations.append(operation) } if namespaceURI == soapBindingNamespace && elementName == "operation" { let opName = operationsByName.keys.first { elementStack.dropLast().last == "binding" && $0 == currentService?.operations.last?.name } if let opName = opName { operationsByName[opName]?.soapAction = attributeDict["soapAction"] ?? "" operationsByName[opName]?.style = WSDLStyle(rawValue: attributeDict["style"] ?? "") ?? .document } } } func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) { if namespaceURI == wsdlNamespace && elementName == "service" { guard var service = currentService else { return } if let addr = endpointAddress, let url = URL(string: addr) { service.endpoint = url } service.operations = operationsByName.values .filter { service.operations.map(\.name).contains($0.name) } .map { op in var merged = op if let last = service.operations.last(where: { $0.name == op.name }) { merged.soapAction = last.soapAction.isEmpty ? op.soapAction : last.soapAction } return merged } definition?.services.append(service) currentService = nil endpointAddress = nil } elementStack.removeLast() } }逻辑说明:didStartElement里维护了两个关键状态,elementStack记录当前节点路径,operationsByName暂存合并中的操作。portType里的operation先登记基础信息,binding里的soap:operation再补充 soapAction 和 style,最后在service结束时统一合并到服务下。
参数说明:namespaceURI与常量wsdlNamespace、soapBindingNamespace比较时,不用管原文前缀是wsdl还是wsdl1,这是命名空间匹配的核心优势。binding里的soapAction属性可能为空字符串,但这不代表没有 SOAPAction,请求阶段仍要把它放进 header,这个边界我在第 4 章的避坑里会再展开。
3.3 参数说明与提取边界:soapAction、style 和 endpoint
解析阶段的成果可以用一张表归纳,方便你对照自己的实现去检查:
| 提取项 | 来源节点 | 关键属性 | 边界条件 |
|---|---|---|---|
| endpoint | soap:address | location | 缺失时 endpoint 为 nil,调用阶段需兜底 |
| soapAction | soap:operation | soapAction | 可能为空字符串,不能当 nil 处理 |
| style | soap:operation | style | document 或 rpc,影响请求体结构 |
| bindingName | port | binding | 只做关联标记,不参与匹配 |
| targetNamespace | definitions | targetNamespace | 操作参数命名空间的首选来源 |
还要注意 SOAP 1.2 的存在。部分新系统虽然用 WSDL 描述老接口,但 soap 绑定命名空间写成http://schemas.xmlsoap.org/wsdl/soap12/,如果解析器只认soap/那一段,会遇到合法文件却取不到节点。常见做法是把两个 URI 都放进常量数组,判断时做包含匹配,并保留原始值日志。
4. 避坑:解析 WSDL 最容易踩的五个坑和排查顺序
这章写的都是我在实际接入过程中遇到过的真实问题,每一条都按“现象、原因、解决”三明治写清楚。如果你刚开始写解析器,建议把这五条直接贴在代码注释里当检查清单。
4.1 中文乱码:XML 编码声明被提前解码破坏
现象:解析某个老系统导出的 WSDL,接口名和参数名都正常,但注释和枚举值全是乱码;或者控制台打出来的 XML 原文里中文变成一串问号。
原因:WSDL 文件头声明的是GB2312或GBK,但代码先用了String(data: encoding: .utf8)把Data强行转成字符串,转换失败后产生替代字符,之后再交给 XML 工具就已经损坏了。
解决:直接让XMLParser消费原始Data,它会根据 XML 声明自己识别编码。预览原文时再单独做检测:
// 错误做法:先转字符串再解析 let text = String(data: data, encoding: .utf8)! // 正确做法:XMLParser 自己处理编码 let parser = XMLParser(data: data)经验数值:我维护过的旧系统 WSDL 里,非 UTF-8 声明出现的概率比想象高,差不多每四个文件就有一个。凡是涉及中文注释的文件,最好加一条data.count和首字节的日志,方便日后定位编码问题。
4.2 前缀一变解析全崩:用 qName 匹配命名空间不可靠
现象:第一个 WSDL 用soap:address能顺利取到地址,换成另一个文件后明明有address元素,解析结果却丢了 endpoint。
原因:qualifiedName(qName)带着文档实际声明的前缀,第二个文件可能把前缀声明成了s12或soap12,硬编码qName == "soap:address"就失效了。
解决:打开shouldProcessNamespaces,然后只比命名空间和本地名:
// 错误判断 if qName == "soap:address" { ... } // 正确判断 if namespaceURI == soapBindingNamespace && elementName == "address" { ... }这个坑的本质是前缀只是文档里的“局部别名”,不是协议的一部分。按namespaceURI + elementName匹配后,相同语义的节点在任何前缀下都不会识别失败。
4.3 schemaLocation 是相对路径:导入外部 Schema 找不到文件
现象:解析带<xsd:import schemaLocation="CommonTypes.xsd"/>的 WSDL,文件确实和 WSDL 在同一个目录,却报文件不存在。
原因:把远程 WSDL 下载到本地临时目录后,schemaLocation的相对路径没有和源 WSDL 的 URL 关联,代码只用FileManager直接找CommonTypes.xsd,自然找不到。
解决:在parse方法里增加一个baseURL参数,解析相对路径时用URL的标准机制:
let schemaURL = URL(string: "CommonTypes.xsd", relativeTo: baseURL)如果baseURL为 nil,再退回到直接拼接当前工作目录的路径。这个坑在远程拉取场景里几乎必踩,建议在拿到 WSDL 数据的同时就保存来源 URL。
4.4 连续解析状态残留:第二个文件带出第一个文件的操作
现象:同一个解析器实例先解析文件 A,再解析文件 B,B 的结果里多了 A 的某个operation,甚至出现endpoint错乱。
原因:currentService、operationsByName、elementStack在第二次parse前没有清空。如果解析过程中某个节点提前返回,状态残留会更隐蔽。
解决:在parse入口无条件调用resetInternalState(),把栈、字典、当前引用全部重置。更稳妥的做法是一次解析用一个实例,高并发场景下不要共享解析器对象,因为这本质上是可变状态机,不是无状态函数。
4.5 SOAP Fault 和 HTTP 500 混在一起:把业务失败当网络失败
现象:服务端返回 HTTP 500,代码直接把请求标记为失败,丢掉了响应体里的具体错误信息。
原因:SOAP 服务的错误语义不统一,有的用 200 + Fault,有的用 500 + Fault。只看状态码会把真正有用的Fault内容丢掉。
解决:网络层先收响应体,再检查 XML 里是否有Fault节点:
let data = try await URLSession.shared.data(for: request) // 先解析 XML,判断是否包含 Fault,再决定怎么抛错这条虽然不是解析器本身的逻辑,但在接入阶段会直接反咬解析结果,所以一并列进避坑清单。
5. 从解析结果到实际调用:SOAP 请求构建、URLSession 接入与回退策略
解析器输出的是干净的 Swift 模型,但真正让服务跑通还需要一段“拼请求”的胶水代码。这一章以模拟项目 X 的OrderService为例,演示从模型到 HTTP 请求的完整链路。
5.1 从操作模型拼 SOAP 请求体:XML 构造与转义
拿到了WSDLOperation和参数表,最常见的做法是拼一个 SOAP 信封。这里最容易翻车的是参数值里的特殊字符,如果值里有<或&,不转义会让整个请求体解析失败:
func escapeXML(_ value: String) -> String { var escaped = value escaped = escaped.replacingOccurrences(of: "&", with: "&") escaped = escaped.replacingOccurrences(of: "<", with: "<") escaped = escaped.replacingOccurrences(of: ">", with: ">") escaped = escaped.replacingOccurrences(of: "\"", with: """) return escaped } func makeSOAPBody(operation: WSDLOperation, params: [String: String]) -> Data { var xml = """ <?xml version="1.0" encoding="utf-8"?> <soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" xmlns:ns="\(operation.parameterNamespace)"> <soap:Body> <ns:\(operation.name)> """ for (key, value) in params { xml += "<\(key)>\(escapeXML(value))</\(key)>" } xml += """ </ns:\(operation.name)> </soap:Body> </soap:Envelope> """ return xml.data(using: .utf8) ?? Data() }逻辑说明:operation.parameterNamespace来自解析时的targetNamespace,没有它,参数名会变成无命名空间的裸标签,服务端大概率无法识别。对document和rpc两种 style,有些服务端对根元素包装要求不同,大多数现代服务用 document literal,上述写法已经够用;如果你遇到 rpc 风格,需要在Body内多包一层以操作名命名的元素。
转义函数escapeXML是必须的,尤其是处理地址、备注、金额这类字段。参数值里的特殊字符不转义时,XML 解析器会把<当成新标签的开始,轻则请求失败,重则产生 XML 注入风险。
5.2 网络层接入:URLSession 与 SOAPAction header
请求头是 SOAP 接入最容易被忽略的部分。至少两个 header 必须设置:
var request = URLRequest(url: endpoint) request.httpMethod = "POST" request.setValue("text/xml; charset=utf-8", forHTTPHeaderField: "Content-Type") request.setValue(operation.soapAction, forHTTPHeaderField: "SOAPAction") request.httpBody = makeSOAPBody(operation: operation, params: params) let (data, response) = try await URLSession.shared.data(for: request)参数说明:SOAPAction有些服务端要求非空,有些服务端要求必须等于 WSDL 里定义的值,极端情况下发空字符串也会被校验。一个稳妥习惯是:如果解析出的 soapAction 为空,header 不设置;如果非空,则原样传递。Content-Type固定为text/xml; charset=utf-8,SOAP 1.2 服务端可能要application/soap+xml,可以先看 WSDL 的绑定命名空间判断。
5.3 回退策略与日志埋点:解析和调用都要有后悔药
接入阶段我习惯给解析器留三个退路。第一个是 endpoint 缺失时从配置文件或环境变量注入服务地址,不直接抛异常;第二个是解析失败时保存原始 WSDL 文件的 MD5 和路径,方便下次升级解析规则后重新跑;第三个是在发送前打一条关键日志,记录目标 endpoint、操作名、参数个数和 soapAction,但不记录参数值,避免敏感数据落盘。
日志埋点位置建议选在三个地方:parse入口记录文件大小和 URL,service结束记录服务名与操作数量,网络层发送前记录请求头。每次对接新服务时,这三条日志能帮你快速定位是解析错了、拼包错了还是网络层错了。
6. 用一个可重复的冒烟脚本把解析器钉在回归线上
解析器这种东西,最怕“这个文件能跑,换个文件就崩”。所以我习惯把它放进一个独立的冒烟测试,用一份固定样例 WSDL 验证三层模型的关键字段,跑一次就知道有没有改坏。
import XCTest final class WSDLParserSmokeTests: XCTestCase { func testParseOrderServiceWSDL() throws { let url = try XCTUnwrap( Bundle.module.url(forResource: "order-service", withExtension: "wsdl") ) let parser = WSDLParser() let definition = try parser.parse(contentsOf: url) XCTAssertEqual(definition.targetNamespace, "http://www.example.com/order") let service = try XCTUnwrap(definition.services.first) XCTAssertEqual(service.name, "OrderService") XCTAssertNotNil(service.endpoint) XCTAssertFalse(service.operations.isEmpty) let firstOp = try XCTUnwrap(service.operations.first) XCTAssertFalse(firstOp.name.isEmpty) XCTAssertEqual(firstOp.style, .document) } }这个测试的价值不在覆盖面,而在于把三个最脆弱的断言固定住:endpoint不能为空,operations不能为空,targetNamespace必须与源声明一致。只要能坚持跑,解析器重构时就敢大胆动内部实现。
除了单文件冒烟,我还会加一条双文件连续解析的用例:用同一解析器实例先后解析两个不同的 WSDL,第二次的结果里不能出现第一次的服务名。这条专门盯第 4 章说的状态残留问题,属于必测项。
从那以后我每换一个 WSDL 文件,都要强制走一遍单文件冒烟、双文件连续解析、中文编码检查这三步,这套流程帮我挡掉过不止一次线上事故。希望帮到你。
本文还有配套的精品资源,点击获取