静态文件

OxPHP 直接从文档根目录提供静态文件服务,无需调用 PHP。每个文件都会以自动 MIME 类型检测的方式提供,并配有内存缓存以加速重复访问,同时具备完整的 HTTP 缓存能力:ETag、条件请求,以及用于分段下载的 Range 请求。

工作原理

当某个请求匹配到静态文件时:

  1. 匹配文件 —— 路由层将 URL 路径解析为磁盘上的某个文件
  2. MIME 检测 —— 根据文件扩展名确定内容类型
  3. 缓存检查 —— 在访问文件系统之前先检查文件缓存
  4. 条件检查 —— 如果请求携带 If-None-MatchIf-Modified-Since,OxPHP 会评估该条件,并可能返回 304 Not Modified 而不发送响应体
  5. Range 检查 —— 如果 GET 或 HEAD 请求携带 Range 头,OxPHP 会以 206 Partial Content 响应:GET 仅接收所请求的字节范围,HEAD 则接收相同的范围头但不含响应体
  6. 响应 —— 不超过 1 MiB 的文件从内存缓存中提供;更大的文件则直接从磁盘流式传输

配置

变量 默认值 说明
STATIC_MAX_AGE 30d 静态文件的 Cache-Control: max-age。可接受 30s5m2h30d1w1y、纯秒数(例如 3600),或用 off 完全禁用缓存头。取代已弃用的 STATIC_CACHE_TTL
STATIC_REVALIDATE off 设为 on 可对内存中的内容缓存启用 mtime 重新校验(每个文件最多每 3 秒重新检查一次;改动会在该时间窗口内变得可见)。取代已弃用的 STATIC_CACHE(后者的 off 含义相反)。

MIME 检测

MIME 类型会根据文件扩展名自动确定。如果无法确定类型,服务器会回退为 application/octet-stream。常见的映射包括:

扩展名 Content-Type
.html text/html
.css text/css
.js text/javascript
.json application/json
.png image/png
.svg image/svg+xml
.woff2 font/woff2

文件缓存

OxPHP 使用内存缓存来减少对频繁请求文件的磁盘 I/O:

  • 不超过 1 MiB(1,048,576 字节)的文件会被读入内存并缓存。缓存总预算为 64 MiB(67,108,864 字节)。当预算超出时,会淘汰最近最少使用的条目以腾出空间。
  • 大于 1 MiB 的文件始终直接从磁盘流式传输。Content-Length 头会根据文件元数据设置,以便客户端预先知道总大小。

文件缓存在首次请求每个文件时填充,并在后续请求之间保留。默认情况下,缓存条目会一直保留,直到被 LRU 策略淘汰。

内容重新校验

设置 STATIC_REVALIDATE=on 可启用基于 mtime 的重新校验。在此模式下,服务器会用一次 stat() 系统调用重新检查已缓存文件的修改时间,每个文件最多每 3 秒一次,而非每次请求都检查。如果文件在磁盘上发生了变化,陈旧的条目会被淘汰并自动重新读取文件。在 3 秒的时间窗口内,已缓存的条目会直接从内存中提供,不产生任何系统调用,因此该开销是摊销的,而非每次请求都要付出。磁盘上的改动会在 3 秒内变得可见。

开发环境

在开发环境中开启 STATIC_REVALIDATE=on,这样你无需重启服务器即可看到文件改动。在生产环境中将其保持未设置(默认 off),以获得最大吞吐量且零每请求系统调用开销。

HTTP 缓存

Cache-Control

当设置了 STATIC_MAX_AGE(默认为 30d)时,每个静态文件响应都会包含 Cache-Control 头:

http
Cache-Control: public, max-age=2592000

max-age 的值是转换为秒的 TTL。设置 STATIC_MAX_AGE=off 可完全省略该头。

ETag 与 Last-Modified

每个静态文件响应都会包含:

  • ETag —— 一个格式为 "<size>-<mtime_hex>" 的强 ETag,由文件大小和最后修改时间派生而来。强验证器同样满足 If-Range,因此被中断的下载可以安全地恢复。当响应以 brotli 压缩方式提供时,该标签会弱化为 W/"…" —— 压缩后的字节是一种不同的表示形式,弱标签仍可重新校验(304),但可以防止在恢复下载时混用压缩与未压缩的片段。
  • Last-Modified —— 一个基于文件修改时间的 RFC 7231 HTTP 日期

这些头允许浏览器和 CDN 校验已缓存的副本,而无需重新下载文件。

条件请求(304)

OxPHP 会评估条件请求头,以避免发送未变化的文件内容:

  • If-None-Match —— 客户端发送它已缓存的 ETag。如果它与当前文件匹配,OxPHP 返回 304 Not Modified 且不含响应体。
  • If-Modified-Since —— 客户端发送一个时间戳。如果文件自该时间以来未被修改,OxPHP 返回 304。

按照 RFC 7232,If-None-Match 的优先级高于 If-Modified-Since。对于已在内存缓存中的文件,条件检查在没有任何磁盘 I/O 的情况下运行。

Range 请求(206)

静态文件响应会通告 Accept-Ranges: bytes,携带单一范围 Range 头的 GET 请求只会接收所请求的字节:

http
GET /videos/intro.mp4 HTTP/1.1 Range: bytes=1048576- HTTP/1.1 206 Partial Content Content-Range: bytes 1048576-52428799/52428800 Content-Length: 51380224

这使得浏览器中的 <video>/<audio> 拖动定位、可恢复下载(wget -c、下载管理器)以及 PDF 的分段加载成为可能。RFC 9110 中的全部三种范围形式均受支持:bytes=N-Mbytes=N-(从偏移量到末尾)以及 bytes=-N(末尾 N 个字节)。

  • 无法满足的范围(起点超出文件末尾)会返回 416 Range Not Satisfiable,并附带 Content-Range: bytes */<size>
  • If-Range 会被遵守:当客户端发送其部分副本的 ETag(或 Last-Modified 日期)而文件自那时起已发生变化时,OxPHP 会返回完整的 200 响应,而不是不匹配的片段。日期形式只有在文件的修改秒数完全过去之后才被接受 —— 一个刚写入的文件可能在同一秒内再次变化而不改变日期,因此它尚不是强验证器(RFC 9110)。
  • 携带多个范围bytes=0-1,4-5)的请求会以 200 OK 接收完整文件 —— 不会生成 multipart/byteranges 响应。
  • 携带 Range 头的 HEAD 请求会接收与 GET 相同的 206/Content-Range 头但不含响应体,这与 nginx 和 Apache 的行为一致。
  • Range 与压缩互斥。 对于接受 brotli 的客户端,会在将以压缩方式提供的表示形式上禁用范围处理,且压缩响应不会通告 Accept-Ranges —— 否则恢复的下载可能会把未压缩的字节拼接到压缩的前缀上。只有从内存缓存中提供的文件(不超过 1 MiB)才会被压缩,因此范围对真正需要它的内容始终有效:视频、归档文件、图像,以及每一个从磁盘流式传输的文件。可压缩文件的响应始终携带 Vary: Accept-Encoding —— 即使以未压缩方式提供 —— 以便共享缓存区分不同的变体。
  • 206 响应永远不会被压缩,而且范围处理不适用于 PHP 响应 —— 仅适用于静态文件。

示例:用 curl 恢复被中断的下载:

bash
curl -C - -O https://example.com/dist/app-installer.dmg

禁用缓存

存在两个独立的缓存层,以及各自对应的一个变量:

变量 控制对象 off 的效果
STATIC_MAX_AGE=off 浏览器缓存(HTTP 头) 不发送 Cache-ControlETagLast-Modified
STATIC_REVALIDATE=on 服务器内存缓存 每个文件最多每 3 秒重新检查一次文件 mtime;陈旧条目会被自动淘汰

在开发环境中,设置 STATIC_REVALIDATE=on,让服务器始终提供最新内容。可选地也设置 STATIC_MAX_AGE=off,以完全阻止浏览器缓存。

故障排查

服务器持续提供陈旧文件

默认情况下,内存中的内容缓存不会检查文件在磁盘上是否已发生变化。在开发期间设置 STATIC_REVALIDATE=on 以启用 mtime 重新校验 —— 服务器会自动检测文件改动(在 3 秒内)。

浏览器持续提供陈旧文件

如果服务器返回的是最新内容而浏览器仍显示旧版本,那么罪魁祸首就是浏览器自身的缓存。设置 STATIC_MAX_AGE=off 以停止发送缓存头,或使用浏览器的强制刷新(Shift+F5 或 Cmd+Shift+R)。

文件以 `application/octet-stream` 提供

OxPHP 使用文件扩展名来确定 MIME 类型。如果扩展名缺失或无法识别,它会回退为 application/octet-stream。请为你的文件添加正确的扩展名,或确保你的框架在 PHP 响应中显式设置 Content-Type 头。

大文件似乎很慢

大于 1 MiB 的文件在每次请求时都从磁盘流式传输,不会缓存在内存中。对于非常大的文件,可在 OxPHP 前面放置一个 CDN,将它们缓存在边缘节点。或者,重新组织你的资源,让频繁提供的文件保持在 1 MiB 以下。

在期望 200 时却返回了 304 响应

304 意味着客户端已经拥有当前版本。这是正确的行为。如果你在开发期间需要强制获得最新响应,请设置 STATIC_MAX_AGE=off 以停止发送 ETagLast-Modified 头。

Docker 示例

compose.yaml
services: app: image: ghcr.io/oxphp/oxphp:0.10.0 ports: - "8080:80" volumes: - ./src:/var/www/html environment: - DOCUMENT_ROOT=/var/www/html/public - ENTRY_FILE=index.php - STATIC_MAX_AGE=1y

最佳实践

  • 在生产环境中使用较长的 TTL 配合带缓存清除标识的文件名(例如 app.a1b2c3.js)。设置 STATIC_MAX_AGE=1y 以获得最大程度的浏览器和 CDN 缓存。
  • 在开发期间设置 STATIC_REVALIDATE=on,让服务器自动检测文件改动。可选地也设置 STATIC_MAX_AGE=off 以绕过浏览器缓存。
  • 在 OxPHP 前面放置一个 CDN,适用于高流量站点。ETagLast-ModifiedCache-Control 头可与所有主流 CDN 提供商配合使用。
  • 让你的构建工具处理资源哈希。 Vite 和 Laravel Mix 等框架会自动生成带哈希的文件名,使较长的缓存 TTL 变得安全。

另请参阅

  • 压缩 —— 针对可压缩静态文件响应的 Brotli 压缩
  • 路由 —— URL 路径如何解析为磁盘上的文件
  • 配置参考 —— 环境变量的完整列表