Do you want to pick up from where you left of?
Take me there

Η Κατανομή στο OTP

Μπορούμε να τρέξουμε τις εφαρμογές μας elixir σε ένα σύνολο διαφορετικών κόμβων που κατανέμονται σε ένα ή και πολλούς διακομιστές. Η Elixir μας επιτρέπει να επικοινωνούμε μεταξύ αυτών των κόμβων μέσω μερικών διαφορετικών μηχανισμών τους οποίους θα δούμε σε αυτό το μάθημα.

Επικοινωνία Μεταξύ Κόμβων

Η Elixir τρέχει στο Erlang VM, το οποίο σημαίνει ότι έχει πρόσβαση στην πανίσχυρη λειτουργία κατανομής της Elixir.

Ένα κατανεμημένο σύστημα Erlang αποτελείται από έναν αριθμό συστημάτων χρόνου εκτέλεσης Erlang που επικοινωνούν μεταξύ τους. Κάθε σύστημα χρόνου εκτέλεσης ονομάζεται κόμβος.

Ένας κόμβος είναι κάθε σύστημα χρόνου εκτέλεσης Erlang που του έχει δοθεί όνομα. Μπορούμε να ξεκινήσουμε ένα κόμβο ανοίγοντας μια συνεδρία iex και δίνοντάς του ένα όνομα:

iex --sname alex@localhost
iex(alex@localhost)>

Ας ανοίξουμε έναν άλλο κόμβο σε ένα άλλο παράθυρο τερματικού:

iex --sname kate@localhost
iex(kate@localhost)>

Αυτοί οι δύο κόμβοι μπορούν να στείλουν μηνύματα μεταξύ τους με τη χρήση της Node.spawn_link/2.

Επικοινωνόντας με την Node.spawn_link/2

Αυτή η συνάρτηση δέχεται δύο ορίσματα:

Δημιουργεί τη σύνδεση στον απομακρυσμένο κόμβο και εκτελεί τη δοθείσα συνάρτηση σε αυτό τον κόμβο, επιστρέφοντας τον αριθμό PID της συνδεδεμένης διεργασίας.

Ας ορίσμουμε μια ενότητα, την Kate στον κόμβο kate που ξέρει πως να παρουσιάσει την Kate, το άτομο:

iex(kate@localhost)> defmodule Kate do
...(kate@localhost)>   def say_name do
...(kate@localhost)>     IO.puts "Hi, my name is Kate"
...(kate@localhost)>   end
...(kate@localhost)> end

Στέλνοντας Μηνύματα

Τώρα, μπορούμε να χρησιμοποιήσουμε την Node.spawn_link/2 για να έχετε τον κόμβο alex να ζητήσει από τον κόμβο kate να καλέσει τη συνάρτηση say_name/0:

iex(alex@localhost)> Node.spawn_link(:kate@localhost, fn -> Kate.say_name end)
Hi, my name is Kate
#PID<10507.132.0>

Μία σημείωση στην Είσοδο/Έξοδο και τους Κόμβους

Σημειώστε ότι, παρόλο που η Kate.say_name/0 εκτελείται στον απομακρυσμένο κόμβο, είναι ο τοπικός, ή κόμβος κλήσης που δέχεται την έξοδο της IO.puts. Αυτό συμβαίνει γιατί ο τοπικός κόμβος είναι ο αρχηγός της ομάδας. Η εικονική μηχανή της Erlang διαχειρίζεται την Είσοδο/Έξοδο μέσω διεργασιών. Αυτό μας επιτρέπει να εκτελούμε εργασίες εισόδου/εξόδου, σαν την IO.puts, μεταξύ κατανεμημένων κόμβων. Αυτές οι κατανεμημένες διεργασίες διαχειρίζονται από τη διεργασία εισόδου/εξόδου αρχηγό της ομάδας. Ο αρχηγός ομάδας είναι πάντα ο κόμβος που εκκινεί τη διεργασία. Έτσι, από τη στιγμή που ο κόμβος alex είναι αυτός από τον οποίο καλούμε την spawn_link/2, ο κόμβος αυτός είναι ο αρχηγός ομάδας και η έξοδος της IO.puts θα κατευθυνθεί στην προκαθορισμένη ροή εξόδου αυτού του κόμβου.

Απαντώντας σε Μηνύματα

Τι συμβαίνει αν θέλουμε ο κόμβος που δέχεται το μήνυμα να στείλει πίσω κάποια απάντηση στον αποστολέα; Μπορούμε να χρησιμοποιήσουμε μια απλή διάταξη receive/1 και send/3 για να καταφέρουμε ακριβώς αυτό.

Θα βάλουμε τον κόμβο μας alex να εκκινήσει μια σύνδεση με τον κόμβο kate και να δώσει στον κόμβο kate μια ανώνυμη συνάρτηση να εκτελέσει. Αυτή η ανώνυμη συνάρτηση θα περιμένει για μια απάντηση μιας συγκεκριμένης τούπλας που θα περιγράφει το μήνυμα και τον αριθμό PID του κόμβου alex. Αυτός θα απαντήσει σε αυτό το μήνυμα στέλνοντας πίσω ένα μήνυμα στον αριθμό PID του κόμβου alex:

iex(alex@localhost)> pid = Node.spawn_link :kate@localhost, fn ->
...(alex@localhost)>   receive do
...(alex@localhost)>     {:hi, alex_node_pid} -> send alex_node_pid, :sup?
...(alex@localhost)>   end
...(alex@localhost)> end
#PID<10467.112.0>
iex(alex@localhost)> pid
#PID<10467.112.0>
iex(alex@localhost)> send(pid, {:hi, self()})
{:hi, #PID<0.106.0>}
iex(alex@localhost)> flush()
:sup?
:ok

Μια σημείωση στην επικοινωνία μεταξύ κόμβων σε διαφορετικά δίκτυα

Αν θέλετε να στείλετε μηνύματα μεταξύ κόμβων σε διαφορετικά δίκτυα, πρέπει να ξεκινήσουμε τους ονοματισμένους κόμβους με ένα κοινόχρηστο cookie:

iex --sname alex@localhost --cookie secret_token
iex --sname kate@localhost --cookie secret_token

Μόνο οι κόμβοι που ξεκινούν με το ίδιο cookie θα μπορούν να συνδεθούν αποτελεσματικά ό ένας στον άλλο.

Περιορισμοί της Node.spawn_link/2

Παρά το οτί η Node.spawn_link/2 παρουσιάζει τη σχέση μεταξύ κόμβων και τον τρόπο με τον οποίο μπορούμε να στείλουμε μηνύματα μεταξύ τους, δεν είναι η σωστή επιλογή για μια εφαρμογή που θα λειτουργήσει σε κατανεμημένους κόμβους. Η Node.spawn_link/2 ανοίγει διεργασίες σε απομόνωση, δηλαδή διεργασίες που δεν επιτηρούνται. Μακάρι να υπήρχε ένας τρόπος να ανοίγουν ασύγχρονες, επιτηρούμενες διεργασίες μεταξύ κόμβων

Κατανεμημένες Εργασίες

Οι κατανεμημένες εργασίες μας επιτρέπουν να ανοίξουμε επιτηρούμενες εργασίες μεταξύ κόμβων. Θα χτίσουμε μια απλή εφαρμογή επιτηρητή που αξιοποιεί τις κατανεμημένες εργασίες για να επιτρέψει στους χρήστες να μιλούν μεταξύ τους μέσω μιας συνεδρίας iex, μεταξύ κατανεμημένων κόμβων.

Ορίζοντας την Eφαρμογή Επιτηρητή

Δημιουργήστε την εφαρμογή σας:

mix new chat --sup

Προσθήκη του Επιτηρητή Εργασίας στο Δέντρο Επιτήρησης

Μια Εργασία Επιτηρητή επιτηρεί δυναμικά εργασίες. Εκκινείται χωρίς παιδιά, συχνά κάτω από ένα δικό της επιτηρητή, και μπορεί να χρησιμοποιηθεί αργότερα για να επιτηρεί έναν αριθμό εργασιών.

Θα προσθέσουμε μια Εργασία Επιτηρητή στο δέντρο επιτήρησης της εφαρμογής μας και θα την ονομάσουμε Chat.TaskSupervisor.

# lib/chat/application.ex
defmodule Chat.Application do
  @moduledoc false

  use Application

  def start(_type, _args) do
    children = [
      {Task.Supervisor, name: Chat.TaskSupervisor}
    ]

    opts = [strategy: :one_for_one, name: Chat.Supervisor]
    Supervisor.start_link(children, opts)
  end
end

Τώρα ξέρουμε ότι όταν η εφαρμογή μας εκκινεί σε κάποιον κόμβο, η Chat.Supervisor τρέχει και είναι έτοιμη να επιτηρήσει εργασίες.

Στέλνοντας Μηνύματα με Επιτηρούμενες Διεργασίες

Θα ξεκινήσουμε τις επιτηρούμενες διεργασίες με τη συνάρτηση Task.Supervisor.async/5.

Αυτή η συνάρτηση πρέπει να δεχτεί τέσσερα ορίσματα:

Μπορείτε να περάσετε ένα πέμπτo, προαιρετικό όρισμα περιγράφοντας τις επιλογές τερματισμού. Δεν θα ασχοληθούμε με αυτό εδώ.

Η εφαρμογή μας Chat είναι αρκετά απλή. Στέλνει μηνύματα σε απομακρυσμένους κόμβους και αυτοί απαντούν σε αυτά τα μηνύματα με τη χρήση της IO.puts στην STDOUT του απομακρυσμένου κόμβου.

Αρχικά, ας ορίσμουμε μια συνάρτηση, την Chat.receive_message/1, την οποία θέλουμε η εργασία μας να εκτελέσει στον απομακρυσμένο κόμβο.

# lib/chat.ex
defmodule Chat do
  def receive_message(message) do
    IO.puts message
  end
end

Στη συνέχεια, ας διδάξουμε την ενότητά μας Chat πως να στέλνει το μήνυμα στον απομακρυσμένο κόμβο χρησιμοποιώντας την εργασία επιτήρησης. Θα ορίσουμε μια συνάρτηση, την Chat.send_message/2 που θα θεσπίσει αυτή τη διαδικασία:

# lib/chat.ex
defmodule Chat do
  ...

  def send_message(recipient, message) do
    spawn_task(__MODULE__, :receive_message, recipient, [message])
  end

  def spawn_task(module, fun, recipient, args) do
    recipient
    |> remote_supervisor()
    |> Task.Supervisor.async(module, fun, args)
    |> Task.await()
  end

  defp remote_supervisor(recipient) do
    {Chat.TaskSupervisor, recipient}
  end
end

Ας τη δούμε στην πράξη.

Σε ένα παράθυρο τερματικού, εκκινήστε την εφαρμογή μας σε μια ονομασμένη συνεδρία iex:

iex --sname alex@localhost -S mix

Ανοίξτε ένα άλλο παράθυρο τερματικού και εκκινήστε την εφαρμογή μας σε έναν διαφορετικά ονομασμένο κόμβο:

iex --sname kate@localhost -S mix

Τώρα, από τον κόμβο alex, ας στείλουμε ένα μήνυμα στον κόμβο kate:

iex(alex@localhost)> Chat.send_message(:kate@localhost, "hi")
:ok

Αλλάξτε στο παράθυρο kate και θα πρέπει να δείτε το μήνυμα:

iex(kate@localhost)> hi

Ο κόμβος kate μπορεί να απαντήσει πίσω στον κόμβο alex:

iex(kate@localhost)> hi
Chat.send_message(:alex@localhost, "how are you?")
:ok
iex(kate@localhost)>

Και θα εμφανιστεί στη συνεδρία iex του κόμβου alex:

iex(alex@localhost)> how are you?

Ας δούμε ξανά τον κώδικά μας και ας αναλύσουμε τι συμβαίνει εδώ.

Έχουμε μια συνάρτηση Chat.send_message/2 η οποία δέχεται το όνομα του απομακρυσμένου κόμβου στον οποίο θέλουμε να τρέξουμε τις επιτηρούμενες εργασίες μας και το μήνυμα που θέλουμε να στείλουμε σε αυτό τον κόμβο.

Η συνάρτηση αυτή καλεί τη συνάρτησή μας spawn_task/4 η οποία ξεκινά μια ασύγχρονη εργασία στον απομακρυσμένο κόμβο με το δεδομένο όνομα, επιτηρούμενη από τον Chat.TaskSupervisor σε αυτό τον κόμβο. Ξέρουμε ότι ο Επιτηρητής Εργασίας με το όνομα Chat.TaskSupervisor τρέχει σε αυτό τον κόμβο επειδή αυτός ο κόμβος επίσης τρέχει μια έκδοση της εφαρμογής μας Chat και ο Chat.TaskSupervisor εκκινείται σαν μέρος του δέντρου επιτήρησης της εφαρμογής μας Chat.

Λέμε στον Chat.TaskSupervisor να επιτηρεί μια εργασία που εκτελεί τη συνάρτηση Chat.receive_message με ένα όρισμα, το μήνυμα που περάσαμε στην spawn_task/4 από την send_message/2.

Έτσι, η Chat.receive_message("h1") καλείται στον απομακρυσμένο, kate, κόμβο, με αποτέλεσμα το μήνυμα “hi”, να εμφανιστεί στη ροή STDOUT του κόμβου αυτού. Σε αυτή την περίπτωση, από τη στιγμή που η εργασία επιτηρείται στον απομακρυσμένο κόμβο, αυτός ο κόμβος είναι ο αρχηγός ομάδας για αυτή τη διεργασία I/O.

Απαντώντας σε Μηνύματα από Απομακρυσμένους Κόμβους

Ας κάνουμε την εφαρμογή μας Chat λιγό πιο έξυπνη. Ως τώρα, ένας αριθμός χρηστών μπορούν να τρέξουν την εφαρμογή σε μια ονομασμένη συνεδρία iex και να ξεκινήσουν τη συνομιλία. Αλλά ας πούμε ότι υπάρχει ένας μεσαίου μεγέθους άσπρος σκύλος με όνομα Moebi που δεν θέλει να μείνει παραπονεμένος. Ο Moebi θέλει να συμπεριληφθεί στην εφαρμογή Chat αλλά δυστυχώς δεν ξέρει πως να πληκτρολογεί, καθότι είναι σκύλος. Έτσι, θα μάθουμε την ενότητά μας Chat πως να απαντά σε κάθε μήνυμα που στέλνεται σε έναν κόμβο με όνομα moebi@localhost για λογαριασμό του Moebi. Ασχέτως του τι θα πείτε στον Moebi, θα απαντήσει με το "chicken?", καθώς ο μόνος του αληθινός πόθος είναι να τρώει κοτόπουλο.

Θα ορίσουμε μια άλλη έκδοση της συνάρτησης send_message/2 που θα πραγματοποιεί αντιπαραβολή προτύπου στο όρισμα recipient. Αν ο παραλήπτης είναι ο :moebi@localhost, θα

# lib/chat.ex
...
def send_message(:moebi@localhost, message) do
  spawn_task(__MODULE__, :receive_message_for_moebi, :moebi@localhost, [message, Node.self()])
end

Στη συνέχεια, θα ορίσουμε μια συνάρτηση, την receive_message_for_moebi/2 η οποία θα τυπώσει με την IO.puts στη ροή STDOUT του κόμβου moebi και θα στείλει ένα μήνυμα πίσω στον αποστολέα:

# lib/chat.ex
...
def receive_message_for_moebi(message, from) do
  IO.puts message
  send_message(from, "chicken?")
end

Καλώντας την send_message/2 με το όνομα του κόμβου που έστειλε το αρχικό μήνυμα (ο “κόμβος αποστολέας”), λέμε στον απομακρυσμένο κόμβο να εκκινήσει μια επιτηρούμενη εργασία πίσω σε αυτό τον κόμβο αποστολέα.

Ας το δούμε σε δράση. Σε τρία διαφορετικά παράθυρα τερματικού, ανοίξτε τρεις διαφορετικά ονομασμένους κόμβους:

iex --sname alex@localhost -S mix
iex --sname kate@localhost -S mix
iex --sname moebi@localhost -S mix

Ας βάλουμε τον alex να στείλει ένα μήνυμα στον moebi:

iex(alex@localhost)> Chat.send_message(:moebi@localhost, "hi")
chicken?
:ok

Μπορούμε να δούμε ότι ο κόμβος alex δέχθηκε την απάντηση, "chicken?". Αν ανοίξουμε τον κόμβο kate, θα δούμε ότι δεν λήφθηκε κανένα μήνυμα, από τη στιγμή που κανένας εκ των alex και moebi δεν του έστειλε μήνυμα (συγγνώμη kate). Και αν ανοίξουμε το παράθυρο τερματικού του κόμβου moebi, θα δούμε το μήνυμα που έστειλε ο κόμβος του alex:

iex(moebi@localhost)> hi

Δοκιμάζοντας τον Κατανεμημένο Κώδικα

Ας ξεκινήσουμε γράφοντας μια απλή δοκιμή για τη συνάρτησή μας send_message.

# test/chat_test.exs
defmodule ChatTest do
  use ExUnit.Case, async: true
  doctest Chat

  test "send_message" do
    assert Chat.send_message(:moebi@localhost, "hi") == :ok
  end
end

Αν τρέξουμε τις δοκιμές μας με τη χρήση της mix test, θα δούμε ότι αποτυγχάνουν με το παρακάτω σφάλμα:

** (exit) exited in: GenServer.call({Chat.TaskSupervisor, :moebi@localhost}, {:start_task, [#PID<0.158.0>, :monitor, {:sophie@localhost, #PID<0.158.0>}, {Chat, :receive_message_for_moebi, ["hi", :sophie@localhost]}], :temporary, nil}, :infinity)
         ** (EXIT) no connection to moebi@localhost

Αυτό το σφάλμα βγάζει νόημα - δεν μπορούμε να συνδεθούμε σε ένα κόμβο με όνομα moebi@localhost καθώς δεν υπάρχει τέτοιος κόμβος.

Μπορούμε να κάνουμε αυτή τη δοκιμή να τρέξει με τα παρακάτω βήματα:

Πολύ δουλειά και σίγουρα δεν μπορεί να θεωρηθεί μια αυτοματοποιημένη διαδικασία δοκιμής.

Υπάρχουν δύο διαφορετικές προσεγγίσεις που μπορούμε να πάρουμε εδώ:

  1. Προαιρετικά να αποκλείσουμε δοκιμές που απαιτούν κατανεμημένους κόμβους, αν ο απαιτούμενος κόμβος δεν τρέχει.
  2. Ρύθμιση της εφαρμογής μας να αποφεύγει να ανοίγει εργασίες σε απομακρυσμένους κόμβους στο περιβάλλον δοκιμών.

Ας ρίξουμε μια ματιά στην πρώτη προσέγγιση.

Προαιρετική Εξαίρεση Δοκιμών με Ετικέτες

Θα προσθέσουμε μια ετικέτα ExUnit σε αυτή τη δοκιμή:

# test/chat_test.exs
defmodule ChatTest do
  use ExUnit.Case, async: true
  doctest Chat

  @tag :distributed
  test "send_message" do
    assert Chat.send_message(:moebi@localhost, "hi") == :ok
  end
end

Και θα προσθέσουμε μια λογική συνθήκη στο βοηθό δοκιμών μας για να εξαιρούμε δοκιμές με αυτή την ετικέτα αν οι δοκιμές δεν τρέχουν σε ονομασμένο κόμβο.

# test/test_helper.exs
exclude =
  if Node.alive?, do: [], else: [distributed: true]

ExUnit.start(exclude: exclude)

Ελέγχουμε αν ο κόμβος είναι ζωντανός, δηλαδή αν ο κόμβος είναι μέρος ενός κατανεμημένου συστήματος με την Node.alive?. Αν όχι, θα καλέσουμε την ExUnit για να παρακάμψουμε όσες δοκιμές έχουν την ετικέτα distributed: true. Αλλιώς, θα του πούμε να μην εξαιρέσει καμμία δοκιμή.

Τώρα, αν τρέξουμε την κλασική mix test, θα δούμε:

mix test
Excluding tags: [distributed: true]

Finished in 0.02 seconds
1 test, 0 failures, 1 excluded

Και αν θέλουμε να τρέξουμε τις κατανεμημένες δοκιμές μας, απλά θα πρέπει να κάνουμε τα βήματα που περιγράφονται στην προηγούμενη ενότητα: τρέξτε τον κόμβο moebi@localhost και τρέξτε τις δοκιμές σε ένα ονομασμένο κόμβο με τη χρήση της iex.

Ας ρίξουμε μια ματιά στην άλλη προσέγγιση δοκιμής - ρύθμιση της εφαρμογής ώστε να συμπεριφέρεται διαφορετικά σε διαφορετικά περιβάλλοντα.

Ρύθμιση Εφαρμογής Ανάλογα με το Περιβάλλον

Το μέρος του κώδικα που λέει στον Task.Supervisor να εκκινήσει μια επιτηρούμενη εργασία σε ένα απομακρυσμένο κόμβο είναι εδώ:

# app/chat.ex
def spawn_task(module, fun, recipient, args) do
  recipient
  |> remote_supervisor()
  |> Task.Supervisor.async(module, fun, args)
  |> Task.await()
end

defp remote_supervisor(recipient) do
  {Chat.TaskSupervisor, recipient}
end

Η Task.Supervisor.async/5 δέχεται ένα πρώτο όρισμα του επιτηρητή που θέλουμε να χρησιμοποιήσουμε. Αν περάσουμε μια τούπλα {SupervisorName, location}, θα εκκινήσει τον δεδομένο επιτηρητή στο δεδομένο απομακρυσμένο κόμβο. Παρ’ όλα αυτά, αν περάσουμε στον Task.Supervisor σαν πρώτο όρισμα το όνομα ενός επιτηρητή, θα χρησιμοποιήσει αυτό τον επιτηρητή για να επιτηρήσει την εργασία τοπικά.

Ας κάνουμε τη συνάρτηση remote_supervisor/1 να είναι διαμορφώσιμη βάσει του περιβάλλοντος. Στο περιβάλλον ανάπτυξης, θα επιστρέφει {Chat.TaskSupervisor, recipient}, και στο περιβάλλον δοκιμών θα επιστρέφει Chat.TaskSupervisor.

Θα το κάνουμε αυτό μέσω των μεταβλητών εφαρμογής.

Δημιουργήστε ένα αρχείο, config/dev.exs, και προσθέστε:

# config/dev.exs
import Config
config :chat, remote_supervisor: fn(recipient) -> {Chat.TaskSupervisor, recipient} end

Δημιουργήστε ένα αρχείο, config/test.exs, και προσθέστε:

# config/test.exs
import Config
config :chat, remote_supervisor: fn(_recipient) -> Chat.TaskSupervisor end

Θυμηθείτε να βγάλετε από σχόλιο αυτή τη γραμμή στο config/config.exs:

import Config
import_config "#{config_env()}.exs"

Τέλος, θα αναβαθμίσουμε τη συνάρτησή μας Chat.remote_supervisor/1 ώστε να ψάξει και να χρησιμοποιήσει τη συνάρτηση που αποθηκεύτηκε στη μεταβλητή εφαρμογής μας:

# lib/chat.ex
defp remote_supervisor(recipient) do
  Application.get_env(:chat, :remote_supervisor).(recipient)
end

Συμπέρασμα

Οι ενσωματωμένες δυνατότητες κατανομής της Elixir, τις οποίες έχει λόγω της δύναμης του Erlang VM, είναι ένα από τα χαρακτηριστικά που την κάνει τόσο δυνατό εργαλείο. Μπορούμε να φανταστούμε το να χρησιμοποιούμε την ικανότητα της Elixir για κατανεμημένη επεξεργασία για να τρέχουμε ταυτόχρονες εργασίες παρασκηνίου, για να υποστηρίζουμε εφαρμογες υψηλής απόδοσης, για να τρέχουμε ακριβές σε επεξεργασία λειτουργίες - ότι θέλουμε.

Αυτό το μάθημα μας δίνει μια βασική εισαγωγή στην έννοια της κατανομής στην Elixir και μας δίνει τα εργαλεία που χρειαζόμαστε για να ξεκινήσουμε να χτίζουμε κατανεμημένες εφαρμογές. Χρησιμοποιώντας επιτηρούμενες εργασίες, μπορούμε να στέλνουμε μηνύματα σε άλλους κόμβους μιας κατανεμημένης εφαρμογής.

Έπιασες λάθος ή θέλεις να συνεισφέρεις στο μάθημα; Επεξεργαστείτε αυτό το μάθημα στο GitHub!