适合谁收藏
- 正在让 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 长度。
http
GET /api/status?line=1 HTTP/1.1
Host: 192.168.1.20:8088
Connection: keep-alive
http
POST /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 是否能解释 Body,Content-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,目标包含查询参数"怎样进入对象,以及"不能默认等于现场安全动作"怎样证明本次处理已经结束。若两段之间的连续关系无法解释"结构化请求比散落字符串更容易测试。",就不能把局部代码截图当成实现证据。
片段一:入口、声明与前置条件
iecst
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。
片段二:状态推进、边界与输出
iecst
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 JSON | application/json | 服务端按契约解析 |
| 空 Body POST | 方法为 POST 但 Body 为空 | 行为由服务端契约验证 |
场景 1:GET 无 Body
对一个只读查询路径发起 GET,抓包检查请求行之后是否错误地附带了上一次 POST 的 Body 或 Content-Length。服务端应只按 URI 处理查询,PLC 侧发送长度也应反映空 Body。这个场景用于确认方法切换时缓冲区已经清空,避免旧数据在下一笔事务里悄悄泄漏。
场景 2:POST 文本
用 POST 发送一段 text/plain 文本,比较 PLC 字符串长度、Header 中的 Content-Length 与服务端实际收到的字节数。三者必须一致,且响应应能回显或确认正文内容。若把字符数和字节数混用,中文或特殊字符会让长度判断失真,因此此处要以真实报文而不是界面显示为准。
场景 3:POST 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 -> 完整源码加更 -> 综合收束。