多文件上傳是表單處理的進階場景,其核心在於理解 $_FILES 超全局數組的特殊結構。本文深度解析多文件上傳時 $_FILES 的多維數組組織形式,演示標準的批量處理流程,並指出常見的遍歷陷阱與安全加固要點。
<form action="uploads.php" method="post" enctype="multipart/form-data">
<!-- 關鍵:name 屬性以 [] 結尾,告訴 PHP 這是數組 -->
<input type="file" name="files[]" multiple>
<input type="file" name="files[]" multiple>
<button type="submit">上傳</button>
</form>
關鍵屬性解析:
enctype="multipart/form-data":必須設置,否則文件不會上傳name="files[]":方括號標記使 PHP 將同名字段組合為數組multiple:HTML5 屬性,允許用戶在單個 input 中選擇多文件$_FILES['file'] = [
'name' => 'photo.jpg', // 原始文件名
'type' => 'image/jpeg', // MIME 類型(瀏覽器提供,不可信)
'tmp_name' => '/tmp/phpA1b2C3', // 臨時存儲路徑
'error' => 0, // 錯誤代碼(0=成功)
'size' => 102400, // 文件大小(位元組)
];
$_FILES['files'] = [
'name' => ['photo1.jpg', 'photo2.png', 'doc.pdf'],
'type' => ['image/jpeg', 'image/png', 'application/pdf'],
'tmp_name' => ['/tmp/phpA1b2C3', '/tmp/phpD4e5F6', '/tmp/phpG7h8I9'],
'error' => [0, 0, 0],
'size' => [102400, 204800, 512000],
];
結構特徵: 第一維鍵為屬性名(name/type/tmp_name/error/size),第二維為文件索引(0,1,2...)。這是「列優先」結構,而非直觀的「行優先」(每個文件一組屬性)。
<?php
// uploads.php
// 配置
const UPLOAD_DIR = __DIR__ . '/uploads';
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf'];
// 確保目錄存在
if (!is_dir(UPLOAD_DIR)) {
mkdir(UPLOAD_DIR, 0755, true);
}
// 獲取文件數量(以 name 數組長度為準)
$fileCount = count($_FILES['files']['name'] ?? []);
$results = ['success' => [], 'failed' => []];
for ($i = 0; $i < $fileCount; $i++) {
// 組裝單個文件信息(行優先結構,便於處理)
$file = [
'name' => $_FILES['files']['name'][$i],
'type' => $_FILES['files']['type'][$i],
'tmp_name' => $_FILES['files']['tmp_name'][$i],
'error' => $_FILES['files']['error'][$i],
'size' => $_FILES['files']['size'][$i],
];
// 1. 檢查上傳錯誤
if ($file['error'] !== UPLOAD_ERR_OK) {
$results['failed'][] = [
'name' => $file['name'],
'error' => "上傳錯誤代碼: {$file['error']}"
];
continue;
}
// 2. 檢查文件大小
if ($file['size'] > MAX_SIZE) {
$results['failed'][] = [
'name' => $file['name'],
'error' => '文件超過大小限制'
];
continue;
}
// 3. 驗證真實 MIME 類型(防偽裝)
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$realMime = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
if (!in_array($realMime, ALLOWED_TYPES, true)) {
$results['failed'][] = [
'name' => $file['name'],
'error' => "文件類型不允許: {$realMime}"
];
continue;
}
// 4. 生成安全文件名
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
$newName = bin2hex(random_bytes(16)) . '.' . $ext;
$destPath = UPLOAD_DIR . '/' . $newName;
// 5. 移動文件
if (move_uploaded_file($file['tmp_name'], $destPath)) {
$results['success'][] = [
'original' => $file['name'],
'saved_as' => $newName,
'size' => $file['size'],
'mime' => $realMime,
];
} else {
$results['failed'][] = [
'name' => $file['name'],
'error' => '文件保存失敗'
];
}
}
// 輸出結果
header('Content-Type: application/json; charset=utf-8');
echo json_encode($results, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
<?php
// 錯誤!這會遍歷 5 個屬性鍵,而非文件
foreach ($_FILES['files'] as $key => $value) {
// $key 依次為:name, type, tmp_name, error, size
// $value 為對應的數組
}
<?php
// 錯誤!$_FILES['files'] 不是 [ [file1_attrs], [file2_attrs], ... ]
foreach ($_FILES['files'] as $file) {
// $file 會是 'name' => ['a.jpg', 'b.jpg'] 這樣的結構
}
<?php
$count = count($_FILES['files']['name']);
for ($i = 0; $i < $count; $i++) {
$file = [
'name' => $_FILES['files']['name'][$i],
// ... 其他屬性
];
// 處理 $file
}
當單個 <input type="file" name="files[]" multiple> 選擇多文件時,$_FILES 結構完全相同,仍為列優先多維數組。區別在於前端只需一個 input 元素。
| 風險 | 防護措施 |
|---|---|
| 文件名注入 | 使用 basename() + 隨機重命名,不信任原始文件名 |
| MIME 偽裝 | finfo_file() 檢測真實類型,配合擴展名白名單 |
| 大文件 DoS | 設置 upload_max_filesize、post_max_size、代碼層雙重限制 |
| 腳本執行 | 上傳目錄禁用 PHP 執行權限(Nginx: location ~ \.php$ { deny all; }) |
| 路徑遍歷 | 不使用用戶輸入路徑,固定保存目錄 |
| 臨時文件殘留 | move_uploaded_file 失敗時自動清理,成功後臨時文件自動刪除 |
現代框架(Laravel、Symfony、Slim)均封裝了 UploadedFile 物件:
<?php
// 概念示例(實際使用框架提供的實現)
class UploadedFile {
public function getClientFilename(): string;
public function getClientMediaType(): string;
public function getError(): int;
public function getSize(): int;
public function getStream(): StreamInterface;
public function moveTo(string $targetPath): void;
}
// 使用示例
foreach ($request->getUploadedFiles()['files'] as $file) {
if ($file->getError() === UPLOAD_ERR_OK) {
$file->moveTo(UPLOAD_DIR . '/' . $file->getClientFilename());
}
}