
1. 引言:Dompdf批量生成PDF的挑战
dompdf是一个广受欢迎的php库,用于将html转换为pdf,其在生成单个或少量pdf文件时表现出色。然而,当业务需求涉及到批量生成大量pdf文件(例如数百个甚至更多),且每个文件可能包含海量数据(如数千行记录)时,直接在web请求中同步执行此过程往往会遭遇严重的性能瓶颈和超时问题。用户在尝试生成100+个项目的pdf,其中部分项目数据量高达2000+行时,就明确遇到了请求超时的问题。
2. 当前实现方式及问题分析
用户最初的实现方式是在一个Web请求中,通过循环遍历所有待生成PDF的项目,为每个项目执行数据库查询、数据处理,然后调用Dompdf渲染并保存PDF文件。
核心代码片段(简化版):
// Web控制器或路由处理逻辑
$finalItems = array('item1', 'item2', 'item3', /* ... 更多项目 ... */);
foreach ($finalItems as $item) {
// 1. 数据查询与准备
// 假设此处包含多个DB::table查询,获取销售、采购、库存等数据
$saleData = DB::table('sale_data')->where('item_name', $item)->get();
$purchaseData = DB::table('purchase_data')->where('item_name', $item)->get();
$stock_trf = DB::table('stock_transfer')->where('item_name', $item)->get();
$res = array_merge(json_decode(json_encode($saleData), true), json_decode(json_encode($purchaseData), true), json_decode(json_encode($stock_trf), true));
// 2. Dompdf渲染与保存
$pdf = PDF::loadView('myPDF', compact('res')); // 加载Blade视图
$pdf->setPaper('a3', 'landscape');
$pdf->save(public_path() . '/pdf/item_' . $item . '.pdf');
// $pdf->stream('item_' . $item . '.pdf'); // 如果直接下载,但此处是批量保存
}问题分析:
- PHP执行时间限制 (set_time_limit): PHP脚本在Web服务器环境下通常有默认的执行时间限制(如30秒或60秒)。当循环生成大量PDF时,总耗时很容易超出这个限制,导致脚本中断。
- Web服务器超时: 除了PHP自身的限制,Web服务器(如Apache、Nginx)也有请求超时设置。即使PHP脚本的执行时间被延长,Web服务器也可能在达到其超时限制后终止连接。
- 资源消耗: 每次迭代都需要进行数据库查询、数据合并、HTML渲染和PDF转换,这些都是CPU和内存密集型操作。批量执行会导致服务器资源在短时间内被大量占用,影响其他请求的响应,甚至导致服务器不稳定。
- 用户体验: 用户发起请求后需要长时间等待,直到所有PDF生成完毕。这种同步等待不仅体验差,还可能导致用户误以为系统无响应而重复操作。
3. 专业解决方案:离线处理与后台任务
解决此类问题的核心思想是将耗时且资源密集型的PDF生成任务从Web服务器的即时请求中剥离出来,作为后台任务异步执行。这种“离线处理”的模式具有显著优势:
- 规避超时限制: 后台任务通常不受Web服务器和PHP set_time_limit 的约束。
- 提升用户体验: Web请求可以立即响应用户,告知任务已提交,无需等待漫长的处理过程。
- 资源优化: 后台任务可以在服务器负载较低时执行,或者通过任务队列进行调度,避免资源瞬时过载。
- 系统健壮性: 后台任务更容易实现错误处理、重试机制和任务状态追踪。
4. 实现步骤与示例代码
4.1 步骤一:前端触发任务并记录
用户在Web界面选择要生成PDF的项目后,前端发送一个轻量级的请求到后端。后端控制器不直接生成PDF,而是将任务信息(例如待处理的项目ID列表、用户ID、生成日期范围等)记录下来,并立即返回一个成功响应给用户。
Web控制器示例:
// app/Http/Controllers/PdfGeneratorController.php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage; // 用于存储临时文件
class PdfGeneratorController extends Controller
{
public function generateBulkPdfs(Request $request)
{
$itemIds = $request->input('item_ids', []); // 从前端获取项目ID数组
$fromDate = $request->input('from_date');
$toDate = $request->input('to_date');
$siteId = $request->input('site_id');
if (empty($itemIds)) {
return response()->json(['message' => '请选择至少一个项目进行PDF生成。'], 400);
}
// 将任务信息保存到临时文件或数据库任务队列
$taskData = [
'item_ids' => $itemIds,
'from_date' => $fromDate,
'to_date' => $toDate,
'site_id' => $siteId,
'user_id' => auth()->id(), // 如果需要关联用户
'status' => 'pending', // 任务状态
'created_at' => now(),
];
$taskId = uniqid('pdf_task_');
Storage::put("pdf_tasks/{$taskId}.json", json_encode($taskData));
// 启动后台脚本(此处以exec为例,更推荐使用Laravel Queue)
// 注意:这里的路径需要根据实际项目结构调整
$command = 'php ' . base_path('artisan') . ' pdf:generate ' . $taskId . ' > /dev/null 2>&1 &';
exec($command);
return response()->json(['message' => 'PDF生成任务已提交,请稍后查看或等待通知。', 'task_id' => $taskId]);
}
}4.2 步骤二:创建命令行脚本(Artisan Command)
在Laravel框架中,最优雅的方式是创建一个Artisan命令。这个命令将负责读取任务信息,并在命令行环境下执行PDF生成逻辑。
创建Artisan命令:
php artisan make:command GenerateBulkPdfs
Artisan命令示例 (app/Console/Commands/GenerateBulkPdfs.php):
argument('taskId');
$this->info("Starting PDF generation for task: {$taskId}");
// 从存储中读取任务数据
if (!Storage::exists("pdf_tasks/{$taskId}.json")) {
$this->error("Task data not found for ID: {$taskId}");
return Command::FAILURE;
}
$taskData = json_decode(Storage::get("pdf_tasks/{$taskId}.json"), true);
$itemIds = $taskData['item_ids'];
$fromDate = $taskData['from_date'];
$toDate = $taskData['to_date'];
$siteId = $taskData['site_id'];
$generatedPdfs = [];
$pdfOutputDirectory = public_path('pdf'); // PDF保存目录
// 确保PDF输出目录存在
if (!file_exists($pdfOutputDirectory)) {
mkdir($pdfOutputDirectory, 0777, true);
}
foreach ($itemIds as $item) {
try {
$this->info("Processing item: {$item}");
// 原始代码中的数据库查询和数据准备逻辑
$getGrp = DB::table('item_master')->select('group')->where('item_name', $item)->get();
$rs = json_decode(json_encode($getGrp), true);
$getGP = call_user_func_array('array_merge', $rs);
$saleData = DB::table('sale_data')->where('item_name', $item)->where('site_id', $siteId)->whereBetween('bill_date', [$fromDate, $toDate])->get();
$purchaseData = DB::table('purchase_data')->where('item_name', $item)->where('site_id', $siteId)->whereBetween('bill_date', [$fromDate, $toDate])->get();
$stock_trf = DB::table('stock_transfer')->where('item_name', $item)->where('site_id', $siteId)->whereBetween('bill_date', [$fromDate, $toDate])->get();
$sales = json_decode(json_encode($saleData), true);
$purchase = json_decode(json_encode($purchaseData), true);
$stock = json_decode(json_encode($stock_trf), true);
$res = array_merge($sales, $purchase, $stock);
$groupName = $getGP['group']; // 假设需要这个变量
// 加载视图并生成PDF
$pdf = PDF::loadView('myPDF', compact('res', 'groupName')); // 确保myPDF视图能访问这些变量
$pdf->setPaper('a3', 'landscape');
$pdfFileName = 'item_' . str_replace('/', '_', $item) . '.pdf'; // 替换非法文件名字符
$pdfPath = $pdfOutputDirectory . '/' . $pdfFileName;
$pdf->save($pdfPath);
$generatedPdfs[] = $pdfFileName;
$this->info("Generated PDF for item {$item}: {$pdfFileName}");
} catch (\Exception $e) {
$this->error("Error generating PDF for item {$item}: " . $e->getMessage());
// 记录错误或进行其他处理
}
}
// 更新任务状态(例如,保存生成的PDF列表到任务数据,或发送通知)
$taskData['status'] = 'completed';
$taskData['generated_pdfs'] = $generatedPdfs;
Storage::put("pdf_tasks/{$taskId}.json", json_encode($taskData));
$this->info("All PDFs generated for task: {$taskId}. Total: " . count($generatedPdfs));
return Command::SUCCESS;
}
}注意: 视图文件 myPDF.blade.php 的内容应与原始问题中的HTML视图类似,确保数据循环和显示逻辑正确。
{{-- resources/views/myPDF.blade.php --}}
PDF Report
Report for Group: {{ $groupName ?? 'N/A' }}
| Batch No. | MFG Date | EXP Date | Quantity | Balance | Bill No. | Bill Date | Customer Name |
|---|---|---|---|---|---|---|---|
| {{ $sldata['batch_no'] ?? '' }} | {{ $sldata['mfg_date'] ?? '' }} | {{ $sldata['exp_date'] ?? '' }} | {{ $sldata['quantity_in_kgltr'] ?? '' }} | @php $tocl = (int)($sldata['quantity_in_kgltr'] ?? 0); $last_balance -= $tocl; echo $last_balance; @endphp | {{ $sldata['bill_no'] ?? '' }} | {{ isset($sldata['bill_date']) ? date('d-m-Y', strtotime($sldata['bill_date'])) : '' }} | {{ $sldata['sales_to_customer_name'] ?? '' }} |
| No data available for this item. | |||||||
4.3 步骤三:调用命令行脚本
在Web控制器中,使用PHP的 exec() 函数来启动Artisan命令,并使用 & 符号将其置于后台运行,确保Web请求不会等待命令执行完毕。
exec() 函数调用:
// 在Web控制器中 (如上面 PdfGeneratorController 的 generateBulkPdfs 方法中)
$command = 'php ' . base_path('artisan') . ' pdf:generate ' . $taskId . ' > /dev/null 2>&1 &';
exec($command);- php artisan pdf:generate {taskId}: 这是要执行的Artisan命令。
- > /dev/null: 将命令的标准输出重定向到空设备,避免在Web服务器日志











