Przewodnik po programowaniu zorientowanym na proces w Eliksirze i OTP

Opublikowany: 2022-03-11

Ludzie lubią kategoryzować języki programowania według paradygmatów. Istnieją języki zorientowane obiektowo (OO), języki imperatywne, języki funkcjonalne itp. Może to być pomocne w ustaleniu, które języki rozwiązują podobne problemy i jakie typy problemów język ma rozwiązywać.

W każdym przypadku paradygmat ma na ogół jeden „główny” cel i technikę, która jest siłą napędową tej rodziny języków:

  • W językach OO jest to klasa lub obiekt jako sposób na hermetyzację stanu (danych) z manipulacją tym stanem (metodami).

  • W językach funkcjonalnych może to być manipulacja samymi funkcjami lub niezmiennymi danymi przekazywanymi z funkcji do funkcji.

Chociaż Elixir (i wcześniej Erlang) są często klasyfikowane jako języki funkcjonalne, ponieważ zawierają niezmienne dane wspólne dla języków funkcjonalnych, chciałbym przedstawić, że reprezentują one odrębny paradygmat od wielu języków funkcjonalnych . Istnieją i są przyjmowane ze względu na istnienie OTP, więc zaklasyfikowałbym je jako języki zorientowane na proces .

W tym poście uchwycimy znaczenie programowania zorientowanego na proces podczas korzystania z tych języków, zbadamy różnice i podobieństwa do innych paradygmatów, zobaczymy konsekwencje zarówno dla szkolenia, jak i adopcji, a zakończymy krótkim przykładem programowania zorientowanego na proces.

Co to jest programowanie zorientowane na proces?

Zacznijmy od definicji: Programowanie zorientowane procesowo to paradygmat oparty na komunikujących się procesach sekwencyjnych, wywodzący się z artykułu Tony'ego Hoare'a z 1977 roku. Jest to również popularnie nazywane aktorskim modelem współbieżności. Inne języki mające pewien związek z tym oryginalnym dziełem to Occam, Limbo i Go. Dokument formalny zajmuje się tylko komunikacją synchroniczną; większość modeli aktorów (w tym OTP) również wykorzystuje komunikację asynchroniczną. Zawsze możliwe jest zbudowanie komunikacji synchronicznej na szczycie komunikacji asynchronicznej, a OTP obsługuje obie formy.

W tej historii firma OTP stworzyła system do obliczeń odpornych na awarie, komunikując procesy sekwencyjne. Udogodnienia odporne na awarie pochodzą z podejścia „niech się nie uda” z solidnym odzyskiwaniem błędów w postaci nadzorców i wykorzystaniem przetwarzania rozproszonego, które umożliwia model aktora. „Niech się nie uda” można zestawić z „zapobieganiem niepowodzeniu”, ponieważ to pierwsze jest znacznie łatwiejsze do dostosowania i zostało udowodnione w OTP, że jest znacznie bardziej niezawodne niż drugie. Powodem jest to, że wysiłek programistyczny wymagany do zapobiegania awariom (jak pokazano w modelu wyjątków sprawdzanych w języku Java) jest znacznie bardziej zaangażowany i wymagający.

Tak więc programowanie zorientowane na proces można zdefiniować jako paradygmat, w którym struktura procesu i komunikacja między procesami systemu są głównymi problemami .

Programowanie zorientowane obiektowo a programowanie zorientowane na proces

W programowaniu obiektowym głównym problemem jest statyczna struktura danych i funkcji. Jakie metody są wymagane do manipulowania załączonymi danymi i jakie powinny być połączenia między obiektami lub klasami. Zatem diagram klas UML jest najlepszym przykładem tego skupienia, jak widać na rysunku 1.

Programowanie zorientowane na proces: Przykładowy diagram klas UML

Można zauważyć, że powszechną krytyką programowania obiektowego jest brak widocznego przepływu sterowania. Ponieważ systemy składają się z dużej liczby klas/obiektów zdefiniowanych oddzielnie, mniej doświadczonej osobie może być trudno wyobrazić sobie przepływ sterowania w systemie. Dotyczy to szczególnie systemów z dużym dziedziczeniem, które używają abstrakcyjnych interfejsów lub nie mają silnego typowania. W większości przypadków ważne jest, aby programista zapamiętał dużą część struktury systemu, aby był efektywny (jakie klasy mają jakie metody i które są używane w jaki sposób).

Siłą podejścia programistycznego zorientowanego obiektowo jest to, że system może być rozszerzony o obsługę nowych typów obiektów z ograniczonym wpływem na istniejący kod, o ile nowe typy obiektów są zgodne z oczekiwaniami istniejącego kodu.

Programowanie funkcjonalne a programowanie zorientowane na proces

Wiele funkcjonalnych języków programowania zajmuje się współbieżnością na różne sposoby, ale ich głównym celem jest niezmienne przekazywanie danych między funkcjami lub tworzenie funkcji z innych funkcji (funkcje wyższego rzędu, które generują funkcje). W większości język skupia się na pojedynczej przestrzeni adresowej lub pliku wykonywalnym, a komunikacja między takimi plikami wykonywalnymi jest obsługiwana w sposób specyficzny dla systemu operacyjnego.

Na przykład Scala to funkcjonalny język zbudowany na wirtualnej maszynie Java. Chociaż może uzyskać dostęp do funkcji Java w celu komunikacji, nie jest nieodłączną częścią języka. Chociaż jest to powszechny język używany w programowaniu Spark, jest to ponownie biblioteka używana w połączeniu z językiem.

Mocną stroną paradygmatu funkcjonalnego jest możliwość wizualizacji przepływu sterowania w systemie, biorąc pod uwagę funkcję najwyższego poziomu. Przepływ sterowania jest jawny, ponieważ każda funkcja wywołuje inne funkcje i przekazuje wszystkie dane z jednego do drugiego. W paradygmacie funkcjonalnym nie ma skutków ubocznych, co ułatwia określenie problemu. Wyzwaniem w przypadku systemów czysto funkcjonalnych jest to, że „efekty uboczne” muszą mieć trwały stan. W dobrze zaprojektowanych systemach utrzymywanie stanu jest obsługiwane na najwyższym poziomie przepływu sterowania, dzięki czemu większość systemu jest wolna od skutków ubocznych.

Eliksir/OTP i programowanie zorientowane na proces

W Elixir/Erlang i OTP, prymitywy komunikacji są częścią maszyny wirtualnej, która wykonuje język. Zdolność do komunikacji między procesami i między maszynami jest wbudowana i centralna dla systemu językowego. Podkreśla to znaczenie komunikacji w tym paradygmacie iw tych systemach językowych.

Podczas gdy język Elixir jest przede wszystkim funkcjonalny pod względem logiki wyrażonej w języku, jego użycie jest zorientowane na proces .

Co to znaczy być zorientowanym na proces?

Bycie zorientowanym na proces, jak zdefiniowano w tym poście, oznacza najpierw zaprojektowanie systemu w formie tego, jakie procesy istnieją i jak się komunikują. Jednym z głównych pytań jest to, które procesy są statyczne, a które dynamiczne, które są tworzone na żądanie do żądań, które służą długofalowemu celowi, które przechowują stan współdzielony lub część współdzielonego stanu systemu i jakie cechy system jest z natury współbieżny. Tak jak OO ma typy obiektów, a funkcjonalne typy funkcji, programowanie zorientowane na proces ma typy procesów.

Jako taki, projekt zorientowany na proces to identyfikacja zestawu typów procesów wymaganych do rozwiązania problemu lub zaspokojenia potrzeby .

Aspekt czasu szybko pojawia się w pracach projektowych i wymaganiach. Jaki jest cykl życia systemu? Jakie niestandardowe potrzeby są okazjonalne, a jakie stałe? Gdzie jest obciążenie w systemie i jaka jest oczekiwana prędkość i objętość? Dopiero po zrozumieniu tego typu rozważań projekt zorientowany na proces zaczyna określać funkcję każdego procesu lub logikę, która ma zostać wykonana.

Implikacje szkoleniowe

Konsekwencją tej kategoryzacji dla szkolenia jest to, że szkolenie powinno zaczynać się nie od składni języka lub przykładów „Hello World”, ale od myślenia inżynieryjnego systemów i skupienia się na projektowaniu alokacji procesów .

Problemy związane z kodowaniem są drugorzędne w stosunku do projektowania i alokacji procesów, które najlepiej rozwiązać na wyższym poziomie i obejmują międzyfunkcyjne myślenie o cyklu życia, QA, DevOps i wymaganiach biznesowych klienta. Każde szkolenie w Elixirze lub Erlangu musi (i generalnie zawiera) zawierać OTP i powinno od samego początku mieć orientację procesową, a nie podejście typu „Teraz możesz kodować w Elixirze, więc zróbmy współbieżność”.

Implikacje adopcyjne

Implikacją dla przyjęcia jest to, że język i system są lepiej stosowane do problemów, które wymagają komunikacji i/lub dystrybucji komputerów. Problemy, które dotyczą pojedynczego obciążenia pracą na jednym komputerze, są w tej dziedzinie mniej interesujące i można je lepiej rozwiązać w innym języku. Długowieczne systemy ciągłego przetwarzania są głównym celem dla tego języka, ponieważ ma wbudowaną od podstaw odporność na błędy.

W przypadku dokumentacji i prac projektowych bardzo pomocne może być użycie notacji graficznej (jak na rysunku 1 dla języków OO). Sugestią dla Elixir i programowania zorientowanego na procesy z UML byłby diagram sekwencji (przykład na rysunku 2), aby pokazać relacje czasowe między procesami i zidentyfikować, które procesy są zaangażowane w obsługę żądania. Nie istnieje typ diagramu UML do uchwycenia cyklu życia i struktury procesu, ale można go przedstawić za pomocą prostego diagramu prostokątnego i strzałkowego dla typów procesów i ich relacji. Na przykład rysunek 3:

Przykładowy schemat sekwencji UML z programowaniem zorientowanym procesowo

Przykładowy schemat struktury procesu programowania zorientowanego procesowo

Przykład orientacji na proces

Na koniec omówimy krótki przykład zastosowania orientacji procesowej do problemu. Załóżmy, że mamy za zadanie dostarczyć system obsługujący globalne wybory. Wybrano ten problem, ponieważ wiele pojedynczych działań jest wykonywanych w seriach, ale agregacja lub podsumowanie wyników jest pożądane w czasie rzeczywistym i może powodować znaczne obciążenie.

Wstępny projekt procesu i alokacja

Możemy początkowo zauważyć, że oddawanie głosów przez każdą osobę jest impulsem ruchu do systemu z wielu dyskretnych wejść, nie jest uporządkowane czasowo i może mieć duże obciążenie. Aby wesprzeć to działanie, chcielibyśmy, aby duża liczba procesów zbierała te dane wejściowe i przesyłała je do bardziej centralnego procesu w celu zestawienia. Procesy te mogłyby być zlokalizowane w pobliżu populacji w każdym kraju, które generowałyby głosy, a tym samym zapewniały niskie opóźnienia. Zatrzymywaliby lokalne wyniki, natychmiast rejestrowali swoje dane wejściowe i przesyłali je do zestawienia w partiach, aby zmniejszyć przepustowość i narzut.

Widzimy początkowo, że będą musiały istnieć procesy śledzące głosy w każdej jurysdykcji, w których należy przedstawić wyniki. Załóżmy dla tego przykładu, że musimy śledzić wyniki dla każdego kraju oraz w każdym kraju według prowincji/stanu. Aby wesprzeć to działanie, chcielibyśmy, aby co najmniej jeden proces na kraj przeprowadzał obliczenia i zachowywał bieżące sumy oraz inny zestaw dla każdego stanu/prowincji w każdym kraju. Zakłada to, że musimy być w stanie odpowiedzieć na sumy dla kraju i stanu/prowincji w czasie rzeczywistym lub z małym opóźnieniem. Jeśli wyniki można uzyskać z systemu bazy danych, możemy wybrać inną alokację procesów, w której sumy są aktualizowane przez procesy przejściowe. Zaletą korzystania z dedykowanych procesów do tych obliczeń jest to, że wyniki pojawiają się z szybkością pamięci i można je uzyskać z małym opóźnieniem.

Wreszcie widzimy, że bardzo wiele osób będzie oglądać wyniki. Procesy te można podzielić na wiele sposobów. Możemy chcieć rozłożyć obciążenie, umieszczając procesy w każdym kraju odpowiedzialnym za wyniki tego kraju. Procesy mogą buforować wyniki z procesów obliczeniowych, aby zmniejszyć obciążenie zapytaniami procesów obliczeniowych i/lub procesy obliczeniowe mogą okresowo przesyłać swoje wyniki do odpowiednich procesów wyników, gdy wyniki zmienią się o znaczną ilość lub po proces obliczeniowy staje się bezczynny, co wskazuje na spowolnienie tempa zmian.

We wszystkich trzech typach procesów możemy niezależnie skalować procesy, dystrybuować je geograficznie i zapewnić, że wyniki nigdy nie zostaną utracone dzięki aktywnemu potwierdzaniu transferów danych między procesami.

Jak już wspomniano, rozpoczęliśmy przykład od projektu procesu niezależnego od logiki biznesowej w każdym procesie. W przypadkach, gdy logika biznesowa ma określone wymagania dotyczące agregacji danych lub położenia geograficznego, które mogą iteracyjnie wpływać na alokację procesu. Nasz dotychczasowy projekt procesu pokazano na rysunku 4.

Przykład rozwoju zorientowanego na proces: Wstępny projekt procesu

Zastosowanie oddzielnych procesów do odbierania głosów pozwala na odebranie każdego głosu niezależnie od innych głosów, rejestrowanie po otrzymaniu i grupowanie do następnego zestawu procesów, co znacznie zmniejsza obciążenie tych systemów. W przypadku systemu, który zużywa dużą ilość danych, zmniejszanie objętości danych przez użycie warstw procesów jest powszechnym i użytecznym wzorcem.

Wykonując obliczenia w wyizolowanym zestawie procesów, możemy zarządzać obciążeniem tych procesów oraz zapewnić ich stabilność i wymagania dotyczące zasobów.

Umieszczając prezentację wyników w wyizolowanym zestawie procesów, zarówno kontrolujemy obciążenie reszty systemu, jak i umożliwiamy dynamiczne skalowanie zestawu procesów dla obciążenia.

Dodatkowe wymagania

Dodajmy teraz kilka skomplikowanych wymagań. Załóżmy, że w każdej jurysdykcji (kraju lub stanie) zestawienie głosów może skutkować proporcjonalnym wynikiem, wynikiem „zwycięzca bierze wszystko” lub brakiem wyniku, jeśli oddano niewystarczającą liczbę głosów w stosunku do populacji tej jurysdykcji. Każda jurysdykcja sprawuje kontrolę nad tymi aspektami. Dzięki tej zmianie wyniki krajów nie są prostą agregacją surowych wyników głosowania, ale agregacją wyników stanu/prowincji. Zmienia to alokację procesu z oryginalnej na wymaganie, aby wyniki z procesów stanowych/wojewódzkich były wprowadzane do procesów krajowych. Jeśli protokół używany między zbieraniem głosów a procesami stan/prowincja i prowincja do kraju jest taki sam, wówczas logika agregacji może być ponownie wykorzystana, ale potrzebne są odrębne procesy przechowujące wyniki, a ich ścieżki komunikacji są różne, jak pokazano na rysunku 5.

Przykład rozwoju zorientowanego na proces: Zmodyfikowany projekt procesu

Kod

Aby uzupełnić przykład, omówimy implementację przykładu w Elixir OTP. Aby uprościć sprawę, ten przykład zakłada, że ​​serwer sieciowy, taki jak Phoenix, jest używany do przetwarzania rzeczywistych żądań internetowych, a te usługi sieciowe wysyłają żądania do procesu określonego powyżej. Ma to tę zaletę, że upraszcza przykład i skupia się na Eliksirze/OTP. W systemie produkcyjnym posiadanie oddzielnych procesów ma pewne zalety, a także oddzielne problemy, umożliwia elastyczne wdrażanie, rozkłada obciążenie i zmniejsza opóźnienia. Pełny kod źródłowy wraz z testami można znaleźć na https://github.com/technomag/voting. Źródło jest skrócone w tym poście dla czytelności. Każdy proces poniżej pasuje do drzewa nadzoru OTP, aby zapewnić ponowne uruchomienie procesów w przypadku awarii. Zobacz źródło, aby uzyskać więcej informacji na temat tego aspektu przykładu.

Rejestrator głosów

Ten proces odbiera głosy, rejestruje je w magazynie trwałym i przesyła wyniki do agregatorów. Moduł VoteRecoder wykorzystuje Task.Supervisor do zarządzania krótkotrwałymi zadaniami w celu rejestrowania każdego głosu.

 defmodule Voting.VoteRecorder do @moduledoc """ This module receives votes and sends them to the proper aggregator. This module uses supervised tasks to ensure that any failure is recovered from and the vote is not lost. """ @doc """ Start a task to track the submittal of a vote to an aggregator. This is a supervised task to ensure completion. """ def cast_vote where, who do Task.Supervisor.async_nolink(Voting.VoteTaskSupervisor, fn -> Voting.Aggregator.submit_vote where, who end) |> Task.await end end

Agregator głosów

Ten proces agreguje głosy w jurysdykcji, oblicza wynik dla tej jurysdykcji i przekazuje podsumowania głosowania do następnego wyższego procesu (jurysdykcji wyższego poziomu lub osoby prezentującej wyniki).

 defmodule Voting.Aggregator do use GenStage ... @doc """ Submit a single vote to an aggregator """ def submit_vote id, candidate do pid = __MODULE__.via_tuple(id) :ok = GenStage.call pid, {:submit_vote, candidate} end @doc """ Respond to requests """ def handle_call {:submit_vote, candidate}, _from, state do n = state.votes[candidate] || 0 state = %{state | votes: Map.put(state.votes, candidate, n+1)} {:reply, :ok, [%{state.id => state.votes}], state} end @doc """ Handle events from subordinate aggregators """ def handle_events events, _from, state do votes = Enum.reduce events, state.votes, fn e, votes -> Enum.reduce e, votes, fn {k,v}, votes -> Map.put(votes, k, v) # replace any entries for subordinates end end # Any jurisdiction specific policy would go here # Sum the votes by candidate for the published event merged = Enum.reduce votes, %{}, fn {j, jv}, votes -> # Each jourisdiction is summed for each candidate Enum.reduce jv, votes, fn {candidate, tot}, votes -> Logger.debug "@@@@ Votes in #{inspect j} for #{inspect candidate}: #{inspect tot}" n = votes[candidate] || 0 Map.put(votes, candidate, n + tot) end end # Return the published event and the state which retains # Votes by jourisdiction {:noreply, [%{state.id => merged}], %{state | votes: votes}} end end

Prezenter wyników

Ten proces odbiera głosy od agregatora i buforuje te wyniki w żądaniach obsługi w celu przedstawienia wyników.

 defmodule Voting.ResultPresenter do use GenStage … @doc """ Handle requests for results """ def handle_call :get_votes, _from, state do {:reply, {:ok, state.votes}, [], state} end @doc """ Obtain the results from this presenter """ def get_votes id do pid = Voting.ResultPresenter.via_tuple(id) {:ok, votes} = GenStage.call pid, :get_votes votes end @doc """ Receive votes from aggregator """ def handle_events events, _from, state do Logger.debug "@@@@ Presenter received: #{inspect events}" votes = Enum.reduce events, state.votes, fn v, votes -> Enum.reduce v, votes, fn {k,v}, votes -> Map.put(votes, k, v) end end {:noreply, [], %{state | votes: votes}} end end

Na wynos

Ten post badał Elixir/OTP pod kątem jego potencjału jako języka zorientowanego na proces, porównał go z paradygmatami obiektowymi i funkcjonalnymi oraz dokonał przeglądu implikacji tego dla szkolenia i adopcji.

Post zawiera również krótki przykład zastosowania tej orientacji do przykładowego problemu. Jeśli chcesz ponownie przejrzeć cały kod, oto link do naszego przykładu w serwisie GitHub, aby nie trzeba było go przewijać.

Kluczowym wnioskiem jest postrzeganie systemów jako zbioru procesów komunikacyjnych. Najpierw zaplanuj system z punktu widzenia projektu procesu, a następnie z punktu widzenia kodowania logicznego.