Laravel
Táto stránka pridá Hermes do nového projektu v Laraveli 11, 12 alebo 13. Controller vloží user.registered a php artisan 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 laravel/laravel app
cd app
composer require tomaj/hermes:dev-master
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. Predvolený driver redis potrebuje rozšírenie phpredis (ext-redis).
2. Konfigurácia drivera
config/hermes.php číta všetko z .env. Nastavte HERMES_DRIVER na redis, predis, rabbitmq alebo sqs a každej aplikácii dajte vlastnú HERMES_QUEUE.
<?php
declare(strict_types=1);
return [
// redis (ext-redis), predis, rabbitmq or sqs
'driver' => env('HERMES_DRIVER', 'redis'),
'queue' => env('HERMES_QUEUE', 'hermes'),
'max_items' => (int) env('HERMES_MAX_ITEMS', 1000),
'shutdown_file' => storage_path('framework/hermes-shutdown'),
'redis' => [
'host' => env('HERMES_REDIS_HOST', '127.0.0.1'),
'port' => (int) env('HERMES_REDIS_PORT', 6379),
],
'rabbitmq' => [
'host' => env('HERMES_RABBITMQ_HOST', '127.0.0.1'),
'port' => (int) env('HERMES_RABBITMQ_PORT', 5672),
'user' => env('HERMES_RABBITMQ_USER', 'guest'),
'password' => env('HERMES_RABBITMQ_PASSWORD', 'guest'),
'vhost' => env('HERMES_RABBITMQ_VHOST', '/'),
],
'sqs' => [
'region' => env('HERMES_SQS_REGION', 'eu-central-1'),
'endpoint' => env('HERMES_SQS_ENDPOINT'),
'key' => env('AWS_ACCESS_KEY_ID', ''),
'secret' => env('AWS_SECRET_ACCESS_KEY', ''),
],
];app/Hermes/DriverFactory.php premení toto pole na driver. Je to ten istý súbor vo všetkých frameworkoch; pozrite Integrácia do frameworkov.
<?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);
}
}Hermes otvára vlastné spojenie s Redisom namiesto Redis::connection(). Spojenie Laravelu pridáva ku každému kľúču REDIS_PREFIX a zdieľa ho cache aj fronty Laravelu.
3. Registrácia služieb
Provider viaže štyri singletony. Producenti dostávajú EmitterInterface; DispatcherInterface, kde sa registrujú handlery, si vytvára len príkaz workera.
<?php
declare(strict_types=1);
namespace App\Providers;
use App\Hermes\DriverFactory;
use App\Hermes\UserRegisteredHandler;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Support\ServiceProvider;
use Psr\Log\LoggerInterface;
use Tomaj\Hermes\Dispatcher;
use Tomaj\Hermes\DispatcherInterface;
use Tomaj\Hermes\Driver\DriverInterface;
use Tomaj\Hermes\Emitter;
use Tomaj\Hermes\EmitterInterface;
use Tomaj\Hermes\Shutdown\SharedFileShutdown;
use Tomaj\Hermes\Shutdown\ShutdownInterface;
final class HermesServiceProvider extends ServiceProvider
{
#[\Override]
public function register(): void
{
$this->app->singleton(
DriverInterface::class,
static fn(Application $app): DriverInterface => DriverFactory::create($app->make('config')->get('hermes')),
);
$this->app->singleton(
ShutdownInterface::class,
static fn(Application $app): ShutdownInterface => new SharedFileShutdown($app->make('config')->get('hermes.shutdown_file')),
);
// Producers (controllers, jobs, commands) depend on EmitterInterface.
$this->app->singleton(
EmitterInterface::class,
static fn(Application $app): EmitterInterface => new Emitter(
$app->make(DriverInterface::class),
$app->make(LoggerInterface::class),
),
);
// Only the worker command resolves the dispatcher.
$this->app->singleton(DispatcherInterface::class, static function (Application $app): DispatcherInterface {
$dispatcher = new Dispatcher(
$app->make(DriverInterface::class),
$app->make(LoggerInterface::class),
$app->make(ShutdownInterface::class),
);
$dispatcher->registerHandler('user.registered', $app->make(UserRegisteredHandler::class));
return $dispatcher;
});
}
}Pridajte provider do bootstrap/providers.php:
<?php
declare(strict_types=1);
use App\Providers\AppServiceProvider;
use App\Providers\HermesServiceProvider;
return [
AppServiceProvider::class,
HermesServiceProvider::class,
];Logger je väzba LoggerInterface z Laravelu, takže Hermes zapisuje riadky Start handle message a End handle message do storage/logs/laravel.log cez vaše logovacie kanály. Pozrite Logovanie.
4. Vloženie správy z controllera
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Tomaj\Hermes\EmitterInterface;
use Tomaj\Hermes\Message;
final class UserController extends Controller
{
public function __construct(private readonly EmitterInterface $emitter) {}
public function store(Request $request): JsonResponse
{
$data = $request->validate(['email' => ['required', 'email']]);
// Save the user here, then hand the slow work to the worker.
$this->emitter->emit(new Message('user.registered', ['email' => $data['email']]));
return new JsonResponse(['status' => 'registered'], 201);
}
}<?php
declare(strict_types=1);
use App\Http\Controllers\UserController;
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
return view('welcome');
});
Route::post('/users', [UserController::class, 'store']);routes/web.php má ochranu CSRF, takže formulár musí posielať @csrf a obyčajný JSON POST dostane 419. Pre JSON API dajte route do routes/api.php (php artisan install:api).
Správy môže vkladať aj príkaz. Tento je producent, ktorý sa používa nižšie:
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Tomaj\Hermes\EmitterInterface;
use Tomaj\Hermes\Message;
final class RegisterUserCommand extends Command
{
protected $signature = 'app:register-user {email}';
protected $description = 'Emit user.registered for one email address';
public function handle(EmitterInterface $emitter): int
{
$email = (string) $this->argument('email');
$emitter->emit(new Message('user.registered', ['email' => $email]));
$this->info("Queued the welcome email for {$email}.");
return self::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;
}
}Provider ho vytvára cez $app->make(), takže jeho konštruktor si môže vypýtať akúkoľvek službu. Výnimkou spustíte opakovanie, vrátením false to vzdáte; pozrite Opakovanie.
6. Spustite workera
Laravel 11, 12 a 13 registrujú každý príkaz v app/Console/Commands.
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Tomaj\Hermes\DispatcherInterface;
final class HermesWorkCommand extends Command
{
protected $signature = 'hermes:work {--priority=* : Handle only these priorities}';
protected $description = 'Run the Hermes worker until it is told to stop';
public function handle(DispatcherInterface $dispatcher): int
{
$priorities = array_map(intval(...), (array) $this->option('priority'));
// Blocks until shutdown (hermes:restart) or the max items limit.
$dispatcher->handle($priorities);
return self::SUCCESS;
}
}php artisan hermes:work # terminal 1
php artisan app:register-user jane@example.com # terminal 2
grep 'Welcome email sent' storage/logs/laravel.logphp artisan hermes:work --priority=200 spracúva len prioritu 200, ak má váš driver frontu s touto prioritou. Pozrite Priorita.
7. Reštart workerov pri nasadení
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Tomaj\Hermes\Shutdown\ShutdownInterface;
final class HermesRestartCommand extends Command
{
protected $signature = 'hermes:restart';
protected $description = 'Stop every running Hermes worker after its current message';
public function handle(ShutdownInterface $shutdown): int
{
$shutdown->shutdown();
$this->info('Workers will stop after their current message. Supervisor or systemd starts new ones.');
return self::SUCCESS;
}
}Ako posledný krok nasadenia spustite php artisan hermes:restart. Každý worker sa zastaví po aktuálnej správe a Supervisor spustí nový s novým kódom:
; /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 artisan 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.logsystemd a Kubernetes opisuje Nasadenie do produkcie.
8. Otestujte producenta
V kontajneri naviažte mock EmitterInterface. Od Hermesu 6.0 je Emitter final a nedá sa zamockovať.
<?php
declare(strict_types=1);
namespace Tests\Feature;
use Tests\TestCase;
use Tomaj\Hermes\EmitterInterface;
use Tomaj\Hermes\MessageInterface;
final class UserControllerTest extends TestCase
{
public function testRegistrationEmitsUserRegistered(): 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();
$this->app->instance(EmitterInterface::class, $emitter);
$this->postJson('/users', ['email' => 'jane@example.com'])->assertCreated();
}
public function testInvalidEmailEmitsNothing(): void
{
$emitter = $this->createMock(EmitterInterface::class);
$emitter->expects(self::never())->method('emit');
$this->app->instance(EmitterInterface::class, $emitter);
$this->postJson('/users', ['email' => 'not-an-email'])->assertUnprocessable();
}
}Handlery testujte samostatne, bez drivera; pozrite Testovanie.
Emitovanie po commite do databázy
Ak controller vloží riadok v DB::transaction() a emitne o ňom správu, zaregistrujte namiesto Emitter BufferedEmitter a nasmerujte naň alias EmitterInterface, aby worker nikdy nehľadal riadok, ktorý ešte nie je commitnutý. V HermesServiceProvider:
use Illuminate\Database\Events\TransactionCommitted;
use Illuminate\Database\Events\TransactionRolledBack;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Tomaj\Hermes\BufferedEmitter;
// register()
$this->app->singleton(
BufferedEmitter::class,
static fn(Application $app): BufferedEmitter => new BufferedEmitter(
new Emitter($app->make(DriverInterface::class), $app->make(LoggerInterface::class)),
static fn(): bool => DB::transactionLevel() > 0,
),
);
$this->app->alias(BufferedEmitter::class, EmitterInterface::class);
// boot()
Event::listen(TransactionCommitted::class, function (TransactionCommitted $event): void {
if ($event->connection->transactionLevel() === 0) {
$this->app->make(BufferedEmitter::class)->flush();
}
});
Event::listen(TransactionRolledBack::class, function (TransactionRolledBack $event): void {
if ($event->connection->transactionLevel() === 0) {
$this->app->make(BufferedEmitter::class)->discard();
}
});Mimo transakcie predikát odosiela hneď. Kontroly transactionLevel() === 0 zabezpečia, že vnorená DB::transaction() nič neodošle ani nezahodí; robí to len najvonkajšia. Správa emitnutá vo vnorenej transakcii, ktorá sa vráti späť, zostane v bufferi a odošle sa po commite vonkajšej transakcie. Predikát sa pozerá na predvolené spojenie; pri viacerých spojeniach porovnávajte v listeneroch aj $event->connectionName. Väzba, alias aj listenery boli overené s Laravel 13.38 na SQLite.
Náhodná zložka opakovania a väzba Random\Randomizer
Od Hermesu 6.0 Dispatcher prijíma ako štvrtý argument voliteľný ?Random\Randomizer (pozrite Integrácia do frameworkov). Provider vyššie volá new Dispatcher(...) s tromi argumentmi, takže väzba Random\Randomizer inde v aplikácii časy opakovania nezmení.
Ak triedu namiesto toho získate cez app(Dispatcher::class), Laravel štvrtý argument autowiruje a vloží vašu väzbu Random\Randomizer. Ponechajte v provideri new Dispatcher(...) alebo pridajte v register() kontextovú väzbu, aby dispatcher dostal null a zachoval predvolené správanie:
use Random\Randomizer;
use Tomaj\Hermes\Dispatcher;
$this->app->when(Dispatcher::class)->needs(Randomizer::class)->give(fn () => null);Celý zdrojový kód
Všetky súbory, s install.sh, e2e.sh a programom pre Supervisor, sú v examples/frameworks/laravel na GitHube. Overené s Laravelom 11.57.0, 12.70.0 a 13.38.0 na PHP 8.4, so všetkými drivermi.
Časté otázky
Prečo nepoužiť vlastnú frontu Laravelu?
Použite ju, ak vyhovuje. Hermes sa hodí, keď tie isté správy vkladajú alebo spracúvajú iné PHP aplikácie, napríklad služby v Nette alebo Symfony, alebo keď chcete priority Hermesu a jeho rozloženie v Redise. Obe môžu bežať vedľa seba na rôznych kľúčoch; pozrite Integrácia do frameworkov.
Zastaví php artisan queue:restart workery Hermesu?
Nie. Signál reštartu Laravelu je v jeho cache. Workery Hermesu sledujú súbor SharedFileShutdown, preto použite php artisan hermes:restart. Pozrite Ukončenie a limit správ.
Môžem workera spustiť cez php artisan schedule:work alebo z jobu?
Nie. hermes:work blokuje, kým ho niekto nezastaví. Spúšťajte ho ako samostatný dlhobežiaci proces pod Supervisorom alebo systemd, ako v Nasadení do produkcie.
Akého klienta Redisu používa?
HERMES_DRIVER=redis používa rozšírenie phpredis, predis balík Predis. Nezávisí od REDIS_CLIENT. Pozrite Redis a Predis.
Ako používať SQS lokálne?
Nastavte HERMES_DRIVER=sqs, HERMES_SQS_ENDPOINT=http://127.0.0.1:4566 a ľubovoľné AWS_ACCESS_KEY_ID a AWS_SECRET_ACCESS_KEY pre lokálny server moto alebo LocalStack. Pozrite Amazon SQS.