文件上传

本指南演示如何在 Viswoole 中接收并处理上传文件:用 #[InjectFile] 注入 UploadedFile 对象、用 #[FileRule] 校验类型与大小、用 moveTo() 落盘保存,并给出可直接复用的完整接口示例。

准备上传表单

上传接口通过 multipart/form-data 编码接收文件,HTML 表单必须设置 enctype

html
<form action="/upload/avatar" method="POST" enctype="multipart/form-data">
  <input type="file" name="avatar" accept="image/*" />
  <button type="submit">上传</button>
</form>

步骤一:用 #[InjectFile] 声明上传参数

#[InjectFile]Viswoole\HttpServer\AutoInject\InjectFile)标注在方法参数上,框架自动从上传文件中按表单字段名取值:

  • 字段对应单个文件时注入 UploadedFile
  • 字段对应多个文件时注入 UploadedFile[]
  • 文件缺失时抛出 ValidateException(可空类型或声明默认值则允许缺失)。
php
use Viswoole\HttpServer\AutoInject\InjectFile;
use Viswoole\HttpServer\Message\UploadedFile;

#[RouteMapping(method: 'POST')]
public function avatar(#[InjectFile] UploadedFile $avatar): array
{
    return [
        'name' => $avatar->getClientFilename(), // 客户端原始文件名
        'size' => $avatar->getSize(),           // 字节数
    ];
}

步骤二:用 #[FileRule] 校验文件

#[FileRule]Viswoole\HttpServer\Validate\FileRule)是验证规则注解,与 #[InjectFile] 叠加使用,注入完成后自动校验:

php
use Viswoole\HttpServer\Validate\FileRule;

#[RouteMapping(method: 'POST')]
public function avatar(
    #[FileRule(
        fileMime: 'image/png|image/jpeg',
        maxSize: 5242880,               // 5MB
        message: '请上传 5MB 以内的 PNG/JPEG 图片'
    ), InjectFile]
    UploadedFile $avatar,
): array {}
参数类型默认值说明
fileMimestring'*'允许的 MIME 类型,多个用 | 分隔,'*' 表示不限制
maxSizeint0单个文件最大字节数,小于等于 0 不限制
countint0要求的文件数量(必须恰好相等),小于等于 0 不限制
messagestring''自定义错误消息,非空时覆盖默认提示

校验失败抛出 ValidateException(400 响应),默认错误消息:

  • 必须上传文件(字段为空数组等异常情况)
  • 必须上传 {count} 个文件
  • 文件类型必须为 {fileMime}
  • 文件大小不能超过 {maxSize} 字节

MIME 校验基于文件内容而非声明值

FileRule 使用 UploadedFile::getRealMimeType()(finfo 内容检测)做白名单匹配,客户端声明的 Content-Type 可被任意伪造,不能作为校验依据。仅支持精确 MIME 列表,不支持 image/* 前缀通配,如需匹配一类文件请逐一列出。

步骤三:保存文件

moveTo() 将临时文件移动到目标路径(含文件名),目标目录不存在时自动递归创建(权限 0755):

php
$originalName = $file->getClientFilename() ?? 'upload.bin';
// 用随机名避免重名与路径注入,扩展名取自原始文件名
$extension = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
$newName = date('YmdHis') . '_' . bin2hex(random_bytes(8)) . '.' . $extension;

$targetPath = app()->getRootPath() . '/runtime/uploads/avatars/' . $newName;
$file->moveTo($targetPath);

移动特性(源码 UploadedFile::moveTo()):

  • CLI 环境使用 rename(),其他环境使用 move_uploaded_file()
  • 文件只能移动一次,重复调用或对已移动/上传出错的文件操作会抛出 RuntimeException
  • 移动成功后再调用 getStream() 同样会抛异常。

多文件上传

表单字段以 [] 结尾并添加 multiple 属性即可多选:

html
<form action="/upload/photos" method="POST" enctype="multipart/form-data">
  <input type="file" name="photos[]" multiple accept="image/*" />
  <button type="submit">批量上传</button>
</form>

参数声明为数组即注入 UploadedFile[](框架自动将 Swoole 的多文件结构转置为对象数组,源码 Request::parseFiles()):

php
#[RouteMapping(method: 'POST')]
public function photos(
    #[FileRule(fileMime: 'image/png|image/jpeg', maxSize: 5242880, count: 3), InjectFile]
    array $photos, // 要求恰好 3 张图片
): array {
    $paths = [];
    foreach ($photos as $index => $file) {
        $paths[] = $this->save($file, "photo_{$index}");
    }
    return ['paths' => $paths];
}

UploadedFile API 参考

Viswoole\HttpServer\Message\UploadedFile 封装单个上传文件:

成员类型说明
$tmp_pathstring(readonly 属性)临时文件路径
getClientFilename()?string客户端原始文件名
getClientMediaType()?string客户端声明的 MIME 类型(可伪造,仅供展示)
getSize()?int文件大小(字节)
getError()int上传错误码,UPLOAD_ERR_OK(0) 表示正常
getRealMimeType()?string通过 finfo 检测的真实 MIME 类型,临时文件不存在或检测失败为 null
isMoved()bool文件是否已被移动
getStream()FileStream获取只读文件流(延迟初始化),上传出错或已移动时抛异常
moveTo(string $targetPath)void移动文件到目标路径(含文件名)

需要读取文件内容而非落盘时使用 getStream()

php
$content = $file->getStream()->getContents(); // 读取全部内容

FileStream API 参考

Viswoole\HttpServer\Message\FileStream 是 PHP 文件资源流的面向对象封装,析构时自动关闭底层资源:

方法返回值说明
create(string $filePath, string $mode = 'r')static工厂方法,按 fopen 模式打开文件
read(int $length)string从当前位置读取指定字节数
getContents()string读取当前位置起的全部剩余内容
write(string $string)int写入数据(仅可写模式)
getSize()?int流大小(字节)
tell()int当前指针位置
seek(int $offset, int $whence = SEEK_SET)void移动指针
rewind()void指针回到开头
eof()bool是否到达流末尾
isReadable() / isWritable() / isSeekable()bool流能力判断
getMetadata(?string $key = null)mixed流元数据
detach()resource|null分离并返回底层 PHP 资源流
close()void关闭资源流

完整示例:头像上传接口

php
<?php
declare(strict_types=1);

namespace App\Controller;

use App\Service\AvatarService;
use Viswoole\HttpServer\AutoInject\InjectFile;
use Viswoole\HttpServer\Contract\ResponseInterface;
use Viswoole\HttpServer\Message\UploadedFile;
use Viswoole\HttpServer\Validate\FileRule;
use Viswoole\Router\Annotation\AutoController;
use Viswoole\Router\Annotation\RouteMapping;

#[AutoController(prefix: 'upload')]
class UploadController
{
    public function __construct(private readonly AvatarService $service)
    {
    }

    /**
     * 上传头像
     *
     * POST /upload/avatar,字段名 avatar
     */
    #[RouteMapping('avatar', method: 'POST')]
    public function avatar(
        #[FileRule(
            fileMime: 'image/png|image/jpeg|image/webp',
            maxSize: 2097152, // 2MB
            message: '请上传 2MB 以内的 PNG/JPEG/WebP 图片'
        ), InjectFile]
        UploadedFile $avatar,
        ResponseInterface $response,
    ): ResponseInterface {
        // 随机文件名:避免重名冲突,且不信任客户端文件名
        $extension = strtolower(
            pathinfo($avatar->getClientFilename() ?? '', PATHINFO_EXTENSION)
        );
        $newName = date('Ymd') . '_' . bin2hex(random_bytes(8)) . '.' . $extension;

        // 按日期分目录存储,moveTo 会自动创建目录
        $relativePath = 'uploads/avatars/' . date('Ym') . '/' . $newName;
        $avatar->moveTo(app()->getRootPath() . '/runtime/' . $relativePath);

        // 业务侧仅登记相对路径与文件信息
        $this->service->record($relativePath, $avatar->getSize());

        return $response->json([
            'code' => 0,
            'message' => '上传成功',
            'data' => ['path' => $relativePath, 'size' => $avatar->getSize()],
        ]);
    }
}

用 curl 验证(默认端口 9501):

bash
curl -X POST http://127.0.0.1:9501/upload/avatar \
  -F "avatar=@/path/to/photo.jpg"

安全与配置建议

  • 服务端检测真实类型:白名单校验交给 FileRule(基于内容检测),不要依赖 getClientMediaType() 或文件扩展名;
  • 不要信任原始文件名:客户端文件名可能包含 ../ 等字符,仅用于提取扩展名或展示,落盘一律使用随机生成的文件名;
  • 上传目录禁止执行:存储目录不放 Web 可执行路径,或关闭该目录的脚本解析;
  • 限制请求体大小:上传上限受多重配置约束——PHP 的 upload_max_filesize、Swoole Server 的 package_max_length、反向代理(如 nginx 的 client_max_body_size),需按需统一调整;
  • 及时处理错误码getError() !== UPLOAD_ERR_OK 时文件不可用,moveTo()/getStream() 会直接抛异常。

下一步