简介:面向油气行业软件开发者的 Java WITSML 客户端源码分析包,围绕 WITSML 1.3.1 与 1.4.1 两套标准,解决井、钻具组合、压力等井下数据在异构系统间的查询、上传与解析问题。代码核心覆盖 API 调用封装、WITSML XML 文档的解析与生成、基于 HTTP 的 SOAP 通信,以及异常处理与并发处理设计,并配套 Mock 客户端与测试用例,便于在无真实服务器时验证交互逻辑。压缩包共 154 个文件,以 102 个 xml 数据与配置样例、40 个 java 源码为主体,辅以 3 个 scala 文件、wsdl 服务描述与 shell 脚本、yml、md 说明等,整体约 272KB,体量轻便、结构清晰。该资源已有 484 人学习,适合需要理解 WITSML 接口实现细节、借鉴 XML 处理与网络通信写法的中高级 Java 开发者,可用于数据集成、分析工具取数与自动化工作流等场景。
1. witsml client 的第一次翻车,从一条空结果说起
接手 WITSML 数据接入的第一个下午,我信心满满地拼了一个 SOAP 请求发出去,服务端返回Result=1,XMLout里却只有一个空的<logs/>。折腾到晚上才发现,问题出在查询模板根元素上的version属性和服务端声明的版本对不上,命名空间差一个字符,服务端就当没收到有效过滤条件。这份 Java 版 witsml client 源码,解决的就是这类反复消耗时间的琐事:把 WSDL 生成、会话鉴权、查询模板拼装、optionsIn组装、XMLout解包和Result判定收进一条可复用的调用链。它适合三类人:正在做钻井、录井数据接入的后端;要把第三方 WITSML 服务端接进自有数据平台的数据工程师;以及刚拿到一个 WSDL、不知道从wsimport还是从手写 SOAP 开始的新手。看完至少能少走我当天晚上的那条弯路。
2. 拆开 Java 源码包:四层调用链与 WSDL 生成的取舍
这份源码不是一个大main方法堆到底的样例,它按调用链切成了四层。理解这四层的边界,比记住某个方法名更重要,因为接不同服务端时,你改动的永远只是其中一两层,剩下的可以原样复用。
2.1 四层怎么切,换服务端时改哪一层
我把源码按“从外到里”的顺序看了一遍,层次大致是这样:
| 层 | 职责 | 换服务端时要动的地方 |
|---|---|---|
| 传输与会话 | 端点地址、HTTP Basic 鉴权、连接/读超时、gzip、连接复用 | 配置文件或环境变量 |
| 存根层 | WSDL 生成的WMLSSoap接口、JAXB 类型、SOAPAction | 重新生成一次 |
| 查询构造 | 查询模板 XML、optionsIn参数串、版本号与命名空间 | 少量改动 |
| 领域映射 | XMLout解析成业务对象、SuppMsgOut记录 | 解析器与字段映射 |
分层的价值在排错时最明显。返回空结果时,先怀疑查询构造层;连接被拒绝或 401 时,问题一定在传输层;抛出UnmarshalException或者ClassCastException,基本是存根层和服务端 XSD 版本不匹配。以前所有逻辑揉在一个类里,任何异常都像玄学;切开之后,每一类异常都指向一个固定位置。
有一点要提前说清楚:WITSML 服务端的 WSDL 里大量使用xsd:any和可选元素,生成出来的 Java 类型会是一堆JAXBElement<...>包装,直接调 setter 会写得非常啰嗦。源码里做了两层封装——底层保留生成的存根,上层再包一个只暴露“对象类型 + uid + 索引区间 + 返回元素”的构建器,业务代码基本不碰生成的类型。这个设计在后面对接不同版本服务端时省了很多事。
2.2 从 WSDL 生成客户端骨架:离线生成和动态代理的取舍
生成客户端骨架有两条路。一条是离线生成,用 JDK 自带的wsimport或 Maven 里绑定到generate-sources阶段的插件,把桩代码固化进工程;另一条是运行时动态代理,只依赖 WSDL 地址。我一般选离线生成,原因很实际:桩代码可读、可打断点、编译期能发现签名错误,而且服务端 WSDL 偶尔会挂,动态代理在启动时就可能直接失败。
# 从服务端 WSDL 生成客户端桩代码 # -keep 保留生成的 .java 源码,方便打断点和改包名 # -s 源码输出目录,跟随 Maven 标准结构 # -p 生成类的包名,避免和业务包混在一起 # -b JAXB 全局绑定文件,解决 dateTime 和命名冲突 wsimport -keep \ -s src/main/java \ -p com.example.witsml.stub \ -b src/main/resources/bindings.xjb \ "http://10.0.0.10:8080/witsml/services/WMLS?wsdl"-b那个绑定文件不是可选项。WITSML 里几乎所有时间字段都是带时区的xsd:dateTime,默认映射成XMLGregorianCalendar,转Instant时要处理时区偏移,稍不注意就出现 8 小时的错位。源码里的做法是强制映射成String,把时间解析交给业务层统一处理。
<!-- bindings.xjb:把 dateTime 映射成 String,保留原始时区偏移不做隐式转换 --> <jaxb:bindings xmlns:jaxb="http://java.sun.com/xml/ns/jaxb" xmlns:xs="http://www.w3.org/2001/XMLSchema" version="2.1"> <jaxb:globalBindings> <jaxb:javaType name="java.lang.String" xmlType="xs:dateTime"/> </jaxb:globalBindings> </jaxb:bindings>生成之后不要急着写业务,先看两件事:生成的WMLS接口方法名是不是wmlsGetFromStore这类标准命名,以及包里有没有出现ObjectFactory之外的重复类。如果出现同名类冲突,说明 WSDL 里引了多个版本的 XSD,得在绑定文件里用jaxb:schemaBindings按命名空间拆包,否则编译期就会打架。
还有一个版本差异要留意。WITSML 1.3.1 和 1.4.1.1 的命名空间不同,前者是http://www.witsml.org/schemas/131,后者是http://www.witsml.org/schemas/1series。用哪个 WSDL 生成,桩代码就绑定哪个命名空间,跨版本对接时最省事的办法是按版本各生成一套存根,用一个工厂按配置选择,不要在同一个存根里做字符串替换。
2.3 传输层三件套:鉴权、超时、gzip
见过太多“本地能跑、现场超时”的案例,根因几乎都在这三件事上。WITSML 服务端普遍用 HTTP Basic 鉴权,BindingProvider里两个 key 就能设;但超时如果不设,默认是无限等待,现场网络抖一下线程就挂死在那儿。
// 建立端口并配置传输层参数 WMLSService service = new WMLSService(new URL(wsdlUrl)); WMLSSoap port = service.getWMLSSoap(); BindingProvider bp = (BindingProvider) port; Map<String, Object> ctx = bp.getRequestContext(); // 端点地址允许运行时覆盖,WSDL 里写死的地址经常是内网测试机 ctx.put(BindingProvider.ENDPOINT_ADDRESS_PROPERTY, endpoint); ctx.put(BindingProvider.USERNAME_PROPERTY, username); ctx.put(BindingProvider.PASSWORD_PROPERTY, password); // 超时单位是毫秒,读超时要按查询体量给,不能照抄连接超时 int connectTimeoutMs = 5_000; int readTimeoutMs = 120_000; ctx.put("com.sun.xml.ws.connect.timeout", connectTimeoutMs); ctx.put("com.sun.xml.ws.request.timeout", readTimeoutMs); // 请求体较大时开启 gzip,前提是服务端能力列表里声明了压缩支持 ctx.put("com.sun.xml.ws.transport.http.client.streaming.chunk.size", 8192);连接超时给 5 秒够了,读超时则完全取决于查询内容。拉一条完整的log曲线,几万行数据在慢链路上跑两三分钟很常见,读超时按 120 秒起步,再根据maxReturnNodes往下压。gzip 不是无条件开的,得先确认服务端GetCap里声明了压缩能力,否则有些实现会直接返回 400,这种翻车在现场很难当场定位。
2.4 先探活再干活:GetVersion 与 GetCap
源码里有一个常被忽略但很值钱的设计:任何业务调用之前先调一次GetVersion和GetCap,把结果缓存起来。前者确认服务端接口版本,后者返回一大段能力描述 XML,里面能读出支持的对象类型、压缩能力、最大返回节点数、是否支持对象选择模板。
// 探活:先确认版本,再拿能力清单,结果缓存到会话级 String version = port.wmlsGetVersion(); String capabilities = port.wmlsGetCap(); // 能力清单是 XML,重点关注这几项,用于决定后续请求怎么发 // - version:服务端实现的 WITSML 版本,决定命名空间和模板字段 // - compression:是否支持 gzip,决定传输层开不开压缩 // - maxReturnNodes:单次返回节点上限,决定要不要分页 // - objectSelection:是否支持先取对象选择模板再查询 System.out.println("server version = " + version); System.out.println("capabilities = " + capabilities);这一步的实际收益是避免“猜”。比如服务端只支持到 1.3.1,你按 1.4.1.1 拼模板,返回一定是空或者负值;服务端声明maxReturnNodes是 5000,你一次要 5 万节点,对方可能截断返回、也可能直接拒绝,具体行为各家不同。探活之后按能力清单决定请求形态,比事后看日志猜要省事得多。这些信息建议随连接一起缓存,别每次查询都调一遍。
3. GetFromStore 跑通:查询模板、optionsIn 与响应解包
查询是客户端里调用频率最高的动作,也是最容易被当成“随便拼个 XML”的地方。WITSML 的查询模型和 SQL 完全不同:你提交的不是查询语句,而是一个“空壳对象树”,用哪些字段做过滤、返回哪些元素,全靠模板结构和optionsIn参数表达。
3.1 模板 XML 才是真正的入参
WMLS_GetFromStore的第一个参数就是模板字符串。模板根元素是对象复数名,子元素里填过滤条件;不填的字段表示“不过滤”,但返回时是否带出来由returnElements决定。
<!-- 查询某口井某井眼中指定深度区间的测井曲线,1.4.1.1 命名空间 --> <logs xmlns="http://www.witsml.org/schemas/1series" version="1.4.1.1"> <log uidWell="W-0001" uidWellbore="B-0001"> <!-- 按深度索引截取区间,不写就是全井段,很容易把几十兆数据拉回来 --> <startIndex>2000</startIndex> <endIndex>2500</endIndex> </log> </logs>如果索引类型是时间而不是深度,过滤字段换成startDateTimeIndex和endDateTimeIndex,格式必须带时区,2024-03-01T00:00:00Z或+08:00都行,纯本地时间会被服务端按 UTC 解释。源码里把模板拼装单独抽了一个构建器,入参是对象类型、井 uid、井眼 uid、索引区间和索引类型,输出是字符串。好处是模板结构只有一处可改,版本升级时不用满工程搜<logs。
version属性必须和命名空间匹配,这一条是血泪经验。xmlns写 1series、version写 1.3.1,部分服务端不报错,只是返回空结果,比报错还难查。
3.2 optionsIn 里的几个关键开关
optionsIn是分号分隔的键值串,不传也能调通,但传错比不传更糟。常用参数就几个:
| 参数 | 取值示例 | 作用与注意点 |
|---|---|---|
returnElements | id-only/header-only/all | 控制返回粒度,不写等价于全量,大对象必须显式限定 |
maxReturnNodes | 2000 | 单次返回节点上限,超过上限可能被截断,需配合分页 |
requestObjectSelectionCapability | true | 返回对象选择模板而不是数据,用于摸清有哪些井和井眼 |
| 请求压缩相关项 | 由服务端能力决定 | 仅在GetCap声明支持时启用 |
returnElements是最容易吃亏的一个。探测对象是否存在时用id-only,只要几个字节;列井眼清单用header-only,能拿到名称和 uid;只有真正要曲线数据时才用all。我见过一个同步任务没写这个参数,每次把整条log全拉回来再在内存里丢掉大半,跑久了必 OOM。
requestObjectSelectionCapability=true这个用法值得单独说。传一个空模板进去,服务端返回的是“结构模板”,里面列出当前用户可见的井、井眼以及各对象的可选字段。用它做一次初始化,把井和井眼的 uid 缓存下来,后面的业务查询就不用硬编码 uid 了。
3.3 调用与结果判定:Result 只有三种语义
// 执行查询,XMLout 和 SuppMsgOut 通过 Holder 带回 Holder<String> xmlOut = new Holder<>(); Holder<String> suppMsgOut = new Holder<>(); String query = buildLogQuery("W-0001", "B-0001", 2000, 2500); String options = "returnElements=all;maxReturnNodes=2000"; short result = port.wmlsGetFromStore(query, options, null, xmlOut, suppMsgOut); if (result == 1) { // 完全成功,解析 XMLout parseLogs(xmlOut.value); } else if (result == 2) { // 部分成功,XMLout 里可能只有部分对象,必须看 SuppMsgOut 定位缺了什么 log.warn("partial success, suppMsg={}", suppMsgOut.value); parseLogs(xmlOut.value); } else { // 负值表示失败,具体含义按服务端能力清单或随包文档对照,不要自己猜 throw new WitsmlException("GetFromStore failed, result=" + result + ", suppMsg=" + suppMsgOut.value); }Result的三种语义必须分开处理:1是完全成功,2是部分成功,负值是失败。把2当成功是最隐蔽的坑,因为XMLout非空、程序不报错,只是数据少了一截,等到下游对账才发现。SuppMsgOut通常是一段 XML 或纯文本,里面写清楚哪个对象失败、失败原因是什么,出问题第一时间打它。
负值的具体含义各版本略有差异,源码里没有硬编码一张映射表,而是把值和SuppMsgOut一起抛给上层,由运维查服务端文档。这个处理看起来偷懒,实际上比写死一张可能过时的对照表稳妥。
3.4 XMLout 解包:命名空间感知是底线
XMLout是一段带命名空间的 XML,直接按标签名找元素一定失败。源码里用命名空间感知的 DOM 解析,或者干脆用 JAXB 反序列化回生成的类型。前者灵活,后者类型安全,我一般对大对象用 JAXB,对只需要几个字段的场景用 DOM。
// 命名空间感知解析,避免 getElementsByTagName 找不到节点 DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); factory.setNamespaceAware(true); Document doc = factory.newDocumentBuilder() .parse(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); // 用本地名匹配,兼容 1.3.1 和 1.4.1.1 两套命名空间 NodeList logs = doc.getElementsByTagNameNS("*", "log"); for (int i = 0; i < logs.getLength(); i++) { Element logEl = (Element) logs.item(i); String uid = logEl.getAttribute("uid"); // 子元素取值同样要按命名空间找,直接 getElementsByTagName 在带前缀时会漏 NodeList indexNodes = logEl.getElementsByTagNameNS("*", "startIndex"); System.out.println("log uid=" + uid + ", startIndex=" + (indexNodes.getLength() > 0 ? indexNodes.item(0).getTextContent() : "")); }用*匹配本地名是为了让同一段解析代码兼容两个版本,代价是丢掉了命名空间校验。如果确定只对接一个版本,把*换成确切的命名空间 URI 更严谨,能提前发现模板串了版本的问题。
3.5 大对象分页:按索引区间切,不要按节点数切
log和trajectory这类对象动辄几万行,一次性拉回来既超时又占内存。分页有两种切法:按索引区间切和按maxReturnNodes切。前者可控,后者依赖服务端实现是否老实截断。
// 按深度区间分页拉取,步长按数据密度调,页与页之间留一点重叠便于校验 double step = 100.0; for (double start = 0; start < totalDepth; start += step) { double end = Math.min(start + step, totalDepth); String q = buildLogQuery("W-0001", "B-0001", start, end); Holder<String> out = new Holder<>(); Holder<String> msg = new Holder<>(); short r = port.wmlsGetFromStore(q, "returnElements=all", null, out, msg); if (r <= 0) { log.error("page failed, range={}-{}, msg={}", start, end, msg.value); continue; // 单页失败不阻断整体,记录后继续,最后统一补偿 } parseLogs(out.value); }步长没有通用值,取决于采样密度。深度索引的曲线每米几十个点,100 米一页大概是几千个节点,比较稳;时间索引的实时数据按小时切更合适。页与页之间留一点重叠,是为了在拼接时校验边界有没有漏点——服务端的区间边界是闭是开,不同实现不完全一致,重叠一两个点能把这个不确定性吃掉。
4. 写回链路:AddToStore 与 UpdateInStore 的幂等设计
读通了写不顺,是这类客户端的常态。写入的难点不在调接口,而在“什么时候该 Add、什么时候该 Update”,以及一次写多个对象时部分失败怎么收场。
4.1 写入顺序:父对象不存在,子对象一定失败
WITSML 的对象树是有父子关系的:well下面是wellbore,wellbore下面才是log、trajectory、message这些。写子对象时如果父对象的 uid 在服务端不存在,AddToStore会返回负值,SuppMsgOut里通常写“父对象不存在”。
源码里的写入入口固定做三步:先id-only查well,没有就 Add;再查wellbore,没有就 Add;最后写业务对象。这三步看起来啰嗦,但比“直接写、失败再补父对象”稳定得多,因为后半句在并发场景下会互相踩。
4.2 Add 还是 Update:先探测再决定
// 幂等写入:先按 uid 探测对象是否存在,再决定 Add 还是 Update boolean exists = existsById(port, "well", wellUid, null, null); String payload = buildWellXml(wellUid, wellName); Holder<String> suppMsg = new Holder<>(); if (!exists) { // 新增:uid 由客户端生成,服务端只做校验,重复提交会失败 short r = port.wmlsAddToStore(payload, null, null, suppMsg); if (r <= 0) throw new WitsmlException("add well failed: " + suppMsg.value); } else { // 更新:只提交需要改的字段,缺失字段的处理方式取决于服务端实现 short r = port.wmlsUpdateInStore(payload, null, null, suppMsg); if (r <= 0) throw new WitsmlException("update well failed: " + suppMsg.value); }探测用returnElements=id-only,返回 1 表示存在,返回负值表示不存在或没权限,这两种情况要分开看,不能一律当“不存在”然后去 Add——没权限时去 Add 会得到一个更莫名其妙的错误。
existsById的实现就是一次GetFromStore,模板只填 uid,选项写returnElements=id-only。有读者会问为什么不直接 Update,失败再 Add。原因在并发:两个进程同时判断“不存在”,同时 Add,第二个必然失败,而失败后重试的逻辑又得再判一次,绕回来还是探测最省心。
uid 的生成规则要统一。源码里用“业务前缀 + 稳定哈希”,比如井名转小写去空格后加短哈希,好处是同一个业务实体重复导入时 uid 相同,天然幂等;坏处是改名之后 uid 不变,得靠 Update 改name字段。用随机 UUID 更简单,但重跑一次同步就会多出一份重复数据,这种后悔药没处买。
4.3 批量写入与压缩的边界
AddToStore的模板支持一次提交多个对象,根元素下挂多个子元素即可。批量能显著减少往返,但边界要自己把握:单请求对象数控制在几十个、请求体控制在几兆以内比较稳,再大就拆批。
<!-- 一次提交两条曲线对象,根元素下并列即可 --> <logs xmlns="http://www.witsml.org/schemas/1series" version="1.4.1.1"> <log uidWell="W-0001" uidWellbore="B-0001" uid="L-0001"> <name>depth-index-log</name> <indexType>measured depth</indexType> </log> <log uidWell="W-0001" uidWellbore="B-0001" uid="L-0002"> <name>time-index-log</name> <indexType>date time</indexType> </log> </logs>批量提交时,Result=2的概率明显上升,因为只要有一条不合法,整体就可能变成部分成功。这时候必须解析SuppMsgOut,逐条对账:哪些写进去了、哪些没有。源码里对批量写入做了一层包装,把提交的对象 uid 列表和SuppMsgOut一起落日志,事后可以按 uid 精确补偿,而不是整批重跑。
压缩在写入侧收益更明显,几兆的批量请求压完可能只剩几百 KB。同样要先确认服务端能力,再在传输层打开;如果服务端不支持,客户端压缩反而会让对方解析失败。
4.4 Result=2 的收场方式
部分成功的处理策略要提前定,不能等到线上出问题现想。常见三种:整批回滚(WITSML 没有事务,做不到)、整批重试(会重复写入已成功的对象)、按 uid 补偿(推荐)。
按 uid 补偿的做法是:批量提交前记录本次要写的 uid 清单;失败后拿SuppMsgOut解析出失败项;只对失败项做一次 Add 或 Update。这套逻辑源码里有一个独立的重试器,最多重试两轮,两轮还失败的进死信队列人工处理。写回链路最怕的不是失败,是“不知道哪条失败了”,所以日志里一定要有 uid 和SuppMsgOut原文。
5. 排查清单:五个把请求打回原形的细节
下面五条都是实际对接中反复出现的,按“现象 → 原因 → 解决”整理,遇到问题时按顺序过一遍,能省掉大半排查时间。
现象一:Result=1但XMLout是空壳。原因通常是模板的version属性和命名空间不匹配,或者 uid 大小写与服务端存储的不一致。WITSML 的 uid 在部分实现里区分大小写,W-0001和w-0001是两个对象。解决办法是先用requestObjectSelectionCapability=true拉一次对象清单,拿服务端返回的原始 uid 去查,别用自己拼的。
现象二:查询大对象时连接被重置或读超时。原因是没写returnElements,服务端按全量返回,几万节点一次性序列化。解决是显式指定id-only或header-only做探测,取数据时按索引区间分页,并把读超时按页大小调到位。顺带检查客户端有没有在内存里缓存整页 XML 字符串,大对象建议流式解析。
现象三:写入成功但数据重复。原因是 uid 用了随机值,同一批数据重跑一次就多一份;或者 Update 时提交的对象缺少父级 uid,服务端把它当成了新对象。解决是固定 uid 生成规则,写入前统一走“探测 → Add/Update”流程,模板里uidWell、uidWellbore、uid三个属性一个都不能少。
现象四:某些服务端返回 500,SuppMsgOut为空。常见于 SOAPAction 缺失或Content-Type没带字符集,尤其在手写过 SOAP 信封、绕开生成存根的时候。解决是优先用 WSDL 生成的存根调用,别手拼信封;如果必须手拼,抓一次正常请求的 HTTP 头逐项对照,重点看 SOAPAction 和 Content-Type。
现象五:时间字段整体偏移 8 小时。原因是模板里写的是本地时间,没带时区后缀,服务端按 UTC 解释;或者解析时用了默认时区把带偏移的字符串转成了本地时间。解决是模板里的时间一律带Z或显式偏移,解析层统一转Instant再按展示需要格式化,中间不经过任何默认时区。
这五条里前两条出现频率最高,基本覆盖了六成以上的“查不到数据”工单。剩下的是服务端行为和版本差异,只能靠GetCap加日志一点点摸。
6. 收口成 SDK:超时、重试与一次端到端验证
把调用跑通只是及格线,真正让这套客户端能在生产里待住的是几个默认值。源码里这些值一开始都是随手写的,后来按线上表现逐个调过,下面是目前比较稳的一组:
| 参数 | 初始值 | 建议值 | 理由 |
|---|---|---|---|
| 连接超时 | 无限制 | 5 秒 | 服务端不可达时快速失败,避免线程堆积 |
| 读超时 | 无限制 | 60~120 秒 | 按单页数据量给,分页后不需要太长 |
| 单次最大节点 | 不限 | 2000~5000 | 超过后序列化开销陡增,且容易触发服务端截断 |
| 重试次数 | 0 | 2 次,仅对传输异常 | 负值结果不重试,先看SuppMsgOut再决定 |
| 分页步长 | 全量 | 深度 100 米 / 时间 1 小时 | 按数据密度调,页间留少量重叠 |
| 并发连接数 | 默认 | 4~8 | 服务端通常有会话数限制,压测后再定 |
重试要区分异常类型。SocketTimeoutException、连接重置这类可以重试,因为请求大概率没到业务层;返回负值的结果不要重试,那是业务错误,重试一百次还是同样的负值,只会把日志刷满。
// 传输异常重试,业务负值不重试 int maxRetry = 2; for (int attempt = 0; ; attempt++) { try { short r = port.wmlsGetFromStore(query, options, null, out, msg); if (r <= 0) { // 业务失败直接返回,交给上层看 SuppMsgOut return r; } return r; } catch (WebServiceException e) { // 只有网络层异常才重试,且要退避,避免把服务端打得更惨 if (attempt >= maxRetry) throw e; Thread.sleep(500L * (attempt + 1)); } }验证环节我一般固定走三步。第一步GetVersion,确认接口通、鉴权过;第二步GetCap,把能力清单落一份日志,后续排查有对照;第三步做一次“写入 →id-only查回 → 按 uid 更新 → 再查回比对字段”的闭环,字段比对至少覆盖 uid、name、索引类型和时间字段。这三步跑通,基本可以判定客户端和服务端之间的契约是成立的,后面出问题大概率是数据本身或权限。
还有一个容易被忽略的技巧:把每次请求的模板、optionsIn、Result、SuppMsgOut原文按 uid 落一份滚动日志文件,保留最近几天。WITSML 的报错信息经常只有一句“invalid data”,没有请求原文根本没法复现。这份日志在联调期救过好几次场,比在代码里加断点高效得多。
从那以后我每次接新的 WITSML 服务端,都强制先跑这三步验证再写业务代码,哪怕对方催得再急也不跳过——跳过的那次,后面花了三倍时间补。希望帮到你。
本文还有配套的精品资源,点击获取