适合谁收藏
- 正在让 PLC 主动访问 HTTP 服务的工程师。
- 需要处理请求构造、响应边界、超时与连接复用的人。
- 希望把 Client 故障定位到确定状态和错误出口的读者。
本篇位置客户端篇,第 7/7 篇;主系列第 18/28 篇。
现场问题
最危险的处理方式,是 Client 一报错就立即重发。对于读取类请求可能只是重复流量,对于带动作的 POST,则可能让服务端执行两次。
PLC 需要先判断事务在哪一步失败、请求是否可能已经送达、方法是否允许重试,再决定关闭、复位或等待人工处理。
先给结论
恢复流程固定为:锁存错误证据、停止当前读写、关闭失效句柄、清理事务缓冲区、回到可控状态。是否重试由上层策略决定,协议栈不无条件重复业务请求。
读图重点
这张图只压缩本篇的判断路径。读图时先找“连接失败”对应的输入边界,再沿着“对端返回不可解析消息”检查状态怎样推进,最后用“记录原始响应并停止盲重试”确认输出是否已经形成验收证据。
把对象和边界分开
| 对象或阶段 | 工程职责 | 现场观察点 |
|---|---|---|
| 连接失败 | 请求尚未发送 | 可按次数和退避策略重试 |
| 发送中断 | 服务端是否收到不确定 | 保留请求标识并谨慎重试 |
| 响应超时 | 请求可能已执行 | 先查询结果或按业务幂等判断 |
| 协议错误 | 对端返回不可解析消息 | 记录原始响应并停止盲重试 |
从协议约束到代码职责
协议约束
恢复流程固定为:锁存错误证据、停止当前读写、关闭失效句柄、清理事务缓冲区、回到可控状态。是否重试由上层策略决定,协议栈不无条件重复业务请求。 这条结论先限定消息什么时候成立,再限定哪个角色可以消费结果。若绕过协议边界直接驱动业务,半包、超时、重复执行和连接残留就会进入应用层。
工程抽象
当前工程暴露超时、NBS 错误、HTTP 错误、最近响应和 ClientMetrics。真机测试应覆盖端口拒绝、服务端延迟、半响应、主动关闭和恢复后的下一次正常请求。
文章只把“自动重试”作为有前提的策略,不把它写成默认最佳实践。工业现场更看重动作唯一性和可追溯性。
- 连接失败:TCP 尚未建立,请求字节没有离开 PLC。记录目标地址、端口、连接状态和底层错误后,可以按限定次数与退避时间重连。
- 发送中断:部分字节可能已经到达服务端,不能仅凭 Write 失败判断业务没有执行。必须保留请求标识、已发送长度和失败时刻,再由上层决定是否补偿。
- 响应超时:请求可能已经完整送达并被处理,只是响应没有在期限内返回。GET 可在策略允许时重试;带动作的 POST 应先查询结果或依赖幂等键,禁止直接重复执行。
- 协议错误:连接存在但报文不能被 Parser 接受。此时保存原始响应、解析阶段和 HTTP 错误码,关闭当前连接并停止自动重试,避免同一坏报文反复消耗扫描周期。
程序单元
本篇主证据来自FB_HttpClient.st中以E_HttpClientState.iFault为定位点的连续源码。这里不是为了展示语法,而是把协议约束落到确定程序单元:输入先进入结构体或缓冲区,状态机只在本周期处理可确认的部分,长度和结束条件决定能否前进,错误码与指标负责把失败原因带出对象边界。这样一来,“先判断失败阶段,再谈重试。”可以在代码、在线变量和外部报文之间逐项对照,而不是依赖经验猜测。
本篇核心源码片段
下面两段代码来自同一个真实文件FB_HttpClient.st,以E_HttpClientState.iFault为中心连续截取,没有改写变量、删除分支或用伪代码替代。第一段用于确认入口与前置条件,第二段用于确认状态、边界和输出。核对时重点看“请求尚未发送”怎样进入对象,以及“记录原始响应并停止盲重试”怎样证明本次处理已经结束。若两段之间的连续关系无法解释“恢复前必须保留错误和原始报文证据。”,就不能把局部代码截图当成实现证据。
片段一:入口、声明与前置条件
hConnection := hConnection, szSize := 0, pData := ADR(aTxBuf) ); tonWrite( IN := FALSE, PT := GVL_Http.cnWriteTimeout ); M_Reset(); RETURN; END_IF bDone := FALSE; CASE eState OF E_HttpClientState.iDisabled: eState := E_HttpClientState.iIdle; E_HttpClientState.iIdle: bBusy := FALSE; IF rtrigSend.Q THEN M_PrepareRequest(); stMetrics.udiRequestCount := stMetrics.udiRequestCount + 1; IF NOT bRequestQueued THEN eState := E_HttpClientState.iFault; ELSIF (hConnection <> 0) AND bTcpConnected AND NOT stRequest.bConnectionClose THEN bReuseAttempt := TRUE; eState := E_HttpClientState.iSend; ELSE bReuseAttempt := FALSE; eState := E_HttpClientState.iTcpConnect; END_IF END_IF E_HttpClientState.iTcpConnect: bBusy := TRUE; fbTcpClient( xEnable := TRUE, udiTimeOut := udiTimeOut, ipAddr := ipServer, uiPort := uiEffectivePort, eError => eTcpError, hConnection => hConnection ); bTcpConnected := fbTcpClient.xActive AND (hConnection <> 0); IF fbTcpClient.xError THEN eLastNbsError := eTcpError; stMetrics.udiConnectErrorCount := stMetrics.udiConnectErrorCount + 1; M_SetClientError( eError := E_HttpError.iTcpClientFailed, sMessage := 'TCP connect failed' ); eState := E_HttpClientState.iFault; ELSIF bTcpConnected THEN eState := E_HttpClientState.iSend; ELSIF (udiNowMs <> 0) AND ((udiNowMs - udiStateEnterMs) >= udiTimeoutMs) THEN M_SetClientError( eError := E_HttpError.iTimeout, sMessage := 'TCP connect timeout'这一段先回答对象在什么输入和状态下开始工作。阅读时要核对变量的初值、长度上限和启动条件,不能只看某个布尔量是否变成 TRUE。
片段二:状态推进、边界与输出
); eState := E_HttpClientState.iFault; END_IF E_HttpClientState.iSend: bBusy := TRUE; fbTcpClient( xEnable := TRUE, udiTimeOut := udiTimeOut, ipAddr := ipServer, uiPort := uiEffectivePort, eError => eTcpError, hConnection => hConnection ); M_ServiceWrite(); IF (NOT bWriteBusy) AND (NOT bWriteExecute) THEN eState := E_HttpClientState.iReceive; END_IF E_HttpClientState.iReceive: bBusy := TRUE; fbTcpClient( xEnable := TRUE, udiTimeOut := udiTimeOut, ipAddr := ipServer, uiPort := uiEffectivePort, eError => eTcpError, hConnection => hConnection ); M_ServiceRead(); M_ProcessResponse(); IF (udiNowMs <> 0) AND ((udiNowMs - udiStateEnterMs) >= udiTimeoutMs) THEN M_SetClientError( eError := E_HttpError.iTimeout, sMessage := 'HTTP response timeout' ); eState := E_HttpClientState.iFault; END_IF E_HttpClientState.iDone: bBusy := FALSE; bDone := TRUE; IF stRequest.bConnectionClose OR xCloseConnection THEN fbTcpClient( xEnable := FALSE, ipAddr := ipServer, uiPort := uiEffectivePort, hConnection => hConnection ); bTcpConnected := FALSE; ELSE fbTcpClient( xEnable := TRUE, udiTimeOut := udiTimeOut, ipAddr := ipServer, uiPort := uiEffectivePort, eError => eTcpError, hConnection => hConnection );第二段继续展示同一连续源码范围。把它与第一段合起来,才能判断输入怎样被锁存、状态何时推进、边界何时满足,以及错误出口是否保留了足够诊断信息。
验证路径
| 场景 | 操作 | 通过口径 |
|---|---|---|
| 端口拒绝 | 无监听服务 | 连接错误并可恢复 |
| 响应延迟 | 超过设定超时 | Timeout 证据完整 |
| 中途断开 | Body 未收齐时关闭 | 不输出成功 |
| 故障后恢复 | 服务恢复再触发请求 | 旧状态不污染新事务 |
场景 1:端口拒绝
把目标端口改为确定没有服务监听的端口,触发一次 GET。Client 应停在连接阶段,bDone不得出现,连接错误与失败计数只锁存一次;复位后句柄回到无效值,状态重新进入 Idle。这个场景证明请求尚未发送时可以安全重连。
场景 2:响应延迟
让测试服务接收请求后故意延迟响应,延迟时间必须大于 Client 的接收超时。检查状态先进入 Receive,再以iTimeout收口,同时保留已发送请求和等待时长;不能因为没有响应就把本次事务记为未发送,也不能在协议栈内部自动重放 POST。
场景 3:中途断开
让服务端声明一个确定的Content-Length,只发送部分 Body 后主动断开。Parser 必须保持消息未完成,Client 输出传输或协议错误,已有半包不能进入业务响应;关闭并清理后,接收缓冲区长度应回到零。
场景 4:故障后恢复
在完成一次端口拒绝或中途断开后恢复服务,再由上层明确触发一笔新 GET。新事务应建立新连接、重新累计 Tx/Rx,并得到合法状态码与完整 Body;旧错误可供历史诊断,但不得继续拉高bError或污染新响应。
常见误判
- 任何错误都立即重试,没有判断请求是否可能已经送达服务端。
- 延长超时掩盖状态机没有推进,最终只是让故障更晚暴露。
- 故障后直接发下一条请求,没有先关闭句柄和清理接收缓冲区。
这些误判的共同点,是拿一个局部现象替代完整事务。定位时必须回到本篇的输入、状态、边界和输出四个坐标,并用相同输入完成回归。
这一篇你最该记住
- 先判断失败阶段,再谈重试。
- POST 超时不能默认安全重发。
- 恢复前必须保留错误和原始报文证据。
系列导航
- 系列:CodeSys HTTP 系列教程,第 18/28 篇。
- 阶段:客户端篇,职责线位置 7/7。
- 上一篇:第17篇
- 下一篇:第19篇
- 发布顺序:基础认知 -> Server -> Client -> 完整源码加更 -> 综合收束。