搞定 Laravel Excel 动态列: MySQL 导出用户分组矩阵
2025-04-28 02:06:38
用 Laravel 和 Maatwebsite/Excel 从 MySQL 数据生成 Excel 矩阵
碰到的问题挺常见的:我们想从数据库里拉取数据,然后用 maatwebsite/excel
这个包在 Laravel 9 里生成一个 Excel 文件。目标格式长这样:
用户姓 | 用户名 | 分组 A | 分组 B | 分组 X |
---|---|---|---|---|
Doe | John | X | X | |
Doe | Jane | X |
你有三张表:users
(用户表)、groups
(分组表),还有一张 group_user
(用户分组关联表),用来连接 user_id
和 group_id
,因为一个用户可以属于多个分组。
你已经成功搞定了动态表头,查询了分组列表,并在前面加上了“用户姓”和“用户名”。
// App\Exports\MyUsersExport.php
use App\Models\Group;
use Maatwebsite\Excel\Concerns\WithHeadings;
class MyUsersExport implements WithHeadings
{
public function headings(): array
{
$header = [];
// 假设你的 Group 模型是 App\Models\Group
$groupList = Group::orderBy('name')->get(); // 获取所有分组,建议排序保证列顺序稳定
foreach($groupList as $group) {
$header[] = $group->name; // 直接用分组名
}
// 把固定的列名插到最前面
array_unshift($header,"用户姓", "用户名");
return $header;
}
// ... 其他方法
}
(注意:这里的代码和你原来的有点不一样,直接获取所有 Group 模型实例,并且假设 Group 模型里有个 name
字段。建议加上 orderBy
来保证每次导出的列顺序一致。)
接着,你也查询出了用户数据,包含了他们的姓名和所属分组:
// App\Exports\MyUsersExport.php
use App\Models\MyUser; // 假设你的用户模型是这个
use Maatwebsite\Excel\Concerns\FromArray; // 或者 FromCollection
class MyUsersExport implements FromArray // 或者 FromCollection, WithHeadings
{
// ... headings() 方法 ...
public function array(): array // 如果用 FromArray
{
$datas = [];
$myUsers = MyUser::with(['groupMatching.group'])->get(); // 预加载关联关系和关联的分组信息
foreach ($myUsers as $myUser) {
$userGroups = [];
foreach ($myUser->groupMatching as $groupMatch) {
// 确保 groupMatching 关联到了 group 模型,并且 group 模型有 name 属性
if ($groupMatch->group) {
$userGroups[] = $groupMatch->group->name;
}
}
// 按照你之前的结构构建,但这里只收集信息,后面处理
$datas[$myUser->id] = [
"lastname" => $myUser->lastname, // 注意这里用 lastname
"firstname" => $myUser->firstname, // 这里用 firstname
"groups" => $userGroups // 存储用户拥有的分组名数组
];
}
// $datas 结构现在类似:
// [
// 4 => ["lastname" => "Doe", "firstname" => "John", "groups" => ["Group A", "Group X"]],
// 5 => ["lastname" => "Doe", "firstname" => "Jane", "groups" => ["Group X"]]
// ]
// 问题来了:怎么把这个 $datas 转换成 Excel 需要的二维数组,
// 即 ['Doe', 'John', 'X', '', 'X'] 这样的行?
// 这就是接下来要解决的。
// 直接返回这个 $datas 是不行的,格式不对。
// return $datas; // 不能直接这样返回
// 需要进一步转换... (见下面的解决方案)
return $this->transformData($datas); // 调用一个转换方法
}
// 或者用 FromCollection
public function collection() // 如果用 FromCollection
{
$myUsers = MyUser::with(['groupMatching.group'])->get();
// 同样需要转换,但可以直接在 Collection 上操作 (见解决方案一)
// return $myUsers; // 不能直接返回这个
// 需要进一步转换... (见下面的解决方案)
return $this->mapUsersToRows($myUsers); // 调用一个转换方法
}
// 辅助转换方法(具体实现在下面)
protected function transformData(array $rawData): array
{
// ... 实现数据转换逻辑 ...
return [];
}
protected function mapUsersToRows($users)
{
// ... 实现数据转换逻辑 ...
return collect([]); // 返回一个 Collection
}
}
你拿到的 $datas
数组大概是这样:
array:2 [▼
4 => array:3 [▼
"lastname" => "Doe"
"firstname" => "John"
"groups" => array:2 [▼ // 注意这里和原问题略有不同,假设只有两个组了
0 => "Group A"
1 => "Group X"
]
]
5 => array:3 [▼
"lastname" => "Doe"
"firstname" => "Jane"
"groups" => array:1 [▼
0 => "Group X"
]
]
]
下一步该咋办呢?怎么才能在匹配的列名下显示一个 'X',得到目标效果?
你试着找 map
函数和例子,但没找到方向。我们来看看怎么搞定它。
为啥直接导出有点难?
关键在于,maatwebsite/excel
的 FromArray
或 FromCollection
接口期望你提供一个最终形态 的二维数组或集合。每一项(内层数组或对象/数组)代表 Excel 中的一行 ,并且顺序 和结构 需要和你 headings()
方法定义的表头完全对应。
你现在拿到的 $datas
或 $myUsers
集合,虽然包含了所有信息,但格式不对。用户分组信息是嵌套在里面的 groups
数组,而 Excel 需要的是扁平化的行数据,每个分组对应一个独立的列,值是 'X' 或者空字符串。
所以,我们需要加一步数据转换:遍历每个用户,根据所有可能的分组 (由 headings()
定义),构建出他们对应的那一行 Excel 数据。
搞定它!解决方案来了
核心思路就是在导出类 (MyUsersExport
) 中准备好最终格式的数据。你有两种主要方式可以实现这个转换,使用 FromCollection
通常更符合 Laravel 的风格,但用 FromArray
也可以做到。
核心思路:准备好每一行的数据
不管用哪种方法,核心步骤都差不多:
- 获取所有分组名称 :跟
headings()
方法里一样,先拿到一个包含所有可能出现的分组名称的列表。这个列表决定了 Excel 的动态列。最好排序一下,保证每次导出列的顺序不变。 - 获取用户信息及其所属分组 :查询用户数据,并预加载 (eager load) 他们关联的分组信息,提高效率。
- 遍历用户 :对每个用户进行处理。
- 构建单行数据 :
- 创建一个数组,开头是固定的用户信息(比如姓、名)。
- 然后,遍历所有分组名称列表 。
- 对于列表中的每个分组名称,检查当前用户 是否属于这个分组。
- 如果属于,就在行数组里对应位置添加 'X'。
- 如果不属于,就添加空字符串 ''。
- 最终得到一个完整代表该用户在 Excel 中一行的数组。
- 收集所有行 :把每个用户构建好的行数组,添加到一个总的数组或集合里。
- 返回结果 :把这个包含所有行数据的二维数组(如果用
FromArray
)或集合(如果用FromCollection
)返回给maatwebsite/excel
。
下面我们看具体的代码实现。
方法一:使用 FromCollection
和 map()
方法转换集合 (推荐)
这种方法更“Laravel 化”,利用集合 (Collection
) 提供的 map()
方法来转换数据。
<?php
namespace App\Exports;
use App\Models\Group;
use App\Models\MyUser;
use Maatwebsite\Excel\Concerns\FromCollection;
use Maatwebsite\Excel\Concerns\WithHeadings;
use Illuminate\Support\Collection;
class MyUsersExport implements FromCollection, WithHeadings
{
protected $allGroupNames; // 存储所有分组名称,避免重复查询
public function __construct()
{
// 在构造函数里就把所有分组名查好并存起来
// 保证 headings() 和 collection() 使用的是同一套分组列表和顺序
$this->allGroupNames = Group::orderBy('name')->pluck('name')->toArray();
}
/**
* 定义 Excel 表头
* @return array
*/
public function headings(): array
{
$header = $this->allGroupNames; // 直接用存好的分组名
array_unshift($header, "用户姓", "用户名"); // 添加固定列头
return $header;
}
/**
* 准备要导出的数据集合
* @return Collection
*/
public function collection(): Collection
{
// 查询用户,预加载分组信息以提高效率
// 注意:需要确保 MyUser 模型中定义了 groupMatching 关联,
// 并且 groupMatching 模型(假设是 GroupUser)定义了到 Group 模型的关联(比如叫 group)
$users = MyUser::with(['groupMatching.group'])->get();
// 使用集合的 map 方法来转换每个用户对象为一行数据数组
return $users->map(function ($user) {
// 先准备好当前用户拥有的分组名称列表,方便快速查找
$userGroupNames = [];
if ($user->groupMatching) { // 检查关联是否存在且不为空
foreach ($user->groupMatching as $match) {
// 确保关联的 group 对象存在且有 name 属性
if ($match->group && $match->group->name) {
$userGroupNames[] = $match->group->name;
}
}
// 如果需要快速查找,可以转换成键值对,值为 true
$userGroupNames = array_flip($userGroupNames); // 例如: ['Group A' => 0, 'Group X' => 1] 变成 ['Group A' => true, 'Group X' => true] 查询更快
}
// 开始构建这一行的数据
$rowData = [
$user->lastname, // 第一列:用户姓
$user->firstname, // 第二列:用户名
];
// 遍历所有可能的分组名称(按表头顺序)
foreach ($this->allGroupNames as $groupName) {
// 检查当前用户是否拥有这个分组
// 使用 isset() 配合 array_flip() 后的数组查找效率高
if (isset($userGroupNames[$groupName])) {
$rowData[] = 'X'; // 属于该分组,填 'X'
} else {
$rowData[] = ''; // 不属于,填空字符串
}
}
// 返回构建好的单行数据数组
return $rowData;
});
}
/**
* (可选) 进阶技巧:处理大量数据
* 如果用户数据非常多,直接 get() 可能导致内存溢出。
* 可以改用 FromQuery 配合 chunk() 来分块处理。
* 这需要对导出类结构做些调整,实现 `Maatwebsite\Excel\Concerns\FromQuery` 接口
* 并可能需要 `Maatwebsite\Excel\Concerns\WithMapping` 来处理每一行映射。
* 例如,使用 WithMapping 接口:
*
* public function query() { return MyUser::query(); } // 不在这里 get()
* public function map($user): array { // $user 是单个 MyUser 模型实例
* // ... 在这里执行上面 map 闭包里的逻辑 ...
* // 需要注意的是,每次 map 调用时都需要获取该用户的分组信息
* // 这可能会导致 N+1 查询问题,除非你在 query() 里做了 with() 预加载
* // 或者在这里单独为每个 $user 查询分组 $user->load('groupMatching.group')
* $user->loadMissing('groupMatching.group'); // 只在需要时加载
* $userGroupNames = $user->groupMatching->pluck('group.name')->filter()->flip();
* $rowData = [$user->lastname, $user->firstname];
* foreach ($this->allGroupNames as $groupName) {
* $rowData[] = isset($userGroupNames[$groupName]) ? 'X' : '';
* }
* return $rowData;
* }
*
* 结合 FromQuery 和 WithMapping 可以有效处理大数据量导出。
*/
}
原理说明:
- 我们实现了
FromCollection
接口,所以需要提供一个collection()
方法返回最终的Illuminate\Support\Collection
实例。 - 在构造函数
__construct()
中预先查好所有分组名称allGroupNames
并存储起来,这样headings()
和collection()
可以共用,保证顺序一致性,也避免了重复查询。 collection()
方法首先获取所有用户及其关联的分组信息 (with(['groupMatching.group'])
是关键,避免 N+1 查询问题)。- 接着,使用 Laravel Collection 的
map()
方法,它会遍历$users
集合中的每一个$user
对象。 map()
的闭包函数接收每个$user
对象,目标是返回一个代表该用户 Excel 行的数组rowData
。- 在闭包内部:
- 先整理出当前用户实际拥有的分组名称列表
$userGroupNames
。这里做了优化,用array_flip()
将其转换为键值对(['组名' => true]
),这样后面用isset()
检查会非常快。 - 初始化
$rowData
数组,放入固定的姓和名。 - 然后,迭代 所有可能的分组名称列表 (
$this->allGroupNames
)。 - 对每个全局分组名
$groupName
,使用isset($userGroupNames[$groupName])
检查当前用户是否属于该分组。 - 根据检查结果,向
$rowData
追加 'X' 或空字符串。 - 最后返回
$rowData
。
- 先整理出当前用户实际拥有的分组名称列表
map()
方法最终会返回一个新的集合,其中每个元素都是处理好的$rowData
数组。这个集合正是FromCollection
所需的格式。
进阶使用技巧:
- 大数据量处理 :如果用户和分组数量巨大,一次性
get()
所有用户并处理可能耗尽内存。这时可以考虑改用FromQuery
接口,结合WithMapping
接口。FromQuery
允许你返回一个查询构建器 (Query Builder
),maatwebsite/excel
会自动进行分块查询。WithMapping
则提供一个map($row)
方法,让你定义如何将数据库中的每一行(或模型实例)映射成 Excel 的一行数据。如上面代码注释中的示例所示,这需要将行构建逻辑移到map()
方法里。 - 性能优化 :确保数据库查询已优化,特别是关联查询。在
groupMatching
和groups
表上建立合适的索引 (user_id
,group_id
) 非常重要。with()
预加载是避免 N+1 查询的关键。array_flip()
技巧可以加速分组检查。
方法二:使用 FromArray
手动构建二维数组 (备选)
如果你更习惯使用 FromArray
接口,或者不想依赖 Laravel Collection 的 map
方法,也可以手动构建最终的二维数组。
<?php
namespace App\Exports;
use App\Models\Group;
use App\Models\MyUser;
use Maatwebsite\Excel\Concerns\FromArray;
use Maatwebsite\Excel\Concerns\WithHeadings;
class MyUsersExport implements FromArray, WithHeadings
{
protected $allGroupNames;
public function __construct()
{
$this->allGroupNames = Group::orderBy('name')->pluck('name')->toArray();
}
public function headings(): array
{
$header = $this->allGroupNames;
array_unshift($header,"用户姓", "用户名");
return $header;
}
/**
* 准备要导出的数据数组
* @return array
*/
public function array(): array
{
// 获取所有用户及分组信息
$users = MyUser::with(['groupMatching.group'])->get();
$exportData = []; // 初始化最终的二维数组
foreach ($users as $user) {
// 获取当前用户拥有的分组名列表
$userGroupNames = [];
if ($user->groupMatching) {
foreach ($user->groupMatching as $match) {
if ($match->group && $match->group->name) {
$userGroupNames[] = $match->group->name;
}
}
// 同样建议转换成键值对加速查找
$userGroupNames = array_flip($userGroupNames);
}
// 构建单行数据
$rowData = [
$user->lastname,
$user->firstname,
];
// 遍历所有分组名称
foreach ($this->allGroupNames as $groupName) {
// 检查用户是否有此分组
if (isset($userGroupNames[$groupName])) {
$rowData[] = 'X';
} else {
$rowData[] = '';
}
}
// 将构建好的行添加到结果数组中
$exportData[] = $rowData;
}
// 返回包含所有行数据的二维数组
return $exportData;
}
}
原理说明:
- 此方法实现
FromArray
接口,需要提供一个array()
方法返回一个二维数组。 - 与方法一类似,先获取所有用户数据和所有分组名称。
- 初始化一个空数组
$exportData
用于存放最终的表格数据。 - 使用
foreach
循环遍历每个$user
对象。 - 在循环内部,逻辑和方法一的
map
闭包里基本一致:获取用户的分组,构建$rowData
数组,填充固定的姓、名,然后遍历所有分组名称,根据用户是否属于该分组来追加 'X' 或空字符串。 - 最后,将完整的
$rowData
数组添加到$exportData
中。 - 循环结束后,返回
$exportData
这个二维数组。
对比: 两种方法都能达到目的。方法一(FromCollection
+ map
)通常被认为更符合 Laravel 的集合操作风格,代码可能稍微简洁一些。方法二(FromArray
)则更直观地体现了构建二维数组的过程。对于大数据量场景,都需要考虑使用 FromQuery
和 WithMapping
进行优化。
别忘了控制器里的调用
最后,在你的控制器 (Controller) 方法里,像平常一样调用导出类就行了:
<?php
namespace App\Http\Controllers;
use App\Exports\MyUsersExport;
use Maatwebsite\Excel\Facades\Excel;
class ExportController extends Controller
{
public function exportUsersWithGroups()
{
// 定义导出的文件名
$fileName = 'users_groups_matrix_' . date('YmdHis') . '.xlsx';
// 直接下载文件
return Excel::download(new MyUsersExport(), $fileName);
// 或者,如果你想先存储文件再提供下载链接
// Excel::store(new MyUsersExport(), 'exports/' . $fileName, 'public');
// return response()->json(['message' => '文件已生成', 'url' => Storage::disk('public')->url('exports/' . $fileName)]);
}
}
然后设置一个路由指向这个控制器方法,比如:
// routes/web.php
use App\Http\Controllers\ExportController;
Route::get('/export/users-groups', [ExportController::class, 'exportUsersWithGroups'])->name('export.users.groups');
访问 /export/users-groups
这个 URL,就应该能下载到你想要的 Excel 文件了。
这样,通过在导出类中对原始数据进行转换和映射,你就成功地根据动态的分组列生成了用户-分组矩阵的 Excel 文件。