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
mkdir welcome-email && cd welcome-email
mkdir -p public src bin tests deploy{
"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
}composer installTý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
services:
redis:
image: redis:8-alpine
ports:
- "6379:6379"
mailpit:
image: axllent/mailpit
ports:
- "1025:1025" # SMTP
- "8025:8025" # web UIdocker compose up -dOtvorte 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
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
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
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
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
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.
# terminal 1: the worker
php bin/worker.php# terminal 2: the web app
php -S localhost:8000 -t publicOtvorte http://localhost:8000, zadajte meno a e-mailovú adresu a stlačte Register. Alebo z tretieho terminálu:
curl -d 'name=Jane&email=jane@example.com' http://localhost:8000/Worker vypíše:
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:
docker compose stop mailpit
curl -d 'name=Bob&email=bob@example.com' http://localhost:8000/Handler vyhodí výnimku a worker ju zaloguje:
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:
docker compose exec redis redis-cli zrange hermes_schedule 0 -1Znova spustite Mailpit:
docker compose start mailpitDo 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
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());
}
}vendor/bin/phpunit tests10. 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.
[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=trueSkopírujte ho na miesto a cesty upravte podľa svojho servera:
sudo cp deploy/hermes-worker.conf /etc/supervisor/conf.d/
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl statushermes-worker:hermes-worker_00 RUNNING pid 1643852, uptime 0:00:03
hermes-worker:hermes-worker_01 RUNNING pid 1643858, uptime 0:00:03Po každom nasadení spustite:
php bin/restart.phpPredisShutdown 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:
# /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.targetsudo systemctl enable --now hermes-worker@1 hermes-worker@2Úskalia
- Správa zmizne z Redisu ešte pred spustením handlera.
SPOPju odstráni ako prvý. Ak proces workera zomrie uprostredhandle(), e-mail sa nepošle a ani sa neopakuje. Opakuje sa len vtedy, keď handler vyhodí výnimku. - Vrátenie
falseneopakuje. 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.phpexistuje 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í
setMaxProcessItemsaj 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.