系列导航

导读 · 上一篇:27:CLI、OptionParser 与退出码 · 下一篇:29:配置、日志、信号与有序关闭 · 完整源码包

一次 HTTP 调用经过哪些接口

客户端调用 Net::HTTP,服务器接收 socket 字节,Web server 解析协议并构造请求环境,Rack 应用根据环境返回响应。框架可以在 Rack 之上增加路由、控制器和会话,但 Rack 自身不等于一个已经监听端口的服务器。

把这些层混在一起,会产生两种错误验收:只调用 Ruby 对象就宣称网络通信成功,或仅验证 TCP 连接成功就宣称业务响应正确。本篇分别建立真实回环 HTTP 客户端实验和 Rack 调用合同实验,保留各自能够证明的范围。

前置是异常、依赖、测试和数据边界。版本为 CRuby 3.4.11、net-http 0.6.0、Rack 3.2.1。实现位于 lib/taskbook/http.rb,实验入口为 labs/28/run.rb,只访问 127.0.0.1。

客户端先限制目标,再建立连接

教学客户端接收已经解析的 URI,并明确限制为回环 HTTP:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def self.fetch_local(uri, timeout: 1)
unless uri.scheme == 'http' && uri.host == '127.0.0.1'
raise ArgumentError, 'loopback HTTP only'
end

http = Net::HTTP.new(uri.host, uri.port, nil)
http.open_timeout = timeout
http.read_timeout = timeout
http.write_timeout = timeout
http.max_retries = 0

response = http.start { |session| session.get(uri.request_uri) }
raise IOError, "HTTP status #{response.code}" unless response.is_a?(Net::HTTPSuccess)
response.body
end

第三个参数为 nil,避免环境代理影响本地受控实验。真实客户端是否允许代理应有明确配置;这里不是要建立通用 URL 下载功能,因此不需要支持任意主机、重定向和凭据。

start 的 block 把会话使用与关闭关联起来。HTTP 失败状态被转换为工具层面的失败;网络断开和读取超时仍保留各自异常。收到一个完整的 503 响应,和根本没有收到响应,是不同诊断事实,即使上层最终都选择失败退出,也不应混成同一条证据。

Net::HTTP 是应用层客户端,不负责证明远端业务动作的幂等性。请求是否可重试,需要结合方法语义与服务实际行为。实验把 max_retries 设为零,是为了让一次超时对应一次请求尝试,避免自动重试改变事件和耗时解释。

三种超时不能直接相加成总时限

连接超时约束建立连接阶段,读取超时约束读取等待,写入超时约束写入等待。它们不自动形成从调用开始到结束的统一截止时间。一个响应持续分段到达时,每次读取都未超时,总请求仍可能持续很久。

因此不能看到 read_timeout = 1 就宣称“整个请求一定一秒内结束”。重试、解析、DNS、连接复用和多次读写会影响总耗时。需要全操作 deadline 时,应在更高层定义剩余预算,确认底层机制如何应用预算,并分别验证取消和资源清理。

net-http 0.6.0 源码 是本篇版本的实现依据。在线 API 文档可能随项目更新,因此针对超时与重试的推论固定到已安装版本,并用受控服务实际触发一次 Net::ReadTimeout。

实验服务接收请求后延迟 0.15 秒才结束处理,客户端读取超时设为 0.03 秒。判定是异常类型,不把实测墙钟时间锁死为精确数字。调度和系统负载会影响具体耗时;本例只需证明服务器尚未提供响应时,读取等待能够失败。

本地端点需要真实关闭

实验使用 TCPServer.new('127.0.0.1', 0) 让操作系统分配可用端口,读取实际端口后构造客户端 URI。这样避免固定端口占用,也避免为了等待服务器启动使用猜测性的长睡眠。

服务线程接收一个连接,读取请求行和头结束,写出预定的有限响应。三个场景分别提供 200 响应、503 响应和延迟响应。这个服务只为实验发送受控字节,不是完整 HTTP server;没有实现请求长度、连接复用、分块编码和恶意输入防御,不能部署使用。

客户端完成或失败后,外层确保关闭监听器并等待服务线程结束。延迟场景中客户端可能已关闭连接,服务端写入可能得到断管或连接重置;这些预期关闭后果需要处理,不能让后台线程错误悄悄逃出验证。

监听 socket 的关闭状态与线程退出共同构成生命周期证据。只看到客户端输出 ok,不能证明测试没有留下后台资源。测试若失败,也必须执行清理;否则后续运行会被上一次残留服务影响。

Rack 应用返回的是三元组

Taskbook 的 Rack 应用实现 call(env),成功时返回统计对象的 JSON 响应:

1
2
3
4
5
6
7
8
9
def response(status, value)
body = JSON.generate(value)
[
status,
{ 'content-type' => 'application/json',
'content-length' => body.bytesize.to_s },
[body]
]
end

状态是整数,headers 使用小写键,body 支持规定的消费协议。Content-Length 使用字节数而非字符数;响应出现中文时,两者可能不同。Rack 3 对 header 等细节与旧版存在差异,不能无条件照搬 Rack 2 时代示例。

Rack 3.2 规范 给出了接口要求。服务器负责把请求变成 env,再把这个返回值发送给客户端。数组里的 body 不等于已经写到网络;直接调用 app.call 的测试验证的是应用返回,而不是远端收到响应。

本例的响应体是有限字符串数组,没有持有额外资源。如果响应体实现了 close,消费它的服务器或中间件还需要完成关闭。替换原 body 的中间件也必须考虑原资源的清理,不能只换一个数组就忘掉原来的流。

路由、方法和媒体类型分别校验

应用只接受 POST /tasks,要求 Content-Type 为 application/json。不存在路径返回 404,方法不符返回 405,媒体类型不符返回 415,输入任务非法返回 400。成功返回 200 与统计结果。

这些状态在教学应用里表达不同失败位置,避免把所有错误都说成“服务器异常”。具体产品还可能需要允许媒体类型参数、处理 HEAD、添加 Allow 头、区分体积超限状态等。本例的精确媒体类型匹配是有意的有限合同;它没有假装支持通用 HTTP API 的所有约定。

输入依旧调用 Taskbook::IO.read 与核心校验,不另写一套 HTTP 特有字段规则。这样 CLI 接受与 HTTP 接受的任务集合可以保持一致。请求流由 Rack 环境提供,应用借用它读取,不把生命周期强行交给领域函数。

错误响应只返回固定的公开分类,不包含完整异常栈或原始请求正文。预期输入错误可转换成 400,程序缺陷则不应该被一个宽泛 rescue 全部伪装成用户错误。错误分类越宽,越容易让真正缺陷从监控和测试里消失。

Rack::Lint 与网络场景分别判定

应用测试使用 Rack::Lint 包装,再由 MockRequest 构造合法环境:

1
2
3
4
5
6
7
8
9
10
11
12
request = Rack::MockRequest.new(
Rack::Lint.new(Taskbook::HTTPApp.new)
)

response = request.post(
'/tasks',
'CONTENT_TYPE' => 'application/json',
input: '[]'
)

raise unless response.status == 200
raise unless JSON.parse(response.body)['total'] == 0

Lint 检查协议形状,业务断言检查统计内容。两者不能互相替代:一个返回 200、合法 headers、错误总数的应用可以满足 Rack 格式,却违反 Taskbook 业务合同。第 23 篇的错误 summary 负控制正好会被业务断言识别。

MockRequest 没有在这里启动网络服务,因此它不证明 TLS、代理、服务器进程或网络关闭正确。前面的真实 socket 实验则没有把完整 Rack server 接到网络上,它证明 Net::HTTP 与受控端点的三种行为。报告保留这种分离,避免把两个局部成功拼成从未运行过的完整部署链路。

状态码、响应体与连接完成不是同一个事件

客户端收到状态行,并不意味着响应体已经完整到达。服务器可能在发送部分 body 后断开,或声明了错误长度,导致客户端读取失败。一个只检查 response.code 的测试会漏掉这类问题,因此成功场景还比较实际 body,资源场景再检查会话结束。

应用返回三元组同样早于网络发送完成。Rack 应用构造一个字符串数组很快,但服务器消费、编码、发送和客户端读取都在之后发生。将应用返回作为“用户已经收到结果”的指标,会低估传输失败。需要端到端确认时,应有真实服务器适配层和客户端共同参与验证。

本例拒绝非成功响应时只保留状态码,不自动把远端响应体写进异常消息。响应体可能包含敏感信息或非预期大文本,诊断应该限制大小并按字段选择。真实客户端还要限制最大响应体;当前受控端点只返回有限固定内容,所以教学函数没有被宣传为可安全下载任意资源的通用工具。

重定向是另一个权限边界。即使初始 URL 通过主机限制,跟随后续 Location 也可能离开允许范围。本例不自动跟随重定向,避免把 loopback 限制变成只验证第一跳的表面检查。未来增加此能力时,每次跳转都需要重新校验目标与次数上限。

本地 HTTP 没有验证证书校验。将相同客户端扩展到 HTTPS 时,不能为了让测试通过关闭验证;应使用受控证书与明确可信根分别检查成功和拒绝路径。代理环境、DNS 解析和外网重定向同样需要各自场景,而不是从一次回环成功推断。

同一测试还应避免依赖外部网络可用性。回环端点的响应由脚本控制,所以外部服务宕机不会把确定性的协议检查变成偶发失败。

运行与练习

1
2
cd examples/ruby
bundle exec ruby -Ilib labs/28/run.rb

证据位于 evidence/28/run.txt。成功要求 200 内容正确、503 被拒绝、延迟触发读取超时、监听器关闭,以及 Rack Lint 和统计结果通过。运行环境必须允许绑定回环端口;权限失败是环境限制,不能算成 HTTP 行为已验证。

练习一:把 body 换成含中文的字符串,故意用 length 生成 Content-Length,比较协议长度与实际字节。练习二:让 Rack 应用的统计总数固定为零,确认 Lint 仍可能通过而业务断言失败,再恢复实现。由此分别说明协议正确与领域正确。