Nette
Táto stránka pridá Hermes do nového nette/web-project. Presenter vloží user.registered a php bin/console hermes:work ju spracuje. Najprv si prečítajte Integráciu do frameworkov, kde sú časti spoločné pre všetky frameworky.
1. Inštalácia
composer create-project nette/web-project app
cd app
composer require tomaj/hermes:dev-master contributte/console
composer require predis/predis # only for the predis driver
composer require php-amqplib/php-amqplib # only for the rabbitmq driver
composer require aws/aws-sdk-php # only for the sqs driverKým Hermes 6.0 nemá tag, vyžadujte tomaj/hermes:dev-master ako vyššie; po vydaní prejdite na ^6.0. Nette nemá vlastnú konzolu; contributte/console pridá do DI kontajnera príkazy Symfony Console.
2. Konfigurácia služieb
Celý Hermes je v jednom súbore NEON. Driver a názov fronty pre každé prostredie dajte do config/local.neon a každej aplikácii dajte vlastnú queue.
extensions:
console: Contributte\Console\DI\ConsoleExtension(%consoleMode%)
parameters:
consoleMode: false
hermes:
driver: redis # redis (ext-redis), predis, rabbitmq or sqs; override it in local.neon
queue: hermes
max_items: 1000
redis:
host: 127.0.0.1
port: 6379
rabbitmq:
host: 127.0.0.1
port: 5672
user: guest
password: guest
vhost: /
sqs:
region: eu-central-1
endpoint: null # http://127.0.0.1:4566 for a local SQS
key: ''
secret: ''
services:
# One driver for the web process and the worker.
hermes.driver:
type: Tomaj\Hermes\Driver\DriverInterface
create: App\Hermes\DriverFactory::create(%hermes%)
- Tomaj\Hermes\Shutdown\SharedFileShutdown(%tempDir%/hermes-shutdown)
# Hermes logs through Tracy's logger (log/info.log).
- Tracy\Bridges\Psr\TracyToPsrLoggerAdapter
# Producers type-hint EmitterInterface.
- Tomaj\Hermes\Emitter
# The worker command type-hints DispatcherInterface.
hermes.dispatcher:
create: Tomaj\Hermes\Dispatcher
setup:
- registerHandler('user.registered', @App\Hermes\UserRegisteredHandler)
- App\Hermes\UserRegisteredHandler
- App\Console\HermesWorkCommand
- App\Console\HermesRestartCommand
- App\Console\RegisterUserCommandDriverFactory premení parameter hermes na driver:
<?php
declare(strict_types=1);
namespace App\Hermes;
use Aws\Sqs\SqsClient;
use InvalidArgumentException;
use PhpAmqpLib\Connection\AMQPLazyConnection;
use Predis\Client;
use Redis;
use Tomaj\Hermes\Driver\AmazonSqsDriver;
use Tomaj\Hermes\Driver\DriverInterface;
use Tomaj\Hermes\Driver\LazyRabbitMqDriver;
use Tomaj\Hermes\Driver\PredisSetDriver;
use Tomaj\Hermes\Driver\RedisSetDriver;
/**
* Builds the Hermes driver from plain configuration, so the web process and the worker
* always use the same driver, queue name and connection.
*/
final class DriverFactory
{
/**
* @param array{
* driver: string,
* queue: string,
* max_items: int,
* redis: array{host: string, port: int},
* rabbitmq: array{host: string, port: int, user: string, password: string, vhost: string},
* sqs: array{region: string, endpoint: ?string, key: string, secret: string},
* } $config
*/
public static function create(array $config): DriverInterface
{
$queue = $config['queue'];
$driver = match ($config['driver']) {
'redis' => new RedisSetDriver(self::redis($config['redis']), $queue),
'predis' => new PredisSetDriver(new Client(['host' => $config['redis']['host'], 'port' => $config['redis']['port']]), $queue),
'rabbitmq' => new LazyRabbitMqDriver(
new AMQPLazyConnection(
$config['rabbitmq']['host'],
$config['rabbitmq']['port'],
$config['rabbitmq']['user'],
$config['rabbitmq']['password'],
$config['rabbitmq']['vhost'],
),
$queue,
[],
1, // seconds to sleep when the queue is empty
),
'sqs' => new AmazonSqsDriver(self::sqs($config['sqs']), $queue),
default => throw new InvalidArgumentException("Unknown Hermes driver \"{$config['driver']}\""),
};
// Start a fresh worker process after this many messages.
$driver->setMaxProcessItems($config['max_items']);
return $driver;
}
/**
* @param array{host: string, port: int} $config
*/
private static function redis(array $config): Redis
{
$redis = new Redis();
$redis->connect($config['host'], $config['port']);
return $redis;
}
/**
* @param array{region: string, endpoint: ?string, key: string, secret: string} $config
*/
private static function sqs(array $config): SqsClient
{
$options = [
'region' => $config['region'],
'version' => 'latest',
'credentials' => ['key' => $config['key'], 'secret' => $config['secret']],
];
if ($config['endpoint'] !== null && $config['endpoint'] !== '') {
$options['endpoint'] = $config['endpoint']; // a local SQS such as moto or ElasticMQ
}
return new SqsClient($options);
}
}Bootstrap načíta hermes.neon a dostane bootConsoleApplication() pre bin/console:
<?php
declare(strict_types=1);
namespace App;
use Nette;
use Nette\Bootstrap\Configurator;
class Bootstrap
{
private readonly Configurator $configurator;
private readonly string $rootDir;
public function __construct()
{
$this->rootDir = dirname(__DIR__);
$this->configurator = new Configurator();
$this->configurator->setTempDirectory($this->rootDir . '/temp');
}
public function bootWebApplication(): Nette\DI\Container
{
$this->initializeEnvironment();
$this->setupContainer();
return $this->configurator->createContainer();
}
public function bootConsoleApplication(): Nette\DI\Container
{
$this->configurator->addStaticParameters(['consoleMode' => true]);
$this->initializeEnvironment();
$this->setupContainer();
return $this->configurator->createContainer();
}
public function initializeEnvironment(): void
{
//$this->configurator->setDebugMode('secret@23.75.345.200'); // enable for your remote IP
$this->configurator->enableTracy($this->rootDir . '/log');
$this->configurator->createRobotLoader()
->addDirectory(__DIR__)
->register();
}
private function setupContainer(): void
{
$configDir = $this->rootDir . '/config';
$this->configurator->addConfig($configDir . '/common.neon');
$this->configurator->addConfig($configDir . '/services.neon');
$this->configurator->addConfig($configDir . '/hermes.neon');
if (is_file($configDir . '/local.neon')) {
$this->configurator->addConfig($configDir . '/local.neon'); // per-machine overrides, not in git
}
}
}#!/usr/bin/env php
<?php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
$bootstrap = new App\Bootstrap();
$container = $bootstrap->bootConsoleApplication();
$application = $container->getByType(Contributte\Console\Application::class);
exit($application->run());3. Logovanie cez Tracy
TracyToPsrLoggerAdapter dá Hermesu logger Tracy ako LoggerInterface. Riadky Start handle message idú do log/info.log. Tracy nemá úroveň notice, takže oznámenie Exiting hermes dispatcher - shutdown skončí v log/warning.log. Ak chcete PSR-3 úrovne bez zmeny, zaregistrujte namiesto toho ako LoggerInterface logger Monolog. Pozrite Logovanie.
4. Vloženie správy z presentera
<?php
declare(strict_types=1);
namespace App\Presentation\User;
use Nette\Application\Attributes\Requires;
use Nette\Application\UI\Presenter;
use Tomaj\Hermes\EmitterInterface;
use Tomaj\Hermes\Message;
final class UserPresenter extends Presenter
{
public function __construct(private readonly EmitterInterface $emitter)
{
parent::__construct();
}
/** POST /user/create with an email field */
#[Requires(methods: 'POST')]
public function actionCreate(): void
{
$email = $this->getHttpRequest()->getPost('email');
if (!is_string($email) || filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
$this->getHttpResponse()->setCode(422);
$this->sendJson(['error' => 'A valid email is required.']);
}
// Save the user here, then hand the slow work to the worker.
$this->emitter->emit(new Message('user.registered', ['email' => $email]));
$this->getHttpResponse()->setCode(201);
$this->sendJson(['status' => 'registered']);
}
}POST /user/create s email vráti 201, chýbajúci e-mail 422 a GET 405. Príkaz producenta, ktorý sa používa nižšie:
<?php
declare(strict_types=1);
namespace App\Console;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Tomaj\Hermes\EmitterInterface;
use Tomaj\Hermes\Message;
#[AsCommand(name: 'app:register-user', description: 'Emit user.registered for one email address')]
final class RegisterUserCommand extends Command
{
public function __construct(private readonly EmitterInterface $emitter)
{
parent::__construct();
}
#[\Override]
protected function configure(): void
{
$this->addArgument('email', InputArgument::REQUIRED);
}
#[\Override]
protected function execute(InputInterface $input, OutputInterface $output): int
{
$email = (string) $input->getArgument('email');
$this->emitter->emit(new Message('user.registered', ['email' => $email]));
$output->writeln("Queued the welcome email for {$email}.");
return Command::SUCCESS;
}
}5. Napíšte handler
<?php
declare(strict_types=1);
namespace App\Hermes;
use Psr\Log\LoggerInterface;
use Tomaj\Hermes\Handler\RetryableHandlerInterface;
use Tomaj\Hermes\Handler\RetryTrait;
use Tomaj\Hermes\MessageInterface;
final class UserRegisteredHandler implements RetryableHandlerInterface
{
use RetryTrait;
public function __construct(private readonly LoggerInterface $logger) {}
#[\Override]
public function handle(MessageInterface $message): bool
{
$email = $message->getPayload()['email'] ?? null;
if (!is_string($email)) {
return false; // a broken message stays broken, do not retry it
}
// Send the welcome email here. Throw an exception to retry later.
$this->logger->info("Welcome email sent to {$email}", ['message_id' => $message->getId()]);
return true;
}
}Výnimkou spustíte opakovanie, vrátením false to vzdáte; pozrite Opakovanie.
6. Spustite workera
<?php
declare(strict_types=1);
namespace App\Console;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Tomaj\Hermes\DispatcherInterface;
#[AsCommand(name: 'hermes:work', description: 'Run the Hermes worker until it is told to stop')]
final class HermesWorkCommand extends Command
{
public function __construct(private readonly DispatcherInterface $dispatcher)
{
parent::__construct();
}
#[\Override]
protected function configure(): void
{
$this->addOption('priority', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Handle only these priorities');
}
#[\Override]
protected function execute(InputInterface $input, OutputInterface $output): int
{
/** @var list<string> $priorities */
$priorities = $input->getOption('priority');
// Blocks until shutdown (hermes:restart) or the max items limit.
$this->dispatcher->handle(array_map(intval(...), $priorities));
return Command::SUCCESS;
}
}php bin/console hermes:work # terminal 1
php bin/console app:register-user jane@example.com # terminal 2
grep 'Welcome email sent' log/info.logAk sa po zmene súboru NEON kontajner neprestaví, zmažte temp/cache (v debug režime sa prestavuje sám).
7. Reštart workerov pri nasadení
<?php
declare(strict_types=1);
namespace App\Console;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Tomaj\Hermes\Shutdown\ShutdownInterface;
#[AsCommand(name: 'hermes:restart', description: 'Stop every running Hermes worker after its current message')]
final class HermesRestartCommand extends Command
{
public function __construct(private readonly ShutdownInterface $shutdown)
{
parent::__construct();
}
#[\Override]
protected function execute(InputInterface $input, OutputInterface $output): int
{
$this->shutdown->shutdown();
$output->writeln('Workers will stop after their current message. Supervisor or systemd starts new ones.');
return Command::SUCCESS;
}
}Ako posledný krok nasadenia, po vymazaní cache, spustite php bin/console hermes:restart. Workery sa zastavia po aktuálnej správe a Supervisor spustí nové:
; /etc/supervisor/conf.d/hermes-worker.conf
; Two workers. hermes:restart stops them after their current message, and Supervisor starts new ones.
; With ext-pcntl, SIGTERM (stopsignal) also lets a worker finish its message; stopwaitsecs is the limit before SIGKILL.
[program:hermes-worker]
command=php bin/console hermes:work
directory=/var/www/app
process_name=%(program_name)s_%(process_num)02d
numprocs=2
user=www-data
autostart=true
autorestart=true
startsecs=1
stopwaitsecs=60
stopsignal=TERM
redirect_stderr=true
stdout_logfile=/var/log/hermes-worker.logSúbor na ukončenie je temp/hermes-shutdown. Ak nasadenie nahrádza priečinok temp, workery zo starého vydania nový súbor nikdy neuvidia; použite cestu mimo vydania, napríklad zdieľaný priečinok. Pozrite Ukončenie a limit správ a Nasadenie do produkcie.
8. Otestujte producenta
Príkazu odovzdajte mock EmitterInterface. Od Hermesu 6.0 je Emitter final a nedá sa zamockovať.
<?php
declare(strict_types=1);
namespace Tests;
use App\Console\RegisterUserCommand;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Tester\CommandTester;
use Tomaj\Hermes\EmitterInterface;
use Tomaj\Hermes\MessageInterface;
final class RegisterUserCommandTest extends TestCase
{
public function testEmitsUserRegistered(): void
{
// Mock the interface: Emitter is final from Hermes 6.0.
$emitter = $this->createMock(EmitterInterface::class);
$emitter->expects(self::once())
->method('emit')
->with(self::callback(
static fn(MessageInterface $message): bool => $message->getType() === 'user.registered'
&& $message->getPayload() === ['email' => 'jane@example.com'],
))
->willReturnSelf();
$tester = new CommandTester(new RegisterUserCommand($emitter));
self::assertSame(0, $tester->execute(['email' => 'jane@example.com']));
}
}Emitovanie po commite do databázy
Ak presenter vloží riadok v transakcii a emitne o ňom správu, obaľte emitter do BufferedEmitter, aby worker nikdy nehľadal riadok, ktorý ešte nie je commitnutý. Riadok - Tomaj\Hermes\Emitter v hermes.neon nahraďte týmto, kde databaseTransaction je vaša služba s isInTransaction(), registerOnCommit() a registerOnRollback():
hermes.emitter:
create: Tomaj\Hermes\BufferedEmitter(Tomaj\Hermes\Emitter(@hermes.driver, @Psr\Log\LoggerInterface), [@databaseTransaction, isInTransaction])
setup:
- @databaseTransaction::registerOnCommit([@self, flush])
- @databaseTransaction::registerOnRollback([@self, discard])@self je BufferedEmitter, takže hooky odosielajú a zahadzujú tú istú inštanciu, ktorú producenti dostanú pre EmitterInterface. Overené na skompilovanom kontajneri s nette/di 3.2.7. Nette Database vlastné callbacky po commite nemá; overte, že ich vaša služba transakcií volá len pre najvonkajšiu transakciu.
Náhodná zložka opakovania a služba Random\Randomizer
Od Hermesu 6.0 Dispatcher prijíma voliteľný ?Random\Randomizer $randomizer (pozrite Integrácia do frameworkov). Nette ho autowiruje: ak má kontajner službu Random\Randomizer, dispatcher ju dostane. Ak chcete zachovať predvolený bezpečný engine, na Hermese 6.0 alebo novšom zapíšte dispatcher takto:
hermes.dispatcher:
create: Tomaj\Hermes\Dispatcher(randomizer: null)Na Hermese 5.x Nette zlyhá s Unable to pass specified arguments to Tomaj\Hermes\Dispatcher::__construct(), preto to tam vynechajte.
Celý zdrojový kód
Všetky súbory, s install.sh, e2e.sh a programom pre Supervisor, sú v examples/frameworks/nette na GitHube. Overené s nette/application 3.3.0, nette/di 3.2.6, contributte/console 0.11.0 a Tracy 2.12 na PHP 8.4, so všetkými drivermi.
Časté otázky
Prečo contributte/console a nie obyčajný PHP skript?
Príkazy dostávajú závislosti z kontajnera ako presentery a --priority aj nápovedu máte zadarmo. Funguje aj obyčajný bin/worker.php, ktorý vytvorí kontajner a zavolá handle(); pozrite návod na uvítací e-mail.
Môžem namiesto Tracy použiť Monolog?
Áno. Zaregistrujte službu Monolog\Logger a odstráňte riadok s TracyToPsrLoggerAdapter, aby sa autowiroval len jeden LoggerInterface. Pozrite Logovanie.
Kam dať driver pre jednotlivé prostredia?
Do config/local.neon, ktorý Bootstrap načíta ako posledný. Napríklad parameters: hermes: driver: rabbitmq. Ako si vybrať, nájdete v Prehľade driverov.
Čaká presenter na handler?
Nie. emit() len zapíše správu do fronty a vráti sa; worker ju spracuje neskôr. Pozrite Pojmy.
Ako spustiť viac workerov?
Zvýšte numprocs v programe pre Supervisor. Každý worker berie z tej istej fronty po jednej správe. Pozrite Nasadenie do produkcie.