半关闭后仍返回最后一条响应
一句话总结
传输调用成功、对方收到了消息、对方的业务已经完成,这三件事是互不相同的。
为什么需要它
send 返回了 3,而要发送的帧是 20 个字节。如果因为没有异常就记录为完成,剩下的 17 个字节就被丢掉了。自己循环时,必须只按返回的大小前进。如果 send 返回 0,说明没有进展,必须当作终止错误处理,否则反复调用就会变成死循环。使用 Python 的 sendall,就可以通过“全部发送或抛出异常”这一 API 来代为完成这个循环。
即便如此,sendall 也并不意味着“对方的订单处理已经完成”。在本地传输路径上交出字节,与远端程序解析帧,是两回事。在本课程中,要确认的是真实的回显响应是否返回。就连回显,也不意味着支付、保存之类业务的完成。支付的重试和幂等性需要另外的契约。
工作原理
监听套接字接受连接。accept 返回的连接套接字负责读写数据。最终的检查器用 127.0.0.1 和端口 0 创建监听套接字,并把操作系统选定的端口传给客户端。它不假定某个特定端口是空闲的,所以很难与其他测试冲突。loopback 在这个 Pod 内部,不需要放行外部互联网。
学习者的 handle_connection 接收连接套接字。它重复做这件事:读取一个帧,并回应同样的 bytes。如果是 None,说明没有新帧,是正常结束。b"" 是长度为 0 的有效消息,所以要照样构造帧并返回。如果写成 if not payload,就会混淆这两者,从而丢失空消息之后的数据。请比较 API 所定义的结束标记,而不是值的真假。
客户端发送多个帧之后,调用 shutdown(SHUT_WR)。这样就不再发送了,但仍然可以继续接收响应。服务器在发送完已收到的帧的响应之后,遇到正常 EOF 并关闭。由于套接字是双向通信,“对方已经发送完毕”和“我无法响应”并不相同。如果不理解半关闭,就会出现丢弃最后一个响应的 bug。
with sock 在成功和异常时都会关闭连接套接字。它不会连调用方所拥有的监听套接字也关闭。这就是要在接口中写明哪个函数拥有哪个套接字的生命周期的原因。打开的文件描述符是有限的资源。只在异常时泄漏的代码,在正常请求测试中看起来没有问题,但在故障反复出现时会耗尽资源。
发送时间也很重要。本课程的 send_frame 使用调用方设定的套接字 timeout。send_frame 自身并不确定新的发送预算。recv_frame 在自己的工作结束后会恢复原有的值,所以服务器的调用方在交出连接之前,要设置符合整体策略的默认 timeout。实现了接收时间限制,并不等于限制了所有的发送等待。多个客户端的慢消费者问题和队列上限,将在下一阶段另行讨论。
在现场相遇的样子
单元测试可以向解析器提供精确切分的片段。真实的 TCP 测试能确认 connect、accept、半关闭和套接字回收,但无法强制规定 recv 片段的大小。两种测试要同时保留,才能既证明边界计算,又证明真实通信。不会声称通过这个本地回显实验验证了 TLS、认证、互联网延迟、丢包恢复和大规模吞吐量。
把客户端的观测分成“已经发完请求”“收到了响应头”“响应正文已经完整”“收到了正常 EOF”,就能缩小失败的位置。如果只留下“连接成功”这样一条日志,就无从得知服务器是否处理了请求。日志里只记录消息 ID 或字节长度等必要信息,而不是无条件输出真实的用户正文,这个习惯也很重要。本课程的测试数据是不含敏感信息的自制示例。
如果在响应到来之前连接断了,客户端很难区分服务器是没有做业务,还是做了业务但丢了响应。所以真实订单系统的重试,需要用来识别同一操作的键和结果查询规则。重新发送字节很简单,但不重复处理业务则是另外的设计。这就是完成回显程序之后,可以接续到 API 幂等性课程的原因。
最终检查器运行负责一个连接的 handler。这并不是无限并发连接的服务器设计。是用线程拆分连接还是用事件循环处理,以及如何限制队列和连接数,将在后续课程中结合真实负载进行比较。如果在单个连接上边界和清理都还不对的情况下就先增加并发,同样的错误会以更复杂的形式出现,所以先把小的契约做准确。
下一项实验要做什么
检查器在同一个连接上发送小帧、空帧和 UTF-8 正文,并半关闭发送方向。检查响应字节是否完全一致,以及服务器结束后套接字是否已关闭。学习者的函数会被真正执行,只写出看似合理的输出的文件无法通过。如果超过测试时间,请先检查是否存在无限等待。临时实验会话结束后文件不会保留,所以需要的代码要在结束前另行保存。