Proiectarea aplicației scalabile cu Elixir: de la proiectul umbrelă la sistemul distribuit

Abordările Elixir / Erlang OTP impun dezvoltatorilor să împartă programele în părți independente. În timp ce „gen_servers” încapsulează părți ale logicii de afaceri pe micro-nivel, „aplicațiile” prezintă o parte mai generală („service”) a sistemului. Programele complexe scrise în Elixir sunt întotdeauna o colecție de aplicații OTP care comunică.

Principala întrebare apărută în timpul dezvoltării unor astfel de programe este cum să împărțiți sistemul complex în părți separate. Problema mai importantă este însă modul de organizare a comunicării între ei.

În articol, aș împărtăși principiile de design pe care le urmez atunci când creez un proiect Elixir mai mult sau mai puțin complex. Vom discuta despre modul de a împărți proiectul în mici microservicii de întreținut (aplicații Elixir) și cum să organizăm module în interiorul lor folosind „contexte”.

Dar accentul principal va fi proiectarea de interfețe flexibile între aplicațiile Elixir. Veți vedea cum pot fi schimbate în timp ce scalează de la un simplu proiect umbrelă la un sistem distribuit. Voi acoperi câteva abordări: apel de procedură Erlang la distanță, activități distribuite și protocol HTTP. Și, ca bonus, voi arăta cum se poate limita accesul simultan la microservicii.

Proiect Umbrella

Proiect Umbrella

Cu „proiectul umbrelă” Elixir se poate împărți logica complexă în părți separate chiar de la începutul procesului de dezvoltare. Dar, în același timp, permite păstrarea întregii logici într-o singură repo. Astfel, puteți începe să dezvoltați microservicii viitoare cu o durere de cap minimă.

Am pregătit un proiect demo de eșafodă pentru a demonstra exemple reale de cod. Numele proiectului este „ml_tools” care indică „Instrumente de învățare automată”. Proiectul permite utilizatorilor să aplice modele predictive diferite pe seturile de date și să-l aleagă pe cel mai bun. Utilizatorii ar trebui să poată aplica algoritmi diferiți seturilor de date și să vizualizeze rezultatele.

Împărțirea proiectului în mai multe aplicații este destul de evidentă din cerințele:

  • seturi de date - aplicație responsabilă pentru gestionarea datelor: creează, citește și actualizează seturi de date.
  • utils - un set de servicii de utilitate diferite care preprocesează și vizualizează datele.
  • modele - un serviciu care implementează diferiți algoritmi pentru modelarea predictivă. „Model liniar”, „pădure la întâmplare”, „mașină vector de susținere” etc.
  • principal - aplicație de nivel superior care utilizează alte aplicații și expune API de nivel superior.

Fiecare cerere este pornită sub propriul său supraveghetor, astfel, acționează ca un serviciu independent.

- - structura proiectului - -

aplicaţii /
  seturi de date /
    lib /
      seturi de date /
        fetchers /
          fetchers.ex
          aws.ex
          kaggle.ex
        colecții /
          ...
        interfețe /
          fetchers.ex
          collections.ex
  modele /
  utils /
  principal/
...

După ce am împărțit responsabilitatea de nivel superior în mai multe părți, să explorăm fiecare serviciu în detalii. În fiecare aplicație, trebuie să împărțim codul în module sau seturi de module. Prefer să definesc module de nivel înalt bazate pe contexte care sunt prezente într-o aplicație specifică.

De exemplu, aplicația seturi de date este responsabilă pentru stocarea colecțiilor de date în propria sa bază de date și, de asemenea, pentru preluarea datelor din diferite surse. Așadar, aplicația va avea două dosare în directorul lib / seturi de date: „colecții” și „aducători”. Fiecare folder are fișier .ex cu același nume care conține un modul care implementează interfața de context și alte module de utilitate.

Aruncați o privire la lib / seturi de date / obținute. Dosarul are modulul Datasets.Fetchers care implementează o interfață pentru contextul „fetchers” - funcții care returnează date din „Datele de date publice AWS” și „Datele de date Kaggle”. Deci, pe lângă acest modul, sunt Datasets.Fetchers.Aws și Datasets.Fetchers.Kaggle care vor implementa accesul la sursa specifică.

Aceeași diviziune legată de context poate fi implementată și în alte aplicații. modelele sunt împărțite de un algoritm specific: Models.Lm (model liniar) sau Models.Rf (Random Forest). utils implementează pre-procesare a datelor (Utils.PreProcessing) și vizualizare (Utils.Visualization).

Și, desigur, există o aplicație de nivel superior (principal) care utilizează toate microservisele. Această aplicație are, de asemenea, mai multe contexte: modulul Main.Zillow pentru codul legat de concurența Zillow, și modulul Main.Screening pentru pasagerii Screening Algorithm Challenge.

Aplicația principală are alte aplicații ca dependențe în Main.Mixfile:

defp deps face
  [
    {: datasets, in_umbrella: true},
    {: modele, in_umbrella: true},
    {: utils, in_umbrella: true}
  ]
Sfârșit

Ceea ce face ca modulele din diferite aplicații să fie disponibile în aplicația principală.

Deci, în general, există trei niveluri de organizare a codurilor în proiectul Elixir:

  • „Nivel de serviciu” - cea mai evidentă modalitate de a împărți sistemul complex în aplicații Elixir separate (seturi de date, modele, utile).
  • „Nivel de context” - rupe responsabilitatea în cadrul unui anumit serviciu prin implementarea „modulelor de context” (Datasets.Fetchers, Datasets.Collections).
  • „Nivel de implementare” - module particulare care definesc structurile și funcțiile de date (Datasets.Fetchers.Aws, Datasets.Fetchers.Kaggle)

Pro și umbre contra proiectului Umbrella

După cum am menționat mai sus, principalul avantaj al utilizării „proiectului umbrelă” este că aveți tot codul într-un singur loc și îl puteți rula împreună în mediul de dezvoltare și testare. Este posibil să vă jucați cu întregul sistem și, cel mai important, să scrieți teste de integrare care vor testa componentele în întregime. Acest lucru este foarte important în stadiul incipient al dezvoltării proiectului!

În același timp, proiectul este deja împărțit în părți relativ independente și gata pentru scalare.

Comparați acest lucru cu o abordare în multe alte limbaje de programare, unde începeți de obicei de la proiectul monolit și apoi încercați să extrageți unele părți pentru a aplica separat. Deoarece pornind de la abordarea micro-servicii complică enorm procesul de dezvoltare.

Dar este timpul să începeți să vă faceți griji pentru încapsulare!

Este posibil să fi observat că ideea de a include toate aplicațiile în principalele dependențe ale aplicațiilor nu este atât de bună. Și ai dreptate!

Limba Elixir nu are suficiente construcții pentru încapsularea corectă. Există numai module și funcții (publice și private). Dacă adăugați un alt proiect ca dependență, toate modulele vor fi disponibile pentru dvs., astfel puteți apela orice funcție publică. Iar o implementare naivă a adaptării datelor Zillow în aplicația principală va arăta astfel:

defmodule Main.Zillow do
  def rf_fit do
    Datasets.Fetchers.zillow_data
    |> Utils.PreProcessing.normalize_data
    |> Modele.Rf.fit_model
  Sfârșit
Sfârșit

Unde Datasets.Fetchers, Utils.PreProcessing și Models.Rf sunt module din diferite aplicații. Această libertate de utilizare fără gândire a modulelor dintr-o altă aplicație vă va cupla serviciile și va transforma sistemul într-un monolit!

Deci, există două părți. Încă vrem să avem acces la toate componentele proiectului în timpul dezvoltării și testării. Dar trebuie să interzicem cumva interfața cu aplicații încrucișate.

Singura modalitate de a face acest lucru este crearea de convenții despre care funcții dintr-o aplicație pot fi utilizate într-o alta. Iar cel mai bun mod este extragerea tuturor funcțiilor „publice” în module „interfețe” separate.

Module de interfață

interfeţe

Ideea este de a muta toate funcțiile „publice” ale aplicației (funcții care pot fi numite de alte aplicații) în module separate. De exemplu, aplicația seturilor de date are un modul special de „interfață” pentru funcțiile Fetchers:

defmodule Datasets.Interfaces.Fetchers
  alias Datasets.Fetchers

  defdelegate zillow_data, la: Fetchers
  defdelegate landsat_data, la: Fetchers
Sfârșit

În această simplă implementare, modulul de interfață delege doar apelurile funcționale către modulul corespunzător. Dar, în viitor, când am decis să extragem aplicația seturi de date rulate pe un alt nod, acest modul va avea partea principală a logicii comunicării.

Procedând astfel cu alte aplicații, putem rescrie modulul Main.Zillow:

def rf_fit do
  Datasets.Interfaces.Fetchers.zillow_data
  |> Utils.Interfaces.PreProcessing.normalize_data
  |> Models.Interfaces.Rf.fit_model
Sfârșit

În general, convenția este: dacă doriți să apelați o funcție dintr-o altă aplicație, trebuie să faceți acest lucru prin modulul „interfață”.

Această abordare permite încă o dezvoltare și testare ușoară, dar creează un set de reguli simple care protejează codul de cuplarea strânsă și creează o bază pentru scalarea viitoare!

Scara la sistemul distribuit

Aplicații de interfață

Imaginați-vă că prelucrarea datelor face mult timp, deci decidem să rulăm modele pe un nod separat. Deci trebuie să eliminăm {: modele, in_umbrella: true} dependența și să rulăm aplicația pe un alt nod.

Dacă rulați consola Elixir (mixul iex-S) din folderul principal al aplicației, nu veți mai avea acces la modele de module de aplicație:

iex (1)> Models.Interfaces.Rf.fit_model („date”)
** Funcția (UndefinedFunctionError) Models.Interfaces.Rf.fit_model / 1 nu este definită (modulul Models.Interfaces.Rf nu este disponibil)

Codul modelelor de aplicație este încă în cadrul proiectului umbrelă, dar nu este rulat cu aplicația principală, deci nu este accesibil. Modulele modulele și funcțiile există numai pe un alt nod care rulează numai această aplicație.

Dar, știi, BEAM VM proiectat pentru aplicațiile distribuite, astfel încât există multe modalități de a accesa codul rulat pe o altă mașină.

: rpc

Este ușor să rulați o funcție pe nodul de la distanță folosind modulul Erlang: rpc. : rpc utilizează Protocolul de distribuție Erlang pentru comunicarea între noduri.

Se poate reproduce un experiment simplu: executați proiectul principal cu opțiunea principală --sname într-un tab terminal

iex - nume principal -S mix

și modele proiectează într-o altă filă:

iex - modele de nume -S mix

Acum puteți rula calcule:

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

Deci, ce schimbări trebuie să facem în proiectul nostru pentru a utiliza această abordare?

Ideea este foarte simplă, trebuie să adăugăm încă o aplicație la proiectul nostru care implementează logica comunicării - models_interface.

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

Acesta este un strat foarte subțire care ajută principal la accesarea funcțiilor Models.Interface. Există câteva module mici care duplică doar funcțiile din modulele Interfețe:

defmodule ModelsInterface.Rf face
  def fit_model (date) do
    ModelsInterface.remote_call (Models.Interfaces.Rf ,: fit_model, [date])
  Sfârșit
Sfârșit

Acest modul apelează doar la funcția Models.Interfaces.Rf.fit_model / 1. Implementarea remote_call este în modulul ModelsInterface:

Modele defmoduleInterface face
  def remote_call (modul, distracție, args, env \\ Mix.env) do
    do_remote_call ({module, fun, args}, env)
  Sfârșit

  def remote_node do
    Application.get_env (: models_interface,: nod)
  Sfârșit

  defp do_remote_call ({module, fun, args},: test) do
    aplica (modul, distracție, args)
  Sfârșit
  
  defp do_remote_call ({module, fun, args}, _) do
    : rpc.call (remote_node (), modul, fun, args)
  Sfârșit
Sfârșit

Modulul primește locația nodului din configurație și efectuează apeluri de la distanță. Este posibil să vedeți implementarea specifică a mediului do_remote_call, acest lucru permite simplificarea procesului de testare, vom discuta despre acest lucru mai târziu.

Următoarea refactorizare rapidă: trebuie doar să înlocuiți Models.Interfaces cu ModelsInterface și am terminat! Nu uitați să adăugați aplicația models_interface la dependențele aplicației principale.

defp deps face
  [
    {: datasets, in_umbrella: true},
    {: modele, in_umbrella: adevărat, numai: [: test]},
    {: models_interface, in_umbrella: true},
    {: utils, in_umbrella: true},
    {: specific, "1.4.6", numai:: test}
  ]
Sfârșit

Din nou, am lăsat dependența modelelor, dar numai în mediul de testare. Aceasta permite efectuarea unui apel direct către aplicație în mediul de testare.

Asta e. Nu putem accesa modele prin consola iex:

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

Rezumăm! Singura modificare pe care am făcut-o este o nouă aplicație simplă de interfațare. Încă avem tot codul într-un singur loc și mai avem toate testele trecute!

Sarcini distribuite

Apelurile de procedură directă la distanță sunt utile dacă aveți nevoie de o interfață simplă sincronă cu o altă aplicație. Dar dacă doriți să rulați în mod eficient cod asincron pe nodul de la distanță, ar trebui să alegeți mai bine sarcinile distribuite.

Elixir are un Task.Supervisor specific care poate fi utilizat pentru a supraveghea dinamic sarcinile. Acest supraveghetor va începe în aplicația de la distanță și va supraveghea sarcinile care execută codul. Să folosim sarcini distribuite pentru accesarea aplicației seturilor de date!

În primul rând, trebuie să adăugăm Task.Supervisor copiilor supervizorului aplicațiilor seturilor de date:

defmodule Datasets.Aplicare face
  @moduledoc false

  utilizați Aplicația
  import Supervisor.Spec

  def start (_type, _args) do
    copii = [
      supervizor (Task.Supervisor,
        [[nume: Datasets.Task.Supervisor]],
        [repornire:: temporară, oprire: 10000])
    ]

    opts = [strategie:: one_for_one, nume: Datasets.Supervisor]
    Supervisor.start_link (copii, optează)
  Sfârșit
Sfârșit

Modulul DatasetsInterface (care este aplicația de interfațare separată):

defmodule DatasetsInterface face
  def spawn_task (modul, distracție, args, env \\ Mix.env) do
    do_spawn_task ({module, fun, args}, env)
  Sfârșit

  defp do_spawn_task ({module, fun, args},: test) do
    aplica (modul, distracție, args)
  Sfârșit

  defp do_spawn_task ({module, fun, args}, _) do
    Task.Supervisor.async (remote_supervisor (), modul, fun, args)
    |> Task.await
  Sfârșit

  defp remote_supervisor face
    {
      Application.get_env (: datasets_interface,: task_supervisor),
      Application.get_env (: datasets_interface,: nod)
    }
  Sfârșit
Sfârșit

Așadar, folosim aici modelul async / wait. Diferența este: sarcinile sunt generate pe nodul de la distanță și sunt supravegheate de către supraveghetorul de la distanță. Numele și locația supraveghetorului sunt setate în fișierul de configurare:

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

Și, din nou, există același truc cu mediul de testare!

Alte protocoale

Sarcinile RPC și Distribuite sunt abstractizări Erlang / Elixir încorporate care permit comunicarea folosind termenul Elixir fără o serializare și deserializare suplimentară. Dar dacă trebuie să comunicați cu aplicații care nu sunt scrise în Elixir, aveți nevoie de o abordare mai comună, cum ar fi protocolul HTTP.

Ca exemplu, punem în aplicare o interfață simplă HTTP pentru aplicația noastră de utilaje. Din nou, primul lucru de care avem nevoie este o nouă aplicație utils_interface:

Modulul UtilsInterface are structura similară cu ModelsInterface, dar do_remote_call / 2 arată astfel:

defp do_remote_call ({module, fun, args}, _) do
  {: ok, resp} = HTTPoison.post (remote_url (),
                               serializează ({modul, distracție, args}))
  deserializati (resp.body)
Sfârșit

Pentru acest exemplu, am folosit o simplificare Erlang termen_to_binar și serializare binar_to_term:

defp serializeze (termen), faceți:: erlang.term_to_binary (termen)
defp deserialize (date), faceți:: erlang.binary_to_term (date)

Proiectul utils are nevoie de serverul HTTP pentru a asculta solicitările externe. Am folosit cowboy cu dop pentru asta

defp deps face
  [
    {: cowboy, "~> 1.0.0"},
    {: plug, "~> 1.0"},
    {: specific, "1.4.6", numai:: test}
  ]
Sfârșit

Modulul de fișă care este responsabil de gestionarea cererilor:

defmodule Utils.Interfaces.Plug do
  utilizați Plug.Router

  plug: potrivire
  plug: expediere

  post "/ la distanță" face
    {: ok, body, conn} = Plug.Conn.read_body (conn)
    {module, fun, args} = deserialize (corp)
    rezultat = aplica (modul, distracție, args)
    send_resp (con, 200, serializare (rezultat))
  Sfârșit
Sfârșit

Doar deserializează {modul, fun, args} tuple, funcționează apelul și trimite un rezultat înapoi clientului.

Și nu uitați să porniți „plug-ul” prin intermediul serverului cowboy în aplicația de utilizare

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

Vă rugăm să rețineți că nu este o practică bună să apelați funcțiile direct de la date deserializate. Am făcut-o doar pentru a simplifica exemplul. În lumea reală, ai nevoie de o abordare mai sofisticată!

Limitarea concurentei cu poolboy

Ultima funcție pe care vreau să o descriu în postare vă permite să vă protejați aplicația și resursele sale de „revărsare”. Imaginați-vă, de exemplu, că aplicațiile de modele folosesc destul de multă memorie pentru montarea modelului. Deci, dorim să limităm numărul clienților care doresc să acceseze modele de aplicație. Pentru a face acest lucru, vom crea un grup limitat de procese de lucrători la nivelul interfeței folosind biblioteca poolboy.

poolboy trebuie să fie pornit de către aplicația de supraveghetor:

Modele defmodule.Aplicare face
  utilizați Aplicația

  def start (_type, _args) do
    pool_options = [
      nume: {: local, Models.Interface},
      working_module: Models.Interfaces.Worker,
      dimensiune: 5, debit maxim_5: 5]

    copii = [
      : poolboy.child_spec (Modele.Interface, pool_options, []),
    ]

    opts = [strategie:: one_for_one, nume: Models.Supervisor]
    Supervisor.start_link (copii, optează)
  Sfârșit
Sfârșit

Puteți vedea aici opțiuni poolboy: numele supraveghetorului, modulul lucrătorului, dimensiunea unui pool și max_overflow.

Modulul de lucru este un server GenServer simplu care apelează doar la funcția corespunzătoare:

defmodule Models.Interfaces.Worker face
  utilizați GenServer

  def start_link (_opts) do
    GenServer.start_link (__ MODULLE__,: ok, [])
  Sfârșit

  def init (: ok), faceți: {: ok,% {}}

  def handle_call ({module, fun, args}, _from, state) do
    rezultat = aplica (modul, distracție, args)
    {: răspuns, rezultat, stare}
  Sfârșit
Sfârșit

Iar ultima modificare este în modulul Models.Interfaces.Rf. În loc de delegarea funcțiilor, acesta va genera procesul de lucrători în piscina:

defmodule Models.Interfaces.Rf do
  def fit_model (date) do
    cu_poolboy ({Models.Rf,: fit_model, [date]})
  Sfârșit

  def cu_poolboy (args) do
    lucrător =: poolboy.checkout (Models.Interface)
    rezultat = GenServer.call (muncitor, args,: infinit)
    : poolboy.checkin (Modele.Interface, lucrător)
    rezultat
  Sfârșit
Sfârșit

Asta e! Acum sunteți absolut sigur că aplicația de modele poate gestiona singurul număr limitat de solicitări.

Concluzie

În concluzie, vreau să vă ofer câteva recomandări:

  • Începeți cu microservicii de la bun început. Este foarte ușor de făcut cu proiectul umbrelă Elixir.
  • Utilizați module „context” și „implementare” pentru a organiza logica în cadrul unei aplicații.
  • Gândiți-vă cu atenție la interfețele aplicației. Nu permiteți apeluri directe la funcțiile de implementare între aplicații.
  • Când faceți scalarea către un sistem distribuit, plasați logica „comunicării” în aplicația separată. Folosiți Protocolul de distribuție Erlang pentru comunicarea între aplicațiile BEAM

Sper, abordările și abstractizările descrise în articol vă vor ajuta să scrieți un cod mai bun cu Elixir!

Atingeți the dacă v-a plăcut articolul și nu ezitați să mă contactați dacă aveți întrebări sau propuneri!

Să aveți o săptămână minunată,
Anton