远端 IP、URI、请求目标和 Host 是不同概念。Client 必须把连接地址和 HTTP 语义分开处理,才能访问反向代理或同 IP 多站点服务。
适合谁收藏
- 正在让 PLC 主动访问 HTTP 服务的工程师。
- 需要处理请求构造、响应边界、超时与连接复用的人。
- 希望把 Client 故障定位到确定状态和错误出口的读者。
客户端篇,第 3/7 篇;主系列第 14/28 篇。
现场问题
用 IP 直接访问测试服务时,请求可能正常;换成域名或反向代理后,服务端返回 400 或 404。此时 TCP 地址没有错,问题在 Host 或请求目标。
连接层需要 IP 和端口,HTTP/1.1 请求行需要 path/query,Header 需要 Host。把三者拼成一个字符串会让修改和诊断都变得困难。
先给结论
Client 先把输入归一到结构化请求,再由 Builder 生成请求行和 Header。Host 为空时直接拒绝构造,不发送一条已知不合法的 HTTP/1.1 请求。

读图重点
这张图只压缩本篇的判断路径。读图时先找“sServerIP”对应的输入边界,再沿着“Host Header”检查状态怎样推进,最后用“决定虚拟主机语义”确认输出是否已经形成验收证据。
把对象和边界分开
| 对象或阶段 | 工程职责 | 现场观察点 |
|---|---|---|
| sServerIP | TCP 远端地址 | 决定连接到哪台主机 |
| uiPort | TCP 远端端口 | 默认不等于协议语义 |
| sTarget/sPath | 请求目标与查询串 | 进入请求行 |
| sHost | Host Header | 决定虚拟主机语义 |
从协议约束到代码职责
协议约束
Client 先把输入归一到结构化请求,再由 Builder 生成请求行和 Header。Host 为空时直接拒绝构造,不发送一条已知不合法的 HTTP/1.1 请求。 这条结论先限定消息什么时候成立,再限定哪个角色可以消费结果。若绕过协议边界直接驱动业务,半包、超时、重复执行和连接残留就会进入应用层。
工程抽象
Builder 将方法、目标和 HTTP/1.1 组成 start-line,并始终写入 Host 和 Connection。附加 Header 由调用者提供,但不能包含最终空行,避免破坏 Header/Body 分界。
URL 输入可以为工程调用提供便利,但内部仍应拆回连接参数和请求参数。对 PLC 来说,显式字段比运行时做复杂 URI 推断更容易验证。
- sServerIP:工程职责是“TCP 远端地址”。它不能只停留在命名层面,运行时必须能通过“决定连接到哪台主机”观察到输入、状态或结果;否则这一层即使有代码,也没有形成可验证边界。
- uiPort:工程职责是“TCP 远端端口”。它不能只停留在命名层面,运行时必须能通过“默认不等于协议语义”观察到输入、状态或结果;否则这一层即使有代码,也没有形成可验证边界。
- sTarget/sPath:工程职责是“请求目标与查询串”。它不能只停留在命名层面,运行时必须能通过“进入请求行”观察到输入、状态或结果;否则这一层即使有代码,也没有形成可验证边界。
- sHost:工程职责是“Host Header”。它不能只停留在命名层面,运行时必须能通过“决定虚拟主机语义”观察到输入、状态或结果;否则这一层即使有代码,也没有形成可验证边界。
程序单元
本篇主证据来自 FB_HttpMessageBuilder.st 中以 METHOD PUBLIC M_BuildRequest 为定位点的连续源码。这里不是为了展示语法,而是把协议约束落到确定程序单元:输入先进入结构体或缓冲区,状态机只在本周期处理可确认的部分,长度和结束条件决定能否前进,错误码与指标负责把失败原因带出对象边界。这样一来,“IP 解决连接,Host 解决 HTTP 主机语义。”可以在代码、在线变量和外部报文之间逐项对照,而不是依赖经验猜测。
本篇核心源码片段
下面两段代码来自同一个真实文件 FB_HttpMessageBuilder.st,以 METHOD PUBLIC M_BuildRequest 为中心连续截取,没有改写变量、删除分支或用伪代码替代。第一段用于确认入口与前置条件,第二段用于确认状态、边界和输出。核对时重点看“TCP 远端地址”怎样进入对象,以及“决定虚拟主机语义”怎样证明本次处理已经结束。若两段之间的连续关系无法解释“先构造结构体,再生成报文。”,就不能把局部代码截图当成实现证据。
片段一:入口、声明与前置条件
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这一段先回答对象在什么输入和状态下开始工作。阅读时要核对变量的初值、长度上限和启动条件,不能只看某个布尔量是否变成 TRUE。
片段二:状态推进、边界与输出
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
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 := '';第二段继续展示同一连续源码范围。把它与第一段合起来,才能判断输入怎样被锁存、状态何时推进、边界何时满足,以及错误出口是否保留了足够诊断信息。
验证路径
| 场景 | 操作 | 通过口径 |
|---|---|---|
| 缺少 Host | Host 置空 | 构造阶段直接报错 |
| 错误路径 | 访问不存在的 target | 对端返回 404 而非连接失败 |
| 查询参数 | path 带 query | 请求行保持完整 |
| 附加 Header | 加入自定义字段 | 格式和空行仍合法 |
场景 1:缺少 Host
将 Host 留空后启动构造器,检查它是否在发送前拒绝请求。正确结果是给出明确参数错误、发送长度为零,并且 TCP 连接没有被无意义地建立。这个场景要证明 Host 是请求构造的必填边界,而不是把缺失字段交给对端后再靠 400 猜测原因。
场景 2:错误路径
把 URI 写成格式正确但服务端不存在的 target,随后抓取实际请求行和 Host。预期连接和报文格式均成功,对端才返回 404;PLC 应完整保存该业务状态,而不是把它归为网络异常。借此可验证 URI 的语义错误与 Host、端口或连接故障之间的诊断分层。
场景 3:查询参数
使用带查询参数的路径,例如 /api/status?detail=1,并从通信猫或服务端日志逐字核对请求行。问号后的查询不能丢失、重复编码或被拼进 Host;服务端拿到的路径应与 PLC 输入一致。这个用例专门覆盖字符串拼接最容易发生的边界错误,而不是重复测试普通 GET。
场景 4:附加 Header
加入一条自定义 Header,再检查它是否恰好位于标准 Header 与空行之间。若字段缺少 CRLF、重复换行或把 Body 首字节吞进 Header 区,服务端行为可能看似偶发。通过口径是抓包中的 Header 顺序、空行边界和 Body 起始位置都准确,同时默认 Header 没有被覆盖或重复。
常见误判
- 把连接 IP、请求目标和 Host 当成同一个字符串,换到反向代理后无法定位 404。
- Host 为空仍然发送请求,把可在构造阶段发现的问题推给远端。
- 附加 Header 自带最终空行,导致 Builder 生成两个 Header/Body 分界。
这些误判的共同点,是拿一个局部现象替代完整事务。定位时必须回到本篇的输入、状态、边界和输出四个坐标,并用相同输入完成回归。
这一篇你最该记住
- IP 解决连接,Host 解决 HTTP 主机语义。
- 请求目标不是完整 URL 的机械复制。
- 先构造结构体,再生成报文。
系列导航
- 系列:CodeSys HTTP 系列教程,第 14/28 篇。
- 阶段:客户端篇,职责线位置 3/7。
- 上一篇:第13篇
- 下一篇:第15篇
- 发布顺序:基础认知 -> Server -> Client -> 完整源码加更 -> 综合收束。
评论区预留
这里先保留评论和回复结构,不接入第三方服务。后续统一决定登录、匿名、审核、反垃圾和静态站兼容策略。