Preskočiť na obsah

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 ​

bash
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 driver

Ký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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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;
    }
}
bash
php artisan hermes:work                          # terminal 1
php artisan app:register-user jane@example.com   # terminal 2
grep 'Welcome email sent' storage/logs/laravel.log

php 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
<?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:

ini
; /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.log

systemd 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
<?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:

php
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:

php
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.

Vydané pod licenciou MIT.