Preskočiť na obsah

Uvítací e-mail od nuly ​

Tento návod zostaví malú aplikáciu od prázdneho priečinka. Registračný formulár vloží správu, worker ju vyberie a pošle uvítací e-mail a vy sledujete, ako e-mail dorazí do Mailpitu. Potom zámerne vyradíte SMTP a uvidíte, ako Hermes opakuje, a nakoniec workera spustíte pod Supervisorom.

Trvá to asi 15 minút. Každý súbor je kompletný, takže každý blok môžete skopírovať tak, ako je.

Čo potrebujete ​

  • PHP 8.4 alebo novšie s Composerom
  • Docker s Compose pre Redis a Mailpit

Aplikácia používa PredisSetDriver. Predis je obyčajný PHP balík, takže netreba inštalovať žiadne rozšírenie. Je to aj Redis driver, ktorý cez ZREM vracia odložené správy a opakovania do fronty, čo krok s opakovaním nižšie potrebuje. Pozrite Odložené spracovanie.

1. Vytvorte projekt ​

bash
mkdir welcome-email && cd welcome-email
mkdir -p public src bin tests deploy
json
{
    "name": "acme/welcome-email",
    "type": "project",
    "require": {
        "php": ">=8.4",
        "tomaj/hermes": "dev-master",
        "predis/predis": "^2.3 || ^3.0",
        "symfony/mailer": "^7.0",
        "monolog/monolog": "^3.0"
    },
    "autoload": {
        "psr-4": {
            "App\\": "src/"
        }
    },
    "require-dev": {
        "phpunit/phpunit": "^11"
    },
    "autoload-dev": {
        "psr-4": {
            "App\\Tests\\": "tests/"
        }
    },
    "minimum-stability": "dev",
    "prefer-stable": true
}
bash
composer install

Tým sa nainštaluje Hermes z dev-master (6.0, kým nemá tag), Predis, Symfony Mailer na SMTP a Monolog, aby worker vypisoval, čo robí.

2. Spustite Redis a Mailpit ​

yaml
services:
  redis:
    image: redis:8-alpine
    ports:
      - "6379:6379"

  mailpit:
    image: axllent/mailpit
    ports:
      - "1025:1025" # SMTP
      - "8025:8025" # web UI
bash
docker compose up -d

Otvorte http://localhost:8025. Schránka v Mailpite je prázdna. Mailpit prijme akýkoľvek e-mail na porte 1025 a zobrazí ho v prehliadači a nič neopustí váš počítač.

3. Jedno miesto pre driver ​

Webová aplikácia aj worker musia používať ten istý driver s rovnakými kľúčmi. Nastavenie dajte do jednej triedy.

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()));
    }
}

'hermes' je fronta, 1 je počet sekúnd, ktoré worker spí pri prázdnej fronte, a 'hermes_schedule' je zoradená množina pre odložené správy a opakovania.

4. Vložte správu z controllera ​

Toto je webová strana. Overí formulár, uložila by používateľa a vloží správu user.registered. Vloženie je jeden príkaz v Redise, takže odpoveď je okamžitá. E-mail sa tu neposiela.

php
<?php

declare(strict_types=1);

use App\Hermes;
use Tomaj\Hermes\Message;

require __DIR__ . '/../vendor/autoload.php';

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $email = filter_input(INPUT_POST, 'email', FILTER_VALIDATE_EMAIL);
    if ($email === false || $email === null) {
        http_response_code(422);
        echo 'Enter a valid email address.';
        return;
    }

    // Save the user here. Then hand the slow part to the worker.
    Hermes::emitter()->emit(new Message('user.registered', [
        'email' => $email,
        'name' => trim((string) ($_POST['name'] ?? '')),
    ]));

    echo "Thanks! A welcome email is on its way to {$email}.";
    return;
}
?>
<form method="post">
    <input name="name" placeholder="Name">
    <input name="email" type="email" placeholder="you@example.com" required>
    <button>Register</button>
</form>

5. Napíšte handler ​

Handler robí pomalú prácu v procese workera. RetryableHandlerInterface dá dispatcheru vedieť, že tento handler chce opakovanie, RetryTrait ho implementuje a maxRetry() ho obmedzí na päť pokusov.

php
<?php

declare(strict_types=1);

namespace App;

use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
use Tomaj\Hermes\Handler\RetryableHandlerInterface;
use Tomaj\Hermes\Handler\RetryTrait;
use Tomaj\Hermes\MessageInterface;

final class WelcomeEmailHandler implements RetryableHandlerInterface
{
    use RetryTrait;

    public function __construct(private MailerInterface $mailer) {}

    #[\Override]
    public function handle(MessageInterface $message): bool
    {
        $payload = $message->getPayload() ?? [];
        $name = ($payload['name'] ?? '') !== '' ? $payload['name'] : 'there';

        $email = new Email();
        $email
            ->from('hello@example.com')
            ->to($payload['email'])
            ->subject('Welcome aboard!')
            ->text("Hi {$name},\n\nthanks for signing up.");

        // A TransportException here (SMTP down, timeout) makes Hermes send a retry copy.
        $this->mailer->send($email);

        return true;
    }

    public function maxRetry(): int
    {
        return 5;
    }
}

6. Napíšte workera ​

Worker zaregistruje handler pre typ user.registered a zavolá handle(), ktoré beží v slučke, kým ho niekto nezastaví.

php
<?php

declare(strict_types=1);

use App\Hermes;
use App\WelcomeEmailHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
use Symfony\Component\Mailer\Mailer;
use Symfony\Component\Mailer\Transport;
use Tomaj\Hermes\Dispatcher;
use Tomaj\Hermes\Shutdown\PredisShutdown;

require __DIR__ . '/../vendor/autoload.php';

$logger = new Logger('worker', [new StreamHandler('php://stdout')]);

$redis = Hermes::redis();
$driver = Hermes::driver($redis);
$driver->setShutdown(new PredisShutdown($redis)); // php bin/restart.php stops every worker
$driver->setMaxProcessItems(1000);                 // exit after 1000 messages, Supervisor starts a fresh one

$mailer = new Mailer(Transport::fromDsn(getenv('MAILER_DSN') ?: 'smtp://127.0.0.1:1025'));

$dispatcher = new Dispatcher($driver, $logger);
$dispatcher->registerHandler('user.registered', new WelcomeEmailHandler($mailer));

$logger->info('Worker started, waiting for messages');
$dispatcher->handle();

A malý skript na zastavenie všetkých workerov, ktorý neskôr použijete pri nasadení:

php
<?php

declare(strict_types=1);

use App\Hermes;
use Tomaj\Hermes\Shutdown\PredisShutdown;

require __DIR__ . '/../vendor/autoload.php';

// Workers started before now exit after their current message.
$shutdown = new PredisShutdown(Hermes::redis());
$shutdown->shutdown();
echo "Shutdown signal sent.\n";

7. Spustite to ​

V priečinku projektu otvorte dva terminály.

bash
# terminal 1: the worker
php bin/worker.php
bash
# terminal 2: the web app
php -S localhost:8000 -t public

Otvorte http://localhost:8000, zadajte meno a e-mailovú adresu a stlačte Register. Alebo z tretieho terminálu:

bash
curl -d 'name=Jane&email=jane@example.com' http://localhost:8000/

Worker vypíše:

text
worker.INFO: Start handle message #57e9ddf5-… (user.registered) priority:100
worker.INFO: End handle message #57e9ddf5-… (user.registered)

Obnovte http://localhost:8025. E-mail „Welcome aboard!“ pre jane@example.com je tam.

8. Sledujte opakovanie ​

Zastavte Mailpit, aby SMTP nefungovalo, a zaregistrujte sa znova:

bash
docker compose stop mailpit
curl -d 'name=Bob&email=bob@example.com' http://localhost:8000/

Handler vyhodí výnimku a worker ju zaloguje:

text
worker.ERROR: Handler App\WelcomeEmailHandler throws exception - …

Keďže handler používa RetryTrait, dispatcher poslal kópiu správy s retries nastaveným na 1 a s executeAt 16 až 45 sekúnd v budúcnosti. Čakajúcu ju uvidíte v Redise:

bash
docker compose exec redis redis-cli zrange hermes_schedule 0 -1

Znova spustite Mailpit:

bash
docker compose start mailpit

Do minúty worker zaloguje druhé Start handle message s tým istým id a "retries":1 a e-mail pre bob@example.com sa objaví v Mailpite. Mailpit drží správy v pamäti, takže predchádzajúci e-mail po reštarte zmizol.

Oneskorenie s každým pokusom rastie: retries⁴ + 15 + jitter × (retries + 1) sekúnd, s náhodnou zložkou od 1 do 30. Päť opakovaní pokryje približne desať minút výpadku SMTP. Pri dlhších výpadkoch zvýšte maxRetry(). Pozrite Opakovanie.

9. Otestujte handler ​

Handler je obyčajná trieda, takže ho otestujete bez Redisu aj bez workera. composer.json z kroku 1 už má PHPUnit v require-dev a v autoload-dev mapuje App\Tests\ na tests/, takže composer install nastavil oboje.

php
<?php

declare(strict_types=1);

namespace App\Tests;

use App\WelcomeEmailHandler;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Mailer\Envelope;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
use Symfony\Component\Mime\RawMessage;
use Tomaj\Hermes\Message;

final class WelcomeEmailHandlerTest extends TestCase
{
    public function testSendsTheWelcomeEmail(): void
    {
        $mailer = new class implements MailerInterface {
            /** @var list<RawMessage> */
            public array $sent = [];

            public function send(RawMessage $message, ?Envelope $envelope = null): void
            {
                $this->sent[] = $message;
            }
        };

        $handler = new WelcomeEmailHandler($mailer);
        $result = $handler->handle(new Message('user.registered', ['email' => 'jane@example.com', 'name' => 'Jane']));

        self::assertTrue($result);
        self::assertCount(1, $mailer->sent);
        $email = $mailer->sent[0];
        self::assertInstanceOf(Email::class, $email);
        self::assertSame('jane@example.com', $email->getTo()[0]->getAddress());
        self::assertStringContainsString('Hi Jane', (string) $email->getTextBody());
    }
}
bash
vendor/bin/phpunit tests

10. Spustite to pod Supervisorom ​

V produkcii udržiava workera v behu správca procesov a keď skončí, spustí nový. Worker zámerne skončí v dvoch prípadoch: po 1000 správach (setMaxProcessItems) a po php bin/restart.php.

ini
[program:hermes-worker]
command=php /var/www/welcome-email/bin/worker.php
directory=/var/www/welcome-email
numprocs=2
process_name=%(program_name)s_%(process_num)02d
autostart=true
autorestart=true
startsecs=1
stopwaitsecs=60
user=www-data
environment=REDIS_URL="tcp://127.0.0.1:6379",MAILER_DSN="smtp://127.0.0.1:1025"
stdout_logfile=/var/log/hermes-worker.log
redirect_stderr=true

Skopírujte ho na miesto a cesty upravte podľa svojho servera:

bash
sudo cp deploy/hermes-worker.conf /etc/supervisor/conf.d/
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl status
text
hermes-worker:hermes-worker_00   RUNNING   pid 1643852, uptime 0:00:03
hermes-worker:hermes-worker_01   RUNNING   pid 1643858, uptime 0:00:03

Po každom nasadení spustite:

bash
php bin/restart.php

PredisShutdown zapíše aktuálny čas do kľúča hermes_shutdown. Každý worker, ktorý sa spustil pred týmto časom, dokončí aktuálnu správu, zaloguje Exiting hermes dispatcher - shutdown a skončí. Supervisor spustí nové workery a tie načítajú nový kód. Workery, ktoré sa spustia po signáli, ho ignorujú.

So systemd namiesto Supervisora beží ten istý worker ako šablóna jednotky:

ini
# /etc/systemd/system/hermes-worker@.service
[Unit]
Description=Hermes worker %i
After=network.target

[Service]
User=www-data
WorkingDirectory=/var/www/welcome-email
Environment=REDIS_URL=tcp://127.0.0.1:6379
Environment=MAILER_DSN=smtp://127.0.0.1:1025
ExecStart=/usr/bin/php bin/worker.php
Restart=always
RestartSec=1

[Install]
WantedBy=multi-user.target
bash
sudo systemctl enable --now hermes-worker@1 hermes-worker@2

Úskalia ​

  • Správa zmizne z Redisu ešte pred spustením handlera. SPOP ju odstráni ako prvý. Ak proces workera zomrie uprostred handle(), e-mail sa nepošle a ani sa neopakuje. Opakuje sa len vtedy, keď handler vyhodí výnimku.
  • Vrátenie false neopakuje. Opakuje len výnimka. Vyhoďte ju, keď by neskorší pokus mohol uspieť.
  • Emitter a worker sa musia zhodovať. Rovnaká trieda drivera, rovnaký kľúč fronty, rovnaký kľúč plánovača. src/Hermes.php existuje práve preto, aby sa nerozišli.
  • Dlho bežiace PHP si drží stav. Worker vytvorí mailer raz. Zastarané SMTP pripojenie, hodnota konfigurácie v cache alebo pomalý únik pamäte zostanú, kým proces neskončí, a preto sa oplatí setMaxProcessItems aj reštart cez Supervisor.
  • Vo verzii 5.x opakovanie potrebuje Predis. S RedisSetDriver (ext-redis) vo verzii 5.x sa odložené správy a opakovania zapíšu do zoradenej množiny, ale nikdy sa nevrátia do fronty. Pozrite Odložené spracovanie.
  • Po nasadení reštartujte. Worker spustený pred nasadením stále beží so starým kódom. Ako posledný krok nasadenia spustite php bin/restart.php.

Celý zdrojový kód ​

Všetky súbory tohto receptu, s README.md, Makefile a kontrolou e2e.sh, sú v examples/welcome-email na GitHube. Naklonujte repozitár, spustite cd examples/welcome-email a make install up, potom make e2e.

Časté otázky ​

Prečo e-mail nedorazí? ​

Skontrolujte, či worker beží a či Mailpit počúva na porte 1025. Log workera ukazuje Start handle message pre každú správu, ktorú vyberie.

Môžem namiesto Mailpitu použiť skutočný SMTP server? ​

Áno. Pre workera nastavte MAILER_DSN, napríklad smtp://user:pass@smtp.example.com:587.

Prečo Predis a nie ext-redis? ​

Nepotrebuje PHP rozšírenie. Opakovanie potrebuje cestu pre odložené správy, ktorú vo verzii 5.x správne presúva len Predis; od 6.0 funguje aj ext-redis. Pozrite Odložené spracovanie.

Koľkokrát sa neúspešný e-mail opakuje? ​

S maxRetry() tohto handlera päťkrát, počas približne desiatich minút. Pozrite Opakovanie.

Ako nasadiť nový kód? ​

Po nasadení spustite php bin/restart.php. Supervisor znova spustí workery s novým kódom. Pozrite Ukončenie.

Vydané pod licenciou MIT.