Ein Leitfaden zur prozessorientierten Programmierung in Elixir und OTP
Veröffentlicht: 2022-03-11Programmiersprachen werden gerne in Paradigmen eingeteilt. Es gibt objektorientierte (OO) Sprachen, imperative Sprachen, funktionale Sprachen usw. Dies kann hilfreich sein, um herauszufinden, welche Sprachen ähnliche Probleme lösen und welche Arten von Problemen eine Sprache lösen soll.
In jedem Fall hat ein Paradigma im Allgemeinen einen „Hauptfokus“ und eine Technik, die die treibende Kraft für diese Sprachfamilie ist:
In OO-Sprachen ist es die Klasse oder das Objekt als Möglichkeit, den Zustand (Daten) mit der Manipulation dieses Zustands (Methoden) zu kapseln.
In funktionalen Sprachen kann dies die Manipulation von Funktionen selbst oder die unveränderlichen Daten sein, die von Funktion zu Funktion weitergegeben werden.
Während Elixir (und davor Erlang) oft als funktionale Sprachen kategorisiert werden, weil sie die unveränderlichen Daten aufweisen, die funktionalen Sprachen gemeinsam sind, würde ich behaupten, dass sie ein von vielen funktionalen Sprachen getrenntes Paradigma darstellen . Sie existieren und werden aufgrund der Existenz von OTP übernommen, und daher würde ich sie als prozessorientierte Sprachen kategorisieren.
In diesem Beitrag werden wir die Bedeutung dessen erfassen, was prozessorientierte Programmierung bei der Verwendung dieser Sprachen ist, die Unterschiede und Ähnlichkeiten zu anderen Paradigmen untersuchen, die Auswirkungen sowohl auf die Schulung als auch auf die Einführung sehen und mit einem kurzen prozessorientierten Programmierbeispiel abschließen.
Was ist prozessorientierte Programmierung?
Beginnen wir mit einer Definition: Die prozessorientierte Programmierung ist ein Paradigma, das auf der Kommunikation sequentieller Prozesse basiert und ursprünglich aus einem Artikel von Tony Hoare aus dem Jahr 1977 stammt. Dies wird im Volksmund auch als Akteursmodell der Nebenläufigkeit bezeichnet. Andere Sprachen mit einem gewissen Bezug zu diesem Originalwerk sind Occam, Limbo und Go. Das formelle Papier befasst sich nur mit synchroner Kommunikation; Die meisten Akteurmodelle (einschließlich OTP) verwenden ebenfalls asynchrone Kommunikation. Es ist immer möglich, synchrone Kommunikation auf asynchroner Kommunikation aufzubauen, und OTP unterstützt beide Formen.
Auf dieser Grundlage hat OTP ein System für fehlertolerantes Rechnen geschaffen, indem sequentielle Prozesse kommuniziert wurden. Die fehlertoleranten Einrichtungen stammen aus einem „Let it fail“-Ansatz mit solider Fehlerbehebung in Form von Supervisoren und der Verwendung einer verteilten Verarbeitung, die durch das Akteurmodell ermöglicht wird. Das „lass es scheitern“ kann dem „verhindern, dass es scheitert“ gegenübergestellt werden, da ersteres viel einfacher zu handhaben ist und sich in OTP als weitaus zuverlässiger erwiesen hat als letzteres. Der Grund dafür ist, dass der Programmieraufwand, der zum Verhindern von Fehlern erforderlich ist (wie im Java-Modell für geprüfte Ausnahmen gezeigt), viel komplizierter und anspruchsvoller ist.
Prozessorientierte Programmierung kann also als ein Paradigma definiert werden, bei dem die Prozessstruktur und die Kommunikation zwischen Prozessen eines Systems im Vordergrund stehen .
Objektorientierte vs. prozessorientierte Programmierung
Bei der objektorientierten Programmierung steht die statische Struktur von Daten und Funktion im Vordergrund. Welche Methoden sind erforderlich, um die eingeschlossenen Daten zu manipulieren, und welche Verbindungen sollten zwischen Objekten oder Klassen bestehen? Daher ist das Klassendiagramm von UML ein Paradebeispiel für diesen Fokus, wie in Abbildung 1 zu sehen ist.
Es kann angemerkt werden, dass eine häufige Kritik an der objektorientierten Programmierung darin besteht, dass es keinen sichtbaren Kontrollfluss gibt. Da Systeme aus einer großen Anzahl separat definierter Klassen/Objekte zusammengesetzt sind, kann es für eine weniger erfahrene Person schwierig sein, den Kontrollfluss eines Systems zu visualisieren. Dies gilt insbesondere für Systeme mit viel Vererbung, die abstrakte Schnittstellen verwenden oder keine starke Typisierung haben. In den meisten Fällen ist es für den Entwickler wichtig, sich einen großen Teil der Systemstruktur zu merken, um effektiv zu sein (welche Klassen haben welche Methoden und welche werden auf welche Weise verwendet).
Die Stärke des objektorientierten Entwicklungsansatzes besteht darin, dass das System erweitert werden kann, um neue Objekttypen mit begrenztem Einfluss auf bestehenden Code zu unterstützen, solange die neuen Objekttypen den Erwartungen des bestehenden Codes entsprechen.
Funktionale vs. prozessorientierte Programmierung
Viele funktionale Programmiersprachen behandeln Parallelität auf verschiedene Weise, aber ihr Hauptaugenmerk liegt auf der unveränderlichen Datenübergabe zwischen Funktionen oder der Erstellung von Funktionen aus anderen Funktionen (Funktionen höherer Ordnung, die Funktionen erzeugen). Größtenteils liegt der Schwerpunkt der Sprache immer noch auf einem einzelnen Adressraum oder einer ausführbaren Datei, und die Kommunikation zwischen solchen ausführbaren Dateien wird auf eine betriebssystemspezifische Weise gehandhabt.
Zum Beispiel ist Scala eine funktionale Sprache, die auf der Java Virtual Machine aufbaut. Es kann zwar auf Java-Einrichtungen für die Kommunikation zugreifen, ist aber kein fester Bestandteil der Sprache. Während es sich um eine gemeinsame Sprache handelt, die in der Spark-Programmierung verwendet wird, handelt es sich wiederum um eine Bibliothek, die in Verbindung mit der Sprache verwendet wird.
Eine Stärke des funktionalen Paradigmas ist die Fähigkeit, den Kontrollfluss eines Systems zu visualisieren, wenn die Funktion der obersten Ebene gegeben ist. Der Kontrollfluss ist insofern explizit, als jede Funktion andere Funktionen aufruft und alle Daten von einer zur nächsten weiterleitet. Im funktionalen Paradigma gibt es keine Nebenwirkungen, was die Problembestimmung erleichtert. Die Herausforderung bei rein funktionalen Systemen besteht darin, dass „Nebenwirkungen“ erforderlich sind, um einen dauerhaften Zustand zu haben. In gut strukturierten Systemen wird das Beibehalten des Zustands auf der obersten Ebene des Kontrollflusses gehandhabt, wodurch der größte Teil des Systems frei von Nebenwirkungen ist.
Elixir/OTP und prozessorientierte Programmierung
In Elixir/Erlang und OTP sind die Kommunikationsprimitive Teil der virtuellen Maschine, die die Sprache ausführt. Die Fähigkeit zur Kommunikation zwischen Prozessen und zwischen Maschinen ist in das Sprachsystem integriert und von zentraler Bedeutung. Dies unterstreicht die Bedeutung der Kommunikation in diesem Paradigma und in diesen Sprachsystemen.
Während die Elixir-Sprache in Bezug auf die in der Sprache ausgedrückte Logik überwiegend funktional ist, ist ihre Verwendung prozessorientiert .
Was bedeutet es, prozessorientiert zu sein?
Prozessorientiert zu sein, wie in diesem Beitrag definiert, bedeutet, ein System zunächst in Form dessen zu entwerfen, welche Prozesse existieren und wie sie kommunizieren. Eine der Hauptfragen ist, welche Prozesse statisch und welche dynamisch sind, welche bei Bedarf für Anforderungen erzeugt werden, die einem langfristigen Zweck dienen, die einen gemeinsamen Zustand oder einen Teil des gemeinsamen Zustands des Systems enthalten und welche Funktionen von Das System ist von Natur aus gleichzeitig. So wie OO Typen von Objekten hat und Funktional Typen von Funktionen hat, hat prozessorientierte Programmierung Typen von Prozessen.
Als solches ist ein prozessorientiertes Design die Identifizierung der Menge von Prozesstypen, die erforderlich sind, um ein Problem zu lösen oder einen Bedarf zu decken .
Der Zeitaspekt geht schnell in die Design- und Anforderungsbemühungen ein. Wie ist der Lebenszyklus des Systems? Welche kundenspezifischen Anforderungen sind gelegentlich und welche konstant? Wo ist die Last im System und was ist die erwartete Geschwindigkeit und das Volumen? Erst nachdem diese Art von Überlegungen verstanden wurden, beginnt ein prozessorientiertes Design damit, die Funktion jedes Prozesses oder die auszuführende Logik zu definieren.
Auswirkungen auf das Training
Die Auswirkung dieser Kategorisierung auf das Training ist, dass das Training nicht mit der Sprachsyntax oder „Hello World“-Beispielen beginnen sollte, sondern mit systemtechnischem Denken und einem Design-Fokus auf die Prozesszuordnung .
Die Codierungsbelange sind zweitrangig gegenüber dem Prozessdesign und der Zuordnung, die am besten auf einer höheren Ebene angegangen werden, und beinhalten funktionsübergreifendes Denken über Lebenszyklus, QA, DevOps und Geschäftsanforderungen des Kunden. Jeder Schulungskurs in Elixir oder Erlang muss (und tut dies im Allgemeinen) OTP beinhalten und sollte von Anfang an eine Prozessorientierung haben, nicht den Ansatz vom Typ „Jetzt können Sie in Elixir programmieren, also machen wir Parallelität“.
Adoptionsimplikationen
Die Implikation für die Annahme ist, dass die Sprache und das System besser auf Probleme angewendet werden können, die eine Kommunikation und/oder Verteilung von Computern erfordern. Probleme, die eine einzelne Arbeitslast auf einem einzelnen Computer darstellen, sind in diesem Bereich weniger interessant und können besser mit einer anderen Sprache angegangen werden. Langlebige kontinuierliche Verarbeitungssysteme sind ein Hauptziel für diese Sprache, da sie über eine von Grund auf eingebaute Fehlertoleranz verfügt.
Für Dokumentations- und Designarbeiten kann es sehr hilfreich sein, eine grafische Notation zu verwenden (wie Abbildung 1 für OO-Sprachen). Der Vorschlag für Elixir und prozessorientierte Programmierung von UML wäre das Sequenzdiagramm (Beispiel in Abbildung 2), um zeitliche Beziehungen zwischen Prozessen darzustellen und zu identifizieren, welche Prozesse an der Bearbeitung einer Anfrage beteiligt sind. Es gibt keinen UML-Diagrammtyp zur Erfassung des Lebenszyklus und der Prozessstruktur, aber es könnte mit einem einfachen Kasten- und Pfeildiagramm für Prozesstypen und ihre Beziehungen dargestellt werden. Zum Beispiel Abbildung 3:
Ein Beispiel für Prozessorientierung
Abschließend werden wir ein kurzes Beispiel für die Anwendung der Prozessorientierung auf ein Problem durchgehen. Angenommen, wir haben die Aufgabe, ein System bereitzustellen, das globale Wahlen unterstützt. Dieses Problem wird dadurch gewählt, dass viele einzelne Aktivitäten in Bursts durchgeführt werden, aber die Aggregation oder Zusammenfassung der Ergebnisse in Echtzeit wünschenswert ist und eine erhebliche Belastung erfahren könnte.

Anfängliches Prozessdesign und Zuweisung
Wir können zunächst sehen, dass die Abgabe von Stimmen durch jeden Einzelnen ein Verkehrsstoß zum System aus vielen diskreten Eingaben ist, nicht zeitlich geordnet ist und eine hohe Last haben kann. Um diese Aktivität zu unterstützen, würden wir uns eine große Anzahl von Prozessen wünschen, die alle diese Eingaben sammeln und sie an einen zentraleren Prozess zur Tabellierung weiterleiten. Diese Prozesse könnten sich in der Nähe der Bevölkerung in jedem Land befinden, das Stimmen generieren würde, und somit eine geringe Latenzzeit bieten. Sie würden lokale Ergebnisse aufbewahren, ihre Eingaben sofort protokollieren und sie zur tabellarischen Erfassung in Stapeln weiterleiten, um Bandbreite und Overhead zu reduzieren.
Wir können zunächst sehen, dass es Prozesse geben muss, die die Abstimmungen in jeder Gerichtsbarkeit verfolgen, in der Ergebnisse präsentiert werden müssen. Nehmen wir für dieses Beispiel an, dass wir die Ergebnisse für jedes Land und innerhalb jedes Landes nach Provinz/Staat nachverfolgen müssen. Um diese Aktivität zu unterstützen, würden wir mindestens einen Prozess pro Land wünschen, der die Berechnung durchführt und die aktuellen Summen beibehält, und einen weiteren Satz für jeden Staat/Provinz in jedem Land. Dies setzt voraus, dass wir in der Lage sein müssen, Gesamtsummen für Land und Staat/Provinz in Echtzeit oder mit geringer Latenz zu beantworten. Wenn die Ergebnisse aus einem Datenbanksystem erhalten werden können, wählen wir möglicherweise eine andere Prozesszuordnung, bei der die Summen durch transiente Prozesse aktualisiert werden. Der Vorteil der Verwendung spezieller Prozesse für diese Berechnungen besteht darin, dass die Ergebnisse mit Speichergeschwindigkeit erfolgen und mit geringer Latenz erhalten werden können.
Schließlich können wir sehen, dass viele, viele Leute die Ergebnisse sehen werden. Diese Prozesse können auf viele Arten partitioniert werden. Möglicherweise möchten wir die Last verteilen, indem wir in jedem Land Prozesse platzieren, die für die Ergebnisse dieses Landes verantwortlich sind. Die Prozesse könnten die Ergebnisse der Berechnungsprozesse zwischenspeichern, um die Abfragebelastung der Berechnungsprozesse zu verringern, und/oder die Berechnungsprozesse könnten ihre Ergebnisse regelmäßig an die richtigen Ergebnisprozesse weiterleiten, wenn sich die Ergebnisse um einen erheblichen Betrag ändern, oder auf der Der Berechnungsprozess wird inaktiv, was auf eine verlangsamte Änderungsrate hinweist.
Bei allen drei Prozesstypen können wir die Prozesse unabhängig voneinander skalieren, sie geografisch verteilen und sicherstellen, dass Ergebnisse nie verloren gehen, indem Datentransfers zwischen Prozessen aktiv bestätigt werden.
Wie besprochen haben wir das Beispiel mit einem Prozessdesign begonnen, das von der Geschäftslogik in jedem Prozess unabhängig ist. In Fällen, in denen die Geschäftslogik spezifische Anforderungen an die Datenaggregation oder Geographie hat, die sich iterativ auf die Prozesszuordnung auswirken können. Unser bisheriges Prozessdesign ist in Abbildung 4 dargestellt.
Die Verwendung separater Prozesse zum Empfangen von Stimmen ermöglicht es, dass jede Stimme unabhängig von jeder anderen Stimme empfangen, beim Empfang protokolliert und für die nächste Gruppe von Prozessen gestapelt wird, wodurch die Belastung dieser Systeme erheblich reduziert wird. Für ein System, das eine große Datenmenge verbraucht, ist die Reduzierung des Datenvolumens durch die Verwendung von Prozessschichten ein gängiges und nützliches Muster.
Indem wir die Berechnung in einem isolierten Satz von Prozessen durchführen, können wir die Belastung dieser Prozesse verwalten und ihre Stabilität und Ressourcenanforderungen sicherstellen.
Indem wir die Ergebnispräsentation in einem isolierten Satz von Prozessen platzieren, steuern wir sowohl die Last für den Rest des Systems als auch ermöglichen, dass der Satz von Prozessen dynamisch für die Last skaliert wird.
Zusätzliche Anforderungen
Lassen Sie uns nun einige erschwerende Anforderungen hinzufügen. Nehmen wir an, dass in jeder Gerichtsbarkeit (Land oder Bundesstaat) die tabellarische Erfassung der Stimmen zu einem proportionalen Ergebnis, einem „Winner-takes-all“-Ergebnis oder keinem Ergebnis führen kann, wenn im Verhältnis zur Bevölkerung dieser Gerichtsbarkeit nicht genügend Stimmen abgegeben werden. Jede Gerichtsbarkeit hat die Kontrolle über diese Aspekte. Mit dieser Änderung sind die Ergebnisse der Länder keine einfache Aggregation der rohen Abstimmungsergebnisse, sondern eine Aggregation der Bundesstaats-/Provinzergebnisse. Dadurch ändert sich die Prozesszuordnung vom Original dahingehend, dass Ergebnisse aus den Bundesstaats-/Provinzprozessen in die Länderprozesse einfließen müssen. Wenn das Protokoll, das zwischen der Stimmensammlung und den Prozessen von Staat/Provinz und Provinz zu Land verwendet wird, dasselbe ist, kann die Aggregationslogik wiederverwendet werden, aber es werden unterschiedliche Prozesse benötigt, die die Ergebnisse enthalten, und ihre Kommunikationspfade sind unterschiedlich, wie in Abbildung gezeigt 5.
Der Code
Um das Beispiel zu vervollständigen, sehen wir uns eine Implementierung des Beispiels in Elixir OTP an. Zur Vereinfachung wird in diesem Beispiel davon ausgegangen, dass ein Webserver wie Phoenix verwendet wird, um tatsächliche Webanforderungen zu verarbeiten, und diese Webdienste stellen Anforderungen an den oben identifizierten Prozess. Dies hat den Vorteil, dass das Beispiel vereinfacht wird und der Fokus auf Elixir/OTP bleibt. In einem Produktionssystem hat es einige Vorteile, wenn es sich um separate Prozesse handelt, und trennt Bedenken, ermöglicht eine flexible Bereitstellung, verteilt die Last und reduziert die Latenz. Den vollständigen Quellcode mit Tests finden Sie unter https://github.com/technomage/voting. Die Quelle ist in diesem Beitrag zur besseren Lesbarkeit abgekürzt. Jeder der folgenden Prozesse passt in einen OTP-Überwachungsbaum, um sicherzustellen, dass Prozesse bei einem Fehler neu gestartet werden. Weitere Informationen zu diesem Aspekt des Beispiels finden Sie in der Quelle.
Abstimmungsrekorder
Dieser Prozess empfängt Stimmen, protokolliert sie in einem dauerhaften Speicher und stapelt die Ergebnisse an die Aggregatoren. Das Modul VoteRecoder verwendet Task.Supervisor, um kurzlebige Aufgaben zu verwalten, um jede Stimme aufzuzeichnen.
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
Abstimmungsaggregator
Dieser Prozess aggregiert Stimmen innerhalb einer Gerichtsbarkeit, berechnet das Ergebnis für diese Gerichtsbarkeit und leitet Abstimmungszusammenfassungen an den nächsthöheren Prozess (eine Gerichtsbarkeit auf höherer Ebene oder einen Ergebnisdarsteller) weiter.
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
Ergebnismoderator
Dieser Prozess empfängt Stimmen von einem Aggregator und speichert diese Ergebnisse zwischen, um Anforderungen zum Präsentieren von Ergebnissen zu bedienen.
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
Wegbringen
Dieser Beitrag untersuchte Elixir/OTP von seinem Potenzial als prozessorientierte Sprache, verglich dies mit objektorientierten und funktionalen Paradigmen und überprüfte die Auswirkungen davon auf Schulung und Einführung.
Der Beitrag enthält auch ein kurzes Beispiel für die Anwendung dieser Orientierung auf ein Beispielproblem. Falls Sie den gesamten Code überprüfen möchten, finden Sie hier noch einmal einen Link zu unserem Beispiel auf GitHub, damit Sie nicht zurückscrollen müssen, um danach zu suchen.
Der Schlüssel zum Erfolg besteht darin, Systeme als eine Sammlung von kommunizierenden Prozessen zu betrachten. Planen Sie das System zuerst aus Sicht des Prozessdesigns und dann aus Sicht der Logikcodierung.