在Laravel中使用FilePond上传文件及测试用例

2023-06-01 00:00:00 laravel 测试 上传文件

什么是FilePond?

FilePond 是一个 JavaScript 库,允许你在你的 Web 应用程序中上传文件。

官网:

https://pqina.nl/filepond/

它为上传文件提供了一个简单的、可访问的、具有视觉吸引力的界面。

它可以为你建立自己的文件上传功能提供一个很好的起点,而不需要太担心界面的风格和可访问性。


FilePond允许你同步或异步上传。

这意味着你既可以在提交表单时在一个请求中上传文件(同步),也可以在提交表单前在单独的请求中上传文件(异步)。使用异步方法通常可以提供更好的用户体验,因为用户可以在上传文件的同时继续填写表单中的其他字段。为了本文的目的,我们将专注于异步方法。

还有一些插件,你可以与 FilePond 一起使用,以增加额外的功能。

例如,你可以使用FilePondPluginImagePreview插件来显示正在上传的图片的预览。

事实上,我们将在本文的后面看一下这个插件。


FilePond还提供了分块上传文件的能力。

如果你想上传较大的文件,而这些文件可能太大,无法在一次请求中上传,这就很有用。

然而,在本教程中,我们将只关注在单个请求中上传文件。

如果你想了解更多关于如何分块上传文件的信息,你可以查看 FilePond 文档。

https://pqina.nl/filepond/docs/api/server/#process-chunks


FilePond的异步文件上传流程是如何工作的?

为了解释用户如何使用 FilePond 在表单中异步上传文件,让我们看一下一个例子。

我们设想用户正在更新他们的个人资料,使用一个允许他们更新姓名和个人资料图片的表单。

我们假设用户想上传一个avatar.png文件作为他们新的个人资料图片。

流程步骤:

用户点击表单上FilePond组件中的 "浏览"。

传统的文件上传对话框出现,这样用户就可以选择他们想从设备中上传的avatar.png文件。

一旦文件被选中,FilePond就会使用POST请求将avatar.png文件作为multipart/form-data发送到服务器。

服务器(我们的Laravel应用程序)然后将文件保存到一个临时的、独特的位置。例如,它可能将文件保存到tmp/12345abcdef/avatar.png。

然后,服务器将唯一的位置(在这种情况下,12345abcdef/avatar.png)以文本/平原响应的方式返回给FilePond。

FilePond在表单上的一个隐藏输入字段中添加这个唯一的位置。

当步骤3-6运行时,用户可以在文件上传时继续填写表格的其余部分。

一旦文件完成上传,用户就可以提交表单(现在包括隐藏的输入字段)。

服务器(我们的Laravel应用程序)使用唯一的位置,将文件从临时存储位置移动到它的预定位置。

例如, 它可能会将文件从tmp/12345abcdef/avatar.png移动到avatars/user-1.png。

现在我们已经对异步文件上传的工作原理有了一个简单的概念,让我们看看它们与表单中的同步文件上传相比有什么优势。


同步文件上传阻碍了UI

通常情况下,在一个Web应用程序中,当使用同步方法上传文件时,用户可能会点击一个表单中的 "文件上传 "字段。然后他们可能选择他们想上传的文件。一旦他们选择了他们的文件,在用户提交表单之前,该文件实际上并没有上传到服务器(与我们上面看到的异步方法不同)。

这意味着在提交表单时,文件会在一个请求中被上传(与表单的其他字段一起)。


使用这种同步方法有时会阻止用户与用户界面进行交互。

如果文件很大,需要很长时间才能上传,情况尤其如此,因为用户对正在发生的事情不会有太多反馈。


这与异步文件上传的工作方式不同。

在异步方法中,在提交表单之前,文件已经在一个单独的请求中被上传到服务器(或者正在被上传)。


无服务器平台的同步文件上传问题

如果你在无服务器平台上运行你的应用程序,如AWS Lambda,同步文件上传很快就会出现问题。

在写这篇文章时,根据AWS Lambda文档,一个请求的最大尺寸是6MB。

这意味着你需要确保你表单中的数据大小(包括你上传的文件)不超过这个限制。

这意味着,如果你打算在无服务器平台上运行你的应用程序,你需要采用异步方法来上传文件。

根据你的应用,你可能想从你的浏览器直接将它们上传到你的存储提供商(如AWS S3)。

这样做的结果是,这意味着你可以完全避免文件接触到你的服务器。

这不仅可以更安全(因为你避免了潜在的恶意文件在你的服务器上被处理),而且还可以更高效(因为你不需要先把文件上传到你的服务器),并允许你避免6MB的限制。

虽然本文所涉及的一般原则可以应用于直接将文件上传到你的存储供应商,但我们将专注于先将文件上传到你的服务器,然后再将它们移动到你的存储供应商。

然而, 如果你使用的是Laravel Vapor, 你可以查看文档来了解更多关于如何直接上传文件到你的AWS S3桶。


在前端设置FilePond

现在我们已经了解了异步文件上传流程,让我们来看看如何在Laravel应用程序的前端设置FilePond。

FilePond提供了几个适配器,你可以在不同的框架中使用,如Vue,React,和Angular。

然而,在这篇文章中,我们将只使用vanilla JavaScript适配器。

我们将假设我们是在一个新的Laravel安装上工作,并使用Vite来编译资产。

让我们举一个基本的例子。我们正在建立一个CSV导入功能,允许用户上传一个包含产品细节的CSV文件,这些产品将在我们的网络应用中创建。


首先,让我们做一个非常基本的Blade视图,它包含一个带有单个 "文件 "输入字段的表单:

<!DOCTYPE html>
<html>
    <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"/>
 
            <button type="submit">Import Products</button>
        </form>
    </body>
</html>

现在,让我们通过NPM运行以下命令来安装FilePond:

npm i filepond --save

然后,我们可以打开我们的resources/js/app.js文件,添加功能,在我们的输入栏上启用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 字段的输入字段。

注意我们是如何将filepond类添加到查询选择器中的。

这是为了让我们能够区分我们想转换为 FilePond 字段的输入字段和我们可能不想转换的字段。

然后,我们从添加到Blade视图的元标签中抓取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

然后我们可以在控制器中添加一个处理方法,处理文件的上传,并将文件存储在存储区的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组件,允许你上传图片并预览它们。


上传多个文件

有的时候,你可能想在一个表单提交中一次上传多个文件。例如,你可能想为一个产品上传几个图片。

要做到这一点,我们可以在我们的输入元素中添加多重属性:

<input type="file" name="csv[]" 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[],我们可以发送多个文件路径,然后在控制器中以字符串数组的形式访问。

更进一步

现在我们已经看了如何使用FilePond在我们的Laravel应用程序中上传文件, 让我们来看看其他一些你可能想做的事情.


验证

Filepond提供了一些辅助工具,你可以用它来为你的文件上传组件添加验证,例如data-max-file-size。

你可以像这样把这些验证帮助器添加到你的输入元素中:

<input type="file" name="csv" 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类型数组。

在验证方法中,我们又添加了两个检查:

1.检查文件是否存在于存储器中。

2.检查文件的mime类型是否在有效mime类型的数组中。

然后,我们可以像这样使用这个规则来进行验证:

use App\Rules\ValidFileUpload;
 
$validated = $request->validate([
    'csv' => ['required', 'string', new ValidFileUpload(['text/csv'])],
]);

你甚至可以更进一步,增加额外的断言,例如检查文件大小是否超过一定的尺寸。


清理临时文件

随着时间的推移,大量的临时文件会在你的临时文件夹中积累起来。

因此,你可能想编写一个Artisan命令,安排定期运行,从临时文件夹中删除超过一定时间的文件夹。

让我们来看看如何做到这一点。

我们首先通过运行以下命令创建一个新的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小时的临时目录。

这意味着你的临时文件夹应该只包含最近使用过的文件,或者目前可能正在使用的文件。

根据你的应用程序,你可能想改变一个目录可以存在的时间长度,或者它们被删除的频率。


测试你的代码

如果你以前读过我的任何文章,你会知道我是一个测试的忠实粉丝。

为你的代码编写测试是很重要的,特别是当你要在生产中使用它时。

它有助于让你相信你的代码是正确工作的,并使你在未来更容易做出改变。


让我们看看如何为FileUploadController中的文件上传功能编写一些基本测试。

在高层次上,我们想测试一下:

1.如果表单字段支持单个文件,文件可以存储在tmp文件夹中。

2.如果表单字段支持多个文件,文件可以被存储在tmp文件夹中。

3.如果请求中没有传递任何文件,将返回一个错误。

4.如果请求中传递了一个以上的文件,则返回错误。

我们可以写一些基本的测试来涵盖这些情况,就像这样。

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项目中实现同样的方法,为你的应用程序添加文件上传功能。

相关文章