适合谁收藏正在让 PLC 主动访问 HTTP 服务的工程师。需要处理请求构造、响应边界、超时与连接复用的人。希望把 Client 故障定位到确定状态和错误出口的读者。本篇位置客户端篇第 4/7 篇主系列第 15/28 篇。现场问题把 GET 改成 POST 后服务端可能仍返回 400。请求行看起来正确但 Body 有内容却没有 Content-Length或者 JSON Body 仍使用 text/plain。PLC 端常把多个字符串输入直接交给拼接逻辑最终很难判断是方法错、内容类型错还是长度错。先给结论方法只描述意图Body 边界由长度决定内容解释由 Content-Type 决定。三者必须在ST_HttpRequest中一致再交给 Builder。读图重点这张图只压缩本篇的判断路径。读图时先找“GET”对应的输入边界再沿着“当前枚举可表达”检查状态怎样推进最后用“不能默认等于现场安全动作”确认输出是否已经形成验收证据。把对象和边界分开对象或阶段工程职责现场观察点GET通常无 Body目标包含查询参数读取资源或状态POST常带 Body 和 Content-Type提交命令或数据PUT当前枚举可表达是否使用取决于服务端契约DELETE当前枚举可表达不能默认等于现场安全动作用两条报文检查模型是否一致下面两条报文的差别不止是 GET 和 POST。GET 把查询条件放在请求目标中POST 则同时引入内容类型和 Body 长度。GET /api/status?line1 HTTP/1.1 Host: 192.168.1.20:8088 Connection: keep-alivePOST /api/echo HTTP/1.1 Host: 192.168.1.20:8088 Content-Type: application/json Content-Length: 15 Connection: close {value:123}检查顺序应当固定方法是否符合服务端契约请求目标是否完整Host 是否存在Content-Type 是否能解释 BodyContent-Length 是否等于实际传输长度。只改第一行后四项不会自动正确。从协议约束到代码职责协议约束方法只描述意图Body 边界由长度决定内容解释由 Content-Type 决定。三者必须在ST_HttpRequest中一致再交给 Builder。 这条结论先限定消息什么时候成立再限定哪个角色可以消费结果。若绕过协议边界直接驱动业务半包、超时、重复执行和连接残留就会进入应用层。工程抽象请求结构保存方法、目标、Host、Content-Type、Authorization、附加 Header、原始 Header、Body 和边界标志。它让上层业务不必理解报文文本也让测试可以直接构造边界条件。当前 Builder 只有 Body 非空时才写 Content-Length。文章必须明确这是当前实现行为不把它扩大成所有 HTTP 客户端的唯一写法。GET工程职责是“通常无 Body目标包含查询参数”。它不能只停留在命名层面运行时必须能通过“读取资源或状态”观察到输入、状态或结果否则这一层即使有代码也没有形成可验证边界。POST工程职责是“常带 Body 和 Content-Type”。它不能只停留在命名层面运行时必须能通过“提交命令或数据”观察到输入、状态或结果否则这一层即使有代码也没有形成可验证边界。PUT工程职责是“当前枚举可表达”。它不能只停留在命名层面运行时必须能通过“是否使用取决于服务端契约”观察到输入、状态或结果否则这一层即使有代码也没有形成可验证边界。DELETE工程职责是“当前枚举可表达”。它不能只停留在命名层面运行时必须能通过“不能默认等于现场安全动作”观察到输入、状态或结果否则这一层即使有代码也没有形成可验证边界。程序单元本篇主证据来自FB_HttpMessageBuilder.st中以METHOD PUBLIC M_BuildRequest为定位点的连续源码。这里不是为了展示语法而是把协议约束落到确定程序单元输入先进入结构体或缓冲区状态机只在本周期处理可确认的部分长度和结束条件决定能否前进错误码与指标负责把失败原因带出对象边界。这样一来“方法、内容类型和 Body 是一个整体。”可以在代码、在线变量和外部报文之间逐项对照而不是依赖经验猜测。本篇核心源码片段下面两段代码来自同一个真实文件FB_HttpMessageBuilder.st以METHOD PUBLIC M_BuildRequest为中心连续截取没有改写变量、删除分支或用伪代码替代。第一段用于确认入口与前置条件第二段用于确认状态、边界和输出。核对时重点看“通常无 Body目标包含查询参数”怎样进入对象以及“不能默认等于现场安全动作”怎样证明本次处理已经结束。若两段之间的连续关系无法解释“结构化请求比散落字符串更容易测试。”就不能把局部代码截图当成实现证据。片段一入口、声明与前置条件METHOD PUBLIC M_BuildRequest : BOOL VAR_IN_OUT stRequest : ST_HttpRequest; // 结构化协议或测试数据。 END_VAR VAR_OUTPUT sMessage : STRING(GVL_Http.cnMaxMessageSize); // 诊断或协议文本字段。 END_VAR VAR sMethod : STRING(16); // 诊断或协议文本字段。 sTarget : STRING(GVL_Http.cnMaxTargetLen); // 诊断或协议文本字段。 sContentLen : STRING(16); // 诊断或协议文本字段。 uiBodyLen : UINT; // 计数、长度或状态数值。 uiCandidate : UINT; // 计数、长度或状态数值。 END_VAR // IMPLEMENTATION // 工程说明本段集中处理状态、边界或诊断避免跨周期残留。 // 边界说明执行前后保持输出和错误码可被在线诊断追踪。 // 原因请求构造统一写入 Host、Connection 和 Content-Length避免 PLC 端生成歧义 HTTP/1.1 报文。 // 约束首版不生成 chunked 出站请求长连接只允许顺序复用不允许 pipeline 并发请求。 M_Reset(); M_BuildRequest : FALSE; sMessage : ; IF LEN(stRequest.sHost) 0 THEN M_SetError( eNewError : E_HttpError.iMissingHost, sMessage : request host is empty ); RETURN; END_IF sMethod : F_HttpMethodToString( eMethod : stRequest.eMethod ); IF LEN(stRequest.sTarget) 0 THEN sTarget : GVL_Http.cnDefaultPath; ELSE sTarget : stRequest.sTarget; END_IF uiBodyLen : TO_UINT(LEN(stRequest.sBody)); sContentLen : UINT_TO_STRING(uiBodyLen); sMessage : CONCAT(sMethod, ); sMessage : CONCAT(sMessage, sTarget); sMessage : CONCAT(sMessage, HTTP/1.1$R$NHost: ); sMessage : CONCAT(sMessage, stRequest.sHost); sMessage : CONCAT(sMessage, $R$NConnection: ); IF stRequest.bConnectionClose THEN sMessage : CONCAT(sMessage, close$R$N); ELSE sMessage : CONCAT(sMessage, keep-alive$R$N); END_IF IF LEN(stRequest.sAdditionalHeader) 0 THEN sMessage : CONCAT(sMessage, stRequest.sAdditionalHeader); sMessage : CONCAT(sMessage, $R$N); END_IF IF LEN(stRequest.sContentType) 0 THEN sMessage : CONCAT(sMessage, Content-Type: ); sMessage : CONCAT(sMessage, stRequest.sContentType); sMessage : CONCAT(sMessage, $R$N); END_IF IF uiBodyLen 0 THEN sMessage : CONCAT(sMessage, Content-Length: ); sMessage : CONCAT(sMessage, sContentLen); sMessage : CONCAT(sMessage, $R$N); END_IF sMessage : CONCAT(sMessage, $R$N); IF uiBodyLen 0 THEN sMessage : CONCAT(sMessage, stRequest.sBody); END_IF这一段先回答对象在什么输入和状态下开始工作。阅读时要核对变量的初值、长度上限和启动条件不能只看某个布尔量是否变成 TRUE。片段二状态推进、边界与输出uiCandidate : TO_UINT(LEN(sMessage)); IF uiCandidate GVL_Http.cnMaxMessageSize THEN M_SetError( eNewError : E_HttpError.iBufferTooSmall, sMessage : request message exceeds buffer ); RETURN; END_IF udiMessageLength : TO_UDINT(uiCandidate); bDone : TRUE; M_BuildRequest : TRUE; // METHOD M_BuildResponse /// /// 名称 : M_BuildResponse /// 功能 : 将 ST_HttpResponse 构造为 HTTP 响应文本。 /// 说明 : sReason 为空时自动采用常用原因短语。 /// {attribute hide_all_locals} METHOD PUBLIC M_BuildResponse : BOOL VAR_IN_OUT stResponse : ST_HttpResponse; // 结构化协议或测试数据。 END_VAR VAR_OUTPUT sMessage : STRING(GVL_Http.cnMaxMessageSize); // 诊断或协议文本字段。 END_VAR VAR sReason : STRING(64); // 诊断或协议文本字段。 sStatus : STRING(16); // 诊断或协议文本字段。 sContentLen : STRING(16); // 诊断或协议文本字段。 sContentType : STRING(96); // 诊断或协议文本字段。 uiBodyLen : UINT; // 计数、长度或状态数值。 uiCandidate : UINT; // 计数、长度或状态数值。 END_VAR // IMPLEMENTATION // 工程说明本段集中处理状态、边界或诊断避免跨周期残留。 // 边界说明执行前后保持输出和错误码可被在线诊断追踪。 // 原因响应构造始终显式 Content-Length使外部 client 可以用真实字节数验证 Server 行为。 // 风险状态码为 0 或 body 超过上限时必须拒绝构造否则 Server 会发送不可诊断的坏报文。 M_Reset(); M_BuildResponse : FALSE; sMessage : ; IF stResponse.uiStatusCode 0 THEN M_SetError( eNewError : E_HttpError.iInvalidArgument, sMessage : response status code is zero ); RETURN; END_IF IF LEN(stResponse.sReason) 0 THEN sReason : stResponse.sReason; ELSE sReason : F_HttpStatusReason( uiStatusCode : stResponse.uiStatusCode ); END_IF IF LEN(stResponse.sContentType) 0 THEN sContentType : stResponse.sContentType; ELSE sContentType : GVL_Http.cnDefaultContentType; END_IF uiBodyLen : TO_UINT(LEN(stResponse.sBody)); sStatus : UINT_TO_STRING(stResponse.uiStatusCode); sContentLen : UINT_TO_STRING(uiBodyLen); sMessage : CONCAT(HTTP/1.1 , sStatus); sMessage : CONCAT(sMessage, ); sMessage : CONCAT(sMessage, sReason); sMessage : CONCAT(sMessage, $R$NConnection: ); IF stResponse.bConnectionClose THEN第二段继续展示同一连续源码范围。把它与第一段合起来才能判断输入怎样被锁存、状态何时推进、边界何时满足以及错误出口是否保留了足够诊断信息。验证路径场景操作通过口径GET 无 Body访问查询路径请求不携带错误长度POST 文本text/plain Body长度与字符串一致POST JSONapplication/json服务端按契约解析空 Body POST方法为 POST 但 Body 为空行为由服务端契约验证场景 1GET 无 Body对一个只读查询路径发起 GET抓包检查请求行之后是否错误地附带了上一次 POST 的 Body 或Content-Length。服务端应只按 URI 处理查询PLC 侧发送长度也应反映空 Body。这个场景用于确认方法切换时缓冲区已经清空避免旧数据在下一笔事务里悄悄泄漏。场景 2POST 文本用 POST 发送一段text/plain文本比较 PLC 字符串长度、Header 中的Content-Length与服务端实际收到的字节数。三者必须一致且响应应能回显或确认正文内容。若把字符数和字节数混用中文或特殊字符会让长度判断失真因此此处要以真实报文而不是界面显示为准。场景 3POST JSON改用application/json并发送结构合法的 JSON确认 Content-Type、Body 内容和长度一起进入请求构造器。服务端应按 JSON 契约解析而不是把它当普通文本PLC 端则要保留响应状态与错误文本。此测试覆盖的是媒体类型与 Body 配套关系不能用上一场的纯文本回显替代。场景 4空 Body POST保持 POST 方法但传入空 Body明确观察构造器输出的长度字段与服务端响应。不同接口可能接受空请求、拒绝空请求或给出默认行为Client 不应擅自把它判成传输错误。验收重点是请求格式仍合法、业务状态原样保留并且下一次带 Body 的 POST 不受这次空请求影响。常见误判只修改方法枚举不同步检查 Body、Content-Type 和 Content-Length。把 JSON 文本是否有效与 HTTP 传输是否完整混成同一个判断。POST 响应超时后无条件重发忽略服务端可能已经执行过动作。这些误判的共同点是拿一个局部现象替代完整事务。定位时必须回到本篇的输入、状态、边界和输出四个坐标并用相同输入完成回归。这一篇你最该记住方法、内容类型和 Body 是一个整体。长度描述传输边界不描述业务语义。结构化请求比散落字符串更容易测试。系列导航系列CodeSys HTTP 系列教程第 15/28 篇。阶段客户端篇职责线位置 4/7。上一篇第14篇下一篇第16篇发布顺序基础认知 - Server - Client - 完整源码加更 - 综合收束。