HTTP 请求与响应
Rapira 的 HTTP 接入层基于 Pingora 构建,并且已经打包进二进制文件。它在主进程绑定好的 socket 上接受连接,解析请求,把请求交给 PHP,再把 PHP 产出的内容写回去。这里没有上游:每个请求都由你的代码在本地作答。
本页讲的是 HTTP 和 PHP 之间对不上号的那些地方:哪个请求头字段会落到哪个 $_SERVER 键上、客户端把同一个字段发两遍会怎么样、请求体最大能有多大,以及响应发出去时是怎么定界的。
接入层只处理明文 HTTP。需要 TLS 的话,请在 Rapira 前面的代理上终结它——见生产环境部署。
从请求头名字到 $_SERVER 键
CGI 把请求头字段暴露给脚本只有一条规则:把字段名转成大写,每个 - 换成 _,再加上 HTTP_ 前缀(RFC 3875 §4.1.18)。于是 X-Forwarded-For 变成 HTTP_X_FORWARDED_FOR,你的代码读的就是这个键。
接着 PHP 在注册变量时又自己做了一次改写:. 同样变成 _。两次映射各自把不同的字符压成同一个下划线,结果就是报文里三个不同的名字,最后落在同一个键上:
| 报文里的写法 | 在 PHP 中 |
|---|---|
X-Forwarded-For | $_SERVER['HTTP_X_FORWARDED_FOR'] |
X_Forwarded_For | $_SERVER['HTTP_X_FORWARDED_FOR'] |
X.Forwarded.For | $_SERVER['HTTP_X_FORWARDED_FOR'] |
这种名字冲突是一个安全问题。假设 Rapira 前面有一个可信代理会设置 X-Forwarded-For,那么客户端只要发 X_Forwarded_For,就能命中同一个 $_SERVER 键——而代理自己的请求头过滤只认带连字符的写法,永远看不见带下划线的那个。于是客户端可以写入一个值,而你的应用会把它当作来自代理的值。
会撞上 CGI 变量的名字
正因如此,在其他任何一层看到这些请求头之前,Rapira 会先筛一遍字段名:每个字节都落在 [A-Za-z0-9-] 里,这个名字才放行。会造成撞名的字符是 _ 和 .——它们都会压到与连字符写法相同的那个 $_SERVER 键上。这条规则用的是白名单,而不是把这两个字节列进黑名单,所以像 ~ 这种合法但少见的字符同样会被拒绝;将来两套映射里任何一套放宽了范围,这道筛查也依然成立。被拒绝的名字会怎么处理,由 http.unsafe_field_names 决定:
drop(默认)——字段在 PHP 看到之前就被摘掉,每摘一次都会在http目标上记一条warn日志。reject——请求以400作答,不会提供任何内容。
[http]
unsafe_field_names = "drop"没有第三个选项把这道筛查整个关掉,也没有针对单个名字的例外,因为它挡下的撞名本身就是一个安全问题——这个配置项在整套设置中的位置,见配置。
如果你的客户端确实要发带下划线的字段名,正确的做法是把它改成用 - 的写法。代理自己设的字段也一视同仁:带下划线的字段是可信代理写的还是客户端伪造的,Rapira 分辨不出来,所以代理设的 X_Forwarded_For 同样会在 PHP 运行之前被摘掉。Rapira 前面的代理只要在自己的配置里加一行就能完成这次改写,之后这个名字就是普通名字,原样通过。
drop 每摘掉一个字段都会记一条 warn,但默认日志级别是 error,不调高就看不到这些行。如果某个请求头意外没有出现在 $_SERVER 里,先把级别调上去,盯着 http 目标看——具体怎么做见日志。
发了不止一次的字段
HTTP 允许客户端重复发送同一个字段,而 CGI 一个变量只放得下一个值,所以在 PHP 看到任何东西之前,这些重复必须合并成一个值。至于怎么合,Rapira 按字段自身的语法来处理:
- 列表型字段——各个值用
,拼接,这正是 RFC 9110 §5.3 为“以逗号分隔的列表”类字段所允许的重组方式。两行Accept会变成text/*, image/*。 Cookie——同样是列表,但分隔符不是逗号。它的重复项用;拼接,这正是 PHP 解析器期待的 cookie 字符串形式,$_COOKIE才会解析正确。- 单值字段——
Authorization、Proxy-Authorization、Content-Type、Content-Length、Referer和From只保留第一行,多出来的会被丢弃并记一条warn。把它们拼起来会破坏字段值:第二个Authorization拼进第一个之后,就混进了 PHP 马上要 base64 解码的那段凭据里。重复的Content-Length在合并之前就会以400作答,真正走到这条规则的只有其余五个字段。 Host——出现不止一行Host时一律以400作答,绝不合并。RFC 9112 §3.2 把这条定为 MUST,而且只有终结连接的那一层才给得出正确的答复。
字段值自始至终以原始字节交给 PHP。latin1 编码的 cookie、带签名的请求头,客户端发来的每一个字节都原封不动——中途做一次 UTF-8 转换,毁掉的恰恰是那些一个字节都不能变的值。
请求体
PHP 开跑之前,请求体会先被整个读进内存,而 Rapira 为它占用多少内存,由 http.max_body_size_mb 封顶。默认是 8 MiB,和 PHP 自己 post_max_size 的默认值一样。超过上限的请求体会得到 413,而且由于剩下的数据还在链路上,这个响应还会顺手关掉连接,而不去尝试复用。
这个上限会检查两次:
- 一次是对着声明的
Content-Length查,此时请求体一个字节都还没读。 - 另一次是在请求体逐块到达的过程中反复查。分块(chunked)请求事先并不声明长度,限制它内存占用的就只有这第二道检查。
对 HTTP/1.1 请求,Expect: 100-continue 会被兑现:Rapira 先写出中间响应 100 Continue,客户端再把一直攥着的请求体发过来。这里的关键是顺序:Content-Length 检查跑在前面,所以客户端一旦声明了超大的请求体,还没上传就先收到 413。HTTP/1.0 请求带的这个期望会被忽略,RFC 9110 §10.1.1 正是这么要求的。
[http]
max_body_size_mb = 8响应是怎么发出去的
PHP 写出的所有内容都会先缓冲起来,直到请求结束,响应头才真正上线。缓冲就是为了这一点:服务器知道响应体的准确长度,于是能发出一个货真价实的 Content-Length。响应体没有定界,HTTP/1.1 就只能退回到用关闭连接来标记结束——连接一断响应才算完,也就意味着每个请求都得新开一条连接。有了 Content-Length,keep-alive 才成立,连接才能一直保持。
所以定界是服务器的活儿,不是 PHP 的。你代码里设的 Content-Length 或 Transfer-Encoding 会被丢掉,换成缓冲区里实际量出来的长度,过期的长度值因此永远没机会把连接搞得不同步。按定义就没有响应体的响应——204 和 304——则根本不带 Content-Length。
逐跳(hop-by-hop)字段属于某一条连接,而不属于响应本身,所以也轮不到 PHP 来设置(RFC 9110 §7.6.1)。不管你的代码输出了什么,下面这些都会被剥掉:
Connection、Keep-Alive、Upgrade、Trailer、TE、Proxy-Connection,外加两个定界字段 Content-Length 和 Transfer-Encoding。
如果 PHP 确实发了 Connection 头,它点名的那些字段同样会被剥掉——Connection 的值本来就是这个意思——而且这一步跑在 Rapira 插入自己的 Content-Length 之前,所以 Connection: content-length 无法把定界字段从响应里去掉。
其余的一切都按 PHP 写的样子原样通过,重复的也一样:Set-Cookie、Vary 和 Link 本来就可能正当地出现好几次,它们会被全部发出。至于压根没法在报文里表示的响应头,会被丢弃并记一条日志,而不是让整个响应失败,响应的其余部分照常发出。
提前结束响应
响应准备好之后,处理逻辑往往还有事情要做:触发一个 webhook、往队列里写一条记录、把缓存预热一遍。客户端不必等这些。
rapira_finish_request() 会在此处结束响应:缓冲的输出被冲刷出去,响应交给接入层发往客户端,而你的处理逻辑继续往下跑——此时客户端手里已经拿到了完整的响应。它和 fastcgi_finish_request() 是同一套契约,为 php-fpm 写的代码行为一如既往:
<?php
header('Content-Type: text/plain');
echo "Order accepted\n";
rapira_finish_request();
// The client already has the response; this still runs.
$mailer->sendConfirmation($order);
$metrics->flush();它的签名是 rapira_finish_request(): bool。和 Rapira 暴露给 PHP 的其他所有东西一样,它声明在 crates/php_sys/rapira.stub.php 里——把 IDE 指向这个文件,就能得到补全和类型提示。
这个函数按整个进程注册,作用于当前正在处理的那个请求,所以经典模式同样支持它:脚本是常驻还是每个请求重跑一遍,行为都一样。不同模式之间还有哪些差别,见执行模式。
有两点要记住:
- 调用之后再输出,就发不出去了。响应已经关闭,后面的
echo会被直接丢掉——它不会排队等着以后冲刷。客户端必须看到的东西,都得在调用之前写完。 - worker 并没有闲下来。结束响应放走的是客户端,不是进程。在你的处理逻辑返回之前,这个 worker 不会去接下一个请求,所以挪到调用之后的那些活儿,下一个请求照样得等——一共有多少个 worker 能等,见进程模型。这次调用降低的是客户端的延迟,并不会带来并发,所以重活儿应该交给队列。