CQRS i mikroserwisy: komunikacja wewnętrzna między usługami

Ósmy odcinek kursu „Distributed .NET Core” jest już na YouTube więc do dobry moment, aby kontynuować kolejne zagadnienia związane z naszą implementacją mikroserwisów. W poprzednich wpisach omówiłem kolejno zapis i odczyt danych z systemu bazującego na wzorcu CQRS tym samym „zamykając” temat komunikacji użytkownik-system. Istnieje jednak jeszcze jedna, istotna forma komunikacji, niewidoczna dla zewnętrznych podmiotów. Mowa o wewnętrznej wymianie informacji między konkretnymi usługami.

 

Po co usługi komunikują się ze sobą?

Zanim przejdziemy do kodu należy odpowiedzieć na fundamentalne pytanie, jaki jest cel tworzenia komunikacji między konkretnymi usługami? Wspomniałem przecież, że każda z nich „enkapsuluje” konkretny, relatywnie niezależny fragment domeny, a co za tym idzie potrafi obsłużyć pewien podzbiór komend, które przychodzą od użytkownika. Wobec tego, w teorii każda komenda (a zatem zapis do aplikacji) powinna być móc obsłużona przez jakąś usługę. W przypadku kwerend, czyli odczytu z aplikacji również możemy odpytać konkretny mikroserwis o dane, które są nam niezbędne do wyświetlenia użytkownikowi. Na pierwszy rzut oka wygląda to na rozwiązanie kompletne, ale wystarczy pomyśleć tylko o nieco bardziej skomplikowanych przypadkach użycia, aby zrozumieć, że jest to rozumowanie błędne. Bo co jeśli np.:

  • po złożeniu zamówienia chcielibyśmy wysłać email z jego szczegółami?
  • proces złożenia zamówienia obejmuje kilka kroków, z których każdy jest obsługiwany przez inną usługę?
  • Chcemy poprać spłaszczony model danych, który zawiera informacje z różnych części systemu?

Oczywiście można takie pytania mnożyć, ale nie to jest moim celem. Chciałem jedynie zwrócić Twoją uwagę na fakt, że pomimo tego, że projektujemy i implementujemy pojedyncze usługi tak, aby mogły działać maksymalnie niezależnie od innych to czasami jesteśmy najzwyczajniej zmuszeni do tego, aby odpytać innych o dane lub poinformować ich, że jakieś zdarzenie miało miejsce w konkretnej usłudze. To zachowanie wpisuje się zresztą w ideę mikroserwisów tj. fakt, że koniec końców, dla użytkownika końcowego system powinien zachowywać się jak spójna jednostka.

Wobec tego podsumujmy. Usługa komunikuje się z innymi usługami:

  • gdy chce zsynchronizować dane potrzebne do zwrócenia modelu wykraczającego poza jej pierwotną dziedzinę (np. zamówienie i informacje o kliencie)
  • gdy chce poinformować, że jakieś zdarzenie miało miejsce w systemie tym samym uruchamiając dalszą logikę (przykład zamówienie i email)

Wiesz już zatem kiedy wprowadzić ten rodzaj komunikacji. Pozostaje pytanie w jaki sposób się ona odbywa?

 

Zdarzenia jako podstawowy mechanizm komunikacji między usługami

Jeżeli spojrzysz na poprzednią część tego tekstu to zauważysz, że dwukrotnie użyłem słowa „zdarzenie”. Nie jest to przypadek, ponieważ jest to trzeci rodzaj wiadomości (po komendach i kwerendach) jaki możemy wyróżnić w systemie. Zdarzenie jest obiektem reprezentującym jakąś akcję, która miała miejsce w przeszłości. Kropka. Nigdy nie referuje do czegoś co może, ale nie musi się zdarzyć w przyszłości. W związku z powyższym jej „budowa” sprowadza się do dwóch punktów:

  • nazwa zdarzenia powinna referować do czasu przeszłego (używamy raczej strony biernej) np. ProductCreated, DiscountGranted, OrderCanceled.
  • obiekt jest immutable  – tak jak w życiu, przeszłości nie można zmienić wobec tego modelując zdarzenie również zakładamy, że nikt nie będzie go zmieniał.

W tym miejscu warto omówić jeszcze kwestię wysyłania zdarzeń. Podobnie jak miało to miejsce przy zapisie danych, zdarzenia są publikowane na kolejkę w sposób asynchroniczny. Istnieje jednak zasadnicza różnica między obsługą komend i zdarzeń. Otóż jak wspomniałem na początku wpisu, w pierwszym przypadku zakładamy, że komenda zostanie „złapana” i przetworzona przez jedną i tylko jedną usługę, która operuje odpowiednim fragmentem domeny. W przypadku opublikowania zdarzenia nie powinniśmy zakładać niczego. Event może równie dobrze zostać zignorowany (żadna usługa się pod niego nie subskrybowała), może zostać przesłany tylko do jednej usługi, a być może do wielu.

Przykładowo zdarzenie ProductCreated może być istotne dla:

  • usługi mailingowej, która wyśle wiadomość z nową ofertą do klientów
  • N usług social media, z których każda opublikuje tą samą informację
  • usługi marketingowej, która umieści produkt w specjalnej liście nowości

Z drugiej strony zdarzenie CustomerPasswordChanged może nie być istotnie dla innych usług. Dobra, koniec teorii czas zobaczyć trochę kodu!

 

Zdarzenia jak mechanizm synchronizacji danych

Zacznijmy od przypadku, w których chcielibyśmy pobrać dane „przekrojowe” z kilku serwisów. Weźmy przykładowo taki model domenowy:

 


    public class Discount : IIdentifiable
    {
        public Guid Id { get; private set; }
        public Guid CustomerId { get; private set; }
        public string Code { get; private set; }
        public double Percentage { get; private set; }
        public DateTime? UsedAt { get; private set; }

        private Discount()
        {
        }

        public Discount(Guid id, Guid customerId, string code, double percentage)
        {
             /// logic
        }

        public void Use()
        {
           /// logic
        }
    }

 

Jak widzisz klasa Discount posiada właściwość CustomerId, która wskazuje identyfikator klienta do którego ów zniżka przynależy. Załóżmy, że chcielibyśmy zwrócić użytkownikowi dane, które poza informacjami o zniżce zawierają także adres email klienta:

 

    public class CustomerDto
    {
        public Guid Id { get; set; }
        public string Email { get; set; }
    }

    public class DiscountDto
    {
        public Guid Id { get; set; }
        public Guid CustomerId { get; set; }
        public string Code { get; set; }
        public double Percentage { get; set; }
        public bool Available { get; set; }
    }

    public class DiscountDetailsDto
    {
        public CustomerDto Customer { get; set; }
        public DiscountDto Discount { get; set; }
    }

 

Email to informacja, którą najprawdopodobniej znajdziemy w usłudze obsługującej klientów. W jaki sposób możemy zatem zwrócić taki model? Przy obecnej strukturze danych mamy trzy opcje:

1: API Gateway będzie odpowiedzialne za złożenie tego modelu! Kiedy przyjdzie żądanie HTTP najpierw odpytamy usługę zniżek o DiscountDto. Następnie użyjemy zawartego w nim CustomerId, aby odpytać usługę klientów o CustomerDto. Kiedy go otrzymamy, API „sklei” oba modele i zwróci go użytkownikowi.

To rozwiązanie jest słabe co najmniej z trzech powodów. Po pierwsze, API Gateway jest cienką warstwą, która powinna zawierać jak najmniej (a w zasadzie nie zawierać) wszelkiej logiki, walidacji, transformat itd. Dla API naturalnym „językiem” jest protokół HTTP, a nie składanie DTO, które nota bene niejawnie definiuje transakcję pomiędzy API i usługami (pobierz dane z tej usługi, potem z tej, potem wykorzystaj te dane i zwróć to). Po drugie, jeżeli jak w tym przypadku nie możemy równolegle odpytać usług o dane (bo potrzebujemy najpierw modelu A, aby wydobyć z niego Id potrzebne do pobrania modelu B) to czas odpowiedz jest de facto sumą czasów odpowiedzi każdej usługi. Po trzecie, w przypadku niedostępności np. usługi klientów API musiałoby jakoś sobie z tym poradzić. Może, albo zwrócić dane niekompletne, albo nie zwrócić niczego, albo… zwrócić kod HTTP 500 bo taki scenariusz nie został obsłużony 😉

 

2: To usługa zniżek będzie odpowiedzialna za pobranie informacji o kliencie podczas procesowania kwerendy. Ze swojej bazy danych pobierze obiekt Discount, następnie synchronicznie (po HTTP) pobierze dane z usługi klientów dostając CustomerDto, po czym złoży DiscountDetailsDto i zwróci go do API.

To rozwiązanie jest już lepsze, ponieważ odpowiedzialność za skompletowanie danych i złożenie modelu zostało przeniesione z API Gateway do usługi zniżek. Nadal jednak pozostaje realny problem wydajnościowy i fakt, że możliwa jest awaria usługi klientów kiedy będziemy potrzebować CustomerDto.

 

3. Gdy klient zostanie utworzony w systemie, jego usługa opublikuje zdarzenie CustomerCreated, która będzie zawierać jego Id oraz Email. Pod zdarzenie będzie subskrybować się usługa zniżek, która po jego odebraniu utworzy w swojej bazie danych lokalną, spłyconą kopię. Gdy przyjdzie czas na zwrócenie DiscountDetailsDto zamiast prosić synchronicznie o dane, usługa zajrzy do lokalnej bazy i wyciągnie Email.

Według mnie rozwiązanie najlepsze. Po pierwsze dane są przekazywane do usługi zniżek nie wtedy gdy jest to potrzebne, a na bieżąco z tym jak pracuje system. Dzięki lokalnej kopii, usługa może przy procesowaniu kwerendy niezależnie zwrócić dane. Odchodzi zatem problem wydajności i niespodziewanej niedostępności innych usług.

Ktoś mógłby jednak teraz słusznie zauważyć, że korzystając z kolejki równie dobrze możemy napotkać się z problemem niedostępności. No bo co w przypadku gdyby po opublikowaniu CustomerCreated usługa zniżek była niedostępna i nie dokonałaby synchronizacji danych? Fakt, nie ma różnicy czy dane są przekazywane synchronicznie czy asynchronicznie. Jeżeli napotkamy na problem musimy być gotowi na jego obsługę. Różnica polega na tym, że o ile w przypadku żądania HTTP nie mamy wbudowanego mechanizmu, który w łatwy sposób umożliwiłby nam spróbowanie ponownie (musimy raczej korzystać z bibliotek jak Polly) o tyle w przypadku np. RabbitMQ mamy koncept Dead Letter Exchanges, który w znacznym stopniu obniża próg wejścia jeśli chodzi o budowę odpornych usług. Mam nadzieję, że rozumiesz o co mi chodzi. Kolejki to nie „silver bullet” na całe zło.

 

Przejdźmy zatem do implementacji trzeciej strategi. Rozpocznijmy inspekcji wspomnianego zdarzenia CustomerCreated, które znajduje się w usłudze klientów):

 

    //Marker
    public interface IEvent : IMessage
    {
    }

    public class CustomerCreated : IEvent
    {
        public Guid Id { get; }
        public string Email { get; }
        public string FirstName { get; }
        public string LastName { get; }
        public string Address { get; }
        public string Country { get; }

        [JsonConstructor]
        public CustomerCreated(Guid id, string email, string firstName, 
            string lastName, string address, string country)
        {
            Id = id;
            Email = email;
            FirstName = firstName;
            LastName = lastName;
            Address = address;
            Country = country;
        }        
    }

 

Zwróć proszę uwagę na kilka istotnych rzeczy:

  • Nazwa klasy jest zgodna z omówioną konwencją
  • Obiekt jest immutable
  • Obiekt implementuje interfejs IEventJest to podobnie jak w przypadku ICommand zwykły marker potrzebny do generic constraints.

Posiadając klasę zdarzenia możemy zaimplementować komunikację po stronie usługi klientów:

 


    public class CreateCustomerHandler : ICommandHandler<CreateCustomer>
    {
        private readonly IBusPublisher _busPublisher;
        private readonly ICartsRepository _cartsRepository;
        private readonly ICustomersRepository _customersRepository;

        public CreateCustomerHandler(IBusPublisher busPublisher,
            ICartsRepository cartsRepository,
            ICustomersRepository customersRepository)
        {
            _busPublisher = busPublisher;
            _cartsRepository = cartsRepository;
            _customersRepository = customersRepository;
        }

        public async Task HandleAsync(CreateCustomer command, ICorrelationContext context)
        {
            var customer = await _customersRepository.GetAsync(command.Id);
            if (customer.Completed)
            {
                throw new DShopException(Codes.CustomerAlreadyCompleted,
                    $"Customer account was already created for user with id: '{command.Id}'.");
            }

            customer.Complete(command.FirstName, command.LastName, command.Address, command.Country);
            await _customersRepository.UpdateAsync(customer);
            var cart = new Cart(command.Id);
            await _cartsRepository.AddAsync(cart);
            await _busPublisher.PublishAsync(new CustomerCreated(command.Id, customer.Email,
                command.FirstName, command.LastName, command.Address, command.Country), context);
        }
    }

 

Powyżej znajduje się implementacja handlera dla komendy CreateCustomerNie będę wchodził w szczegóły kolejnych kroków tworzenia nowego klienta, ale jak widzisz ostatnim krokiem w jego wykonaniu jest właśnie opublikowanie zdarzenia CustomerCreated. Ważne jest, aby zrozumieć, że ten krok nie mógł zostać przeniesiony np. na sam początek metody HandleAsync, ponieważ nie mielibyśmy gwarancji, że klient na pewno został utworzony (mogła zadziałać np. jakaś walidacja). Implementacji IPublishBus nie będę przytaczał, ponieważ przedstawiłem ją tutaj.  Warto jednak, abyś zwrócił uwagę na dane jakie przechowuje zdarzenie. Jest tu aż 6 parametrów podczas gdy nasza implementacja posiadała jedynie dwie właściwości. W tym miejscu znów pozwolę sobie przypomnieć – każda usługa posiada własne wersje wiadomości, które nie muszę być kopiami 1:1. Usługa klientów publikuje zdarzenie z 6 właściwościami, ale mikroserwis zniżek potrzebuje tylko Emaila, dlatego tylko email zostanie zdeserializowany.

Na tym etapie nasz system po utworzeniu klienta w systemie opublikuje na kolejce zdarzenie informujące o tym inne usługi. Pora zająć się przechwyceniem tej wiadomości po stronie usługi zniżek. Rozpocznijmy od lokalnej kopii CustomerCreated skrojonej na nasze potrzeby:

 

    [MessageNamespace("customers")]
    public class CustomerCreated : IEvent
    {
        public Guid Id { get; }
        public string Email { get; }

        [JsonConstructor]
        public CustomerCreated(Guid id, string email)
        {
            Id = id;
            Email = email;
        }
    }

 

Mamy już klasę, która zapewni nam dane potrzebne do synchronizacji. Nie mamy jednak lokalnej wersji obiektu domenowego klienta. Utwórzmy go:

 


    public class Customer : IIdentifiable
    {
        public Guid Id { get; private set; }
        public string Email { get; private set; }

        public Customer(Guid id, string email)
        {
            Id = id;
            Email = email;
        }
    }

 

Pora dodać kod odpowiedzialny za utworzenie obiektu Customer i zapisania go w bazie danych. W tym celu wykorzystamy schemat znany z obsługi komend. Nasze zdarzenie będzie posiadało dedykowany event handler, czyli klasę która będzie wiedziała jak przetworzyć otrzymane zdarzenie:

 

    public interface IEventHandler<in TEvent> where TEvent : IEvent
    {
        Task HandleAsync(TEvent @event, ICorrelationContext context);
    }

    public class CustomerCreatedHandler : IEventHandler<CustomerCreated>
    {
        private readonly ICustomersRepository _customersRepository;
        private readonly ILogger<CustomerCreatedHandler> _logger;

        public CustomerCreatedHandler(ICustomersRepository customersRepository,
            ILogger<CustomerCreatedHandler> logger)
        {
            _customersRepository = customersRepository;
            _logger = logger;
        }

        public async Task HandleAsync(CustomerCreated @event, ICorrelationContext context)
        {
            await _customersRepository.AddAsync(new Customer(@event.Id, @event.Email));
            _logger.LogInformation($"Created customer with id: '{@event.Id}'.");
        }
    }

 

Jak widzisz w handlerze znajduje się dokładnie to czego można było się spodziewać. Ze zdarzenia wydobyte zostały informacje potrzebne do zapisania klienta w bazie danych zniżek. Ostatni krok to dodanie subskrypcji pod wiadomość w pliku Startup.cs usługi:

 

 app.UseRabbitMq()
                .SubscribeEvent<CustomerCreated>(@namespace: "customers");

 

Jeśli chodzi o implementację tej metody rozszerzającej to ponownie odeślę do wpisu o zapisie danych 😉 I to tyle! Event posłużył za prosty mechanizm synchronizacji danych, dzięki któremu możemy dość sprawnie zwrócić DiscountDetailsDto.  Zobacz jak wygląda jego budowa w usłudze zniżek:

 

    public class GetDiscountHandler : IQueryHandler<GetDiscount, DiscountDetailsDto>
    {
        private readonly IMongoRepository<Discount> _discountsRepository;
        private readonly IMongoRepository<Customer> _customersRepository;

        public GetDiscountHandler(IMongoRepository<Discount> discountsRepository,
            IMongoRepository<Customer> customersRepository)
        {
            _discountsRepository = discountsRepository;
            _customersRepository = customersRepository;
        }
        
        public async Task<DiscountDetailsDto> HandleAsync(GetDiscount query)
        {
            var discount = await _discountsRepository.GetAsync(query.Id);
            if (discount is null)
            {
                return null;
            }

            var customer = await _customersRepository.GetAsync(discount.CustomerId);

            return new DiscountDetailsDto
            {
                Customer = new CustomerDto
                {
                    Id = customer.Id,
                    Email = customer.Email
                },
                Discount = new DiscountDto
                {
                    Id = discount.Id,
                    CustomerId = discount.CustomerId,
                    Code = discount.Code,
                    Percentage = discount.Percentage,
                    Available = !discount.UsedAt.HasValue
                }
            };
        }
    }

 

W tym miejscu warto poruszyć jeszcze jedną kwestię, a mianowicie przypadek w którym potrzebny jest relatywnie duży zbiór danych do synchronizacji. Oczywiście nic nie stoi na przeszkodzie, aby zdarzenie posiadało kilkanaście/kilkadziesiąt pól, które zostaną „wypchnięte” na kolejkę, ale w przypadku gdy obciążenie aplikacji będzie duże może okazać się to przyczyną spadków wydajności. W jaki sposób możemy sobie z tym poradzić? Zamiast umieszczać wszystkie dane w evencie możesz zawrzeć tam jedynie Id zasobu. Po otrzymaniu zdarzenia usługa może już synchronicznie (po HTTP) odpytać o obiekt, który będzie zawierać niezbędne informacje:

 

 

Oczywiście do synchronicznego pobrania danych możesz użyć biblioteki RestEase, którą omawiałem przy okazji wpisu o odczycie danych.

 

Zdarzenia jako mechanizm wywoływania kolejnych kroków w procesie

Przejdźmy do drugiego przypadku uzycia wydarzeń, czyli sytuacji w której proces biznesowy składa się z więcej niż jednego kroku. Przestudiujmy przykład, który opisałem na początku wpisu tj. po utowrzeniu zamówienia chcielibyśmy wysłac użytkownikowi wiadomość email. Całe flow wygląda następująco:

 

Implementację ponownie rozpoczniemy od zdarzenia (w usłudze zamówień):

 

    public class OrderCreated : IEvent
    {
        public Guid Id { get; }
        public Guid CustomerId { get; }
        public IDictionary<Guid, int> Products { get; }

        [JsonConstructor]
        public OrderCreated(Guid id, Guid customerId, IDictionary<Guid, int> products)
        {
            Id = id;
            CustomerId = customerId;
            Products = products;
        }
    }

 

Posiadając zdarzenie możemy je opublikować w command handlerze:

 

    public sealed class CreateOrderHandler : ICommandHandler<CreateOrder>
    {
        private readonly IOrdersRepository _ordersRepository;
        private readonly ICustomersService _customersService;
        private readonly IBusPublisher _busPublisher;

        public CreateOrderHandler(IBusPublisher busPublisher,
            ICustomersService customersService,
            IOrdersRepository ordersRepository)
        {
            _busPublisher = busPublisher;
            _ordersRepository = ordersRepository;
            _customersService = customersService;
        }

        public async Task HandleAsync(CreateOrder command, ICorrelationContext context)
        {
            if (await _ordersRepository.HasPendingOrder(command.CustomerId))
            {
                throw new DShopException("customer_has_pending_order",
                    $"Customer with id: '{command.CustomerId}' has already a pending order.");
            }

            var cart = await _customersService.GetCartAsync(command.CustomerId);
            var items = cart.Items.Select(i => new OrderItem(i.ProductId,
                i.ProductName, i.Quantity, i.UnitPrice)).ToList();
            var order = new Order(command.Id, command.CustomerId, items, "USD");
            await _ordersRepository.AddAsync(order);
            await _busPublisher.PublishAsync(new OrderCreated(command.Id, command.CustomerId,
                items.ToDictionary(i => i.Id, i => i.Quantity)), context);
        }
    }

 

Na tym etapie wykonał się pierwszy krok w procesie. Chielibyśmy teraz wykonać drugi. W tym celu w usłudze powiadomień (Notifications) utowrzona zostanie lokalna kopia zdarzenia i event handler, który po jego otrzymaniu wyśle wiadomość. Zacznijmy od klasy wiadomości:

 

    [MessageNamespace("orders")]
    public class OrderCreated : IEvent
    {
        public Guid Id { get; }
        public Guid CustomerId { get; }

        [JsonConstructor]
        public OrderCreated(Guid id, Guid customerId)
        {
            Id = id;
            CustomerId = customerId;
        }
    }

 

Jak widzisz i w tym przypadku nie jest to mapowanie 1:1 wzgledem wiadomosci z usługi zamówień. Wszystko zalezy od konkretnego przypadku i wymagań biznesowych. Gdyby np. wiadomość miała zawierać listę zakupionych produktów, wtedy właściwość Products byłaby przydatna. Pora na implementację handlera:

 

    public class OrderCreatedHandler : IEventHandler<OrderCreated>
    {
        private readonly MailKitOptions _options;
        private readonly ICustomersRepository _customersRepository;
        private readonly IMessagesService _messagesService;

        public OrderCreatedHandler(
            MailKitOptions options, 
            ICustomersRepository customersRepository, 
            IMessagesService messagesService)
        {
            _options = options;
            _customersRepository = customersRepository;
            _messagesService = messagesService;
        }

        public async Task HandleAsync(OrderCreated @event, ICorrelationContext context)
        {
            var orderId = @event.Id.ToString("N");
            var customer = await _customersRepository.GetAsync(@event.CustomerId);
            var message = MessageBuilder
                .Create()
                .WithReceiver(customer.Email)
                .WithSender(_options.Email)
                .WithSubject(MessageTemplates.OrderCreatedSubject, orderId)
                .WithBody(MessageTemplates.OrderCreatedBody, customer.FirstName, customer.LastName, orderId)
                .Build();

            await _messagesService.SendAsync(message);
        }
    }

 

Jak widzisz po otrzymaniu zdarzenia, wyciagane są dane klienta (w tym przypadku adres email) po czym wiadomość zostaje zbudowana i wysłana. Pytanie skąd w usłudze powiadomień mamy nagle dostęp do emaili klinetów? Myślę, że znasz odpowiedź na to pytanie 😉 Usługa na bierząco synchronizuje dane na identycznej zasadzie jak usługa zniżek. Podkreśle to jeszcze raz, na zdarzenie może nasłuchiwać wiele usług (jak w przypadku CustomerCreated), a nie jedna jak miało to miejsce z komendami.
Pozostaje nam jedynie zasubskrybować się do wiadomości:

 

 app.UseRabbitMq()
                .SubscribeEvent<OrderCompleted>(@namespace: "orders");

 

Ta technika przeprowadzania procesu biznesowego, w którym jedna usluga wywołuje kolejny krok w innej usłudze przy użyciu zdarzeń nazywa się Event Choreography. Wspomnę o niej w osobnym wpisie poświęconym rozporosoznym transakcjom biznesowym. Opisany dziś przykład był bardzo prosty, ponieważ zawierał jedynie dwa kroki i nie nie było w nim za wiele logiki bizneoswej. Ale co by było gdyby np. proces składał się z czterech kroków, a podczas wykonywania trzeciego otrzymalibyśmy błąd np. związany z walidacją danych? W jaki sposób skoordynować teraz rollback, aby stan aplikacji był taki jak przed wywołaniem całej sekwencji? Na to pytanie odpowiem już wkrótce we wspomnianym wyżej wpisie 🙂

Mam nadzieję, że wpis był przydatny. Jeżeli chcesz zobaczyć „na żywo” jak tworzyć opisaną komunikację to zapraszam Ciebie do obejrzenia czwartego odcinka kursu:

You may also like...