在 Filament 中处理批量导入
最后更新于 作者: Alex Six
每个构建的应用程序都会处理一定规模的数据。无论数据是 GitHub 存储库中的一些 Markdown 文件,还是多 TB 数据库系统中的数百万行,每天与我们的应用程序交互的用户都会这样做,以查看和操作这些数据。
当应用程序规模较小且(通常)相对较新时,数据输入看起来非常像 Filament 中表单的工作方式。如果您想向系统添加新数据,您将导航到相关表单,填写字段并提交。如果您想添加更多内容,请重复此过程。这本身没有什么错!对于大多数数据输入,这都是一个很好的解决方案!但是,当您想一次性添加大量数据,而无需花时间数百次点击表单时会发生什么?您花费大量时间点击同一个表单,就是这样。但是,有一个更好的方法!输入 CSV 上传。
使用 CSV 上传批量数据是各种应用程序使用的一种方法,可以让用户轻松上传大量数据。它们很容易从 Excel 等电子表格程序生成,并且向其中添加数据也很简单,因此用户喜欢它们!问题是,即使 CSV 在代码中易于处理,但编写用于将特定 CSV 文件导入特定数据库表的代码可能会很重复且耗时。值得庆幸的是,Filament 现在通过新的“导入操作”使该过程变得快速而轻松。
您需要什么
- 安装了 Filament 的 Laravel 应用程序
- 将从 CSV 创建的 Filament 资源及其相应的模型
- 一个
Importer
类(稍后将详细介绍)
设置应用程序
获取上下文
让我们从为将在本文中使用的应用程序提供一些背景信息开始。
我们正在开发一个应用程序,允许用户登录面板并记录他们收藏中的所有书籍。一些用户只有少量书籍,而另一些用户则拥有整个图书馆,他们希望将这些图书馆加入我们的应用程序,因此,我们被要求创建一个系统,允许这些批量用户一次性上传他们的整个收藏。
假设该应用程序具有以下 Book
模型,并且我们已经创建了一个带有 form()
和 table()
方法的 BookResource
书籍
- ID
- 用户 ID
- 标题
- 作者
设置 CSV 导入先决条件
在我们可以开始编写代码来实现导入器之前,我们需要设置一些先决条件。在幕后,Filament 的 CSV 导入系统使用两个底层 Laravel 系统:作业批次和数据库通知。此外,它还使用 Filament 提供的新表来管理和存储有关导入本身的信息。我们可以使用四个简单的命令来设置这些内容
php artisan queue:batches-tablephp artisan notifications:tablephp artisan vendor:publish --tag=filament-actions-migrations php artisan migrate
一旦这些命令成功运行并且每个底层系统都已设置完毕,我们就准备好开始构建导入!
我们在这篇文章中讨论的代码库可以在这里找到 这里,如果您想与文章一起工作或查看最终产品。存储库中的每个提交都对应于文章的以下部分之一。如果您想找到特定部分的代码,每个提交消息都将包含它所对应部分的名称。
导入 CSV
ImportAction
添加 完成先决步骤后,在 Filament 中设置 CSV 导入的第一步是在您的界面中添加 ImportAction
。通常,此按钮放置在页面的标题部分或表格的标题中。对于我们的示例,我们将我们的 ImportAction
添加到 ListBooks
页面的标题中,以便我们的用户可以在面板的“书籍”部分的上下文中选择上传他们的 CSV。
添加 ImportAction
后,我们的 ListBooks.php
文件应如下所示
<?php namespace App\Filament\Resources\BookResource\Pages; use App\Filament\Imports\BookImporter;use App\Filament\Resources\BookResource;use Filament\Actions;use Filament\Resources\Pages\ListRecords; class ListBooks extends ListRecords{ protected static string $resource = BookResource::class; protected function getActions(): array { return [ Actions\ImportAction::make() ->importer(), Actions\CreateAction::make(), ]; }}
如果您正在学习,并且将上面的代码放到您选择的编辑器中,您可能会注意到 ->importer()
方法抛出“预期 1 个参数。找到 0 个”错误。这是因为,即使我们已将 ImportAction
设置为在按钮交互时运行,我们还没有告诉操作如何导入数据。这是 Importer
类的任务。
Importer
添加 首先,什么是 Importer
?
在 Filament 中,Importer
类包含告诉 Filament 预期从上传的 CSV 文件中获取哪些列以及如何处理这些列的逻辑。这些类以与 Resource
类定义表格列非常相似的方式定义其列期望值,因此,只要您以前使用过 Filament Resource
类,您就会在这里感到宾至如归。
我们可以使用一个简单的 artisan 命令来创建一个导入器,就像 Filament 中大多数其他文件一样
php artisan make:filament-importer Book
或者,如果我们想从现有的数据库架构中生成列(谁不想这样做!),我们可以添加 --generate
标志,如下所示
php artisan make:filament-importer Book --generate
运行后,这些命令将在 app/Filament/Imports
目录中生成并放置您的 Importer
类。如果我们在项目中运行 make:filament-importer
命令(为了举例说明,不带 --generate
标志),我们现在将有一个名为 app/Filament/Imports/BookImporter.php
的文件。
让我们快速浏览一下该文件的重要部分
<?php namespace App\Filament\Imports; use App\Models\Book;use Filament\Actions\Imports\ImportColumn;use Filament\Actions\Imports\Importer;use Filament\Actions\Imports\Models\Import; class BookImporter extends Importer{ protected static ?string $model = Book::class; public static function getColumns(): array { return [ // ]; } public function resolveRecord(): ?Book { // return Book::firstOrNew([ // // Update existing records, matching them by `$this->data['column_name']` // 'email' => $this->data['email'], // ]); return new Book(); } public static function getCompletedNotificationBody(Import $import): string { $body = 'Your book import has completed and ' . number_format($import->successful_rows) . ' ' . str('row')->plural($import->successful_rows) . ' imported.'; if ($failedRowsCount = $import->getFailedRowsCount()) { $body .= ' ' . number_format($failedRowsCount) . ' ' . str('row')->plural($failedRowsCount) . ' failed to import.'; } return $body; }}
首先,我们有模型属性。Importer
使用它来了解将上传的 CSV 数据保存到哪个模型!它是一个小部件,但很重要!
<?php namespace App\Filament\Imports; use App\Models\Book;use Filament\Actions\Imports\ImportColumn;use Filament\Actions\Imports\Importer;use Filament\Actions\Imports\Models\Import; class BookImporter extends Importer{ protected static ?string $model = Book::class; public static function getColumns(): array { return [ // ]; } public function resolveRecord(): ?Book { // return Book::firstOrNew([ // // Update existing records, matching them by `$this->data['column_name']` // 'email' => $this->data['email'], // ]); return new Book(); } public static function getCompletedNotificationBody(Import $import): string { $body = 'Your book import has completed and ' . number_format($import->successful_rows) . ' ' . str('row')->plural($import->successful_rows) . ' imported.'; if ($failedRowsCount = $import->getFailedRowsCount()) { $body .= ' ' . number_format($failedRowsCount) . ' ' . str('row')->plural($failedRowsCount) . ' failed to import.'; } return $body; }}
getColumns()
方法是您将在 Importer
类中花费大部分时间的地方。它具有与 Resource
类上的 form()
和 table()
方法非常相似的 API,但它不是定义要在 Filament 界面中显示的字段和列,而是定义要从上传的 CSV 中 预期 的列,并描述如何处理这些列中的数据。我们将在后面详细介绍,但现在,只需要知道您想从 CSV 中导入的任何数据都必须以某种形式存在于此方法中。
<?php namespace App\Filament\Imports; use App\Models\Book;use Filament\Actions\Imports\ImportColumn;use Filament\Actions\Imports\Importer;use Filament\Actions\Imports\Models\Import; class BookImporter extends Importer{ protected static ?string $model = Book::class; public static function getColumns(): array { return [ // ]; } public function resolveRecord(): ?Book { // return Book::firstOrNew([ // // Update existing records, matching them by `$this->data['column_name']` // 'email' => $this->data['email'], // ]); return new Book(); } public static function getCompletedNotificationBody(Import $import): string { $body = 'Your book import has completed and ' . number_format($import->successful_rows) . ' ' . str('row')->plural($import->successful_rows) . ' imported.'; if ($failedRowsCount = $import->getFailedRowsCount()) { $body .= ' ' . number_format($failedRowsCount) . ' ' . str('row')->plural($failedRowsCount) . ' failed to import.'; } return $body; }}
接下来,我们有 resolveRecord()
方法。此方法针对 CSV 中的每一行调用,负责返回一个将用 CSV 中的数据填充的模型实例。默认情况下,它将创建一个新记录,但我们可以在此方法中更改逻辑以更新现有记录。对这种更改的一个简单快捷方法是取消注释 Book::firstOrNew()
块,它将搜索现有记录,如果找到则更新。否则,它将从 CSV 中的这一行创建一个新记录。
<?php namespace App\Filament\Imports; use App\Models\Book;use Filament\Actions\Imports\ImportColumn;use Filament\Actions\Imports\Importer;use Filament\Actions\Imports\Models\Import; class BookImporter extends Importer{ protected static ?string $model = Book::class; public static function getColumns(): array { return [ // ]; } public function resolveRecord(): ?Book { // return Book::firstOrNew([ // // Update existing records, matching them by `$this->data['column_name']` // 'email' => $this->data['email'], // ]); return new Book(); } public static function getCompletedNotificationBody(Import $import): string { $body = 'Your book import has completed and ' . number_format($import->successful_rows) . ' ' . str('row')->plural($import->successful_rows) . ' imported.'; if ($failedRowsCount = $import->getFailedRowsCount()) { $body .= ' ' . number_format($failedRowsCount) . ' ' . str('row')->plural($failedRowsCount) . ' failed to import.'; } return $body; }}
最后,我们有 `getCompletedNotificationBody()` 方法。此方法决定 CSV 导入完成后在 Filament 通知正文中显示的文本。除了偶尔调整模型名称外,您几乎不需要更改这里的内容。
现在我们已经添加了新的 `BookImporter` 类,我们需要返回并确保已将其添加到我们之前提到的 `ImportAction` 中。我们可以像这样简单地更新 `ImportAction`
<?php namespace App\Filament\Resources\BookResource\Pages; use App\Filament\Imports\BookImporter; use App\Filament\Resources\BookResource; use App\Filament\Imports\BookImporter; use Filament\Actions; use Filament\Resources\Pages\ListRecords; class ListBooks extends ListRecords { protected static string $resource = BookResource::class; protected function getActions(): array { return [ Actions\ImportAction::make()- ->importer(), + ->importer(BookImporter::class), Actions\CreateAction::make(), ]; } }
定义 `Importer` 列
到目前为止,我们已经添加了一个操作按钮来触发导入,并且定义了 `ImportAction` 将使用的 `BookImporter`,但我们尚未告诉 Filament 预期从 CSV 文件中获得哪些类型的数据。为此,我们需要从 `getColumns()` 方法中返回一个 `ImportColumn` 对象数组。我们将假设我们的 `Book` 模型上的每个属性(除了时间戳)在我们的 CSV 中都有一个相应的列。这意味着我们需要为 `user_id`、`title` 和 `author` 创建一个 `ImportColumn`。
让我们先将 `ImportColumn` 对象添加到 `getColumns()` 方法中。在本节的其余部分,我将删除与之无关的方法和命名空间声明,但它们仍然存在于实际类中。
<?php // Namespaces class BookImporter extends Importer{ protected static ?string $model = Book::class; public static function getColumns(): array { return [ ImportColumn::make('user_id'), ImportColumn::make('title'), ImportColumn::make('author'), ]; } // Other methods}
将这三个 `ImportColumn` 对象添加到 `getColumns()` 方法后,您就可以导入第一个 CSV 了!以您喜欢的方式创建一个小的 CSV 文件来测试上传。我建议使用一行数据,其格式类似于以下内容
user_id | title | author |
---|---|---|
1 | test | John Doe |
创建完 CSV 文件后,导航到 Filament 中的 `Book` 表视图,然后单击我们之前创建的 `导入书籍` 操作。您会看到一个 Filepond 文件上传器。CSV 文件在 Filepond 文件上传器中处理完成后,您将在模态框中看到一个名为“列”的字段集。这些选择字段是导入过程中的“映射器”。每个字段的标签对应于我们之前创建的 `ImportColumn` 对象之一。每个标签旁边的选择字段对应于上传的 CSV 的哪一列将把它的数据映射到 Filament 在导入过程中处理的每一行的模型中。如果您的用户上传的 CSV 中包含正确的数据,但列标题与您的预期不完全一致,这将特别有用。例如,如果一个用户上传了一个带有 `User` 而不是 `user_id` 的 CSV,他们仍然可以手动将该列映射到 `Book` 模型上的 `user_id` 属性。
如果您的 CSV 标题与我的标题相同,您会发现选择字段已经用适当的 CSV 列填充。这是因为 Filament 会尝试通过名称自动确定哪个 `ImportColumn` 与哪个 CSV 标题相匹配,作为默认行为。
此时,一旦所有列映射都已选择,您就可以单击“导入”。如果您的项目中一切都设置正确,您将看到新的 `Book` 在 Filament 中的表格中显示!
恭喜!您已成功从 CSV 中导入大量数据!但我们还没有完成,我们还有几种方法可以改进我们已经构建的内容。
增加一些细节
虽然我们已经成功从 CSV 中导入数据,但在将此功能发布到实际环境中之前,您还需要了解一些必要的批量导入功能。
必需的映射
目前,根据我们设置 `ImportColumn` 数组的方式,我们的列都不需要映射到 CSV 中的列。这意味着我们可以将任何映射留空,这会导致 Laravel 在尝试保存没有三个必需参数(`user_id`、`title` 和 `author`)的 `Book` 模型时抛出错误。
幸运的是,Filament 提供了一种简单的解决方法。通过在每个 `ImportColumn` 对象上添加 `requiredMapping()` 方法,Filament 将不允许用户开始导入,直到每列都被映射。
<?php // Namespaces class BookImporter extends Importer { protected static ?string $model = Book::class; public static function getColumns(): array { return [- ImportColumn::make('user_id'), + ImportColumn::make('user_id') + ->requiredMapping(),- ImportColumn::make('title'), + ImportColumn::make('title') + ->requiredMapping(),- ImportColumn::make('author'), + ImportColumn::make('author') + ->requiredMapping(), ]; } // Other methods }
列验证
除了没有任何必需的映射之外,我们当前的导入解决方案也不包含任何验证。就像传入的请求应该始终被验证以确保恶意用户不会试图破坏我们的系统一样,我们也应该始终验证我们的批量导入字段。
为此,我们可以将 `rules()` 方法添加到 `getColumns()` 方法中的每个 `ImportColumn` 中,并将我们从 Laravel 的 `FormRequest` 类中熟知的验证规则传递进去。例如,以下是一些我会添加到我们现有的 `ImportColumn` 对象中的验证规则
<?php // Namespaces class BookImporter extends Importer { protected static ?string $model = Book::class; public static function getColumns(): array { return [ ImportColumn::make('user_id')+ ->rules(['required', 'exists:users,id']) ->requiredMapping(), ImportColumn::make('title')+ ->rules(['required', 'max:255']) ->requiredMapping(), ImportColumn::make('author')+ ->rules(['required', 'max:255']) ->requiredMapping(), ]; } // Other methods }
处理关系
我们的 `getColumns()` 方法开始看起来好多了,但我们还可以采取一个简单的步骤来利用 Laravel 的模型并清理此代码。在 Filament 中的多个地方,我们能够利用我们已经在模型上定义的 Eloquent 关系来填充选择下拉菜单、访问相关数据和保存相关模型。现在,我们也可以使用它们来清理我们的批量导入逻辑!
以 `user_id` 列为例。我对它有两个主要不满。首先,我不喜欢我们明确保存 `user_id` - 我更希望告诉 Laravel 使用我已有的关系逻辑来为我保存用户。其次,我不喜欢我必须指定规则,这些规则基本上是 Laravel 本身已经可以完成的关系检查的重复。
谢天谢地,我们可以通过用 `relationship()` 替换 `user_id` `ImportColumn` 上的 `rules()` 方法来解决这两个问题。
<?php // Namespaces class BookImporter extends Importer { protected static ?string $model = Book::class; public static function getColumns(): array { return [- ImportColumn::make('user_id') - ->rules(['required', 'exists:users'])+ ImportColumn::make('user') + ->relationship() ->requiredMapping() ImportColumn::make('title') ->rules(['required', 'max:255']) ->requiredMapping(), ImportColumn::make('author') ->rules(['required', 'max:255']) ->requiredMapping(), ]; } // Other methods }
在上面的代码差异中,除了用 `relationship()` 替换 `rules()` 方法外,我们还更改了 `ImportColumn` 的名称,使其与我们在 `Book` 模型上定义的关系保持一致(在本例中为 `user`)。
这要好得多,但我仍然可以想到一个需要解决的小问题。用户仍然需要在每行的 CSV 中输入用户的 ID。在许多系统中,用户很难知道他们自己的(以及彼此的)用户 ID。相反,我们应该使用更容易识别且仍然唯一的标识符,例如电子邮件地址!
现在,如果我们尝试这样做,我们会从 Laravel 收到一个错误,因为 `users` 表中没有 ID 像电子邮件地址一样。但是,Filament 为我们提供了一种解决此问题的方法!
<?php // Namespaces class BookImporter extends Importer { protected static ?string $model = Book::class; public static function getColumns(): array { return [ ImportColumn::make('user')- ->relationship() + ->relationship(resolveUsing: 'email') ->requiredMapping() ImportColumn::make('title') ->rules(['required', 'max:255']) ->requiredMapping(), ImportColumn::make('author') ->rules(['required', 'max:255']) ->requiredMapping(), ]; } // Other methods }
现在,当 Filament 尝试导入每一行时,它不会再查找用于将 `Book` 与其 `User` 链接的主键,而是会查找用户的电子邮件地址!
还有更多要发现的
就这样,您现在已经成功地在短短几行代码中实现了完整的批量导入系统!但我们这里只是触及了表面。批量导入系统中还有很多东西有待发现:提供示例 CSV 数据、自定义导入作业、强制转换状态等等!请查看 导入操作文档,了解有关如何自定义自己的导入操作的更多详细信息。
和往常一样,我们很乐意听到您对批量导入的意见!这是我们 v3.1 中最喜欢的功能之一,我们希望它也能很快成为您最喜欢的功能之一!
Zillow 的高级软件开发工程师。@ Filament 的开发者关系负责人。导师。热爱 Laravel、Filament、JS 和 Tailwind。Vim 用户。有一只可爱的柯基犬。在 https://alexandersix.com 上发布帖子