在实际开发中,文件上传几乎是每个Web应用都绕不开的功能。就拿用户头像上传这个场景来说,看似简单的一个功能,背后却隐藏着不少坑。我遇到过不少项目,每个需要上传的地方都重复写类似的代码,不仅效率低下,后期维护起来更是噩梦。比如图片大小限制、格式验证、错误处理这些逻辑,散落在各个控制器里,改一处就得改所有地方。
更头疼的是图片体积问题。用户随手一拍就是几MB的照片,直接上传既浪费带宽又占用存储空间。我曾经接手过一个项目,因为没做图片压缩,一个月就烧掉了上千元的云存储费用。还有错误处理,有的页面返回JSON,有的直接跳转,前端对接起来苦不堪言。
ThinkPHP6自带的文件上传功能已经很完善了,但直接使用的话还是得写不少重复代码。这就是为什么我们需要封装一个统一的uploadFile函数——把验证规则、压缩逻辑、错误处理这些脏活累活都封装起来,让业务代码保持干净清爽。
首先确保你已经创建好ThinkPHP6项目。如果还没安装,可以通过Composer快速创建:
bash复制composer create-project topthink/think tp6-upload-demo
我们需要用到图片处理功能,所以得安装think-image扩展:
bash复制composer require topthink/think-image
这个扩展底层用的是GD库或Imagick,建议安装前确认PHP已启用相关扩展。在php.ini中取消以下行的注释:
ini复制extension=gd
; 或者
; extension=imagick
在config/filesystem.php中配置存储磁盘。默认已经有个public配置,我们只需要确认上传目录可写:
php复制return [
'default' => 'public',
'disks' => [
'public' => [
'type' => 'local',
'root' => app()->getRootPath() . 'public/uploads',
'url' => '/uploads',
'visibility' => 'public',
],
// 其他磁盘配置...
],
];
记得给上传目录设置写权限:
bash复制chmod -R 755 public/uploads
前端部分看似简单,但有些细节不注意就会踩坑。先看一个标准的文件上传表单:
html复制<form action="/profile/upload" method="post" enctype="multipart/form-data">
<div class="form-group">
<label>选择头像(支持JPG/PNG,最大2MB)</label>
<input type="file" name="avatar" class="form-control-file" accept="image/jpeg,image/png">
</div>
<button type="submit" class="btn btn-primary">上传</button>
</form>
关键点:
现代应用更推荐用Ajax上传,可以显示上传进度:
javascript复制$('#upload-form').submit(function(e) {
e.preventDefault();
let formData = new FormData(this);
$.ajax({
url: $(this).attr('action'),
type: 'POST',
data: formData,
processData: false,
contentType: false,
xhr: function() {
let xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', function(e) {
if (e.lengthComputable) {
let percent = Math.round((e.loaded / e.total) * 100);
$('#progress-bar').width(percent + '%');
}
}, false);
return xhr;
},
success: function(response) {
// 处理成功响应
},
error: function(xhr) {
// 处理错误
}
});
});
先实现最基础的上传功能,逐步完善。创建一个app/common/Uploader.php:
php复制<?php
namespace app\common;
use think\facade\Filesystem;
use think\file\UploadedFile;
class Uploader
{
public static function upload(string $field, string $subDir, array $options = [])
{
$defaults = [
'size' => 2, // MB
'ext' => 'jpg,jpeg,png,gif',
];
$options = array_merge($defaults, $options);
if (!request()->isPost()) {
throw new \Exception('只支持POST请求');
}
$file = request()->file($field);
if (!$file) {
throw new \Exception('未上传文件或上传出错');
}
validate(['file' => [
'fileSize' => $options['size'] * 1024 * 1024,
'fileExt' => $options['ext'],
]])->check(['file' => $file]);
$saveName = Filesystem::disk('public')
->putFile($subDir, $file);
return [
'path' => $saveName,
'url' => '/uploads/' . $saveName,
'size' => $file->getSize(),
];
}
}
现在加入图片压缩功能,当图片超过指定大小时自动压缩:
php复制use think\Image;
// 在Uploader类中添加新方法
public static function compressImage(UploadedFile $file, array $options)
{
$image = Image::open($file);
$threshold = $options['threshold'] ?? 1; // MB
if ($file->getSize() < $threshold * 1024 * 1024) {
return $file;
}
$compressDir = runtime_path('compress');
if (!is_dir($compressDir)) {
mkdir($compressDir, 0755, true);
}
$tempPath = $compressDir . '/' . uniqid() . '.' . $file->extension();
$image->thumb(
$options['width'] ?? 800,
$options['height'] ?? 800,
$options['type'] ?? Image::THUMB_SCALING
)->save($tempPath, null, $options['quality'] ?? 80);
return new UploadedFile(
$tempPath,
$file->getOriginalName(),
$file->getMimeType(),
$file->getError(),
true
);
}
然后在upload方法中使用:
php复制if (in_array($file->extension(), ['jpg', 'jpeg', 'png'])
&& $options['compress'] ?? false) {
$file = self::compressImage($file, $options);
}
定义一套标准的错误码和响应格式:
php复制const ERROR_MAP = [
1001 => '文件大小超过限制',
1002 => '文件类型不允许',
1003 => '上传目录不可写',
// ...其他错误码
];
public static function handle(callable $callback)
{
try {
$result = $callback();
return [
'code' => 0,
'data' => $result,
'msg' => 'success',
];
} catch (\Exception $e) {
return [
'code' => self::getErrorCode($e),
'data' => null,
'msg' => $e->getMessage(),
];
}
}
private static function getErrorCode(\Exception $e): int
{
// 根据异常类型返回对应错误码
return 1000; // 默认错误码
}
使用方式:
php复制$result = Uploader::handle(function() use ($field, $subDir) {
return Uploader::upload($field, $subDir, [
'size' => 2,
'compress' => true,
'width' => 500,
]);
});
在控制器中使用封装好的上传模块:
php复制public function avatar()
{
$result = Uploader::handle(function() {
return Uploader::upload('avatar', 'avatars', [
'size' => 2,
'compress' => true,
'width' => 200,
'height' => 200,
]);
});
if ($result['code'] === 0) {
// 更新用户头像路径到数据库
User::update(['avatar' => $result['data']['url']]);
}
return json($result);
}
处理多文件上传也很简单:
php复制public function album()
{
$files = request()->file('photos');
$results = [];
foreach ($files as $file) {
$results[] = Uploader::handle(function() use ($file) {
return Uploader::upload($file, 'photos', [
'size' => 5,
'compress' => true,
]);
});
}
return json($results);
}
可以进一步与ThinkPHP的验证器结合:
php复制public function upload()
{
$data = $this->request->post();
$this->validate($data, [
'user_id' => 'require|number',
'file' => 'require|file',
]);
// ...上传逻辑
}
php复制$safeName = md5(uniqid()) . '.' . $file->extension();
php复制$allowedMimes = ['image/jpeg', 'image/png'];
if (!in_array($file->getMimeType(), $allowedMimes)) {
throw new \Exception('文件类型不允许');
}
bash复制# Ubuntu安装示例
sudo apt-get install clamav
php复制// 保存文件后
$job = new ProcessUploadJob($filePath);
Queue::dispatch($job);
php复制$cdn = new QiniuUploader();
$cdn->upload($localPath, $remotePath);
php复制$sizes = [
'thumb' => [200, 200],
'medium' => [800, 800],
];
foreach ($sizes as $type => $size) {
$image->thumb($size[0], $size[1])->save($path . "_$type.jpg");
}
记录上传日志便于排查问题:
php复制try {
// 上传逻辑...
} catch (\Exception $e) {
Log::error('上传失败', [
'error' => $e->getMessage(),
'file' => $e->getFile(),
'line' => $e->getLine(),
'trace' => $e->getTraceAsString(),
]);
throw $e;
}
最后给出一个完整的生产可用版本:
php复制<?php
namespace app\common;
use think\Image;
use think\facade\Filesystem;
use think\facade\Log;
use think\file\UploadedFile;
class Uploader
{
const ERR_INVALID_REQUEST = 1000;
const ERR_NO_FILE = 1001;
const ERR_SIZE_EXCEED = 1002;
const ERR_TYPE_NOT_ALLOWED = 1003;
const ERR_UPLOAD_FAILED = 1004;
const ERR_COMPRESS_FAILED = 1005;
public static function upload(string $field, string $subDir, array $options = [])
{
$defaults = [
'size' => 2, // MB
'ext' => 'jpg,jpeg,png',
'compress' => true,
'threshold' => 1, // MB
'width' => 800,
'height' => 800,
'quality' => 80,
];
$options = array_merge($defaults, $options);
$file = request()->file($field);
if (!$file) {
throw new \Exception('未上传文件', self::ERR_NO_FILE);
}
try {
validate(['file' => [
'fileSize' => $options['size'] * 1024 * 1024,
'fileExt' => $options['ext'],
]])->check(['file' => $file]);
if ($options['compress'] && in_array($file->extension(), ['jpg', 'jpeg', 'png'])) {
$file = self::compressImage($file, $options);
}
$saveName = Filesystem::disk('public')
->putFile($subDir, $file, function() use ($file) {
return md5(uniqid()) . '.' . $file->extension();
});
return [
'path' => $saveName,
'url' => '/uploads/' . $saveName,
'size' => filesize($file->getPathname()),
];
} finally {
// 清理临时文件
if ($file->isTemporary()) {
@unlink($file->getPathname());
}
}
}
// ...其他方法保持不变...
}
在控制器中使用:
php复制public function uploadAvatar()
{
$result = Uploader::handle(function() {
return Uploader::upload('avatar', 'avatars', [
'size' => 2,
'compress' => true,
'width' => 200,
'height' => 200,
]);
});
if ($result['code'] === 0) {
// 更新数据库等操作
}
return json($result);
}
这个上传模块已经在我多个生产项目中稳定运行,处理过日均上万次的上传请求。关键在于合理的默认配置和清晰的错误处理,让业务代码可以专注于业务逻辑,而不是文件处理的细节。