前言
PHP 提供四种文件包含写法:include、include_once、require、require_once。它们的差别常被简化成一句「require 比 include 严格」,但这句话没说清严格的到底是什么,也没说清「严格」在失败时具体的表现。真实差别有两条:失败时的错误级别不同,以及失败后脚本还走不走下去。include失败只发一条E_WARNING,脚本继续执行,那个包含表达式的值变成false;require失败产生的是致命错误,脚本当场终止。官方手册把后者的级别描述为E_COMPILE_ERROR。
第二个常见误解是关于_once的:「它比较的是文件名字符串,路径写法不同就挡不住」。这个说法不准确。PHP 内部维护一张「已包含文件」的表,键是解析后的真实路径,也就是经过realpath()归一化的结果。所以./a.php和../dir/a.php指向同一个文件时,include_once确实只包含一次。真正会让_once失效的是完全不同的物理文件(比如同一份代码在多个目录各放了一份),以及用流包装器(stream wrapper)打开的资源——那些没有真实路径可比。
还有一点必须提前点明:文件包含本身是最经典的高危功能之一,一旦被包含的路径来自用户输入,就是本地文件包含漏洞。本文只讲防御侧怎么写,不提供任何利用手法。
一、四种写法的错误级别与返回值
四种写法都是语言结构(language construct),不是函数,所以加括号只是看起来像函数调用,实际不受函数那套规则约束。
| 写法 | 文件不存在时 | 脚本是否继续 | 是否去重 | 典型用途 |
|---|
include | 发E_WARNING,表达式值为false | 继续 | 否 | 可选内容,如模板片段、可选配置 |
include_once | 同上 | 继续 | 是 | 可能被重复引入的可选片段 |
require | 致命错误,脚本终止 | 终止 | 否 | 必需依赖,如核心类库 |
require_once | 同上 | 终止 | 是 | 类、函数定义文件 |
include有一个容易被忽略的能力:被包含文件里的return语句会给包含表达式提供一个返回值。有return时,include表达式的值就是那个返回值;没有return时值为1。这让它可以直接当配置读取用:
<?php // 适用于 PHP 8.0+
declare(strict_types=1);
// 假设被包含的 config/app.php 里写的是:
// return ['debug' => false, 'timeout' => 30];
//
// 被包含文件里的 return 会成为 include 表达式的值。
// 注意:require 同样支持这个行为,不是 include 独有。
$config = include __DIR__ . '/config/app.php';
// 文件不存在时 include 返回 false,这里必须显式判断
if ($config === false) {
throw new RuntimeException('配置文件缺失');
}
var_dump($config['timeout']); // int(30)这里有个细节要小心:文件不存在时include返回false,而一个内容为空的成功包含返回1,所以不能用if (!$config)这种宽松判断——如果配置数组恰好是空数组[],它也是假值。
再看失败时的实际表现。下面的输出形态是 PHP 8 下的样子,具体文件名和行号随你的路径而变:
Warning: include(/no/such/file.php): Failed to open stream: No such file or directory
Warning: include(): Failed opening '/no/such/file.php' for inclusion
Fatal error: Uncaught Error: ... (require 才会走到这里)include的两条警告来自两个不同阶段:第一条是「打开流」失败,第二条是「包含」失败。看到两条警告是正常的,不是重复报错。
二、_once 的去重机制与它的边界
_once变体解决的是一个非常具体的问题:同一个文件被多处以不同路径包含进来,里面的函数或类会被重复声明,触发「Cannot redeclare」的致命错误。
<?php // 适用于 PHP 8.0+
declare(strict_types=1);
// 这两种写法指向同一个物理文件
include_once __DIR__ . '/lib/helper.php';
include_once __DIR__ . '/./lib/../lib/helper.php'; // 第二次不会真的再包含
// 相比之下,普通 include 会真的执行两次
include __DIR__ . '/lib/helper.php';
include __DIR__ . '/./lib/helper.php'; // 若文件里定义了函数,这里就致命错误了去重的键是解析后的真实路径,所以..、.、多余的斜杠、符号链接都不会让_once失效——符号链接在解析时会被展开成目标路径。
要判断某个文件是否已经被包含过,可以用get_included_files(),它返回本次请求中所有已包含文件的绝对路径数组:
<?php // 适用于 PHP 8.0+
declare(strict_types=1);
require_once __DIR__ . '/lib/helper.php';
$files = get_included_files();
// 数组的第一个元素永远是入口脚本本身
echo '本次请求已包含 ' . count($files) . ' 个文件' . PHP_EOL;这张表是按请求生命周期维护的,每个请求开始前都会被重置。所以在 PHP-FPM 或 CLI 下,不用担心上一个请求的状态污染本次。
_once也不是没有代价:它每次都要做一次路径解析和查表。这个开销相对于「读取并编译一个 PHP 文件」可以忽略不计,所以不要为了这点开销而改用普通include——用普通include引入定义函数或类的文件,早晚会遇到重复声明的致命错误。
三、路径解析顺序与变量作用域
包含一个相对路径时,PHP 的查找顺序是这样的:
- 如果路径以
/(Unix)或以盘符、反斜杠(Windows)开头,或者以.、..开头,则完全忽略include_path,直接按当前工作目录解析。 - 否则,先在
include_path配置的目录列表里逐个查找。 - 都没找到,再查调用方脚本自身所在的目录。
- 最后查当前工作目录。
第 3 步和第 4 步的存在,是「本地能跑、上线就崩」的经典成因:本地开发时入口脚本和库文件在同一个目录,第 3 步救了场;上线后入口脚本换到了别的位置,同样的相对路径就找不到了。
结论很直接:包含文件永远用__DIR__拼绝对路径。
<?php // 适用于 PHP 8.0+
declare(strict_types=1);
// 可靠:与 cwd 无关,与入口脚本位置无关
require_once __DIR__ . '/lib/Autoloader.php';
// 不可靠:取决于 include_path 和 cwd
// require_once 'lib/Autoloader.php';include_path的多个目录用PATH_SEPARATOR分隔——Unix 上是冒号,Windows 上是分号。想临时追加目录用set_include_path(),读出来用get_include_path()。默认的include_path里常带一个.,那表示当前工作目录,是一个应当去掉的风险点。
关于变量作用域,规则只有一条:被包含的文件继承包含发生处的变量作用域。在全局作用域包含,文件里就能看到全局变量;在函数里包含,文件里的变量就是那个函数的局部变量,函数返回后就没了。
<?php // 适用于 PHP 8.0+
declare(strict_types=1);
$title = '订单列表';
// 在全局作用域包含,被包含文件里可以直接用 $title
include __DIR__ . '/templates/header.php';
function render(): void
{
$title = '局部标题';
// 在函数内包含,文件里的变量都属于 render() 的局部作用域
include __DIR__ . '/templates/header.php';
}这个特性被大量模板引擎(包括 PHP 自身当作模板用)依赖。反过来说,被包含的文件里定义的全局变量会「泄漏」到包含处的作用域,这也是它难以调试的原因之一。
四、安全边界:把包含路径关进笼子
文件包含的高危之处在于:一旦被包含的路径可以由用户影响(最常见的是?page=xxx这类写法),攻击者就可能在服务器上包含不该被包含的文件。防御思路有四层,应该叠加使用,而不是只挑一层。
第一层:不要用用户输入当路径。最彻底的方案是白名单映射,把「用户提供的名字」和「真实文件路径」解耦:
<?php // 适用于 PHP 8.0+
declare(strict_types=1);
/**
* 通过白名单把页面标识映射为真实的模板路径。
* 用户永远只能选一个已登记的 key,无法构造出任意路径。
*/
function resolvePage(string $page): string
{
$map = [
'home' => 'home.php',
'about' => 'about.php',
'contact' => 'contact.php',
];
if (!array_key_exists($page, $map)) {
throw new InvalidArgumentException('未知页面');
}
$baseDir = __DIR__ . '/pages';
$realBase = realpath($baseDir);
if ($realBase === false) {
throw new RuntimeException('页面目录不存在');
}
$target = $realBase . DIRECTORY_SEPARATOR . $map[$page];
// 双重保险:即便白名单被改错,也要确认结果没跑出基准目录
$realTarget = realpath($target);
if ($realTarget === false) {
throw new RuntimeException('目标文件不存在');
}
$prefix = $realBase . DIRECTORY_SEPARATOR;
if (!str_starts_with($realTarget, $prefix)) {
throw new RuntimeException('目标路径越界');
}
return $realTarget;
}
$pageName = $_GET['page'] ?? 'home';
// $_GET 的值一定是字符串,但类型不安全;这里显式转成 string 更清楚
include resolvePage((string) $pageName);注意最后的越界检查必须比到DIRECTORY_SEPARATOR为止。如果只写str_starts_with($realTarget, $realBase),那么当基准目录是/var/www/pages时,/var/www/pages-backup/secret.php也会通过检查——前缀相同但根本不是同一个目录。
第二层:配置层面限制。open_basedir把 PHP 能访问的文件系统范围限制在指定目录内,一旦越界,所有文件函数(包括包含)都会拒绝操作。这是最后一道兜底,但它也会影响file_get_contents()等所有文件操作,配置时要考虑全面。
第三层:关掉远程包含。allow_url_include默认就是Off,永远不要打开。它一旦开启,包含路径就能是 HTTP 或 FTP 地址。配套的allow_url_fopen用于普通文件读取,也应当按需评估,不要为了图方便全局打开。
第四层:入口收敛。让所有包含都发生在少数几个固定位置,用数组或枚举驱动,代码评审时一眼能看出「这个路径是不是可控的」。分散在各处的include $something才是真正的审计噩梦。
关于$_GET的处理还有一点:从 PHP 8.0 起,$_GET、$_POST、$_COOKIE里的值类型仍然一律是字符串或数组(不像某些旧资料说的会自动转成数字),所以「用(int)强转」和「用白名单」这两种做法的差别在于:强转能挡住非数字,但挡不住「数字合法但文件不存在」或者「文件存在但不该被访问」,白名单才是正解。
常见坑点
- ❌ 用
include引入必须存在的核心类库。
✅ 文件缺失时include只发警告就继续往下跑,后续会以「类未定义」的形式在更远的地方崩掉,排查成本高。必须存在的依赖一律用require。
- ❌ 用
if (!include 'config.php')判断配置是否加载成功。
✅ 文件成功包含但没有return时,表达式的值是1;用了return且返回空数组[]时也是假值。应当判断=== false,或者去读return的返回值类型。
- ❌ 反复用
include引入定义函数或类的文件。
✅ 第二次会触发「Cannot redeclare」致命错误。定义类、函数的文件必须用require_once或include_once。
- ❌ 用相对路径包含,例如
include 'lib/Utils.php'。
✅ 相对路径的基准可能是include_path、调用脚本目录或当前工作目录,取决于路径是否以.开头,行为随部署环境变化。一律用__DIR__拼绝对路径。
- ❌ 把
$_GET['page']直接拼进include的路径里。
✅ 这是典型的本地文件包含入口。用白名单映射把标识和真实路径解耦,再加realpath()与基准目录前缀比对做兜底。
- ❌ 越界检查写成
str_starts_with($realPath, $baseDir)。
✅ 前缀比对必须带上目录分隔符,否则/var/www/pages-backup会被误判为在/var/www/pages之内。应写成str_starts_with($realPath, $baseDir . DIRECTORY_SEPARATOR)。
- ❌ 为了「支持远程模板」把
allow_url_include打开。
✅ 该选项默认Off,开启后包含路径可以是远程地址,风险极高。需要远程资源就先用 HTTP 客户端拉取到本地临时文件,再按本地文件的规则校验后包含。
- ❌ 在循环里
include同一个渲染片段,并在片段里定义函数。
✅ 循环会让文件被重复执行。要么用include_once,要么把函数定义移出片段,让片段只做输出。
总结
| 关注点 | 结论 |
|---|
| 失败时的错误级别 | include为E_WARNING且继续执行;require为致命错误并终止 |
| 失败时的返回值 | include表达式为false;用=== false判断最稳 |
| 成功时的返回值 | 有return则取该值,否则为1 |
_once去重依据 | 解析后的真实路径,..、多余斜杠、符号链接都不影响 |
| 相对路径查找顺序 | include_path→ 调用脚本目录 → 当前工作目录 |
| 变量作用域 | 继承包含发生处的作用域,函数内包含即为局部变量 |
| 推荐写法 | 一律require_once __DIR__ . '/x.php' |
| 安全底线 | 白名单映射 +realpath()前缀比对 +open_basedir+allow_url_include=Off |
四种包含写法的选择其实没有争议:定义类与函数的文件用require_once,可选的模板片段用include,路径一律用__DIR__拼绝对路径。真正需要花心思的不是选哪个关键字,而是回答「这个路径会不会被用户影响」——只要答案是「会」,白名单就是唯一可靠的答案。