Elementy składowe aplikacji rozproszonych. Pierwsze podejście

Elementy składowe aplikacji rozproszonych. Pierwsze podejście

W przeszłości Artykuł Zbadaliśmy teoretyczne podstawy architektury reaktywnej. Czas porozmawiać o przepływach danych, sposobach implementacji reaktywnych systemów Erlang/Elixir i wzorcach przesyłania w nich komunikatów:

  • Wymagać odpowiedzi
  • Odpowiedź na żądanie — fragmentaryczna
  • Odpowiedź z prośbą
  • Publikuj-subskrybuj
  • Odwrócony Publikuj-subskrybuj
  • Podział zadań

SOA, MSA i przesyłanie wiadomości

SOA, MSA to architektury systemowe, które definiują zasady budowania systemów, podczas gdy przesyłanie komunikatów zapewnia prymitywne rozwiązania do ich implementacji.

Nie chcę promować tej czy innej architektury systemu. Jestem za stosowaniem najskuteczniejszych i najbardziej przydatnych praktyk dla konkretnego projektu i biznesu. Niezależnie od tego, jaki paradygmat wybierzemy, lepiej tworzyć bloki systemowe z myślą o uniksie: komponenty o minimalnej łączności, odpowiedzialne za poszczególne byty. Metody API wykonują najprostsze możliwe akcje z jednostkami.

Messaging jest, jak sama nazwa wskazuje, brokerem wiadomości. Jego głównym celem jest odbieranie i wysyłanie wiadomości. Odpowiada za interfejsy do przesyłania informacji, tworzenie kanałów logicznych do przesyłania informacji w obrębie systemu, routing i balansowanie, a także obsługę błędów na poziomie systemu.
Wiadomości, które opracowujemy, nie mają na celu konkurowania z królikiem ani go zastępować. Jego główne cechy:

  • Dystrybucja.
    Punkty wymiany można tworzyć na wszystkich węzłach klastra, jak najbliżej kodu, który je wykorzystuje.
  • Prostota
    Skoncentruj się na minimalizacji standardowego kodu i łatwości użytkowania.
  • Najlepsza wydajność.
    Nie staramy się powtarzać funkcjonalności królika, a jedynie podkreślamy warstwę architektoniczną i transportową, którą wpasowujemy w OTP możliwie najprościej, minimalizując koszty.
  • Elastyczność.
    Każda usługa może łączyć wiele szablonów wymiany.
  • Odporność zgodnie z projektem.
  • Skalowalność.
    Wiadomości rozwijają się wraz z aplikacją. W miarę wzrostu obciążenia można przenosić punkty wymiany na poszczególne maszyny.

Komentarz. Pod względem organizacji kodu metaprojekty dobrze nadają się do złożonych systemów Erlang/Elixir. Cały kod projektu znajduje się w jednym repozytorium - projekcie parasolowym. Jednocześnie mikroserwisy są maksymalnie izolowane i wykonują proste operacje, za które odpowiedzialna jest odrębna jednostka. Dzięki takiemu podejściu łatwo jest utrzymać API całego systemu, łatwo wprowadzać zmiany, wygodnie jest pisać testy jednostkowe i integracyjne.

Komponenty systemu współdziałają bezpośrednio lub za pośrednictwem brokera. Z punktu widzenia przesyłania wiadomości każda usługa ma kilka faz życia:

  • Inicjalizacja usługi.
    Na tym etapie konfigurowany i uruchamiany jest proces oraz zależności realizujące usługę.
  • Utworzenie punktu wymiany.
    Usługa może wykorzystywać statyczny punkt wymiany określony w konfiguracji węzła lub dynamicznie tworzyć punkty wymiany.
  • Rejestracja usługi.
    Aby usługa mogła obsługiwać żądania, musi zostać zarejestrowana w punkcie wymiany.
  • Normalne funkcjonowanie.
    Usługa wytwarza użyteczną pracę.
  • Zakończenie pracy.
    Możliwe są 2 rodzaje wyłączenia: normalne i awaryjne. Podczas normalnej pracy usługa zostaje odłączona od punktu wymiany i zatrzymuje się. W sytuacjach awaryjnych funkcja przesyłania wiadomości wykonuje jeden ze skryptów przełączania awaryjnego.

Wygląda dość skomplikowanie, ale kod nie jest taki straszny. Przykłady kodu wraz z komentarzami zostaną podane przy analizie szablonów nieco później.

Wymiana

Punkt wymiany to proces przesyłania wiadomości, który implementuje logikę interakcji z komponentami w ramach szablonu przesyłania wiadomości. We wszystkich przedstawionych poniżej przykładach komponenty współdziałają poprzez punkty wymiany, których połączenie tworzy komunikację.

Schematy wymiany wiadomości (posłowie do PE)

Globalnie wzorce wymiany można podzielić na dwukierunkowe i jednokierunkowe. Te pierwsze oznaczają odpowiedź na wiadomość przychodzącą, drugie nie. Klasycznym przykładem dwukierunkowego wzorca w architekturze klient-serwer jest wzorzec żądanie-odpowiedź. Przyjrzyjmy się szablonowi i jego modyfikacjom.

Żądanie-odpowiedź lub RPC

RPC stosuje się, gdy potrzebujemy otrzymać odpowiedź z innego procesu. Proces ten może działać w tym samym węźle lub znajdować się na innym kontynencie. Poniżej znajduje się schemat interakcji pomiędzy klientem a serwerem za pośrednictwem wiadomości.

Elementy składowe aplikacji rozproszonych. Pierwsze podejście

Ponieważ przesyłanie wiadomości jest całkowicie asynchroniczne, dla klienta wymiana jest podzielona na 2 fazy:

  1. Wysyłanie zapytania

    messaging:request(Exchange, ResponseMatchingTag, RequestDefinition, HandlerProcess).

    wymiana – unikalna nazwa punktu wymiany
    Tag dopasowania odpowiedzi ‒ lokalna etykieta do przetwarzania odpowiedzi. Przykładowo w przypadku wysłania kilku identycznych żądań należących do różnych użytkowników.
    Definicja żądania - treść żądania
    Proces obsługi – PID handlera. Proces ten otrzyma odpowiedź z serwera.

  2. Przetwarzanie odpowiedzi

    handle_info(#'$msg'{exchange = EXCHANGE, tag = ResponseMatchingTag,message = ResponsePayload}, State)

    Ładunek odpowiedzi - odpowiedź serwera.

W przypadku serwera proces również składa się z 2 faz:

  1. Inicjalizacja punktu wymiany
  2. Przetwarzanie otrzymanych żądań

Zilustrujmy ten szablon kodem. Załóżmy, że musimy zaimplementować prostą usługę zapewniającą pojedynczą metodę dokładnego czasu.

Kod serwera

Zdefiniujmy API usługi w api.hrl:

%% =====================================================
%%  entities
%% =====================================================
-record(time, {
  unixtime :: non_neg_integer(),
  datetime :: binary()
}).

-record(time_error, {
  code :: non_neg_integer(),
  error :: term()
}).

%% =====================================================
%%  methods
%% =====================================================
-record(time_req, {
  opts :: term()
}).
-record(time_resp, {
  result :: #time{} | #time_error{}
}).

Zdefiniujmy kontroler usług w time_controller.erl

%% В примере показан только значимый код. Вставив его в шаблон gen_server можно получить рабочий сервис.

%% инициализация gen_server
init(Args) ->
  %% подключение к точке обмена
  messaging:monitor_exchange(req_resp, ?EXCHANGE, default, self())
  {ok, #{}}.

%% обработка события потери связи с точкой обмена. Это же событие приходит, если точка обмена еще не запустилась.
handle_info(#exchange_die{exchange = ?EXCHANGE}, State) ->
  erlang:send(self(), monitor_exchange),
  {noreply, State};

%% обработка API
handle_info(#time_req{opts = _Opts}, State) ->
  messaging:response_once(Client, #time_resp{
result = #time{ unixtime = time_utils:unixtime(now()), datetime = time_utils:iso8601_fmt(now())}
  });
  {noreply, State};

%% завершение работы gen_server
terminate(_Reason, _State) ->
  messaging:demonitor_exchange(req_resp, ?EXCHANGE, default, self()),
  ok.

Kod klienta

Aby wysłać żądanie do usługi, możesz wywołać API żądania przesyłania wiadomości w dowolnym miejscu klienta:

case messaging:request(?EXCHANGE, tag, #time_req{opts = #{}}, self()) of
    ok -> ok;
    _ -> %% repeat or fail logic
end

W systemie rozproszonym konfiguracja komponentów może być bardzo różna i w momencie żądania przesyłanie komunikatów może jeszcze się nie rozpocząć lub kontroler usługi nie będzie gotowy do obsługi żądania. Dlatego musimy sprawdzić odpowiedź na komunikat i zająć się przypadkiem niepowodzenia.
Po pomyślnym wysłaniu klient otrzyma odpowiedź lub błąd od serwisu.
Zajmijmy się obydwoma przypadkami w handle_info:

handle_info(#'$msg'{exchange = ?EXCHANGE, tag = tag, message = #time_resp{result = #time{unixtime = Utime}}}, State) ->
  ?debugVal(Utime),
  {noreply, State};

handle_info(#'$msg'{exchange = ?EXCHANGE, tag = tag, message = #time_resp{result = #time_error{code = ErrorCode}}}, State) ->
  ?debugVal({error, ErrorCode}),
  {noreply, State};

Odpowiedź na żądanie — fragmentaryczna

Najlepiej unikać wysyłania dużych wiadomości. Od tego zależy responsywność i stabilna praca całego systemu. Jeśli odpowiedź na zapytanie zajmuje dużo pamięci, obowiązkowe jest podzielenie jej na części.

Elementy składowe aplikacji rozproszonych. Pierwsze podejście

Podam kilka przykładów takich przypadków:

  • Komponenty wymieniają dane binarne, takie jak pliki. Podział odpowiedzi na małe części pomaga wydajnie pracować z plikami o dowolnym rozmiarze i pozwala uniknąć przepełnienia pamięci.
  • Oferty. Na przykład musimy wybrać wszystkie rekordy z ogromnej tabeli w bazie danych i przenieść je do innego komponentu.

Nazywam te reakcje lokomotywą. W każdym razie 1024 wiadomości o rozmiarze 1 MB są lepsze niż pojedyncza wiadomość o rozmiarze 1 GB.

W klastrze Erlang uzyskujemy dodatkową korzyść - zmniejszenie obciążenia punktu wymiany i sieci, gdyż odpowiedzi są natychmiast wysyłane do odbiorcy z pominięciem punktu wymiany.

Odpowiedź z prośbą

Jest to dość rzadka modyfikacja wzorca RPC służąca do budowania systemów dialogowych.

Elementy składowe aplikacji rozproszonych. Pierwsze podejście

Publikuj-subskrybuj (drzewo dystrybucji danych)

Systemy sterowane zdarzeniami dostarczają je konsumentom, gdy tylko dane będą gotowe. Dlatego systemy są bardziej podatne na model push niż na model pull lub poll. Ta funkcja pozwala uniknąć marnowania zasobów poprzez ciągłe żądanie danych i oczekiwanie na nie.
Rysunek przedstawia proces dystrybucji komunikatu do konsumentów subskrybujących konkretny temat.

Elementy składowe aplikacji rozproszonych. Pierwsze podejście

Klasycznymi przykładami wykorzystania tego wzorca są rozkłady stanu: świat gier w grach komputerowych, dane rynkowe na giełdach, przydatne informacje w źródłach danych.

Spójrzmy na kod subskrybenta:

init(_Args) ->
  %% подписываемся на обменник, ключ = key
  messaging:subscribe(?SUBSCRIPTION, key, tag, self()),
  {ok, #{}}.

handle_info(#exchange_die{exchange = ?SUBSCRIPTION}, State) ->
  %% если точка обмена недоступна, то пытаемся переподключиться
  messaging:subscribe(?SUBSCRIPTION, key, tag, self()),
  {noreply, State};

%% обрабатываем пришедшие сообщения
handle_info(#'$msg'{exchange = ?SUBSCRIPTION, message = Msg}, State) ->
  ?debugVal(Msg),
  {noreply, State};

%% при остановке потребителя - отключаемся от точки обмена
terminate(_Reason, _State) ->
  messaging:unsubscribe(?SUBSCRIPTION, key, tag, self()),
  ok.

Źródło może wywołać funkcję, aby opublikować wiadomość w dowolnym dogodnym miejscu:

messaging:publish_message(Exchange, Key, Message).

wymiana - nazwa punktu wymiany,
Klawisz - klucz routingowy
Wiadomość - ładunek

Odwrócony Publikuj-subskrybuj

Elementy składowe aplikacji rozproszonych. Pierwsze podejście

Rozbudowując pub-sub, możesz uzyskać wzór wygodny do logowania. Zestaw źródeł i odbiorców może być zupełnie inny. Rysunek przedstawia przypadek jednego konsumenta i wielu źródeł.

Wzór podziału zadań

Prawie każdy projekt obejmuje zadania przetwarzania odroczonego, takie jak generowanie raportów, dostarczanie powiadomień i pobieranie danych z systemów zewnętrznych. Przepustowość systemu wykonującego te zadania można łatwo skalować dodając procedury obsługi. Pozostaje nam tylko utworzyć klaster procesorów i równomiernie rozdzielić między nimi zadania.

Przyjrzyjmy się sytuacjom, które mają miejsce na przykładzie 3 handlerów. Już na etapie podziału zadań pojawia się pytanie o uczciwość podziału i przepełnienie handlerów. Dystrybucja okrężna będzie odpowiedzialna za uczciwość, a aby uniknąć sytuacji przepełnienia handlerów, wprowadzimy ograniczenie limit_pobrania wstępnego. W warunkach przejściowych limit_pobrania wstępnego uniemożliwi jednemu opiekunowi otrzymanie wszystkich zadań.

Messaging zarządza kolejkami i priorytetami przetwarzania. Procesory otrzymują zadania w miarę ich nadejścia. Zadanie może zakończyć się sukcesem lub niepowodzeniem:

  • messaging:ack(Tack) - wywoływane, jeśli wiadomość została pomyślnie przetworzona
  • messaging:nack(Tack) - wzywany we wszystkich sytuacjach awaryjnych. Po zwróceniu zadania komunikator przekaże je innemu modułowi obsługi.

Elementy składowe aplikacji rozproszonych. Pierwsze podejście

Załóżmy, że podczas przetwarzania trzech zadań wystąpiła złożona awaria: procesor 1 po otrzymaniu zadania uległ awarii, nie mając czasu zgłosić czegokolwiek do punktu wymiany. W takim przypadku punkt wymiany przekaże zadanie innemu modułowi obsługi po upływie limitu czasu potwierdzenia. Z jakiegoś powodu przewodnik 3 porzucił zadanie i wysłał nack, w wyniku czego zadanie zostało również przekazane innemu przewodnikowi, który pomyślnie je wykonał.

Wstępne podsumowanie

Omówiliśmy podstawowe elementy składowe systemów rozproszonych i uzyskaliśmy podstawową wiedzę na temat ich użycia w Erlang/Elixir.

Łącząc podstawowe wzorce, możesz budować złożone paradygmaty w celu rozwiązania pojawiających się problemów.

W końcowej części cyklu przyjrzymy się ogólnym zagadnieniom organizacji usług, routingu i równoważenia, a także porozmawiamy o praktycznej stronie skalowalności i odporności systemów na awarie.

Koniec drugiej części.

Strzał Fotek Mariusz Christensen
Ilustracje przygotowane przy użyciu websequencediagrams.com

Źródło: www.habr.com

Dodaj komentarz