使用 FilePond 在 Laravel 中上传文件
发布日期:作者: Ashley Allen
在构建 Web 应用程序时,您通常需要添加一个允许用户上传文件的功能。例如,您可能希望允许用户更新他们的个人资料图片或彼此共享文件。
在本文中,我们将探讨如何使用一个名为 "FilePond" 的 JavaScript 库在您的 Laravel 应用程序中上传文件。我们还将简要讨论 Laravel 中文件上传的替代方法。然后我们将看看如何使用 FilePond 一次上传多个文件以及上传图像(带有图像预览)。最后,我们将看看如何验证您上传的文件,如何删除临时文件以及如何为您的文件上传代码编写测试。
什么是 FilePond?
FilePond 是一个 JavaScript 库,允许您在 Web 应用程序中上传文件。
它提供了一个简单、易用且视觉吸引人的文件上传界面。它可以为您构建自己的文件上传功能提供一个很好的起点,而无需过多担心界面的样式和可访问性。
FilePond 允许您同步或异步上传。这意味着您可以在提交表单时以单个请求上传文件(同步),也可以在提交表单之前在单独的请求中上传文件(异步)。使用异步方法通常可以提供更好的用户体验,因为用户可以在文件上传时继续填写表单中的其他字段。出于本文的目的,我们将重点关注异步方法。
还有一些插件可以与 FilePond 一起使用以添加额外的功能。例如,您可以使用 FilePondPluginImagePreview
插件来显示正在上传的图像的预览。事实上,我们将在本文后面看看这个插件。
FilePond 还提供了分块上传文件的能力。如果您想上传可能太大而无法在一个请求中上传的大文件,这很有用。但是,为了本教程的目的,我们只关注在一个请求中上传文件。如果您想了解更多关于如何分块上传文件的信息,您可以查看 FilePond 文档。
FilePond 异步文件上传流程如何工作?
为了解释用户如何使用 FilePond 异步地在表单中上传文件,让我们看一个例子。我们将想象用户正在更新他们的个人资料,使用一个允许他们更新他们的姓名和个人资料图片的表单。我们假设用户想上传一个 avatar.png
文件作为他们的新个人资料图片。
流程可能像这样工作
- 用户单击表单上 FilePond 组件中的“浏览”。
- 传统的上传文件对话框出现,以便用户可以从他们的设备中选择他们想要上传的
avatar.png
文件。 - 一旦文件被选中,FilePond 使用
POST
请求将avatar.png
文件作为multipart/form-data
发送到服务器。 - 服务器(我们的 Laravel 应用程序)然后将文件保存到一个临时、唯一的地址。例如,它可能会将文件保存到
tmp/12345abcdef/avatar.png
。 - 然后服务器在
text/plain
响应中将唯一地址(在本例中为12345abcdef/avatar.png
)返回给 FilePond。 - FilePond 在表单上的
hidden
输入字段中添加此唯一地址。 - 虽然步骤 3-6 正在运行,用户本可以继续填写表单的其余部分,同时文件正在上传。一旦文件上传完毕,用户就可以提交表单(现在包括
hidden
输入字段)。 - 服务器(我们的 Laravel 应用程序)使用唯一地址将文件从临时存储地址移动到其目标地址。例如,它可能会将文件从
tmp/12345abcdef/avatar.png
移动到avatars/user-1.png
。
现在我们已经对异步文件上传是如何工作的有了大致的了解,让我们看看它们相对于表单中的同步文件上传的优势。
同步文件上传阻塞 UI
通常,在使用同步方法上传文件的 Web 应用程序中,用户可能会点击表单中的“文件上传”字段。然后,他们可能会选择他们想要上传的文件。一旦他们选择了他们的文件,文件实际上并没有上传到服务器,直到用户提交表单(不像上面我们看到的异步方法)。这意味着当表单提交时,文件在一个请求中(与表单的其余字段一起)上传。
使用这种同步方法有时会阻止用户与 UI 交互。如果文件很大并且需要很长时间才能上传,尤其如此,因为用户不会对正在发生的事情有任何反馈。
这与异步文件上传的工作方式不同。在异步方法中,文件在表单提交之前已经上传到服务器(或正在上传过程中)在一个单独的请求中。
同步文件上传与无服务器平台的问题
如果您在无服务器平台上运行您的应用程序,例如 AWS Lambda,同步文件上传会很快变得很麻烦。在撰写本文时,根据 AWS Lambda 文档,请求的最大大小为 6MB。这意味着您需要确保表单中数据的(包括上传的文件)大小不超过此限制。
这意味着如果您打算在无服务器平台上运行您的应用程序,您需要采用异步方法上传文件。根据您的应用程序,您可能希望直接从浏览器将其上传到您的存储提供商(例如 AWS S3)。这样做的好处是,您可以完全避免文件触及您的服务器。这样做不仅可以更安全(因为您可以避免潜在的恶意文件在您的服务器上被处理),而且还可以提高性能(因为您不需要先将文件上传到您的服务器),并允许您避免 6MB 的限制。
虽然本文中介绍的一般原则可以应用于直接将文件上传到您的存储提供商,但我们将重点关注先将文件上传到您的服务器,然后将其移动到您的存储提供商。但是,如果您使用的是 Laravel Vapor,您可以查看 文档,以了解有关如何直接将文件上传到您的 AWS S3 存储桶的更多信息。
在前端设置 FilePond
现在我们已经了解了异步文件上传流程,让我们来看看如何在 Laravel 应用程序的前端设置 FilePond。
FilePond 提供了多种适配器,可用于不同的框架,例如 Vue、React 和 Angular。但是,在这篇文章中,我们将只使用原生 JavaScript 适配器。
我们将假设我们正在使用 Vite 编译资产的全新 Laravel 安装上工作。
让我们来看一个基本的例子。假设我们正在构建一个 CSV 导入功能,允许用户上传包含产品详细信息的 CSV 文件,这些文件将在我们的 Web 应用程序中创建。
首先,让我们创建一个非常基础的 Blade 视图,其中包含一个包含单个 "file" input
字段的表单。
<!DOCTYPE html><html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>FilePond Tutorial</title> <meta name="csrf-token" content="{{ csrf_token() }}"> @vite('resources/js/app.js') </head> <body> <form action="{{ route('products.import') }}" method="POST"> @csrf <input type="file" name="csv" class="filepond"/> <button type="submit">Import Products</button> </form> </body></html>
现在,让我们通过运行以下命令通过 NPM 安装 FilePond。
npm i filepond --save
然后,我们可以打开我们的 resources/js/app.js
文件,并在我们的 input
字段上添加功能以启用 FilePond。
import * as FilePond from 'filepond';import 'filepond/dist/filepond.min.css'; const inputElement = document.querySelector('input[type="file"].filepond'); const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content'); FilePond.create(inputElement).setOptions({ server: { process: './uploads/process', headers: { 'X-CSRF-TOKEN': csrfToken, } }});
让我们快速看一下上面代码中做了什么。首先,我们导入了提供所需功能和样式的 FilePond JavaScript 和 CSS 文件。
然后,我们继续找到要转换为 FilePond 字段的 input
字段。注意我们如何在查询选择器中添加了 filepond
类。这样,我们就可以区分要转换为 FilePond 字段的 input
字段和可能不希望转换的字段。
然后,我们从添加到 Blade 视图的 meta
标签中获取 CSRF 令牌。这样做是为了将其传递给 FilePond,以便它在尝试上传文件时将其发送到我们的服务器。如果没有添加它,在尝试上传文件时将收到 HTTP 419 错误响应。
然后,我们创建了 FilePond 实例,并指定当我们想要上传新文件时,它应该发送到我们服务器上的 /uploads/process
URL。FilePond 还提供了指定用于删除临时上传文件的 URL 的功能,但我们不会在本教程中使用此功能。
前端现在应该准备就绪。如果用户选择了一个 CSV 文件,它将被发送到 /uploads/process
URL 并临时存储。表单中的隐藏 csv
字段将填充我们临时存储文件的路径。
在后端设置 FilePond
我们现在可以设置 Laravel 应用程序的后端来处理来自 FilePond 的文件上传。为此,我们需要创建一个负责临时存储上传文件的路由和控制器。
正如我之前提到的,FilePond 确实提供了以块上传文件的能力。但出于本教程的目的,我们将保持简单,只关注单个请求中的文件上传。
我们首先通过运行以下命令创建一个新的 FileUploadController
。
php artisan make:controller FileUploadController
然后,我们可以向控制器添加一个 process
方法,该方法处理文件上传并将文件存储在存储的 tmp
目录中。
declare(strict_types=1); namespace App\Http\Controllers; use Illuminate\Http\Request;use Illuminate\Http\UploadedFile;use Illuminate\Support\Str; final class FileUploadController extends Controller{ public function process(Request $request): string { // We don't know the name of the file input, so we need to grab // all the files from the request and grab the first file. /** @var UploadedFile[] $files */ $files = $request->allFiles(); if (empty($files)) { abort(422, 'No files were uploaded.'); } if (count($files) > 1) { abort(422, 'Only 1 file can be uploaded at a time.'); } // Now that we know there's only one key, we can grab it to get // the file from the request. $requestKey = array_key_first($files); // If we are allowing multiple files to be uploaded, the field in the // request will be an array with a single file rather than just a // single file (e.g. - `csv[]` rather than `csv`). So we need to // grab the first file from the array. Otherwise, we can assume // the uploaded file is for a single file input and we can // grab it directly from the request. $file = is_array($request->input($requestKey)) ? $request->file($requestKey)[0] : $request->file($requestKey); // Store the file in a temporary location and return the location // for FilePond to use. return $file->store( path: 'tmp/'.now()->timestamp.'-'.Str::random(20) ); }}
您可能已经注意到,我们还添加了对接受多个文件上传的表单字段的支持。我们将在本文后面介绍如何在前端设置 FilePond 以支持多个文件上传。
如果用户将文件上传到此控制器,将返回类似以下内容的字符串。
tmp/1678198256-88eXsQV7XB2RU5zXdw0S/9A4eK5mRLAtayW78jhRo3Lc3WdSSrsihpVHhMvzr.png
然后,我们可以在我们的 web.php
文件中注册 /uploads/process
路由,如下所示。
use App\Http\Controllers\FileUploadController;use Illuminate\Support\Facades\Route; Route::post('uploads/process', [FileUploadController::class, 'process'])->name('uploads.process');
您的应用程序现在应该成功上传文件并将其存储在临时目录中。
在控制器中访问上传的文件
现在我们已经在前端设置了 FilePond,并添加了在后端临时存储文件的功能,现在我们可以来看看在提交表单时如何在控制器中访问上传的文件。
我们首先创建一个新的控制器,负责从 CSV 文件中导入产品。我们可以通过运行以下命令来实现。
php artisan make:controller ImportProductController -i
然后,我们可以更新新创建的 ImportProductController
以处理文件导入。
declare(strict_types=1); namespace App\Http\Controllers; use App\Services\ProductImportService;use Illuminate\Http\File;use Illuminate\Http\RedirectResponse;use Illuminate\Http\Request;use Illuminate\Support\Facades\Storage; final class ImportProductController extends Controller{ public function __invoke( Request $request, ProductImportService $productImportService ): RedirectResponse { $validated = $request->validate([ 'csv' => 'required|string', ]); // Copy the file from a temporary location to a permanent location. $fileLocation = Storage::putFile( path: 'imports', file: new File(Storage::path($validated['csv'])) ); $productImportService->import( csvLocation: $fileLocation ); return redirect() ->route('products.index') ->with('success', 'Products imported successfully'); }}
让我们来看看上面的控制器方法中做了什么。
首先,我们添加了 ProductImportService
类的类型提示,以便它将从服务容器中解析出来供我们在控制器方法中使用。这不是我们将在这篇文章中讨论的类,但我们可以假设它负责从 CSV 文件中导入产品。
我们还验证了请求是否包含 csv
字符串字段。我们将在本文后面介绍如何改进此验证。
接下来,我们将文件从其临时位置复制到永久位置,以便我们可以将其传递给 ProductImportService
对象。
完成所有这些操作后,我们将返回一个重定向响应到产品索引页面,并显示成功消息。
我们现在可以在我们的 web.php
文件中注册 ImportProductController
的路由,如下所示。
use App\Http\Controllers\ImportProductController; Route::post('products/import', ImportProductController::class)->name('products.import');
上传图片
FilePond 提供了一个非常方便的 FilePondPluginImagePreview
插件,允许我们显示用户选择上传的图片的预览。我认为这是一个非常好的功能,看起来很棒。它还向用户提供了关于他们选择上传的文件的反馈,以便他们可以确认它是正确的文件。
要使用 FilePondPluginImagePreview
插件,我们可以通过运行以下命令通过 NPM 安装它。
npm i filepond-plugin-image-preview --save
安装完毕后,我们就可以将以下几行导入到我们的 app.js
文件中。
import FilePondPluginImagePreview from 'filepond-plugin-image-preview';import 'filepond-plugin-image-preview/dist/filepond-plugin-image-preview.css';
接下来,我们可以使用 registerPlugin
方法将插件注册到 FilePond。
FilePond.registerPlugin(FilePondPluginImagePreview);
添加这些行后,您的代码可能看起来像这样。
import * as FilePond from 'filepond';import 'filepond/dist/filepond.min.css';import FilePondPluginImagePreview from 'filepond-plugin-image-preview';import 'filepond-plugin-image-preview/dist/filepond-plugin-image-preview.css'; const inputElement = document.querySelector('input[type="file"].filepond'); const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content'); FilePond.registerPlugin(FilePondPluginImagePreview); FilePond.create(inputElement).setOptions({ server: { process: './uploads/process', headers: { 'X-CSRF-TOKEN': csrfToken, } }, allowMultiple: true,});
就是这样!您现在应该有一个可以正常工作的 FilePond 组件,允许您上传图片并预览它们。
上传多个文件
可能会有时您希望一次在一次表单提交中上传多个文件。例如,您可能希望为单个产品上传多张图片。
为此,我们可以向输入元素添加 multiple
属性。
<input type="file" name="csv[]" class="filepond" multiple/>
然后,我们可以将 allowMultiple: true
传递给 setOptions
方法。
FilePond.create(inputElement).setOptions({ server: { process: './uploads/process', fetch: null, revert: null, headers: { 'X-CSRF-TOKEN': csrfToken, } }, allowMultiple: true,});
就是这样!我们已经确保我们的 FileUploadController
可以处理多个文件,因此我们不需要对其进行任何更改。
如果用户尝试上传两个文件,将向服务器发出两个单独的请求以存储这些文件。然后,将向表单添加两个 csv[]
隐藏字段,其中包含上传文件的名称。
注意我们如何需要使用 csv[]
而不是 csv
。这是因为如果我们使用 csv
,我们每次提交表单时只能发送一个文件路径。通过使用 csv[]
,我们可以发送多个文件路径,然后在控制器中将其访问为字符串数组。
更进一步
现在我们已经了解了如何在 Laravel 应用程序中使用 FilePond 上传文件,让我们来看看您可能还想做的一些其他事情。
验证
Filepond 提供了一些助手,您可以使用它们向文件上传组件添加验证,例如 data-max-file-size
。您可以将这些验证助手添加到您的输入元素,如下所示。
<input type="file" name="csv" class="filepond" data-max-file-size="3MB"/>
但是,请记住,客户端验证主要用于 UI/UX 目的,而不是安全性。您还应该始终在服务器端验证您的数据,以确保数据有效。
因此,在尝试处理文件之前,在提交表单后验证文件非常重要。
例如,假设我们提供了允许用户更新其个人资料图片的功能。您不希望此字段接受 CSV 文件。相反,我们希望确保文件是图片。
因此,让我们看看如何编写验证规则以确保上传的文件有效。我们将通过运行以下命令创建一个新的验证规则。
art make:rule ValidFileUpload
我们可以将我们的 ValidFileUpload
规则更新为如下所示。
declare(strict_types=1); namespace App\Rules; use Closure;use Illuminate\Contracts\Validation\ValidationRule;use Illuminate\Support\Facades\Storage; final class ValidFileUpload implements ValidationRule{ public function __construct( private readonly array $validMimeTypes ) { // } public function validate(string $attribute, mixed $value, Closure $fail): void { if (!Storage::exists($value)) { $fail('The file does not exist.'); } if (!in_array(Storage::mimeType($value), $this->validMimeTypes, true)) { $fail('The file is not a valid mime type.'); } }}
在 ValidFileUpload
类中,我们定义了一个接受有效 MIME 类型数组的构造函数。
在 validate
方法中,我们添加了两个检查。
- 检查文件是否存在于存储中。
- 检查文件的 MIME 类型是否在有效 MIME 类型数组中。
然后,我们可以像这样使用此规则进行验证。
use App\Rules\ValidFileUpload; $validated = $request->validate([ 'csv' => ['required', 'string', new ValidFileUpload(['text/csv'])],]);
您甚至可以进一步进行此验证,并添加额外的断言,例如检查文件大小是否不超过特定大小。
清理临时文件
随着时间的推移,您的 tmp
文件夹中会累积大量临时文件。因此,您可能希望编写一个 Artisan 命令,您可以将其安排为定期运行以删除 tmp
文件夹中比某个时间更旧的文件夹。
让我们看看如何做到这一点。我们将通过运行以下命令创建一个新的 DeleteTempUploadedFiles
命令。
art make:command DeleteTempUploadedFiles
然后,我们可以将我们的 DeleteTempUploadedFiles
命令更新为类似如下所示。
declare(strict_types=1); namespace App\Console\Commands; use Carbon\Carbon;use Illuminate\Console\Command;use Illuminate\Support\Facades\Storage; final class DeleteTempUploadedFiles extends Command{ protected $signature = 'app:delete-temp-uploaded-files'; protected $description = 'Delete temporary uploaded files older than 24 hours.'; public function handle(): void { foreach (Storage::directories('tmp') as $directory) { $directoryLastModified = Carbon::createFromTimestamp(Storage::lastModified($directory)); if (now()->diffInHours($directoryLastModified) > 24) { Storage::deleteDirectory($directory); } } }}
在上面的命令中,我们遍历存储的 tmp
文件夹中的所有目录,并检查目录是否比 24 小时更旧。如果是,我们将删除该目录。
然后,我们可以通过将其添加到 app/Console/Kernel.php
类中的 schedule
方法中,将此命令安排为每小时运行一次。
namespace App\Console; use App\Console\Commands\DeleteTempUploadedFiles;use Illuminate\Console\Scheduling\Schedule;use Illuminate\Foundation\Console\Kernel as ConsoleKernel; class Kernel extends ConsoleKernel{ /** * Define the application's command schedule. */ protected function schedule(Schedule $schedule): void { $schedule->command(DeleteTempUploadedFiles::class)->hourly(); } // ...}
假设您已运行应用程序的调度程序,这意味着每小时,您的应用程序将删除所有比 24 小时更旧的临时目录。这意味着您的 tmp
文件夹应该只包含可能最近使用过或当前正在使用的文件。
根据您的应用程序,您可能希望更改目录可以存在的时间长度或删除它们的频率。
测试您的代码
如果您之前阅读过我的任何文章,您就会知道我非常喜欢测试。为您的代码编写测试非常重要,尤其是在将代码用于生产环境时。这有助于您对代码的正确性充满信心,并使将来更容易进行更改。
让我们看看如何为我们的 `FileUploadController` 中的文件上传功能编写一些基本测试。从总体上讲,我们要测试:
- 如果表单字段支持单个文件,则文件可以存储在 `tmp` 文件夹中。
- 如果表单字段支持多个文件,则文件可以存储在 `tmp` 文件夹中。
- 如果请求中没有传递文件,则会返回错误。
- 如果请求中传递了多个文件,则会返回错误。
我们可以编写一些基本测试来涵盖这些场景,如下所示:
declare(strict_types=1); namespace Tests\Feature\Controllers; use Illuminate\Http\UploadedFile;use Illuminate\Support\Facades\Storage;use Illuminate\Support\Str;use Tests\TestCase; final class FileUploadControllerTest extends TestCase{ protected function setUp(): void { parent::setUp(); // Use a fake storage driver so we don't store files on the real disk. Storage::fake(); // Freeze time and define how `Str::random` should work. This allows us // to explicitly check that the file is stored in the correct location // and is being named correctly. $this->freezeTime(); Str::createRandomStringsUsing(static fn (): string => 'random-string'); } /** @test */ public function file_can_be_temporarily_uploaded_for_a_single_file_field(): void { $file = UploadedFile::fake()->image('avatar.png'); $expectedFilePath = 'tmp/'.now()->timestamp.'-random-string'; $this->post(route('uploads.process'), [ 'avatar' => $file, ]) ->assertOk() ->assertSee($expectedFilePath); Storage::assertExists($expectedFilePath); } /** @test */ public function file_can_be_temporarily_uploaded_for_a_multiple_file_field(): void { $file = UploadedFile::fake()->image('avatar.png'); $expectedFilePath = 'tmp/'.now()->timestamp.'-random-string'; $this->post(route('uploads.process'), [ 'avatar' => [ $file ], ]) ->assertOk() ->assertSee($expectedFilePath); Storage::assertExists($expectedFilePath); } /** @test */ public function error_is_returned_if_no_file_is_passed_in_the_request(): void { $this->post(route('uploads.process')) ->assertStatus(422); } /** @test */ public function error_is_returned_if_more_than_one_file_is_passed_in_the_request(): void { $file = UploadedFile::fake()->image('avatar.png'); $this->post(route('uploads.process'), [ 'avatar' => $file, 'invalid' => $file, ]) ->assertStatus(422); }}
虽然这些测试相当基础,但它们应该为您编写自己的文件上传功能测试提供一个良好的起点。您可能希望扩展这些测试以检查是否返回了正确的错误消息。或者,如果您在文件上传流程中添加了身份验证和授权,您可能希望检查只有某些用户才能上传文件。
您可能还想为您的验证规则添加测试。这可以帮助您在将来更自信地添加更多断言,如果您决定使您的验证更严格。
结论
在本文中,我们介绍了如何使用 FilePond 在您的 Laravel 应用程序中异步上传文件。我们还介绍了如何删除临时文件、验证上传的文件以及编写测试以确保文件上传正常工作。
希望现在您应该对在自己的 Laravel 项目中实施这种相同的方法来为您的应用程序添加文件上传功能充满信心。