• Czas czytania ~13 min
  • 21.08.2022

Event Sourcing to termin, który stał się coraz bardziej popularny w społeczności PHP w ciągu ostatnich kilku lat, ale nadal pozostaje tajemnicą dla wielu programistów. Pytania zawsze brzmią jak i dlaczego, i jest to zrozumiałe. Ten samouczek ma na celu pomóc Ci zrozumieć nie tylko, czym jest pozyskiwanie zdarzeń w praktyczny sposób, ale także wiedzieć, kiedy możesz chcieć z niego skorzystać.

W tradycyjnej aplikacji nasz stan aplikacji jest bezpośrednio reprezentowany w bazie danych, z którą jesteśmy połączeni. Nie do końca rozumiemy, jak się tam dostało. Wiemy tylko, że tak jest. Istnieją sposoby, dzięki którym możemy to nieco lepiej zrozumieć, używając narzędzi do audytu zmian modelu, aby zobaczyć, co zostało zmienione i przez kogo. To kolejny krok we właściwym kierunku.Jednak nadal nie rozumiemy krytycznego pytania.

Dlaczego? Dlaczego ten model się zmienił? Jaki jest cel tej zmiany?

W tym miejscu pozyskiwanie zdarzeń ma swoje własne, zachowując historyczny widok tego, co się stało ze stanem aplikacji, ale także dlaczego się zmienił . Event Sourcing umożliwia podejmowanie decyzji w oparciu o przeszłość, umożliwiając generowanie raportów.Ale na podstawowym poziomie informuje, dlaczego zmienił się stan aplikacji. Odbywa się to poprzez wydarzenia.

Zbuduję podstawowy projekt Laravel, aby poprowadzić cię przez to, jak to działa. Aplikacja, którą stworzymy, jest stosunkowo prosta, dzięki czemu można zrozumieć logikę pozyskiwania zdarzeń, zamiast gubić się w logice aplikacji.Budujemy aplikację, w której możemy świętować członków zespołu. To jest to. Prosty i łatwy do zrozumienia. Mamy zespoły z użytkownikami i chcemy móc świętować coś publicznie w zespole.

Zaczniemy od nowego projektu Laravel, ale będę używał Jetstream ponieważ chcę zainicjować uwierzytelnianie oraz strukturę i funkcjonalność zespołu.Po skonfigurowaniu tego projektu otwórz go w wybranym przez siebie IDE (prawidłowa odpowiedź to oczywiście PHPStorm), a my jesteśmy gotowi do zagłębienia się w niektóre źródła zdarzeń w Laravel.

Będziemy chcieli stworzyć dodatkowy model dla naszej aplikacji, jeden z naszych jedynych. Będzie to model Celebration i możesz go utworzyć za pomocą następującego polecenia rzemieślnika:

Zmodyfikuj metodę migracji tak, aby wyglądała następująco:

php artisan make:model Celebration -m

Mamy celebrację powód, proste zdanie, a następnie opcjonalny wiadomość, którą możemy wysłać wraz z uroczystością. Oprócz tego mamy trzy relacje, użytkownika, który jest celebrowany, użytkownika, który wysyła uroczystość i w jakim zespole jest.Dzięki Jetstream użytkownik może należeć do wielu zespołów i może zaistnieć sytuacja, w której obaj użytkownicy są w tym samym zespole, a my chcemy mieć pewność, że celebrujemy ich publicznie we właściwym zespole.

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

Gdy mamy taką konfigurację, spójrzmy na sam model:

Możemy odzwierciedlić relacje w innych modelach, ponieważ są one istotne .Jednak domyślnie dodaję drugą stronę relacji do każdego modelu, aby wyjaśnić powiązanie między modelami, bez względu na to, czy jest to bezwzględnie potrzebne. To nawyk, w który się nabrałem, aby pomóc innym zrozumieć sam model danych.

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

Teraz mamy podstawę naszej aplikacji utworzoną z perspektywy modelowania. Musimy pomyśleć o zainstalowaniu kilku pakietów, które nam pomogą.W mojej aplikacji użyłem Laravel Livewire do sterowania interfejsem użytkownika. Nie będę jednak omawiał szczegółów tego samouczka, ponieważ chcę mieć pewność, że skupię się na aspekcie pozyskiwania zdarzeń.

Jak w przypadku większości projektów, które tworzę, bez względu na to wielkości, przyjąłem modułowy układ aplikacji - podejście Domain Driven Design.To jest po prostu coś, co robię, nie czuję, że musisz to robić sam, ponieważ jest to bardzo subiektywne.

Moim kolejnym krokiem było skonfigurowanie domen, a do tego demo miałem tylko jedną domenę: kulturę. W ramach Culture stworzyłem przestrzenie nazw na wszystko, czego mogę potrzebować. Ale przejdę przez to, abyś zrozumiał proces.

Pierwszym krokiem było zainstalowanie pakietu, który umożliwiłby mi korzystanie z Event Sourcing w Laravel. W tym celu użyłem pakietu Spatie, który wykonuje dla mnie dużo pracy w tle. Zainstalujmy ten pakiet za pomocą kompozytora:

Po zainstalowaniu upewnij się, że postępujesz zgodnie z instrukcjami instalacji pakietu — ponieważ konfiguracja i migracje wymagają publikacji. Po prawidłowym zainstalowaniu uruchom migracje, aby baza danych była w odpowiednim stanie.

composer require spatie/laravel-event-sourcing

Teraz możemy zacząć myśleć o tym, jak chcemy wdrożyć Event Sourcing.Możesz to zaimplementować na kilka sposobów: projektory do projekcji twojego stanu lub agregatów.

php artisan migrate

Projektor to klasa, która znajduje się w twojej aplikacji i obsługuje wysyłane przez ciebie zdarzenia. Zmienią one wtedy stan Twojej aplikacji. To krok poza zwykłą aktualizacją bazy danych.Znajduje się pośrodku, przechwytuje zdarzenie, przechowuje je, a następnie wprowadza zmiany, których potrzebuje – co z kolei „projektuje” nowy stan aplikacji.

Drugie podejście, moja preferowana metoda, agregacje - są to klasy, które podobnie jak projektory obsługują za Ciebie stan aplikacji. Zamiast odpalać zdarzenia samodzielnie w naszej aplikacji, pozostawiamy to agregatowi, aby zrobił to za nas.Pomyśl o tym jak o przekaźniku, prosisz przekaźnik, aby coś zrobił, a on sobie to załatwi.

Zanim będziemy mogli stworzyć nasz pierwszy agregat, jest trochę pracy robić w tle. Jestem wielkim fanem tworzenia magazynu zdarzeń na agregację, aby zapytania były szybsze, a sklep nie zapełniał się bardzo szybko.Jest to wyjaśnione w dokumentacji pakietu, ale sam przeprowadzę Cię przez to, ponieważ nie było to najjaśniejsze w dokumentacji.

Pierwszym krokiem jest utworzenie model i migrację, ponieważ będziesz potrzebować sposobu na zapytanie w przyszłości w celu raportowania itp. Uruchom następujące polecenie rzemieślnika, aby je utworzyć:

Poniższy kod jest tym, co będziesz potrzebować w swojej metodzie up do migracji:

php artisan make:model CelebrationStoredEvent -m

Jak widać, zbieramy sporo danych dotyczących naszych wydarzeń. Teraz model jest dużo prostszy. Powinno to wyglądać tak:

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');
    });
}

Ponieważ rozszerzamy model EloquentStoredEvent, wszystko, co musimy zrobić, to zmienić tabelę, na którą patrzy . Reszta funkcji modelu jest już na miejscu nadrzędnym.

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

Aby korzystać z tych modeli, musisz utworzyć repozytorium, w którym będą wyszukiwane zdarzenia. Jest to dość proste repozytorium - jest to jednak ważny krok. Dodałem swój kod do mojego kodu domeny w src/Domains/Culture/Repositories/, ale możesz dodać swój tam, gdzie jest to najbardziej sensowne:

Teraz, gdy mamy sposób na przechowywanie zdarzeń i odpytywanie ich, możemy przejść do samego zbioru.Ponownie zapisałem moją domenę w mojej domenie, ale możesz trzymać ją w kontekście aplikacji.

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

Ten agregat jak dotąd nie zrobi nic poza połączeniem się z właściwym zdarzeniem sklep dla nas. Aby rozpocząć śledzenie zdarzeń, musimy je najpierw utworzyć. Ale zanim to zrobimy, musimy zatrzymać się i pomyśleć przez chwilę. Jakie dane chcemy przechowywać w wydarzeniu?Czy chcemy przechowywać każdą potrzebną nam nieruchomość? A może chcemy przechowywać tablicę tak, jakby pochodziła z formularza? Nie używam żadnego podejścia, ponieważ po co to proste? Używam obiektów transferu danych we wszystkich moich zdarzeniach, aby zapewnić, że kontekst jest zawsze utrzymywany i zawsze zapewnione jest bezpieczeństwo typów.

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

Zbudowałem pakiet, aby ułatwić mi to .Możesz go użyć, instalując go za pomocą następującego polecenia kompozytora:

Tak jak poprzednio, domyślnie przechowuję obiekty danych w mojej domenie, ale dodaję, gdzie najlepiej sens dla ciebie. Utworzyłem obiekt danych o nazwie Celebration, przez który mogłem przejść do zdarzeń i agregacji:

composer require juststeveking/laravel-data-object-tools

Gdy uaktualniam do PHP 8.2 będzie to o wiele łatwiejsze, ponieważ mogę wtedy tworzyć klasy tylko do odczytu - i tak, mój pakiet już je obsługuje.

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,
        ];
    }
}

Teraz mamy nasz obiekt danych. Możemy wrócić do wydarzenia, które chcemy zapisać. Nazwałem moje CelebrationWasCreated, ponieważ nazwy zdarzeń powinny zawsze być w czasie przeszłym. Przyjrzyjmy się temu wydarzeniu:

Ponieważ używamy Data Objects, nasza klasa pozostaje czysta. Więc teraz, gdy mamy zdarzenie – i obiekt danych, który możemy rozesłać, musimy pomyśleć, jak to wywołać. To prowadzi nas z powrotem do samego agregatu, więc stwórzmy metodę na naszym agregacie, której możemy w tym celu użyć:

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,
    ) {}
}

W tym momencie mamy sposób na poproś klasę o nagranie wydarzenia.Jednak to wydarzenie nie będzie jeszcze trwało - to nastąpi później. Ponadto w żaden sposób nie zmieniamy stanu naszych aplikacji. Więc jak robimy to trochę sourcingu wydarzeń? Ta część sprowadza się dla mnie do implementacji w Livewire, przez którą teraz przeprowadzę Cię.

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;
    }
}

Lubię podejść do tego, wysyłając zdarzenie, które będzie zarządzać tym procesem , ponieważ jest bardziej wydajny.Jeśli myślisz o tym, jak możesz wchodzić w interakcję z aplikacją, możesz odwiedzić ją z Internetu, wysłać żądanie przez punkt końcowy API lub zdarzenie, które może uruchomić polecenie CLI - być może jest to zadanie CRON. We wszystkich tych metodach zazwyczaj potrzebujesz natychmiastowej odpowiedzi, a przynajmniej nie chcesz czekać. Pokażę ci metodę na moim komponencie Livewire, którego użyłem do tego:

Sprawdzam dane wejściowe użytkowników z komponentu, wysyłam nowe zadanie, które można obsłużyć, i zamykam modalne. Przekazuję nowy obiekt danych do pracy za pomocą mojego pakietu. Ma fasadę, która pozwala mi nawadniać zajęcia o różnych właściwościach - i jak dotąd działa całkiem dobrze. Więc co robi ta praca? Rzućmy okiem.

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

Nasza praca przyjmuje obiekt danych do swojego konstruktora, a następnie przechowuje go na czas przetwarzania. Gdy zadanie jest przetwarzane, używa CelebrationAggregateRoot do pobrania agregatu według UUID, a następnie wywołuje metodę createCelebration, którą stworzyliśmy wcześniej. Po wywołaniu tej metody wywołuje persist na samym agregacie.To właśnie przechowa dla nas wydarzenie. Ale znowu nie zmieniliśmy jeszcze stanu naszych aplikacji. Jedyne, co udało nam się zrobić, to zapisać niepowiązane wydarzenie, a nie stworzyć celebrację, którą chcemy stworzyć? Czego więc nam brakuje?

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

Nasze wydarzenia również wymagają obsługi. W drugiej metodzie używamy projektora do obsługi naszych zdarzeń, ale musimy je wywoływać ręcznie.Tutaj jest podobny proces, ale zamiast tego nasz agregat wyzwala zdarzenie, nadal potrzebujemy projektora do obsługi zdarzenia i zmiany stanu naszych aplikacji.

Stwórzmy nasz Projektor, który nazywam Handlerami - ponieważ obsługują zdarzenia. Ale pozostawię tobie, jak chcesz nazwać swoje.

Nasz Projektor/Obsługa, jakkolwiek go nazwiesz, zostanie dla nas rozwiązany z kontenera - a następnie będzie szukał metody z prefiksem on, po którym następuje sama nazwa zdarzenia. Tak więc w naszym przypadku onCelebrationWasCreated.W moim przykładzie używam akcji do wykonania rzeczywistej logiki zdarzenia - pojedynczych klas wykonujących jedno zadanie, które można łatwo sfałszować lub zastąpić. Więc znowu ścigamy drzewo do następnej klasy. Akcja, tak dla mnie wygląda:

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

To jest obecna realizacja akcji. Jak widać, moja klasa akcji sama implementuje kontrakt/interfejs.Oznacza to, że przypisuję interfejs do konkretnej implementacji u mojego usługodawcy. To pozwala mi łatwo tworzyć testowe duble/makiety/alternatywne podejścia bez wywierania efektu domina na rzeczywistą akcję, którą należy wykonać. Nie jest to ściśle związane z sourcingiem zdarzeń, ale ogólne programowanie. Jedyną korzyścią, jaką mamy, jest to, że nasz projektor może być odtwarzany.Jeśli więc z jakiegoś powodu odeszliśmy od Laravela Eloquent, a może użyliśmy czegoś innego, możemy stworzyć nową akcję - związać implementację w naszym kontenerze, odtwarzać nasze zdarzenia i wszystko powinno po prostu działać.

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

Na tym etapie przechowujemy nasze zdarzenia i mamy sposób na zmianę stanu naszej aplikacji - ale czy my?Musimy poinformować bibliotekę Event Sourcing, że zarejestrowaliśmy ten projektor/program obsługi, aby wiedział, że ma go uruchomić w zdarzeniu. Zazwyczaj utworzyłbym EventSourcingServiceProvider na domenę, aby móc zarejestrować wszystkie programy obsługi w jednym miejscu. Mój wygląda następująco:

Jedyne, co pozostało, to upewnić się, że ten usługodawca jest ponownie zarejestrowany.Tworzę dostawcę usług dla każdej domeny, aby zarejestrować dostawców usług podrzędnych - ale to już inna historia i samouczek.

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

Teraz, gdy wszystko złożymy w całość. Możemy poprosić nasz agregat o utworzenie uroczystości, która zarejestruje zdarzenie i utrwali je w bazie danych, a jako efekt uboczny zostanie uruchomiony nasz program obsługi, mutujący stan aplikacji za pomocą nowych zmian.

Wydaje się to trochę rozwlekłe, prawda? Czy jest lepszy sposób? Możliwe, ale w tym momencie wiemy, kiedy wprowadzane są zmiany w stanie naszej aplikacji. Rozumiemy, dlaczego zostały wykonane. Ponadto dzięki naszemu Obiektowi Danych wiemy, kto i kiedy dokonał zmian. Może więc nie jest to najprostsze podejście, ale pozwala nam lepiej zrozumieć naszą aplikację.

Możesz wejść w to tyle, ile potrzebujesz lub zanurzyć palec w Event Sourcing, gdzie ma to największy sens. Mamy nadzieję, że ten samouczek pokazał ci jasną i praktyczną ścieżkę do rozpoczęcia pozyskiwania zdarzeń już dziś.

Jeśli to nie pokazało ci wystarczająco dużo, Spatie był na tyle uprzejmy, aby daje ci kupon o wartości 30% zniżki na kurs Event Sourcing w Laravel, co jest cholernie dobre! Odwiedź Witryna kursu i użyj kodu kuponu LARAVEL-NEWS-EVENT-SOURCING.

Czy w swoich aplikacjach używałeś Event Sourcing? Jak do tego podszedłeś? Daj nam znać na Twitterze!

Comments

No comments yet
Yurij Finiv

Yurij Finiv

Full stack

O

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

O autorze CrazyBoy49z
WORK EXPERIENCE
Kontakt
Ukraine, Lutsk
+380979856297