Faktúra v PDF
Cieľ: keď je objednávka zaplatená, odpovedzte na požiadavku hneď a PDF faktúry vygenerujte vo workeri. Opakovanie ani duplicitná správa nesmú nikdy vytvoriť druhú faktúru.
Súbory
Projekt spúšťa Redis z vlastného docker-compose.yml. src/Hermes.php je rovnaký ako v návode na uvítací e-mail.
<?php
declare(strict_types=1);
namespace App;
use Predis\Client;
use Tomaj\Hermes\Driver\PredisSetDriver;
use Tomaj\Hermes\Emitter;
final class Hermes
{
public static function redis(): Client
{
return new Client(getenv('REDIS_URL') ?: 'tcp://127.0.0.1:6379');
}
public static function driver(Client $redis): PredisSetDriver
{
// Queue key "hermes", poll once a second, delayed messages and retries in "hermes_schedule".
return new PredisSetDriver($redis, 'hermes', 1, 'hermes_schedule');
}
public static function emitter(): Emitter
{
return new Emitter(self::driver(self::redis()));
}
}{
"require": {
"php": ">=8.4",
"tomaj/hermes": "dev-master",
"predis/predis": "^2.3 || ^3.0",
"dompdf/dompdf": "^3.0"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"minimum-stability": "dev",
"prefer-stable": true
}<?php
declare(strict_types=1);
use App\Hermes;
use Tomaj\Hermes\Message;
require __DIR__ . '/../vendor/autoload.php';
// POST /?order=1042 after the payment is confirmed.
$orderId = filter_input(INPUT_GET, 'order', FILTER_VALIDATE_INT);
if (!is_int($orderId)) {
http_response_code(422);
echo 'Missing order id.';
return;
}
Hermes::emitter()->emit(new Message('invoice.generate', ['order_id' => $orderId]));
http_response_code(202);
echo "Invoice for order {$orderId} is being generated.";<?php
declare(strict_types=1);
namespace App;
use Dompdf\Dompdf;
use RuntimeException;
use Tomaj\Hermes\Handler\RetryableHandlerInterface;
use Tomaj\Hermes\Handler\RetryTrait;
use Tomaj\Hermes\MessageInterface;
final class InvoicePdfHandler implements RetryableHandlerInterface
{
use RetryTrait;
public function __construct(private string $directory) {}
#[\Override]
public function handle(MessageInterface $message): bool
{
$orderId = (int) ($message->getPayload()['order_id'] ?? 0);
$target = "{$this->directory}/invoice-{$orderId}.pdf";
// A retry or a duplicate message must not create a second invoice.
if (is_file($target)) {
return true;
}
$order = $this->loadOrder($orderId);
$dompdf = new Dompdf();
$dompdf->loadHtml($this->render($order));
$dompdf->setPaper('A4');
$dompdf->render();
// Write to a temporary file first, so a crash never leaves half a PDF behind.
$tmp = $target . '.tmp';
if (file_put_contents($tmp, $dompdf->output()) === false || !rename($tmp, $target)) {
throw new RuntimeException("Cannot write {$target}");
}
return true;
}
/** @return array{id: int, customer: string, lines: list<array{0: string, 1: float}>} */
private function loadOrder(int $orderId): array
{
// Replace with a database query.
return ['id' => $orderId, 'customer' => 'Jane Doe', 'lines' => [['Hosting, October', 19.0], ['Domain', 12.5]]];
}
/** @param array{id: int, customer: string, lines: list<array{0: string, 1: float}>} $order */
private function render(array $order): string
{
$rows = '';
$total = 0.0;
foreach ($order['lines'] as [$item, $price]) {
$rows .= sprintf('<tr><td>%s</td><td style="text-align:right">%.2f EUR</td></tr>', htmlspecialchars($item), $price);
$total += $price;
}
return sprintf(
'<h1>Invoice %d</h1><p>%s</p><table width="100%%">%s<tr><th>Total</th><th style="text-align:right">%.2f EUR</th></tr></table>',
$order['id'],
htmlspecialchars($order['customer']),
$rows,
$total,
);
}
}<?php
declare(strict_types=1);
use App\Hermes;
use App\InvoicePdfHandler;
use Tomaj\Hermes\Dispatcher;
use Tomaj\Hermes\Shutdown\PredisShutdown;
require __DIR__ . '/../vendor/autoload.php';
$directory = __DIR__ . '/../storage/invoices';
if (!is_dir($directory)) {
mkdir($directory, 0o775, true);
}
$redis = Hermes::redis();
$driver = Hermes::driver($redis);
$driver->setShutdown(new PredisShutdown($redis));
$driver->setMaxProcessItems(200); // dompdf is memory hungry, start a fresh process now and then
$dispatcher = new Dispatcher($driver);
$dispatcher->registerHandler('invoice.generate', new InvoicePdfHandler($directory));
$dispatcher->handle();Spustenie
composer install
docker compose up -d redis
php bin/worker.php # terminal 1
php -S localhost:8001 -t public # terminal 2
curl -X POST 'http://localhost:8001/?order=1042'storage/invoices/invoice-1042.pdf sa objaví do sekundy. Skontrolujte ho cez pdftotext storage/invoices/invoice-1042.pdf -.
Test
Pošlite tú istú objednávku dvakrát. Druhá správa nájde súbor a vráti true bez generovania. Zmažte PDF a pošlite ju znova, aby sa vygenerovalo nové. make e2e spustí obe kontroly.
Úskalia
- Handler musí byť idempotentný. Opakovanie nesie ten istý payload. Kontrola
is_file()spolu so zápisom a následnýmrename()zaručí, že pád ani opakovanie nikdy nenechajú dve faktúry ani polovicu súboru. - Posielajte id, nie dokumenty. Správa obsahuje
order_id. Worker si aktuálnu objednávku načíta sám, takže payload zostane malý a nikdy neobsahuje zastarané dáta. - dompdf spotrebuje veľa pamäte.
setMaxProcessItems(200)nechá workera z času na čas čisto skončiť. Spúšťajte ho pod správcom procesov, ako v návode, aby sa spustil nový. - Súbory ukladajte tam, kde ich prečíta každý worker. Pri viacerých serveroch zapisujte na zdieľané úložisko alebo do S3 namiesto lokálneho disku.
Celý zdrojový kód
Všetky súbory tohto receptu, s README.md, Makefile a kontrolou e2e.sh, sú v examples/invoice-pdf na GitHube. Naklonujte repozitár, spustite cd examples/invoice-pdf a make install up, potom make e2e.
Časté otázky
Prečo kontrolovať, či súbor existuje?
Opakovanie ani duplicitná správa nesmú vytvoriť druhú faktúru.
Prečo najprv zapisovať do dočasného súboru?
rename() je atomický, takže pád nikdy nenechá polovicu PDF pod konečným názvom.
Prečo worker skončí po 200 správach?
dompdf spotrebuje veľa pamäte. Nový proces drží pamäť na rovnakej úrovni. Pozrite Ukončenie a limit správ.
Môžem faktúru aj poslať e-mailom?
Áno. Z handlera vložte druhú správu alebo e-mail pošlite hneď po zapísaní súboru, ako v návode na uvítací e-mail.
Kde ukladať faktúry pri viacerých serveroch?
Na zdieľané úložisko alebo do S3, aby ich mohol čítať každý worker aj webová aplikácia.