• Время чтения ~13 мин
  • 21.08.2022

Event Sourcing — это термин, который в последние несколько лет становится все более популярным в PHP-сообществе, но до сих пор остается загадкой для многих разработчиков. Вопросы всегда как и почему, и это понятно. Это руководство предназначено для того, чтобы помочь вам не только понять, что такое Event Sourcing на практике, но и узнать, когда вы можете захотеть его использовать.

В традиционном приложении состояние нашего приложения напрямую представлено в базе данных, к которой мы подключены. Мы не до конца понимаем, как он туда попал. Все, что мы знаем, это то, что это так. Есть способы, которыми мы могли бы понять это немного лучше, используя инструменты для аудита изменений модели, чтобы мы могли видеть, что было изменено и кем. Это, опять же, шаг в правильном направлении.Тем не менее, мы все еще не понимаем критический вопрос.

Почему? Почему эта модель изменилась? Какова цель этого изменения?

Именно в этом случае Event Sourcing сохраняет свое значение, сохраняя историческое представление о том, что произошло с состоянием приложения, а также почему оно изменилось. . Event Sourcing позволяет вам принимать решения на основе прошлого, позволяя создавать отчеты.Но на базовом уровне он позволяет узнать, почему изменилось состояние приложения. Это делается с помощью событий.

Я создам базовый проект Laravel, чтобы показать вам, как это работает. Приложение, которое мы создадим, будет относительно простым, чтобы вы могли понять логику источника событий, а не запутаться в логике приложения.Мы создаем приложение, в котором мы можем отмечать членов команды. Вот и все. Простой и понятный. У нас есть команды с пользователями, и мы хотим иметь возможность отпраздновать что-то публично в команде.

Мы начнем с нового проекта Laravel, но я буду использовать Jetstream так как я хочу запустить аутентификацию, структуру и функциональность команды.Как только вы настроите этот проект, откройте его в выбранной вами среде IDE (правильный ответ здесь — PHPStorm, конечно), и мы готовы погрузиться в некоторые источники событий в Laravel.

Мы хотим создать дополнительную модель для нашего приложения, одну из наших единственных. Это будет модель Celebration, и вы можете создать ее с помощью следующей команды мастера:

Измените метод миграции вверх, чтобы он выглядел следующим образом:

php artisan make:model Celebration -m

У нас есть праздник reason, простое предложение, затем необязательное сообщение, которое мы могли бы отправить вместе с празднованием. Наряду с этим у нас есть три отношения: пользователь, которого празднуют, пользователь, который отправляет празднование, и в какой команде они находятся.С 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.

Как и в большинстве проектов, которые я создаю, неважно размер, я принял модульную компоновку для приложения - подход к проектированию, управляемому предметной областью.Это просто то, что я делаю, не думайте, что вы должны следить за этим самостоятельно, так как это очень субъективно.

Мой следующий шаг состоял в том, чтобы настроить мои домены, и для этой демонстрации у меня был только один Домен: Культура. В Culture я создал пространства имен для всего, что мне может понадобиться. Но я пройду через это, чтобы вы понимали процесс.

Первым шагом была установка пакета, который позволил бы мне использовать Event Sourcing в Laravel. Для этого я использовал пакет Spatie, который выполняет большую часть фоновой работы за меня. Давайте установим этот пакет с помощью composer:

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

composer require spatie/laravel-event-sourcing

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

php artisan migrate

Проектор — это класс, который находится внутри вашего приложения и обрабатывает события, которые вы отправляете. Затем они изменят состояние вашего приложения. Это шаг за пределы простого обновления вашей базы данных.Он находится посередине, перехватывает событие, сохраняет его, а затем вносит необходимые изменения, которые затем «проецируют» новое состояние для приложения.

Другой подход, который я предпочитаю, агрегаты — это классы, которые, подобно проекторам, обрабатывают для вас состояние приложения. Вместо того, чтобы сами запускать события в нашем приложении, мы оставляем это агрегату, который сделает это за нас.Думайте об этом как о реле: вы просите реле что-то сделать, и оно делает это за вас.

Прежде чем мы сможем создать наш первый агрегат, нам нужно немного поработать делать в фоновом режиме. Я большой поклонник создания хранилища событий для каждого агрегата, чтобы запросы выполнялись быстрее и это хранилище не заполнялось слишком быстро.Это объясняется в документации к пакету, но я проведу вас сам, так как в документации это не совсем понятно.

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

Следующий код — это то, что вам понадобится метод 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();
    }
}

Наши события тоже нужно обрабатывать. В другом методе мы используем Projector для обработки наших событий, но нам приходится вызывать их вручную.Здесь аналогичный процесс, но вместо того, чтобы наш агрегат инициировал событие, нам по-прежнему нужен проектор для обработки события и изменения состояния нашего приложения.

Давайте создадим наш Проектор, который я называю обработчиками — так как они обрабатывают события. Но я оставлю это на ваше усмотрение, как вы хотите назвать свою.

Наш проектор/обработчик, как бы вы его ни назвали, будет разрешен из контейнера для нас, а затем будет искать метод с префиксом 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, а может быть использовали что-то другое, мы можем создать новое действие — привязать реализацию в нашем контейнере, воспроизвести наши события, и все должно просто работать.

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(),
        );
    }
}

На данном этапе мы сохраняем наши события и имеем возможность изменять состояние нашего приложения, но делаем ли мы это?Нам нужно сообщить библиотеке источников событий, что мы зарегистрировали этот проектор/обработчик, чтобы он знал, как запускать его по событию. Обычно я создаю EventSourcingServiceProvider для каждого домена, чтобы зарегистрировать все обработчики в одном месте. Мой выглядит следующим образом:

Осталось только убедиться, что этот поставщик услуг снова зарегистрирован.Я создаю поставщика услуг для каждого домена, чтобы зарегистрировать дочерних поставщиков услуг, но это уже другая история и учебник.

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.

Использовали ли вы Event Sourcing в своих приложениях? Как вы подошли к этому? Дайте нам знать в Твиттере!

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