Skalierbare Anwendung mit Elixir entwerfen: vom Umbrella-Projekt zum verteilten System

Elixir / Erlang-OTP-Abstraktionen zwingen Entwickler, Programme in unabhängige Teile aufzuteilen. Während "gen_servers" Teile der Geschäftslogik auf Mikroebene kapselt, stellen "Anwendungen" einen allgemeineren ("Dienst") Teil des Systems dar. In Elixir geschriebene komplexe Programme sind immer eine Sammlung kommunizierender OTP-Anwendungen.

Bei der Entwicklung solcher Programme stellte sich vor allem die Frage, wie das komplexe System in einzelne Teile aufgeteilt werden kann. Das wichtigere Problem ist jedoch, wie die Kommunikation zwischen ihnen organisiert werden kann.

In diesem Artikel möchte ich die Gestaltungsprinzipien erläutern, denen ich bei der Erstellung eines mehr oder weniger komplexen Elixir-Projekts folge. Wir werden diskutieren, wie man das Projekt in kleine wartbare Microservices (Elixir-Anwendungen) aufteilt und wie man Module in diesen mithilfe von „Kontexten“ organisiert.

Das Hauptaugenmerk wird jedoch auf der Entwicklung flexibler Schnittstellen zwischen Elixir-Anwendungen liegen. Sie werden sehen, wie sie beim Skalieren vom einfachen Umbrella-Projekt zum verteilten System geändert werden können. Ich werde einige Ansätze behandeln: Erlang-Remote-Prozeduraufruf, verteilte Tasks und HTTP-Protokoll. Als Bonus werde ich zeigen, wie man den gleichzeitigen Zugriff auf Microservices einschränken kann.

Regenschirmprojekt

Regenschirmprojekt

Mit Elixir "Umbrella Project" kann man die komplexe Logik zu Beginn des Entwicklungsprozesses in einzelne Teile aufteilen. Gleichzeitig erlaubt es jedoch, die gesamte Logik in einem Repo zu halten. So können Sie mit minimalen Kopfschmerzen beginnen, zukünftige Mikrodienste zu entwickeln.

Ich habe ein Scaffold-Demo-Projekt vorbereitet, um echte Codebeispiele zu demonstrieren. Der Name des Projekts lautet "ml_tools" und steht für "Machine Learning Tools". Mit dem Projekt können Benutzer verschiedene Vorhersagemodelle auf ihre Datensätze anwenden und das beste auswählen. Benutzer sollten in der Lage sein, unterschiedliche Algorithmen auf ihre Datensätze anzuwenden und die Ergebnisse zu visualisieren.

Die Aufteilung des Projekts in mehrere Anwendungen ergibt sich aus den Anforderungen:

  • Datensätze - Anwendung, die für die Verwaltung von Daten zuständig ist: Datensätze erstellen, lesen und aktualisieren.
  • utils - eine Reihe verschiedener Dienstprogramme, die Daten vorverarbeiten und visualisieren.
  • models - ein Dienst, der verschiedene Algorithmen für die vorhersagende Modellierung implementiert. "Lineares Modell", "Zufallswald", "Support Vector Machine" usw.
  • main - Anwendung auf oberster Ebene, die andere Anwendungen verwendet und API auf oberster Ebene verfügbar macht.

Jede Anwendung wird unter einem eigenen Supervisor gestartet und fungiert somit als unabhängiger Dienst.

- - Projektstruktur - -

Apps /
  Datensätze /
    lib /
      Datensätze /
        Holer /
          fetchers.ex
          aws.ex
          kaggle.ex
        Sammlungen /
          ...
        Schnittstellen /
          fetchers.ex
          collections.ex
  Modelle /
  Utensilien /
  Main/
...

Nachdem wir die Hauptverantwortung in mehrere Teile unterteilt haben, wollen wir nun jeden Service im Detail untersuchen. In jeder Anwendung müssen wir den Code in Module oder Gruppen von Modulen aufteilen. Ich bevorzuge es, übergeordnete Module basierend auf Kontexten zu definieren, die in einer bestimmten Anwendung vorhanden sind.

Beispielsweise ist die Datasets-Anwendung dafür verantwortlich, Datensammlungen in einer eigenen Datenbank zu speichern und Daten aus verschiedenen Quellen abzurufen. Die Anwendung verfügt also über zwei Ordner im Verzeichnis lib / datasets: "collections" und "fetchers". Jeder Ordner hat eine .ex-Datei mit demselben Namen, die ein Modul enthält, das die Kontextschnittstelle und andere Dienstprogrammmodule implementiert.

Schauen Sie sich lib / datasets / fetchers an. Der Ordner verfügt über das Modul Datasets.Fetchers, das eine Schnittstelle für den Kontext "Fetchers" implementiert - Funktionen, die Daten aus "AWS Public Datasets" und "Kaggle Datasets" zurückgeben. Neben diesem Modul gibt es also Datasets.Fetchers.Aws und Datasets.Fetchers.Kaggle, die den Zugriff auf die bestimmte Quelle implementieren.

Die gleiche kontextbezogene Unterteilung kann in anderen Anwendungen implementiert werden. Modelle werden nach einem bestimmten Algorithmus aufgeteilt: Models.Lm (Lineares Modell) oder Models.Rf (Random Forest). utils implementiert Datenvorverarbeitung (Utils.PreProcessing) und Visualisierung (Utils.Visualization).

Natürlich gibt es auch eine Top-Level-Anwendung (Hauptanwendung), die alle Mikrodienste nutzt. Diese Anwendung hat auch mehrere Kontexte: Main.Zillow-Modul für Zillow-Wettbewerbscode und Main.Screening-Modul für Passenger Screening Algorithm Challenge.

Die Hauptanwendung hat eine andere Anwendung als Abhängigkeiten in Main.Mixfile:

defp deps do
  [
    {: datasets, in_umbrella: true},
    {: models, in_umbrella: true},
    {: utils, in_umbrella: true}
  ]
Ende

Damit stehen die Module aus verschiedenen Anwendungen innerhalb der Hauptanwendung zur Verfügung.

Im Allgemeinen gibt es im Elixir-Projekt drei Ebenen der Code-Organisation:

  • „Service Level“ - die naheliegendste Möglichkeit, das komplexe System in separate Elixir-Anwendungen (Datensätze, Modelle, Dienstprogramme) aufzuteilen.
  • „Kontextebene“ - Bricht die Verantwortung innerhalb eines bestimmten Dienstes, indem „Kontextmodule“ (Datasets.Fetchers, Datasets.Collections) implementiert werden.
  • „Implementierungsebene“ - bestimmte Module, die Datenstrukturen und -funktionen definieren (Datasets.Fetchers.Aws, Datasets.Fetchers.Kaggle)

Pro und Contra des Regenschirmprojekts

Wie oben erwähnt, besteht der Hauptvorteil der Verwendung von "Umbrella Project" darin, dass Sie den gesamten Code an einem Ort haben und ihn in der Entwicklungs- und Testumgebung zusammen ausführen können. Sie können mit dem gesamten System herumspielen und vor allem Integrationstests schreiben, die die Komponenten insgesamt testen. Dies ist sehr wichtig in der frühen Phase der Projektentwicklung!

Gleichzeitig ist Ihr Projekt bereits in relativ unabhängige Teile aufgeteilt und zur Skalierung bereit.

Vergleichen Sie dies mit einem Ansatz in vielen anderen Programmiersprachen, bei dem Sie normalerweise von einem Monolith-Projekt ausgehen und dann versuchen, einige Teile für eine separate Anwendung zu extrahieren. Denn ausgehend vom Micro-Service-Ansatz wird der Entwicklungsprozess enorm verkompliziert.

Aber es ist Zeit, sich Gedanken über die Verkapselung zu machen!

Möglicherweise haben Sie bemerkt, dass die Idee, alle Apps in Hauptanwendungsabhängigkeiten einzubeziehen, nicht so gut ist. Und du hast recht!

Die Elixier-Sprache verfügt nicht über genügend Konstruktionen für eine ordnungsgemäße Kapselung. Es gibt nur Module und Funktionen (öffentlich und privat). Wenn Sie ein weiteres Projekt als Abhängigkeit hinzufügen, stehen Ihnen alle Module zur Verfügung, sodass Sie jede öffentliche Funktion aufrufen können. Und eine naive Implementierung der Zillow-Datenanpassung in der Hauptanwendung sieht folgendermaßen aus:

defmodule Main.Zillow do
  def rf_fit do
    Datasets.Fetchers.zillow_data
    |> Utils.PreProcessing.normalize_data
    |> Models.Rf.fit_model
  Ende
Ende

Wobei Datasets.Fetchers, Utils.PreProcessing und Models.Rf Module aus verschiedenen Anwendungen sind. Diese Freiheit der gedankenlosen Verwendung von Modulen aus einer anderen Anwendung wird Ihre Dienste koppeln und das System wieder zu einem Monolithen machen!

Es gibt also zwei Seiten. Wir möchten weiterhin, dass alle Teile des Projekts während der Entwicklung und des Tests zugänglich sind. Aber wir müssen irgendwie die anwendungsübergreifende Kopplung verbieten.

Der einzige Weg, dies zu tun, besteht darin, Konventionen darüber zu erstellen, welche Funktionen von einer Anwendung in einer anderen verwendet werden dürfen. Und der beste Weg ist, alle "öffentlichen" Funktionen in separate "Schnittstellen" -Module zu extrahieren.

Schnittstellenmodule

Schnittstellen

Die Idee ist, alle Funktionen der „öffentlichen“ Anwendung (Funktionen, die von anderen Anwendungen aufgerufen werden können) in separate Module zu verschieben. Beispielsweise verfügt die Datasets-Anwendung über ein spezielles Schnittstellenmodul für die Funktionen von Fetchers:

defmodule Datasets.Interfaces.Fetchers do
  Alias ​​Datasets.Fetchers

  defdelegieren Sie zillow_data zu: Fetchers
  defdelegate landsat_data, to: Fetchers
Ende

In dieser einfachen Implementierung delegiert das Schnittstellenmodul Funktionsaufrufe nur an das entsprechende Modul. In Zukunft wird dieses Modul jedoch den Hauptteil der Kommunikationslogik enthalten, wenn wir uns entschließen, ausgeführte Datasets auf einem anderen Knoten zu extrahieren.

Wenn wir dies mit einer anderen Anwendung tun, können wir das Main.Zillow-Modul umschreiben:

def rf_fit do
  Datasets.Interfaces.Fetchers.zillow_data
  |> Utils.Interfaces.PreProcessing.normalize_data
  |> Models.Interfaces.Rf.fit_model
Ende

Im Allgemeinen lautet die Konvention: Wenn Sie eine Funktion aus einer anderen Anwendung aufrufen möchten, müssen Sie dies über das Schnittstellenmodul tun.

Dieser Ansatz ermöglicht weiterhin ein einfaches Entwickeln und Testen, erstellt jedoch einfache Regeln, die den Code vor einer engen Kopplung schützen und eine Grundlage für die zukünftige Skalierung schaffen!

Auf verteiltes System skalieren

Schnittstellenanwendungen

Stellen Sie sich vor, dass die Datenverarbeitung zeitaufwändig wird, und entscheiden Sie sich, Modelle auf einem separaten Knoten auszuführen. Wir müssen also die Abhängigkeit von {: models, in_umbrella: true} entfernen und diese Anwendung auf einem anderen Knoten ausführen.

Wenn Sie die Elixir-Konsole (iex-S-Mix) über den Hauptanwendungsordner ausführen, haben Sie keinen Zugriff mehr auf die Anwendungsmodule der Modelle:

iex (1)> Models.Interfaces.Rf.fit_model ("data")
** (UndefinedFunctionError) Funktion Models.Interfaces.Rf.fit_model / 1 ist undefiniert (Modul Models.Interfaces.Rf ist nicht verfügbar)

Die Anwendung code of models befindet sich noch im Umbrella-Projekt, wird jedoch nicht mit der Hauptanwendung ausgeführt und ist daher nicht verfügbar. Die Modellmodule und -funktionen sind nur auf einem anderen Knoten vorhanden, auf dem nur diese Anwendung ausgeführt wird.

Sie wissen jedoch, dass BEAM VM für verteilte Anwendungen entwickelt wurde. Daher gibt es viele Möglichkeiten, auf den Code zuzugreifen, der auf einem anderen Computer ausgeführt wird.

: rpc

Mit dem Erlang: rpc-Modul ist es einfach, eine Funktion auf einem Remote-Knoten auszuführen. : rpc verwendet das Erlang Distribution Protocol für die Kommunikation zwischen Knoten.

Ein einfaches Experiment kann reproduziert werden: Führen Sie das Hauptprojekt mit der Option --sname main auf einer Registerkarte des Terminals aus

iex --sname main -S mix

und Modelle in einem anderen Tab projizieren:

iex --sname models -S mix

Jetzt können Sie Berechnungen ausführen:

iex (main @ ip-192–168–1–150) 1>: rpc.call (: ”models @ ip-192–168–1–150", Models.Interfaces.Rf,: fit_model, [“data”] )
% {__ struct__: Models.Rf.Coefficient, a: 1, b: 2, data: “data”}

Welche Änderungen müssen wir in unserem Projekt vornehmen, um diesen Ansatz zu nutzen?

Die Idee ist sehr einfach, wir müssen unserem Projekt eine weitere Anwendung hinzufügen, die Kommunikationslogik implementiert - models_interface.

models_interface /
  config /
  lib /
    models_interface /
      models_interface.ex
        lm.ex
        rf.ex
    mix.ex

Dies ist eine sehr dünne Schicht, die den Zugriff auf die Models.Interface-Funktionen erleichtert. Es gibt ein paar kleine Module, die nur Funktionen von Interfaces-Modulen duplizieren:

defmodule ModelsInterface.Rf do
  def fit_model (data) do
    ModelsInterface.remote_call (Models.Interfaces.Rf,: fit_model, [data])
  Ende
Ende

Dieses Modul ruft nur die Funktion Models.Interfaces.Rf.fit_model / 1 auf. Die Implementierung von remote_call erfolgt im ModelsInterface-Modul:

defmodule ModelsInterface do
  def remote_call (module, fun, args, env \\ Mix.env)
    do_remote_call ({module, fun, args}, env)
  Ende

  def remote_node do
    Application.get_env (: models_interface,: node)
  Ende

  defp do_remote_call ({module, fun, args},: test)
    bewerben (Modul, Spaß, Argumente)
  Ende
  
  defp do_remote_call ({module, fun, args}, _)
    : rpc.call (remote_node (), module, fun, args)
  Ende
Ende

Das Modul bezieht die Knotenposition aus der Konfiguration und führt einen Remoteprozeduraufruf durch. Möglicherweise sehen Sie eine umgebungsspezifische Implementierung von do_remote_call. Dies vereinfacht den Testprozess. Wir werden dies später diskutieren.

Das nächste schnelle Refactoring: Ersetzen Sie Models.Interfaces einfach durch ModelsInterface und wir sind fertig! Vergessen Sie nicht, models_interface zu den Abhängigkeiten der Hauptanwendung hinzuzufügen.

defp deps do
  [
    {: datasets, in_umbrella: true},
    {: models, in_umbrella: true, only: [: test]},
    {: models_interface, in_umbrella: true},
    {: utils, in_umbrella: true},
    {: espec, "1.4.6", only:: test}
  ]
Ende

Wieder habe ich die Modellabhängigkeit verlassen, aber nur in der Testumgebung. Dies ermöglicht einen direkten Aufruf der Anwendung in der Testumgebung.

Das ist es. Nein, wir können über die iex-Konsole auf Modelle zugreifen:

iex (main @ ip-192–168–1–150) 1> ModelsInterface.Rf.fit_model („data“)
% {__ struct__: Models.Rf.Coefficient, a: 1, b: 2, data: “data”}

Fassen wir zusammen! Die einzige Änderung, die wir vorgenommen haben, ist eine neue einfache Schnittstellenanwendung. Wir haben immer noch den gesamten Code an einem Ort und wir haben immer noch alle Tests bestanden!

Verteilte Aufgaben

Direkte Remoteprozeduraufrufe sind nützlich, wenn Sie eine einfache synchrone Schnittstelle mit einer anderen Anwendung benötigen. Wenn Sie jedoch effektiv asynchronen Code auf dem Remote-Knoten ausführen möchten, sollten Sie verteilte Tasks auswählen.

Elixir verfügt über einen speziellen Task.Supervisor, mit dem Aufgaben dynamisch überwacht werden können. Dieser Supervisor wird in der Remoteanwendung gestartet und überwacht Aufgaben, die Code ausführen. Verwenden Sie verteilte Tasks, um auf die Datasets-Anwendung zuzugreifen.

Zunächst müssen wir Task.Supervisor den untergeordneten Elementen des Datasets Application Supervisor hinzufügen:

defmodule Datasets.Application do
  @moduledoc false

  Anwendung verwenden
  Supervisor.Spec importieren

  def start (_type, _args) do
    Kinder = [
      Vorgesetzter (Task.Supervisor,
        [[name: Datasets.Task.Supervisor]],
        [Neustart: vorübergehend, Herunterfahren: 10000])
    ]

    opts = [Strategie:: one_for_one, Name: Datasets.Supervisor]
    Supervisor.start_link (Kinder, Opts)
  Ende
Ende

Das DatasetsInterface-Modul (das die separate Schnittstellenanwendung ist):

defmodule DatasetsInterface do
  def spawn_task (module, fun, args, env \\ Mix.env)
    do_spawn_task ({module, fun, args}, env)
  Ende

  defp do_spawn_task ({module, fun, args},: test)
    bewerben (Modul, Spaß, Argumente)
  Ende

  defp do_spawn_task ({module, fun, args}, _)
    Task.Supervisor.async (remote_supervisor (), module, fun, args)
    Task.await
  Ende

  defp remote_supervisor do
    {
      Application.get_env (: datasets_interface,: task_supervisor),
      Application.get_env (: datasets_interface,: node)
    }
  Ende
Ende

Wir verwenden hier also das Async / Warten-Muster. Der Unterschied besteht darin, dass Aufgaben auf dem Remote-Knoten erstellt und vom Remote-Supervisor überwacht werden. Name und Ort des Supervisors werden in der Konfigurationsdatei festgelegt:

config: datasets_interface,
       task_supervisor: Datasets.Task.Supervisor,
       node:: "models @ ip-192-168-1-150"

Und wieder gibt es den gleichen Trick mit der Testumgebung!

Andere Protokolle

RPC und Distributed Tasks sind integrierte Erlang / Elixir-Abstraktionen, die die Kommunikation mit Elixir Term ohne zusätzliche Serialisierung und Deserialisierung ermöglichen. Wenn Sie jedoch mit Anwendungen kommunizieren müssen, die nicht in Elixir geschrieben sind, benötigen Sie einen allgemeineren Ansatz wie das HTTP-Protokoll.

Implementieren wir als Beispiel eine einfache HTTP-Schnittstelle für unsere Utils-Anwendung. Das erste, was wir brauchen, ist eine neue utils_interface-Anwendung:

Das UtilsInterface-Modul hat die gleiche Struktur wie ModelsInterface, aber das do_remote_call / 2 sieht so aus:

defp do_remote_call ({module, fun, args}, _)
  {: ok, resp} = HTTPoison.post (remote_url (),
                               serialize ({module, fun, args}))
  deserialisieren
Ende

In diesem Beispiel habe ich einfache Erlang-Term_to_binary- und binäre_to_term-Serialisierung verwendet:

defp serialize (term), do:: erlang.term_to_binary (term)
defp deserialize (data), do:: erlang.binary_to_term (data)

Das utils-Projekt benötigt einen HTTP-Server, um externe Anfragen abzuhören. Ich habe dafür einen Cowboy mit Stecker verwendet

defp deps do
  [
    {: cowboy, "~> 1.0.0"},
    {: plug, "~> 1.0"},
    {: espec, "1.4.6", only:: test}
  ]
Ende

Das Plug-Modul, das für die Bearbeitung von Anfragen zuständig ist:

defmodule Utils.Interfaces.Plug do
  benutze Plug.Router

  Stecker: Spiel
  Stecker: Versand

  post "/ remote" tun
    {: ok, body, conn} = Plug.Conn.read_body (conn)
    {module, fun, args} = deserialize (body)
    Ergebnis = anwenden (Modul, Spaß, Argumente)
    send_resp (conn, 200, serialize (result))
  Ende
Ende

Es deserialisiert einfach das Tupel {module, fun, args}, ruft die Funktion auf und sendet ein Ergebnis zurück an den Client.

Und vergessen Sie nicht, den "Plug" über den Cowboy-Server in der Utils-Anwendung zu starten

Kinder = [
  Plug.Adapters.Cowboy.child_spec (: http,
       Utils.Interfaces.Plug, [], [port: 4001])
]

Bitte beachten Sie, dass es nicht empfehlenswert ist, Funktionen direkt aus deserialisierten Daten aufzurufen. Ich habe es nur getan, um das Beispiel zu vereinfachen. In der realen Welt brauchen Sie einen differenzierteren Ansatz!

Begrenzung der Parallelität mit dem Poolboy

Mit der letzten Funktion, die ich in diesem Beitrag beschreiben möchte, können Sie Ihre Anwendung und ihre Ressourcen vor einem „Überlaufen“ schützen. Stellen Sie sich zum Beispiel vor, dass die Modellanwendung viel Speicher für die Modellanpassung benötigt. Daher möchten wir die Anzahl der Clients begrenzen, die auf die Modellanwendung zugreifen möchten. Zu diesem Zweck erstellen wir mithilfe der poolboy-Bibliothek einen begrenzten Pool von Arbeitsprozessen auf der Schnittstellenebene.

poolboy muss vom Anwendungsbetreuer gestartet werden:

defmodule Models.Application do
  Anwendung verwenden

  def start (_type, _args) do
    pool_options = [
      name: {: local, Models.Interface},
      worker_module: Models.Interfaces.Worker,
      Größe: 5, max_overflow: 5]

    Kinder = [
      : poolboy.child_spec (Models.Interface, pool_options, []),
    ]

    opts = [strategie:: one_for_one, name: Models.Supervisor]
    Supervisor.start_link (Kinder, Opts)
  Ende
Ende

Hier werden möglicherweise Poolboy-Optionen angezeigt: Name des Supervisors, Worker-Modul, Größe eines Pools und max_overflow.

Das Worker-Modul ist ein einfacher GenServer, der nur die entsprechende Funktion aufruft:

defmodule Models.Interfaces.Worker do
  benutze GenServer

  def start_link (_opts) do
    GenServer.start_link (__ MODULE__,: ok, [])
  Ende

  def init (: ok), do: {: ok,% {}}

  def handle_call ({module, fun, args}, _from, state)
    Ergebnis = anwenden (Modul, Spaß, Argumente)
    {: Antwort, Ergebnis, Status}
  Ende
Ende

Die letzte Änderung betrifft das Modul Models.Interfaces.Rf. Anstelle der Funktionsdelegation wird der Worker-Prozess im Pool erzeugt:

defmodule Models.Interfaces.Rf do
  def fit_model (data) do
    with_poolboy ({Models.Rf,: fit_model, [data]})
  Ende

  def with_poolboy (args) tun
    worker =: poolboy.checkout (Models.Interface)
    result = GenServer.call (worker, args,: infinity)
    : poolboy.checkin (Models.Interface, Arbeiter)
    Ergebnis
  Ende
Ende

Das ist es! Jetzt sind Sie absolut sicher, dass die Models-Anwendung nur eine begrenzte Anzahl von Anforderungen verarbeiten kann.

Fazit

Abschließend möchte ich Ihnen einige Empfehlungen geben:

  • Beginnen Sie von Anfang an mit Microservices. Es ist sehr einfach, mit Elixir Regenschirmprojekt zu tun.
  • Verwenden Sie "Kontext" - und "Implementierungs" -Module, um die Logik innerhalb einer Anwendung zu organisieren.
  • Überlegen Sie sich die Schnittstellen der Anwendung genau. Lassen Sie keine direkten Aufrufe von Implementierungsfunktionen zwischen Anwendungen zu.
  • Platzieren Sie bei der Skalierung auf ein verteiltes System die Kommunikationslogik in der separaten Anwendung. Verwenden Sie das Erlang Distribution Protocol für die Kommunikation zwischen BEAM-Anwendungen

Ich hoffe, die im Artikel beschriebenen Ansätze und Abstraktionen helfen Ihnen dabei, besseren Code mit Elixir zu schreiben!

Klicken Sie auf , wenn Ihnen der Artikel gefallen hat, und zögern Sie nicht, mich zu kontaktieren, wenn Sie Fragen oder Vorschläge haben!

Hab eine schöne Woche,
Anton