• Час читання ~13 хв
  • 21.08.2022

Пошук подій – це термін, який набирає популярності в спільноті PHP протягом останніх кількох років, але він досі залишається загадкою для багатьох розробників. Завжди виникають питання як і чому, і це зрозуміло. Цей підручник розроблено, щоб допомогти вам не лише зрозуміти, що таке джерело подій, у практичний спосіб, але й знати, коли ви можете його використовувати.

У традиційній програмі стан нашої програми безпосередньо представлено в базі даних, до якої ми підключені. Ми не до кінця розуміємо, як воно туди потрапило. Все, що ми знаємо, це те, що це так. Є способи, за допомогою яких ми можемо зрозуміти це трохи краще, використовуючи інструменти для аудиту змін моделі, щоб ми могли побачити, що було змінено та ким. Це знову ж таки крок у правильному напрямку.Однак ми все ще не розуміємо критичного запитання.

Чому? Чому ця модель змінилася? Яка мета цієї зміни?

Це місце, де Event Sourcing тримається, зберігаючи історичний огляд того, що сталося зі станом програми, а також чому він змінився . Event Sourcing дає змогу приймати рішення на основі минулого, створюючи звіти.Але на базовому рівні він дає вам знати, чому змінився стан програми. Це робиться за допомогою подій.

Я створю базовий проект Laravel, щоб розповісти вам, як це працює. Програма, яку ми створимо, відносно проста, щоб ви могли зрозуміти логіку джерела подій, а не губитися в логіці програми.Ми створюємо програму, де ми зможемо відзначати членів команди. Це все. Простий і зрозумілий. У нас є команди з користувачами, і ми хочемо відзначити щось публічно в команді.

Ми почнемо з нового проекту Laravel, але я використовуватиму Jetstream оскільки я хочу запустити автентифікацію, структуру та функціональність команди.Налаштувавши цей проект, відкрийте його у вибраній середі IDE (правильною відповіддю тут, звісно, ​​є PHPStorm), і ми готові зануритися в деякі джерела подій у Laravel.

Ми хочемо створити додаткову модель для нашої програми, одну з єдиних. Це буде модель Celebration, і ви можете створити її за допомогою наступної команди artisan:

Змініть свій метод міграції так, щоб він виглядав так:

php artisan make:model Celebration -m

У нас є причина святкування, просте речення, потім необов’язковий повідомлення, яке ми могли б надіслати разом зі святкуванням. Крім цього, у нас є три відносини: користувач, якого відзначають, користувач, який надсилає святкування, і до якої команди вони входять.За допомогою Jetstream користувач може входити до кількох команд, і може виникнути ситуація, коли обидва користувачі належать до однієї команди, і ми хочемо бути впевненими, що відзначаємо їх публічно в правильній команді.

public function up(): void
{
    Schema::create('celebrations', static function (Blueprint $table): void {
        $table->id();
 
        $table->string('reason');
        $table->text('message')->nullable();
 
        $table
            ->foreignId('user_id')
            ->index()
            ->constrained()
            ->cascadeOnDelete();
 
        $table
            ->foreignId('sender_id')
            ->index()
            ->constrained('users')
            ->cascadeOnDelete();
 
        $table
            ->foreignId('team_id')
            ->index()
            ->constrained()
            ->cascadeOnDelete();
 
        $table->timestamps();
    });
}

Якщо у нас є ці налаштування, давайте подивимось на саму модель:

Ми можемо відобразити зв’язки на інших моделях, якщо вони відповідні .Тим не менш, за замовчуванням я додаю іншу сторону зв’язку до кожної моделі, щоб уточнити зв’язок між моделями, незалежно від того, чи це вкрай необхідно. Це звичка, яку я придбав, щоб допомогти іншим зрозуміти саму модель даних.

declare(strict_types=1);
 
namespace App\Models;
 
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
 
final class Celebration extends Model
{
    use HasFactory;
 
    protected $fillable = [
        'reason',
        'message',
        'user_id',
        'sender_id',
        'team_id',
    ];
 
    public function user(): BelongsTo
    {
        return $this->belongsTo(
            related: User::class,
            foreignKey: 'user_id',
        );
    }
 
    public function sender(): BelongsTo
    {
        return $this->belongsTo(
            related: User::class,
            foreignKey: 'sender_id',
        );
    }
 
    public function team(): BelongsTo
    {
        return $this->belongsTo(
            related: Team::class,
            foreignKey: 'team_id',
        );
    }
}

Тепер у нас є основа нашої програми, створена з точки зору моделювання. Нам потрібно подумати про встановлення деяких пакетів, які нам допоможуть.Для своєї програми я використовував Laravel Livewire для керування інтерфейсом користувача. Однак я не буду вдаватися в деталі цього підручника, оскільки хочу переконатися, що зосереджуся на аспекті джерела подій.

Як і в більшості проектів, які я створюю, неважливо розміру, я прийняв модульний макет для програми - підхід до проектування, керованого доменом.Це просто те, що я роблю, не думаю, що ви повинні стежити за цим самостійно, оскільки це дуже суб’єктивно.

Моїм наступним кроком було налаштування моїх доменів, і для цієї демонстрації у мене був лише один домен: культура. У культурі я створив простори імен для всього, що мені може знадобитися. Але я пройду через це, щоб ви зрозуміли процес.

Першим кроком було встановлення пакета, який би дозволив мені використовувати Event Sourcing у Laravel. Для цього я використав пакет Spatie, який виконує за мене багато фонової роботи. Давайте встановимо цей пакет за допомогою композитора:

Після того, як це буде встановлено, переконайтеся, що ви дотримуєтеся інструкцій зі встановлення для пакета, оскільки конфігурація та міграції потребують публікації. Після правильного встановлення запустіть міграції, щоб ваша база даних була в належному стані.

composer require spatie/laravel-event-sourcing

Тепер ми можемо почати думати про те, як ми хочемо реалізувати джерело подій.Ви можете реалізувати це кількома способами: проектори для проектування вашого стану або агрегатів.

php artisan migrate

Проектор — це клас, який міститься у вашій програмі та обробляє події, які ви відправляєте. Потім вони змінять стан вашої програми. Це крок далі простого оновлення бази даних.Він знаходиться посередині, ловить подію, зберігає її, а потім вносить необхідні зміни, які потім «проектують» новий стан програми.

Інший підхід, якому я віддаю перевагу, — агрегати — це класи, які, як і проектори, обробляють стан програми за вас. Замість того, щоб запускати події самостійно в нашій програмі, ми залишаємо це для агрегату, щоб зробити це за нас.Думайте про це як про ретранслятор, ви просите ретранслятор щось зробити, і він виконує це за вас.

Перш ніж ми зможемо створити наш перший агрегат, потрібно ще трохи попрацювати робити у фоновому режимі. Я великий прихильник створення сховища подій для кожного агрегату, щоб запити були швидшими і цей магазин не заповнювався надшвидко.Це пояснюється в документації пакета, але я сам проведу вас, оскільки в документах це було не зовсім зрозуміло.

Перший крок — створити моделі та міграції, оскільки в майбутньому вам знадобиться спосіб запиту для звітів тощо. Виконайте таку команду artisan, щоб створити їх:

Наведений нижче код вам знадобиться у вашому методі up для міграції:

php artisan make:model CelebrationStoredEvent -m

Як бачите, ми збираємо досить багато даних для наших подій. Тепер модель стала набагато простішою. Це має виглядати так:

public function up(): void
{
    Schema::create('celebration_stored_events', static function (Blueprint $table): void {
        $table->id();
        $table->uuid('aggregate_uuid')->nullable()->unique();
        $table
		->unsignedBigInteger('aggregate_version')
		->nullable()
		->unique();
        $table->integer('event_version')->default(1);
        $table->string('event_class');
 
        $table->json('event_properties');
 
        $table->json('meta_data');
 
        $table->timestamp('created_at');
 
        $table->index('event_class');
        $table->index('aggregate_uuid');
    });
}

Оскільки ми розширюємо модель EloquentStoredEvent, нам потрібно лише змінити таблицю, яку вона переглядає . Решта функціональних можливостей для моделі вже є на батьківській версії.

declare(strict_types=1);
 
namespace App\Models;
 
 
use Spatie\EventSourcing\StoredEvents\Models\EloquentStoredEvent;
 
final class CelebrationStoredEvent extends EloquentStoredEvent
{
    public $table = 'celebration_stored_events';
}

Щоб використовувати ці моделі, ви повинні створити репозиторій для запитів подій. Це досить просте сховище, але це важливий крок. Я додав свій до свого коду домену в розділі src/Domains/Culture/Repositories/, але не соромтеся додати свій там, де це буде для вас найбільш зрозумілим:

Тепер, коли у нас є спосіб зберігати події та запитувати їх, ми можемо перейти до нашого агрегату.Знову ж таки, я зберіг свій у своєму домені, але не соромтеся зберігати свій у контексті вашої програми.

declare(strict_types=1);
 
namespace Domains\Culture\Repositories;
 
use App\Models\CelebrationStoredEvent;
use Spatie\EventSourcing\StoredEvents\Repositories\EloquentStoredEventRepository;
 
final class CelebrationStoredEventsRepository extends EloquentStoredEventRepository
{
    public function __construct(
        protected string $storedEventModel = CelebrationStoredEvent::class,
    ) {
        parent::__construct();
    }
}

Цей агрегат наразі не робитиме нічого, крім підключення до потрібної події магазин для нас. Щоб він почав відстежувати події, нам потрібно спочатку їх створити. Але перш ніж ми це зробимо, нам потрібно на мить зупинитися і подумати. Які дані ми хочемо зберігати в події?Чи хочемо ми зберігати все, що нам потрібно? Або ми хочемо зберегти масив так, ніби він надходить із форми? Я не використовую жоден із цих підходів, бо навіщо робити це просто? Я використовую об’єкти передачі даних у всіх своїх подіях, щоб забезпечити постійну підтримку контексту та безпеку типів.

declare(strict_types=1);
 
namespace Domains\Culture\Aggregates;
 
use Domains\Culture\Repositories\CelebrationStoredEventsRepository;
use Spatie\EventSourcing\AggregateRoots\AggregateRoot;
use Spatie\EventSourcing\StoredEvents\Repositories\StoredEventRepository;
 
final class CelebrationAggregateRoot extends AggregateRoot
{
    protected function getStoredEventRepository(): StoredEventRepository
    {
        return app()->make(
            abstract: CelebrationStoredEventsRepository::class,
        );
    }
}

Я створив пакет, який дозволяє мені робити це легше .Не соромтеся використовувати його, встановивши за допомогою такої команди композитора:

Як і раніше, я зберігаю свої об’єкти даних у своєму домені за замовчуванням, але додаю туди, де це найбільше корисно сенс для вас. Я створив об’єкт даних під назвою Celebration, який міг передати подіям і агрегатам:

composer require juststeveking/laravel-data-object-tools

Коли я оновлюся до PHP 8.2 це буде набагато простіше, оскільки я зможу створювати класи лише для читання - і так, мій пакет уже підтримує їх.

declare(strict_types=1);
 
namespace Domains\Culture\DataObjects;
 
use JustSteveKing\DataObjects\Contracts\DataObjectContract;
 
final class Celebration implements DataObjectContract
{
    public function __construct(
        private readonly string $reason,
        private readonly string $message,
        private readonly int $user,
        private readonly int $sender,
        private readonly int $team,
    ) {}
 
    public function userID(): int
    {
        return $this->user;
    }
 
    public function senderID(): int
    {
        return $this->sender;
    }
 
    public function teamUD(): int
    {
        return $this->team;
    }
 
    public function toArray(): array
    {
        return [
            'reason' => $this->reason,
            'message' => $this->message,
            'user_id' => $this->user,
            'sender_id' => $this->sender,
            'team_id' => $this->team,
        ];
    }
}

Тепер у нас є наш об'єкт даних. Ми можемо повернутися до події, яку хочемо зберегти. Я назвав свій CelebrationWasCreated, оскільки назви подій завжди мають бути в минулому часі. Давайте подивимося на цю подію:

Оскільки ми використовуємо об’єкти даних, наш клас залишається чистим. Отже, тепер, коли у нас є подія та об’єкт даних, який ми можемо надсилати, нам потрібно подумати, як це ініціювати. Це повертає нас до самого агрегату, тому давайте створимо метод на нашому агрегаті, який ми можемо використовувати для цього:

declare(strict_types=1);
 
namespace Domains\Culture\Events;
 
use Domains\Culture\DataObjects\Celebration;
use Spatie\EventSourcing\StoredEvents\ShouldBeStored;
 
final class CelebrationWasCreated extends ShouldBeStored
{
    public function __construct(
        public readonly Celebration $celebration,
    ) {}
}

На даний момент у нас є спосіб попросити клас записати подію.Однак ця подія ще не збережеться - це буде пізніше. Крім того, ми жодним чином не змінюємо стан наших програм. Отже, як нам зробити цей біт джерела подій? Ця частина для мене зводиться до реалізації в Livewire, через яку я вас зараз розповім.

declare(strict_types=1);
 
namespace Domains\Culture\Aggregates;
 
use Domains\Culture\DataObjects\Celebration;
use Domains\Culture\Events\CelebrationWasCreated;
use Domains\Culture\Repositories\CelebrationStoredEventsRepository;
use Spatie\EventSourcing\AggregateRoots\AggregateRoot;
use Spatie\EventSourcing\StoredEvents\Repositories\StoredEventRepository;
 
final class CelebrationAggregateRoot extends AggregateRoot
{
    protected function getStoredEventRepository(): StoredEventRepository
    {
        return app()->make(
            abstract: CelebrationStoredEventsRepository::class,
        );
    }
 
    public function createCelebration(Celebration $celebration): CelebrationAggregateRoot
    {
        $this->recordThat(
            domainEvent: new CelebrationWasCreated(
                celebration: $celebration,
            ),
        );
 
        return $this;
    }
}

Мені подобається підходити до цього, відправляючи подію, яка керуватиме цим процесом , оскільки це більш ефективно.Якщо ви думаєте про те, як ви можете взаємодіяти з програмою, ви можете відвідати її з Інтернету, надіслати запит через кінцеву точку API або запустити команду CLI — можливо, це завдання CRON. У всіх цих методах, як правило, вам потрібна миттєва відповідь або, принаймні, ви не хочете чекати. Я покажу вам метод у своєму компоненті Livewire, який я використав для цього:

Я перевіряю дані, введені користувачами з компонента, відправляю нове завдання, яке можна обробити, і закриваю модальний режим. Я передаю новий об’єкт даних у завдання за допомогою свого пакета. У нього є фасад, який дозволяє мені гідратувати класи за допомогою масиву властивостей — і поки що він працює досить добре. Отже, що робить ця робота? Давайте подивимося.

public function celebrate(): void
{
    $this->validate();
 
    dispatch(new TeamMemberCelebration(
        celebration: Hydrator::fill(
            class: Celebration::class,
            properties: [
                'reason' => $this->reason,
                'message' => $this->content,
                'user' => $this->identifier,
                'sender' => auth()->id(),
                'team' => auth()->user()->current_team_id,
            ]
        ),
    ));
 
    $this->closeModal();
}

Наше завдання приймає об’єкт даних у свій конструктор, а потім зберігає його на час обробки. Коли завдання обробляється, воно використовує CelebrationAggregateRoot для отримання агрегату за UUID, а потім викликає метод createCelebration, який ми створили раніше. Після виклику цього методу він викликає persist на самому агрегаті.Це те, що буде зберігати подію для нас. Але, знову ж таки, ми ще не змінили стан наших програм. Все, що нам вдалося зробити, це зберегти непов’язану подію та не створити свято, яке ми хочемо створити? Отже, чого нам не вистачає?

declare(strict_types=1);
 
namespace App\Jobs\Team;
 
use Domains\Culture\Aggregates\CelebrationAggregateRoot;
use Domains\Culture\DataObjects\Celebration;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Str;
 
final class TeamMemberCelebration implements ShouldQueue
{
    use Queueable;
    use Dispatchable;
    use SerializesModels;
    use InteractsWithQueue;
 
    public function __construct(
        public readonly Celebration $celebration,
    ) {}
 
    public function handle(): void
    {
        CelebrationAggregateRoot::retrieve(
            uuid: Str::uuid()->toString(),
        )->createCelebration(
            celebration: $this->celebration,
        )->persist();
    }
}

Наші події також потрібно обробляти. В іншому методі ми використовуємо проектор для обробки наших подій, але нам потрібно викликати їх вручну.Тут відбувається подібний процес, але натомість наш агрегат ініціює подію, нам все одно потрібен проектор, щоб обробляти подію та змінювати стан наших програм.

Давайте створимо нашу Проектор, який я називаю обробниками - оскільки вони обробляють події. Але я залишаю на ваш розсуд, як ви хочете назвати свій.

Наш проектор/обробник, як би ви його не називали, буде вирішено з контейнера для нас, а потім він шукатиме метод із префіксом on, а потім самою назвою події. Отже, у нашому випадку onCelebrationWasCreated.У моєму прикладі я використовую дію для виконання фактичної логіки події - окремі класи виконують одну роботу, яку можна легко підробити або замінити. Отже, ми знову женемося за деревом до наступного класу. Дія, ось як вона виглядає для мене:

declare(strict_types=1);
 
namespace Domains\Culture\Handlers;
 
use Domains\Culture\Events\CelebrationWasCreated;
use Spatie\EventSourcing\EventHandlers\Projectors\Projector;
use Infrastructure\Culture\Actions\CreateNewCelebrationContract;
 
final class CelebrationHandler extends Projector
{
    public function __construct(
        public readonly CreateNewCelebrationContract $action,
    ) {}
 
    public function onCelebrationWasCreated(CelebrationWasCreated $event): void
    {
        $this->action->handle(
            celebration: $event->celebration,
        );
    }
}

Це поточна реалізація дії. Як бачите, мій клас дії сам реалізує контракт/інтерфейс.Це означає, що я прив’язую інтерфейс до певної реалізації в своєму Постачальнику послуг. Це дозволяє мені легко створювати тестові дублі/імітації/альтернативні підходи, не впливаючи на фактичну дію, яку потрібно виконати. Це не суто джерело подій, а загальне програмування. Єдина перевага, яку ми маємо, полягає в тому, що наш проектор можна відтворювати.Отже, якщо з якоїсь причини ми відійшли від Laravel Eloquent і, можливо, ми використали щось інше, ми можемо створити нову дію — зв’язати реалізацію в нашому контейнері, відтворити наші події, і все повинно працювати.

< br

На цьому етапі ми зберігаємо наші події та маємо спосіб змінювати стан нашої програми - але чи маємо ми це?Нам потрібно повідомити бібліотеці джерела подій, що ми зареєстрували цей проектор/обробник, щоб він знав, що запускати його під час події. Зазвичай я створюю EventSourcingServiceProvider для кожного домену, щоб я міг зареєструвати всі обробники в одному місці. Моя виглядає так:

declare(strict_types=1);
 
namespace Domains\Culture\Actions;
 
use App\Models\Celebration;
use Domains\Culture\DataObjects\Celebration as CelebrationObject;
use Illuminate\Database\Eloquent\Model;
use Infrastructure\Culture\Actions\CreateNewCelebrationContract;
 
final class CreateNewCelebration implements CreateNewCelebrationContract
{
    public function handle(CelebrationObject $celebration): Model|Celebration
    {
        return Celebration::query()->create(
            attributes: $celebration->toArray(),
        );
    }
}

Залишилося лише переконатися, що цей Постачальник послуг повторно зареєстрований.Я створюю постачальника послуг для кожного домену, щоб зареєструвати дочірніх постачальників послуг, але це вже інша історія та підручник.

Тепер, коли ми об’єднуємо все разом. Ми можемо попросити наш агрегат створити святкування, яке зафіксує подію та збереже її в базі даних, і як побічний ефект буде запущено наш обробник, змінюючи стан додатків новими змінами.

Це здається трохи довготривалим, чи не так? Чи є кращий спосіб? Можливо, але на даний момент ми знаємо, коли вносяться зміни до стану нашої програми. Ми розуміємо, навіщо вони були зроблені. Крім того, завдяки нашому об’єкту даних ми знаємо, хто та коли вніс зміни. Тож це може бути не найпростіший підхід, але він дозволяє нам краще зрозуміти нашу програму.

declare(strict_types=1);
 
namespace Domains\Culture\Providers;
 
use Domains\Culture\Handlers\CelebrationHandler;
use Illuminate\Support\ServiceProvider;
use Spatie\EventSourcing\Facades\Projectionist;
 
final class EventSourcingServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        Projectionist::addProjector(
            projector: CelebrationHandler::class,
        );
    }
}

Ви можете занурюватися в це скільки завгодно або зануритися в Event Sourcing, де це має найбільший сенс. Сподіваємось, цей підручник показав вам чіткий і практичний шлях, як почати роботу з пошуком подій сьогодні.

Якщо він не показав вам достатньо, Spatie був достатньо люб’язним, щоб дасть вам купон на 30% знижки на їхній курс Event Sourcing у Laravel, що дуже добре! Відвідайте Веб-сайт курсу та використовуйте код купона LARAVEL-NEWS-EVENT-SOURCING.

Чи використовували ви джерело подій у своїх програмах? Як ви до цього підійшли? Повідомте нас у Twitter!

Comments

No comments yet
Yurij Finiv

Yurij Finiv

Full stack

Про мене

Professional Fullstack Developer with extensive experience in website and desktop application development. Proficient in a wide range of tools and technologies, including Bootstrap, Tailwind, HTML5, CSS3, PUG, JavaScript, Alpine.js, jQuery, PHP, MODX, and Node.js. Skilled in website development using Symfony, MODX, and Laravel. Experience: Contributed to the development and translation of MODX3 i...

Про автора CrazyBoy49z
WORK EXPERIENCE
Контакти
Ukraine, Lutsk
+380979856297