ControlRookie
返回文章

第14篇_Client 03|URI、Host 和请求头怎样构造

远端 IP、URI、请求目标和 Host 是不同概念。Client 必须把连接地址和 HTTP 语义分开处理,才能访问反向代理或同 IP 多站点服务。

远端 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 请求。

Client 03|URI、Host 和请求头怎样构造
Client 03|URI、Host 和请求头怎样构造

读图重点

这张图只压缩本篇的判断路径。读图时先找“sServerIP”对应的输入边界,再沿着“Host Header”检查状态怎样推进,最后用“决定虚拟主机语义”确认输出是否已经形成验收证据。

把对象和边界分开

对象或阶段工程职责现场观察点
sServerIPTCP 远端地址决定连接到哪台主机
uiPortTCP 远端端口默认不等于协议语义
sTarget/sPath请求目标与查询串进入请求行
sHostHost 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 远端地址”怎样进入对象,以及“决定虚拟主机语义”怎样证明本次处理已经结束。若两段之间的连续关系无法解释“先构造结构体,再生成报文。”,就不能把局部代码截图当成实现证据。

片段一:入口、声明与前置条件

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

这一段先回答对象在什么输入和状态下开始工作。阅读时要核对变量的初值、长度上限和启动条件,不能只看某个布尔量是否变成 TRUE。

片段二:状态推进、边界与输出

iecst
    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 := '';

第二段继续展示同一连续源码范围。把它与第一段合起来,才能判断输入怎样被锁存、状态何时推进、边界何时满足,以及错误出口是否保留了足够诊断信息。

验证路径

场景操作通过口径
缺少 HostHost 置空构造阶段直接报错
错误路径访问不存在的 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 -> 完整源码加更 -> 综合收束。
评论和回复区

评论区预留

这里先保留评论和回复结构,不接入第三方服务。后续统一决定登录、匿名、审核、反垃圾和静态站兼容策略。

↑ ↓