Design af skalerbar applikation med Elixir: fra paraplyprojekt til distribueret system

Elixir / Erlang OTP-abstraktioner tvinger udviklere til at opdele programmer i uafhængige dele. Mens “gen_servers” indkapsler dele af forretningslogik på mikroniveau, præsenterer “applikationer” en mere generel (“service”) del af systemet. Komplekse programmer skrevet i Elixir er altid en samling af kommunikerende OTP-applikationer.

Det vigtigste spørgsmål, der opstod under udviklingen af ​​sådanne programmer, er, hvordan man opdeler det komplekse system i separate dele. Men det vigtigste problem er, hvordan man organiserer kommunikationen mellem dem.

I artiklen vil jeg dele designprincipper, jeg følger, når jeg opretter mere eller mindre komplekst Elixir-projekt. Vi vil diskutere, hvordan man opdeler projektet i små vedligeholdelige mikroservices (Elixir-applikationer), og hvordan man organiserer moduler inde i dem ved hjælp af ”kontekster”.

Men hovedfokuset vil være på design af fleksible grænseflader mellem Elixir-applikationer. Du vil se, hvordan de kan ændres, mens du skalerer fra simpelt paraplyprojekt til distribueret system. Jeg vil dække et par tilgange: Erlang Remote procedure-opkald, Distribuerede opgaver og HTTP-protokol. Og som en bonus viser jeg, hvordan man kan begrænse samtidig adgang til mikroservices.

Paraplyprojekt

Paraplyprojekt

Med Elixir "paraplyprojekt" kan man opdele den komplekse logik i separate dele helt i starten af ​​udviklingsprocessen. Men på samme tid giver det mulighed for at holde al logik i en repo. Så du kan begynde at udvikle fremtidige mikroservices med en minimal hovedpine.

Jeg har forberedt et stilladsdemo-projekt for at demonstrere eksempler på reelle kode. Projektets navn er “ml_tools”, der hedder “Machine Learning Tools”. Projektet giver brugerne mulighed for at anvende forskellige forudsigelige modeller på deres datasæt og til at vælge den bedste. Brugere skal kunne anvende forskellige algoritmer på deres datasæt og visualisere resultaterne.

Opdelingen af ​​projektet i flere applikationer er ganske åbenlyst ud fra kravene:

  • datasæt - applikation, der er ansvarlig for styring af data: oprette, læse og opdatere datasæt.
  • redskaber - et sæt forskellige værktøjstjenester, der forarbejder og visualiserer data.
  • modeller - en tjeneste, der implementerer forskellige algoritmer til forudsigelig modellering. "Lineær model", "tilfældig skov", "supportvektormaskine" osv.
  • hoved - applikation på topniveau, der bruger andre applikationer og afslører API på topniveau.

Hver ansøgning startes under sin egen vejleder, så den fungerer som uafhængig service.

- - projektstruktur - -

apps /
  datasæt /
    lib /
      datasæt /
        fetchers /
          fetchers.ex
          aws.ex
          kaggle.ex
        samlingerne /
          ...
        grænseflader /
          fetchers.ex
          collections.ex
  modeller /
  utils /
  vigtigste /
...

Efter at have delt topansvar i flere dele, lad os nu udforske hver tjeneste i detaljer. I hver applikation er vi nødt til at opdele koden i moduler eller sæt af moduler. Jeg foretrækker at definere moduler på højt niveau baseret på kontekster, der er til stede i en bestemt applikation.

F.eks. Er datasætansøgning ansvarlig for at gemme samlinger af data i sin egen database og også for at hente data fra forskellige kilder. Så applikationen har to mapper i biblioteket til biblioteker / datasæt: “samlinger” og “hentere”. Hver mappe har .ex-fil med det samme navn, der indeholder et modul, der implementerer context interface og andre hjælpemoduler.

Se på lib / datasæt / hentere. Mappen har Datasets.Fetchers-modul, der implementerer en grænseflade til “fetchers” -kontekst - funktioner, der returnerer data fra “AWS Public Datasets” og “Kaggle Datasets”. Så udover dette modul er det Datasets.Fetchers.Aws og Datasets.Fetchers.Kaggle, der implementerer adgang til den specifikke kilde.

Den samme kontekstrelaterede opdeling kan implementeres i andre applikationer. modeller er opdelt med en bestemt algoritme: Modeller.Lm (Lineær model) eller Models.Rf (Random Forest). utils implementerer dataforbehandling (Utils.PreProcessing) og visualisering (Utils.Visualization).

Og der er selvfølgelig en topniveau (Hoved) -applikation, der bruger alle mikroservices. Denne applikation har også flere sammenhænge: Main.Zillow-modul til Zillow-konkurrencerelateret kode og Main.Screening-modul til Passenger Screening Algorithm Challenge.

Hovedprogrammet har anden applikation som afhængigheder i Main.Mixfile:

defp deps gør
  [
    {: datasæt, in_umbrella: true},
    {: models, in_umbrella: true},
    {: utils, in_umbrella: true}
  ]
ende

Hvilket gør modulerne fra forskellige applikationer tilgængelige i hovedapplikationen.

Så generelt er der tre niveauer af kodeorganisering i Elixir-projektet:

  • “Serviceniveau” - den mest åbenlyse måde at opdele det komplekse system i separate Elixir-applikationer (datasæt, modeller, redskaber).
  • “Kontekstniveau” - bryder ansvaret inden for bestemte tjenester ved at implementere “kontekstmoduler” (Datasets.Fetchers, Datasets.Collections).
  • "Implementeringsniveau" - bestemte moduler, der definerer datastrukturer og funktioner (Datasets.Fetchers.Aws, Datasets.Fetchers.Kaggle)

Paraplyprojekt fordele og ulemper

Som nævnt ovenfor er den største fordel ved at bruge ”paraplyprojekt”, at du har al koden på et sted og kan køre den sammen i udviklings- og testmiljøet. Du kan lege med hele systemet og vigtigst af alt skrive integrationstest, der tester komponenter helt. Dette er meget vigtigt i den tidlige fase af projektudviklingen!

På samme tid er dit projekt allerede opdelt i relativt uafhængige dele og klar til skalering.

Sammenlign dette med en tilgang på mange andre programmeringssprog, hvor du normalt starter fra monolitprojekt og derefter prøver at udtrække nogle dele for at adskille applikationen. Fordi man starter fra mikro-service-tilgang komplicerer udviklingsprocessen enormt.

Men det er tid til at begynde at bekymre sig om indkapsling!

Du har måske bemærket, at ideen med at inkludere alle apps i de vigtigste applikationsafhængigheder ikke er så god. Og du har ret!

Elixir-sprog har ikke nok konstruktioner til korrekt indkapsling. Der er kun moduler og funktioner (offentlige og private). Hvis du tilføjer et andet projekt som en afhængighed, er alle moduler tilgængelige for dig, så du kan ringe til enhver offentlig funktion. Og en naiv implementering af Zillow-datafitting i themain-applikationen vil se ud:

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

Hvor datasæt.Fetchere, Utils.PreProcessing og Models.Rf er moduler fra forskellige applikationer. Denne frihed til at tænke uden brug af moduler fra en anden applikation vil sammenkoble dine tjenester og vende systemet tilbage til en monolit!

Så der er to sider. Vi ønsker stadig, at alle dele af projektet skal være tilgængelige under udvikling og test. Men vi har på en eller anden måde brug for forbud på tværs af applikationskobling.

Den eneste måde at gøre det på er at oprette konventioner om, hvilke funktioner fra et program der kan bruges i et andet. Og den bedste måde er at udtrække alle "offentlige" funktioner i separate "interface" -moduler.

Grænseflademoduler

interfaces

Tanken er at flytte alle de "offentlige" applikations funktioner (funktioner, der kan kaldes af andre applikationer) til separate moduler. F.eks. Har dataprogrammer et specielt "interface" -modul til Fetchers-funktioner:

defmodule Datasæt.Interfaces.Fetchers do
  alias Datasæt. Fetchere

  defdelegere zillow_data, til: Hentere
  defdelegere landsat_data, til: Hentere
ende

I denne enkle implementering delegerer interfacemodulet netop funktionsopkald til det tilsvarende modul. Men i fremtiden, når vi har besluttet at udtrække kørt datasæt-applikation på en anden knude, vil dette modul have hoveddelen af ​​kommunikationslogik.

Det gør vi sammen med andre applikationer, vi kan omskrive Main.Zillow-modulet:

def rf_fit do
  Datasets.Interfaces.Fetchers.zillow_data
  |> Utils.Interfaces.PreProcessing.normalize_data
  |> Modeller.Interfaces.Rf.fit_model
ende

Generelt er konventionen: hvis du vil kalde en funktion fra et andet program, skal du gøre dette gennem "interface" -modulet.

Denne tilgang tillader stadig let udvikling og test, men skaber sæt enkle regler, der beskytter koden mod tæt kobling og skaber et grundlag for fremtidig skalering!

Skala til det distribuerede system

Interface applikationer

Forestil dig, at databehandling bliver tidskrævende, så vi beslutter at køre modeller på en separat knude. Så vi er nødt til at fjerne {: models, in_umbrella: true} afhængighed og køre applikationen på en anden knude.

Hvis du kører Elixir-konsol (iex -S-mix) fra den vigtigste applikationsmappe, har du ikke adgang til modellerne til applikationsmoduler:

iex (1)> Modeller.Interfaces.Rf.fit_model (“data”)
** (UndefinedFunctionError) funktion Modeller.Interfaces.Rf.fit_model / 1 er undefined (modul Models.Interfaces.Rf er ikke tilgængelig)

Koden til modellernes applikation er stadig inde i paraplyprojektet, men det køres ikke med hovedprogrammet, så det er ikke tilgængeligt. Modulmodulerne og -funktionerne findes kun på en anden knude, der kun kører denne applikation.

Men, du ved, BEAM VM er designet til de distribuerede applikationer, så der er mange måder at få adgang til koden, der køres på en anden maskine.

: rpc

Det er let at køre en funktion på fjernknudepunkt vha. Erlang: rpc-modul. : rpc bruger Erlang Distribution Protocol til kommunikation mellem noder.

Man kan gengive simpelt eksperiment: køre themain-projekt med hovednavnet --navnet i en terminalfane

iex - navn hoved -S mix

og modeller projekt i en anden fane:

iex - navn modeller -S mix

Nu kan du køre beregninger:

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

Så hvilke ændringer er vi nødt til at foretage i vores projekt for at bruge denne tilgang?

Ideen er meget enkel, vi er nødt til at tilføje endnu et program til vores projekt, der implementerer kommunikationslogik - models_interface.

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

Dette er et meget tyndt lag, der hjælper med at få adgang til modellerne. Interfacefunktioner. Der er et par små moduler, der bare kopierer funktioner fra interface-moduler:

defmodule ModelsInterface.Rf do
  def fit_model (data) gør
    ModelsInterface.remote_call (Models.Interfaces.Rf,: fit_model, [data])
  ende
ende

Dette modul kalder bare Models.Interfaces.Rf.fit_model / 1 funktion. Implementeringen af ​​remote_call er i ModelsInterface-modulet:

defmodule ModelsInterface do
  def remote_call (modul, sjov, args, env \\ Mix.env) gør
    do_remote_call ({modul, sjov, args}, env)
  ende

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

  defp do_remote_call ({module, fun, args},: test) do
    Apply (modul, sjov, args)
  ende
  
  defp do_remote_call ({modul, sjov, args}, _) gør
    : rpc.call (remote_node (), modul, sjov, args)
  ende
ende

Modulet får nodeplacering fra konfigurationen og foretager opkald til ekstern procedure. Du ser muligvis en miljøspecifik implementering af do_remote_call, dette gør det muligt at forenkle testprocessen, vi vil diskutere dette senere.

Den næste hurtige refactoring: udskift bare Models.Interfaces med ModelsInterface, og vi er færdige! Glem bare ikke tilføje models_interface-applikation til afhængigheden af ​​hovedapplikationen.

defp deps gør
  [
    {: datasæt, in_umbrella: true},
    {: modeller, in_umbrella: sandt, kun: [: test]},
    {: models_interface, in_umbrella: true},
    {: utils, in_umbrella: true},
    {: espec, "1.4.6", kun:: test}
  ]
ende

Igen forlod jeg modeller afhængighed, men kun i testmiljø. Dette gør det muligt at foretage et direkte opkald til applikationen i testmiljø.

Det er det. Nej, vi er i stand til at få adgang til modeller via iex-konsol:

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

Lad os sammenfatte! Den eneste ændring, vi gjorde, er et nyt simpelt grænsefladeprogram. Vi har stadig al koden ét sted, og vi har stadig alle testene bestået!

Fordelte opgaver

Opkald med direkte fjernprocedure er nyttige, hvis du har brug for en enkel synkron interface med et andet program. Men hvis du effektivt vil køre asynkron kode på fjernknudepunktet, skal du bedre vælge Distribuerede opgaver.

Elixir har en bestemt Task.Supervisor, der kan bruges til dynamisk overvågning af opgaver. Denne vejleder vil starte i fjernapplikationen og overvåge opgaver, der udfører kode. Lad os bruge Distribuerede opgaver til at få adgang til datasæt-applikationen!

Først og fremmest skal vi tilføje Task.Supervisor til børn af datasæt-applikationsvejleder:

defmodule Datasæt. Anvendelse gør
  @moduledoc forkert

  brug applikation
  import Supervisor.Spec

  def start (_type, _args) gør
    børn = [
      vejleder (Task.Supervisor,
        [[navn: Datasets.Task.Supervisor]],
        [genstart:: midlertidig, nedlukning: 10000])
    ]

    opts = [strategi:: one_for_one, navn: Datasets.Supervisor]
    Supervisor.start_link (børn, opts)
  ende
ende

DatasetsInterface-modulet (som er det separate interface-applikation):

defmodule DatasetsInterface do
  def spawn_task (modul, sjov, args, env \\ Mix.env) gør
    do_spawn_task ({modul, sjov, args}, env)
  ende

  defp do_spawn_task ({module, fun, args},: test) do
    Apply (modul, sjov, args)
  ende

  defp do_spawn_task ({modul, sjov, args}, _) gør
    Task.Supervisor.async (remote_supervisor (), modul, sjov, args)
    |> Opgave.await
  ende

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

Så vi bruger async / afventer mønster her. Forskellen er: opgaver spawnes på den eksterne knude og overvåges af fjernovervågeren. Vejviserens navn og placering indstilles i konfigurationsfilen:

config: datasæt_interface,
       task_supervisor: Datasets.Task.Supervisor,
       node:: "modeller @ ip-192-168-1-150"

Og igen er der det samme trick med testmiljø!

Andre protokoller

RPC og Distribuerede opgaver er indbyggede Erlang / Elixir-abstraktioner, der tillader kommunikation ved hjælp af Elixir-udtrykket uden yderligere serialisering og deserialisering. Men hvis det er nødvendigt at kommunikere med applikationer, der ikke er skrevet i Elixir, har du brug for en mere almindelig tilgang, såsom HTTP-protokol.

Lad os som et eksempel implementere enkel HTTP-interface til vores redskaber-applikation. Igen er den første ting, vi har brug for, en ny applikation til utils_interface:

UtilsInterface-modulet har den lignende struktur med ModelsInterface, men do_remote_call / 2 ligner:

defp do_remote_call ({modul, sjov, args}, _) gør
  {: ok, resp} = HTTPoison.post (remote_url (),
                               serialisere ({modul, sjov, args}))
  deserialisering (resp.body)
ende

I dette eksempel har jeg brugt simpel Erlang term_til_binary og binær_til_term serialisering:

defp serialisering (term), gør:: erlang.term_to_binary (term)
defp deserialize (data), gør:: erlang.binary_to_term (data)

Utils-projektet har brug for HTTP-server for at lytte til eksterne anmodninger. Jeg har brugt cowboy med stik til dette

defp deps gør
  [
    {: cowboy, "~> 1.0.0"},
    {: plug, "~> 1.0"},
    {: espec, "1.4.6", kun:: test}
  ]
ende

Plug-modulet, der er ansvarligt for håndtering af anmodninger:

defmodule Utils.Interfaces.Plug do
  Brug Plug.Router

  plug: match
  stik: afsendelse

  post "/ remote" do
    {: ok, body, conn} = Plug.Conn.read_body (conn)
    {modul, sjov, args} = deserialize (body)
    resultat = anvend (modul, sjov, args)
    send_resp (conn, 200, serialisering (resultat))
  ende
ende

Det deserialiserer bare {modul, sjov, args} tuple, fungerer opkald og sender et resultat tilbage til klienten.

Og glem ikke at starte “plug” via cowboy-server i applikationen til redskaber

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

Bemærk, at det ikke er en god praksis at kalde funktioner direkte fra deserialiserede data. Jeg gjorde det kun for at forenkle eksemplet. I den virkelige verden har du brug for en mere sofistikeret tilgang!

Begrænser samtidighed med poolboy

Den sidste funktion, som jeg vil beskrive i indlægget, giver dig mulighed for at beskytte din applikation og dens ressourcer mod "overfyldt". Forestil dig for eksempel, at modellerne anvender en hel del hukommelse til modelmontering. Så vi vil begrænse antallet af klienter, der vil have adgang til modeller-applikationen. For at gøre dette vil vi oprette en begrænset pool af arbejdsprocesser på grænsefladeniveau ved hjælp af poolboy-biblioteket.

poolboy skal startes af applikationsvejleder:

defmodule Models.Application do
  brug applikation

  def start (_type, _args) gør
    pool_options = [
      navn: {: local, Models.Interface},
      worker_module: Models.Interfaces.Worker,
      størrelse: 5, max_overflow: 5]

    børn = [
      : poolboy.child_spec (Modeller.Interface, pool_options, []),
    ]

    vælger = [strategi:: one_for_one, navn: Models.Supervisor]
    Supervisor.start_link (børn, opts)
  ende
ende

Du kan se poolboy-indstillinger her: navn på vejleder, arbejdstermodul, størrelse på en pool og max_overflow.

Arbejdermodulet er en simpel GenServer, der bare kalder tilsvarende funktion:

defmodule Models.Interfaces.Worker do
  Brug GenServer

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

  def init (: ok), gør: {: ok,% {}}

  def handle_call ({module, fun, args}, _from, state) gør
    resultat = anvend (modul, sjov, args)
    {: svar, resultat, tilstand}
  ende
ende

Og den sidste ændring er i Models.Interfaces.Rf-modulet. I stedet for funktionsdelegation, vil det spawn arbejdsprocessen inde i puljen:

defmodule Models.Interfaces.Rf do
  def fit_model (data) gør
    with_poolboy ({Models.Rf,: fit_model, [data]})
  ende

  def with_poolboy (args) gør
    arbejder =: poolboy.checkout (Modeller.Interface)
    resultat = GenServer.call (arbejder, args,: uendelig)
    : poolboy.checkin (Modeller.Interface, arbejder)
    resultat
  ende
ende

Det er det! Nu er du helt sikker på, at modeller af applikationer kan håndtere det kun begrænsede antal anmodninger.

Konklusion

Som en konklusion vil jeg gerne give dig nogle anbefalinger:

  • Start med mikroservices helt fra begyndelsen. Det er meget let at gøre med Elixir paraplyprojekt.
  • Brug moduler "kontekst" og "implementering" til at organisere logik i en applikation.
  • Tænk nøje over applikationens grænseflader. Tillad ikke direkte opkald til implementeringsfunktioner mellem applikationer.
  • Når du skalerer til et distribueret system, skal du placere "kommunikations" -logik i den separate applikation. Brug Erlang Distribution Protocol til kommunikation mellem BEAM-applikationer

Jeg håber, tilgange og abstraktioner, der er beskrevet i artiklen, vil hjælpe dig med at skrive bedre kode med Elixir!

Hit , hvis du nød artiklen, og tøv ikke med at kontakte mig, hvis du har spørgsmål eller forslag!

Hav en dejlig uge,
Anton