Preskočiť na obsah

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
<?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()));
    }
}
json
{
    "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
<?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
<?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
<?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 ​

bash
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ým rename() 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.

Vydané pod licenciou MIT.