文件上传处理
Viswoole 框架提供了完整的文件上传支持,包括单文件上传、多文件上传、文件类型验证、大小限制等功能。通过 #[InjectFile] 注解和 UploadedFile 对象,可以方便地处理各种文件上传场景。
基础概念
UploadedFile 对象
框架将每个上传的文件封装为 UploadedFile 对象,提供文件信息查询和操作方法:
php
namespace Viswoole\HttpServer\Message;
class UploadedFile
{
// 只读属性
public readonly string $tmp_path; // 临时文件路径
// 公共方法
public function getClientFilename(): ?string; // 客户端原始文件名
public function getClientMediaType(): ?string; // MIME 类型(客户端声明)
public function getSize(): ?int; // 文件大小(字节)
public function getError(): int; // 上传错误码(0=成功)
public function isMoved(): bool; // 是否已移动
public function getStream(): FileStream; // 获取文件流
public function moveTo(string $targetPath): void; // 移动文件到目标位置
}单文件上传
基础示例
php
<?php
declare(strict_types=1);
namespace App\Controller;
use Viswoole\HttpServer\AutoInject\InjectFile;
use Viswoole\HttpServer\Message\UploadedFile;
use Viswoole\HttpServer\Contract\ResponseInterface;
use Viswoole\Router\Annotation\AutoController;
use Viswoole\Router\Annotation\RouteMapping;
use App\Response;
#[AutoController]
class UploadController
{
/**
* 上传头像
*
* POST /upload/avatar
* Form Data: avatar=(file)
*
* @param UploadedFile $file 上传的文件
* @param Response $response 响应对象
* @return ResponseInterface
*/
#[RouteMapping(method: 'POST')]
public static function avatar(
#[InjectFile] UploadedFile $file,
Response $response
): ResponseInterface {
// 获取文件信息
$originalName = $file->getClientFilename(); // "photo.jpg"
$mimeType = $file->getClientMediaType(); // "image/jpeg"
$size = $file->getSize(); // 123456 (字节)
// 生成新文件名(避免重名冲突)
$extension = pathinfo($originalName, PATHINFO_EXTENSION);
$newFileName = uniqid('avatar_') . '.' . $extension;
// 移动文件到目标目录
$targetPath = app()->getRootPath() . "/runtime/uploads/avatars/{$newFileName}";
$file->moveTo($targetPath);
return $response->json([
'success' => true,
'message' => '上传成功',
'data' => [
'original_name' => $originalName,
'saved_path' => $targetPath,
'url' => "/uploads/avatars/{$newFileName}",
'size' => $size,
'mime_type' => $mimeType
]
]);
}
}HTML 表单示例
html
<!-- 单文件上传表单 -->
<form action="/upload/avatar" method="POST" enctype="multipart/form-data">
<label for="avatar">选择头像:</label>
<input type="file" id="avatar" name="avatar" accept="image/*" />
<button type="submit">上传</button>
</form>重要: 表单必须设置 enctype="multipart/form-data"。
文件验证规则
使用 FileRule 注解
通过 #[FileRule] 注解可以限制文件的类型、大小和数量:
php
/**
* @param string $fileMime 允许的 MIME 类型,多个用 | 分隔,* 表示不限制
* @param int $maxSize 最大字节数(0=不限制)
* @param int $count 要求的文件数量(0=不限制)
* @param string $message 自定义错误消息
*/
#[Attribute]
class FileRule
{
public function __construct(
public readonly string $fileMime = '*',
public readonly int $maxSize = 0,
public readonly int $count = 0,
string $message = ''
) {}
}验证示例
限制文件类型
php
/**
* 上传图片(仅允许 PNG 和 JPEG)
*/
#[RouteMapping(method: 'POST')]
public static function image(
#[FileRule('image/png|image/jpeg')] // 允许 PNG 和 JPEG
#[InjectFile]
UploadedFile $file,
Response $response
): ResponseInterface {
// 文件已验证通过,直接处理...
return $this->saveImage($file, $response);
}限制文件大小
php
/**
* 上传文档(最大 5MB)
* 5MB = 5 * 1024 * 1024 = 5242880 字节
*/
#[RouteMapping(method: 'POST')]
public static function document(
#[FileRule(
fileMime: 'application/pdf|application/msword',
maxSize: 5242880 // 5MB
)]
#[InjectFile]
UploadedFile $file,
Response $response
): ResponseInterface {
return $this->saveDocument($file, $response);
}要求特定数量的文件
php
/**
* 批量上传图片(必须上传 3 张)
*/
#[RouteMapping(method: 'POST')]
public static function batchImages(
#[FileRule(
fileMime: 'image/*',
count: 3 // 必须恰好 3 个文件
)]
#[InjectFile]
array $files, // 多文件返回数组
Response $response
): ResponseInterface {
$savedPaths = [];
foreach ($files as $index => $file) {
$path = $this->saveImageFile($file, "image_{$index}");
$savedPaths[] = $path;
}
return $response->json([
'success' => true,
'count' => count($savedPaths),
'paths' => $savedPaths
]);
}自定义错误消息
php
/**
* 上传身份证照片
*/
#[RouteMapping(method: 'POST')]
public static function idCard(
#[FileRule(
fileMime: 'image/jpeg,image/png',
maxSize: 2097152, // 2MB
message: '请上传 JPG 或 PNG 格式的身份证照片,大小不超过 2MB'
)]
#[InjectFile]
UploadedFile $file,
Response $response
): ResponseInterface {
// ...
}支持的 MIME 类型
常用文件类型的 MIME 类型:
| 文件类型 | MIME 类型 |
|---|---|
| PNG 图片 | image/png |
| JPEG 图片 | image/jpeg |
| GIF 图片 | image/gif |
| WebP 图片 | image/webp |
| 所有图片 | image/* |
| PDF 文档 | application/pdf |
| Word 文档 | application/msword |
| Excel 表格 | application/vnd.ms-excel |
| 纯文本 | text/plain |
| JSON 数据 | application/json |
| XML 数据 | application/xml |
| 任意类型 | * |
注意: 可以使用 | 分隔多种类型,或使用通配符 * 匹配一类文件。
多文件上传
HTML 表单配置
html
<!-- 多文件上传 -->
<form action="/upload/batch" method="POST" enctype="multipart/form-data">
<label for="photos">选择多张图片:</label>
<!-- 添加 multiple 属性支持多选 -->
<input type="file" id="photos" name="photos[]" multiple accept="image/*" />
<button type="submit">批量上传</button>
</form>关键点:
- input 的
name属性必须以[]结尾:name="photos[]" - 添加
multiple属性允许选择多个文件
控制器处理
php
/**
* 批量上传图片
*
* POST /upload/batch
* Form Data: photos[]=(file1), photos[]=(file2), photos[]=(file3)
*/
#[RouteMapping(method: 'POST')]
public static function batchUpload(
#[FileRule('image/*')] // 验证所有文件都是图片
#[InjectFile]
array $files, // 自动识别为数组
Response $response
): ResponseInterface {
$results = [];
$errors = [];
foreach ($files as $index => $file) {
try {
// 检查上传是否有错误
if ($file->getError() !== UPLOAD_ERR_OK) {
throw new \RuntimeException($this->getUploadErrorMessage($file->getError()));
}
// 检查是否已移动
if ($file->isMoved()) {
throw new \RuntimeException('文件已被移动');
}
// 保存文件
$originalName = $file->getClientFilename();
$extension = pathinfo($originalName, PATHINFO_EXTENSION);
$newName = uniqid('img_') . '_' . ($index + 1) . '.' . $extension;
$targetPath = app()->getRootPath() . "/runtime/uploads/images/{$newName}";
$file->moveTo($targetPath);
$results[] = [
'original_name' => $originalName,
'saved_path' => $targetPath,
'size' => $file->getSize(),
'status' => 'success'
];
} catch (\Exception $e) {
$errors[] = [
'index' => $index,
'error' => $e->getMessage()
];
}
}
return $response->json([
'success' => empty($errors),
'uploaded' => count($results),
'failed' => count($errors),
'data' => $results,
'errors' => $errors
]);
}
/**
* 获取上传错误码对应的中文消息
*/
private static function getUploadErrorMessage(int $errorCode): string
{
return match($errorCode) {
UPLOAD_ERR_INI_SIZE => '文件超过 server.ini 中 upload_max_filesize 限制',
UPLOAD_ERR_FORM_SIZE => '文件超过表单 MAX_FILE_SIZE 限制',
UPLOAD_ERR_PARTIAL => '文件只有部分被上传',
UPLOAD_ERR_NO_FILE => '没有文件被上传',
UPLOAD_ERR_NO_TMP_DIR => '缺少临时文件夹',
UPLOAD_ERR_CANT_WRITE => '文件写入磁盘失败',
UPLOAD_ERR_EXTENSION => 'PHP 扩展阻止了文件上传',
default => '未知上传错误'
};
}文件操作详解
moveTo() - 移动文件
将上传的临时文件移动到目标位置:
php
/**
* @param string $targetPath 目标路径(包含文件名)
* @throws InvalidArgumentException 路径为空时抛出
* @throws RuntimeException 目录创建失败、移动失败、文件已移动等异常
*/
public function moveTo(string $targetPath): void特性:
- CLI 模式下使用
rename()(性能更好) - 非 CLI 模式下使用
move_uploaded_file() - 目标目录不存在时会自动递归创建
- 文件只能移动一次,再次调用会抛出异常
示例:
php
// 移动到指定目录
$file->moveTo('/var/www/uploads/photo.jpg');
// 移动并重命名
$newName = date('Y/m/d/') . uniqid() . '.jpg';
$filePath = app()->getRootPath() . '/storage/uploads/' . $newName;
$file->moveTo($filePath);getStream() - 获取文件流
获取文件的流对象,用于读取文件内容或进行流式处理:
php
/**
* @return FileStream 文件流实例(延迟初始化)
*/
public function getStream(): FileStream示例 - 读取文件内容:
php
$stream = $file->getStream();
$content = $stream->getContents(); // 读取全部内容
// 或逐行读取
while (!$stream->eof()) {
$line = $stream->read(1024); // 每次读取 1KB
// 处理数据...
}
$stream->close();示例 - 复制到其他位置:
php
// 将上传文件复制到云存储
$stream = $file->getStream();
CloudStorage::put('remote/path/file.jpg', $stream);文件信息查询
php
$file = Request::files('document');
// 原始文件名(可能包含特殊字符,需谨慎使用)
$filename = $file->getClientFilename(); // "报告.docx"
// 客户端声明的 MIME 类型(不可完全信任,需服务端验证)
$mimeType = $file->getClientMediaType(); // "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
// 文件大小(字节)
$size = $file->getSize(); // 1234567
// 上传错误码(UPLOAD_ERR_OK = 0 表示成功)
$error = $file->getError(); // 0
// 是否已被移动
$moved = $file->isMoved(); // false
// 临时文件路径
$tmpPath = $file->tmp_path; // /tmp/swoole.xxx完整示例:带完整验证的上传系统
php
<?php
declare(strict_types=1);
namespace App\Controller;
use Viswoole\HttpServer\AutoInject\InjectFile;
use Viswoole\HttpServer\Message\UploadedFile;
use Viswoole\HttpServer\Contract\ResponseInterface;
use Viswoole\Router\Annotation\AutoController;
use Viswoole\Router\Annotation\RouteMapping;
use App\Response;
/**
* 文件管理控制器
* 演示完整的文件上传功能
*/
#[AutoController(prefix: 'api/v1/files')]
class FileController
{
/**
* 上传单个图片
*
* POST /api/v1/files/upload-image
*
* 限制:
* - 格式:PNG、JPEG、GIF、WebP
* - 大小:最大 10MB
*
* @param UploadedFile $file 图片文件
* @return ResponseInterface
*/
#[RouteMapping(method: 'POST')]
public static function uploadImage(
#[FileRule(
fileMime: 'image/png|image/jpeg|image/gif|image/webp',
maxSize: 10485760, // 10MB
message: '请上传 PNG/JPEG/GIF/WebP 格式的图片,大小不超过 10MB'
)]
#[InjectFile]
UploadedFile $file,
Response $response
): ResponseInterface {
try {
// 1. 验证上传状态
self::validateUploadStatus($file);
// 2. 安全性检查:重新检测实际 MIME 类型
$actualMimeType = self::detectMimeType($file);
$allowedTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
if (!in_array($actualMimeType, $allowedTypes)) {
throw new \RuntimeException('文件类型验证失败');
}
// 3. 生成安全的文件名
$originalName = $file->getClientFilename();
$extension = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
$safeName = self::generateSafeFileName($extension);
// 4. 按日期组织目录结构
$datePath = date('Y/m/d');
$relativePath = "images/{$datePath}/{$safeName}";
$fullPath = app()->getRootPath() . "/runtime/uploads/{$relativePath}";
// 5. 移动文件
$file->moveTo($fullPath);
// 6. 返回成功响应
return $response->json([
'code' => 200,
'message' => '上传成功',
'data' => [
'filename' => $safeName,
'original_name' => $originalName,
'path' => $relativePath,
'url' => "/uploads/{$relativePath}",
'size' => $file->getSize(),
'mime_type' => $actualMimeType,
'uploaded_at' => date('c')
]
]);
} catch (\RuntimeException $e) {
return $response
->status(400)
->json([
'code' => 400,
'message' => $e->getMessage(),
'error' => 'UPLOAD_ERROR'
]);
}
}
/**
* 批量上传文件
*
* POST /api/v1/files/batch-upload
*
* 支持 PDF、Word、Excel、图片
* 单个文件最大 20MB,最多 10 个文件
*/
#[RouteMapping(method: 'POST')]
public static function batchUpload(
#[FileRule(
fileMime: 'image/*|application/pdf|application/msword|application/vnd.ms-excel',
maxSize: 20971520, // 20MB
count: 10 // 最多 10 个文件
)]
#[InjectFile]
array $files,
Response $response
): ResponseInterface {
$successCount = 0;
$failCount = 0;
$results = [];
foreach ($files as $index => $file) {
try {
self::validateUploadStatus($file);
$originalName = $file->getClientFilename();
$extension = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
$safeName = self::generateSafeFileName($extension);
$datePath = date('Y/m/d');
$relativePath = "documents/{$datePath}/{$safeName}";
$fullPath = app()->getRootPath() . "/runtime/uploads/{$relativePath}";
$file->moveTo($fullPath);
$results[] = [
'index' => $index,
'status' => 'success',
'filename' => $safeName,
'original_name' => $originalName,
'path' => $relativePath,
'size' => $file->getSize()
];
$successCount++;
} catch (\Exception $e) {
$results[] = [
'index' => $index,
'status' => 'failed',
'error' => $e->getMessage()
];
$failCount++;
}
}
return $response->json([
'code' => $failCount > 0 ? 206 : 200, // Partial Content 或 OK
'message' => $failCount > 0 ? '部分文件上传失败' : '全部上传成功',
'summary' => [
'total' => count($files),
'success' => $successCount,
'failed' => $failCount
],
'data' => $results
]);
}
/**
* 上传并返回文件流(用于即时预览或处理)
*
* POST /api/v1/files/upload-stream
*/
#[RouteMapping(method: 'POST')]
public static function uploadStream(
#[FileRule('text/csv,application/json')]
#[InjectFile]
UploadedFile $file,
Response $response
): ResponseInterface {
self::validateUploadStatus($file);
// 获取文件流并读取内容
$stream = $file->getStream();
$content = $stream->getContents();
$stream->close();
// 解析 CSV 内容
$lines = explode("\n", $content);
$headers = str_getcsv(array_shift($lines));
$data = array_map(fn($line) => array_combine($headers, str_getcsv($line)), $lines);
return $response->json([
'code' => 200,
'filename' => $file->getClientFilename(),
'rows' => count($data),
'preview' => array_slice($data, 0, 10) // 仅返回前 10 行预览
]);
}
/**
* 验证上传状态
*/
private static function validateUploadStatus(UploadedFile $file): void
{
$error = $file->getError();
if ($error !== UPLOAD_ERR_OK) {
throw new \RuntimeException(self::getUploadErrorMessage($error));
}
if ($file->isMoved()) {
throw new \RuntimeException('文件已被移动,无法重复操作');
}
}
/**
* 检测文件真实 MIME 类型(防止伪造扩展名攻击)
*/
private static function detectMimeType(UploadedFile $file): string
{
$finfo = finfo_open(FILEINFO_MIME_TYPE);
if ($finfo === false) {
return $file->getClientMediaType();
}
// 从临时文件路径检测
$mimeType = finfo_file($finfo, $file->tmp_path);
finfo_close($finfo);
return $mimeType ?: $file->getClientMediaType();
}
/**
* 生成安全的文件名
*/
private static function generateSafeFileName(string $extension): string
{
return sprintf(
'%s_%s.%s',
date('YmdHis'),
bin2hex(random_bytes(8)),
$extension
);
}
/**
* 获取上传错误码对应的消息
*/
private static function getUploadErrorMessage(int $code): string
{
return match($code) {
UPLOAD_ERR_INI_SIZE => '文件超出系统允许的最大大小',
UPLOAD_ERR_FORM_SIZE => '文件超出表单允许的最大大小',
UPLOAD_ERR_PARTIAL => '文件只有部分被上传',
UPLOAD_ERR_NO_FILE => '没有选择要上传的文件',
UPLOAD_ERR_NO_TMP_DIR => '服务器临时目录缺失',
UPLOAD_ERR_CANT_WRITE => '文件写入磁盘失败',
UPLOAD_ERR_EXTENSION => '文件上传被 PHP 扩展阻止',
default => '未知上传错误'
};
}
}最佳实践
1. 始终验证文件类型
不要信任客户端声明的 MIME 类型,使用服务端检测:
php
// ✅ 推荐:使用 finfo 检测真实类型
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$realType = finfo_file($finfo, $file->tmp_path);
finfo_close($finfo);
// ❌ 危险:仅检查扩展名
$extension = pathinfo($file->getClientFilename(), PATHINFO_EXTENSION);2. 使用随机文件名避免冲突
php
// ✅ 推荐:使用时间戳+随机字符串
$fileName = date('YmdHis') . '_' . bin2hex(random_bytes(8)) . '.jpg';
// ❌ 避免:直接使用用户提供的文件名
$fileName = $_FILES['file']['name']; // 可能包含 ../ 等危险字符3. 合理组织存储目录
按日期或类型分目录存储,避免单目录文件过多:
text
uploads/
├── images/
│ ├── 2024/
│ │ ├── 01/
│ │ │ ├── 15/
│ │ │ └── 16/
│ │ └── 02/
│ └── ...
├── documents/
│ └── ...
└── temp/4. 设置合理的文件大小限制
在 PHP 配置和业务逻辑中双重限制:
php
// php.ini
upload_max_filesize = 20M
post_max_size = 25M
// 业务代码中的二次验证
#[FileRule(maxSize: 20971520)] // 20MB
#[InjectFile]
UploadedFile $file5. 及时清理临时文件
Swoole 会在请求结束后自动清理临时文件,但如果手动处理:
php
try {
$file->moveTo($destination);
} catch (\Exception $e) {
// 移动失败时,临时文件会在请求结束后自动清理
// 无需手动删除 tmp_path
Log::error('文件移动失败: ' . $e->getMessage());
}6. 记录上传日志
对于重要的文件上传操作,记录详细日志:
php
Log::info('文件上传', [
'user_id' => Auth::id(),
'original_name' => $file->getClientFilename(),
'size' => $file->getSize(),
'mime_type' => $file->getClientMediaType(),
'saved_path' => $targetPath,
'ip' => Request::ip()
]);常见问题与陷阱
Q: 为什么上传大文件时报错?
A: 检查以下配置:
php.ini:upload_max_filesize,post_max_size,memory_limit- Swoole 配置:
package_max_length - Nginx(如果使用反向代理):
client_max_body_size
Q: 如何支持断点续传?
A: 当前版本不支持原生的断点续传。如需此功能,建议:
- 使用分片上传:前端将大文件分割为小块逐个上传
- 服务端接收后合并文件
- 记录已上传的分片信息,支持恢复
Q: 如何防止恶意文件上传?
A:
- 验证 MIME 类型:使用
finfo检测真实类型 - 限制扩展名:只允许白名单中的扩展名
- 文件内容检查:对图片可尝试加载验证其合法性
- 隔离存储:上传文件存放在非 Web 根目录或专用域名
- 权限控制:上传目录禁止执行 PHP 脚本
Q: 多文件上传时如何区分字段?
A: HTML 表单中使用数组命名:
html
<input type="file" name="photos[]" multiple /> <input type="file" name="documents[]" multiple />控制器中分别注入:
php
public static function uploadMixed(
#[InjectFile] array $photos, // photos[]
#[InjectFile] array $documents, // documents[]
Response $response
): ResponseInterface { ... }Q: UploadedFile 对象可以序列化吗?
A: 不可以。UploadedFile 包含临时文件路径引用,序列化无意义。如需持久化存储文件信息,应保存文件路径到数据库。
Q: 如何实现文件预览?
A: 上传成功后返回可访问的 URL,配合 Web 服务器配置:
php
// 返回 URL
$url = '/uploads/images/2024/01/15/xxx.jpg';
// 前端使用
<img src="{{ $url }}" alt="预览">确保 Web 服务器正确配置了静态文件服务。
