Schaalbare applicatie ontwerpen met Elixir: van overkoepelend project naar gedistribueerd systeem

Elixir / Erlang OTP-abstracties dwingen ontwikkelaars programma's op te splitsen in onafhankelijke delen. Terwijl "gen_servers" delen van bedrijfslogica op microniveau inkapselen, vormen "toepassingen" een algemener ("service") deel van het systeem. Complexe programma's geschreven in Elixir zijn altijd een verzameling communicerende OTP-applicaties.

De hoofdvraag verscheen tijdens het ontwikkelen van dergelijke programma's hoe het complexe systeem in afzonderlijke delen kan worden opgesplitst. Maar het belangrijkste probleem is hoe de communicatie tussen hen te organiseren.

In het artikel zou ik ontwerpprincipes delen die ik volg bij het maken van een min of meer complex Elixir-project. We zullen bespreken hoe het project kan worden opgesplitst in kleine onderhoudbare microservices (Elixir-applicaties) en hoe modules erin kunnen worden georganiseerd met behulp van "contexten".

Maar de belangrijkste focus zal liggen op het ontwerpen van flexibele interfaces tussen Elixir-applicaties. U zult zien hoe ze kunnen worden gewijzigd tijdens het schalen van eenvoudig overkoepelend project naar gedistribueerd systeem. Ik zal enkele benaderingen behandelen: Erlang Remote procedureaanroep, Gedistribueerde taken en HTTP-protocol. En als bonus zal ik laten zien hoe iemand de gelijktijdige toegang tot microservices kan beperken.

Paraplu project

Paraplu project

Met het "parapluproject" van Elixir kan men de complexe logica aan het begin van het ontwikkelingsproces in afzonderlijke delen splitsen. Maar tegelijkertijd maakt het het mogelijk om alle logica in één repo te houden. U kunt dus beginnen met het ontwikkelen van toekomstige microservices met een minimale hoofdpijn.

Ik heb een scaffold-demoproject voorbereid om echte codevoorbeelden te demonstreren. De naam van het project is "ml_tools" die staat voor "Machine Learning Tools". Met het project kunnen gebruikers verschillende voorspellende modellen op hun datasets toepassen en de beste kiezen. Gebruikers moeten verschillende algoritmen op hun datasets kunnen toepassen en de resultaten kunnen visualiseren.

De verdeling van het project in verschillende toepassingen is duidelijk te zien aan de vereisten:

  • datasets - applicatie die verantwoordelijk is voor het beheer van gegevens: gegevenssets maken, lezen en bijwerken.
  • utils - een set van verschillende hulpprogramma's die gegevens voorbewerken en visualiseren.
  • modellen - een service die verschillende algoritmen implementeert voor voorspellende modellen. "Lineair model", "random forest", "support vector machine", etc.
  • main - applicatie op het hoogste niveau die andere applicaties gebruikt en de API op het hoogste niveau blootlegt.

Elke applicatie wordt gestart onder zijn eigen supervisor, dus fungeert als onafhankelijke service.

- - projectstructuur - -

apps /
  datasets /
    lib /
      datasets /
        fetchers /
          fetchers.ex
          aws.ex
          kaggle.ex
        collecties /
          ...
        interfaces /
          fetchers.ex
          collections.ex
  modellen /
  utils /
  hoofd/
...

Nu we de verantwoordelijkheid op het hoogste niveau in verschillende delen hebben verdeeld, laten we nu elke service in detail verkennen. Binnen elke toepassing moeten we de code opsplitsen in modules of modulesets. Ik geef er de voorkeur aan om modules op hoog niveau te definiëren op basis van contexten die aanwezig zijn in een specifieke toepassing.

De datasets-applicatie is bijvoorbeeld verantwoordelijk voor het opslaan van gegevensverzamelingen in zijn eigen database en ook voor het ophalen van gegevens uit verschillende bronnen. De toepassing zal dus twee mappen in de map lib / datasets hebben: "collecties" en "fetchers". Elke map heeft een .ex-bestand met dezelfde naam dat een module bevat die contextinterface en andere hulpprogramma-modules implementeert.

Bekijk lib / datasets / fetchers. De map heeft Datasets.Fetchers-module die een interface implementeert voor "fetchers" -context - functies die gegevens retourneren uit "AWS Public Datasets" en "Kaggle Datasets". Dus naast deze module zijn Datasets.Fetchers.Aws en Datasets.Fetchers.Kaggle die toegang tot de specifieke bron zullen implementeren.

Dezelfde contextgerelateerde indeling kan in andere toepassingen worden geïmplementeerd. modellen worden gesplitst door een specifiek algoritme: Models.Lm (Lineair model) of Models.Rf (Random Forest). utils implementeert data pre-processing (Utils.PreProcessing) en visualisatie (Utils.Visualization).

En er is natuurlijk een (hoofd) applicatie op het hoogste niveau die alle microservices gebruikt. Deze applicatie heeft ook verschillende contexten: Main.Zillow-module voor Zillow-wedstrijdgerelateerde code en Main.Screening-module voor Passenger Screening Algorithm Challenge.

De hoofdtoepassing heeft een andere toepassing als afhankelijkheden in Main.Mixfile:

defp deps doen
  [
    {: datasets, in_umbrella: true},
    {: models, in_umbrella: true},
    {: utils, in_umbrella: true}
  ]
einde

Hierdoor zijn de modules van verschillende applicaties beschikbaar in de hoofdapplicatie.

In het algemeen zijn er dus drie niveaus van code-organisatie in het Elixir-project:

  • "Serviceniveau" - de meest voor de hand liggende manier om het complexe systeem op te splitsen in afzonderlijke Elixir-applicaties (datasets, modellen, utils).
  • "Contextniveau" - breekt de verantwoordelijkheid binnen een bepaalde service door het implementeren van "contextmodules" (Datasets.Fetchers, Datasets.Collections).
  • "Implementatieniveau" - bepaalde modules die datastructuren en functies definiëren (Datasets.Fetchers.Aws, Datasets.Fetchers.Kaggle)

Umbrella project voor- en nadelen

Zoals hierboven vermeld, is het belangrijkste voordeel van het gebruik van "paraplu-project" dat u alle code op één plaats hebt en deze samen kunt uitvoeren in de ontwikkel- en testomgeving. Je kunt spelen met het hele systeem en, het allerbelangrijkste, integratietests schrijven die componenten helemaal testen. Dit is erg belangrijk in de vroege fase van projectontwikkeling!

Tegelijkertijd is uw project al opgesplitst in relatief onafhankelijke delen en klaar om te schalen.

Vergelijk dit met een aanpak in veel andere programmeertalen, waar u meestal begint met het monolith-project en vervolgens probeert sommige delen uit te pakken voor een afzonderlijke toepassing. Omdat het starten van een microservicebenadering het ontwikkelingsproces enorm bemoeilijkt.

Maar het is tijd om je zorgen te maken over inkapseling!

Je hebt misschien gemerkt dat het idee om alle apps in de belangrijkste toepassingsafhankelijkheid op te nemen niet zo goed is. En je hebt gelijk!

De taal van het Elixer heeft niet genoeg constructies voor een goede inkapseling. Er zijn alleen modules en functies (openbaar en privé). Als u een ander project als afhankelijkheid toevoegt, zijn alle modules voor u beschikbaar, zodat u elke openbare functie kunt oproepen. En een naïeve implementatie van Zillow-gegevensaanpassing in de hoofdtoepassing ziet eruit als:

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

Where Datasets.Fetchers, Utils.PreProcessing en Models.Rf zijn modules van verschillende applicaties. Deze vrijheid van gedachteloos gebruik van modules van een andere applicatie koppelt uw diensten en maakt van het systeem een ​​monoliet!

Er zijn dus twee kanten. We willen nog steeds dat alle delen van het project toegankelijk zijn tijdens de ontwikkeling en test. Maar we moeten op de een of andere manier de koppeling tussen toepassingen verbieden.

De enige manier om dit te doen is door conventies te maken over welke functies van de ene toepassing in een andere kunnen worden gebruikt. En de beste manier is om alle "openbare" functies uit te pakken in afzonderlijke "interfaces" -modules.

Interfacemodules

interfaces

Het idee is om alle functies van de "openbare" applicatie (functies die door andere applicaties kunnen worden aangeroepen) naar afzonderlijke modules te verplaatsen. De datasets-applicatie heeft bijvoorbeeld een speciale "interface" -module voor de functies van Fetchers:

defmodule Datasets.Interfaces.Fetchers doen
  alias Datasets.Fetchers

  defdelegate zillow_data, naar: Fetchers
  defdelegate landsat_data, naar: Fetchers
einde

In deze eenvoudige implementatie delegeert de interfacemodule alleen functieaanroepen naar de overeenkomstige module. Maar in de toekomst, wanneer we hebben besloten om de run datasets-applicatie op een ander knooppunt te extraheren, zal deze module het grootste deel van de communicatielogica hebben.

Door dit met andere applicaties te doen, kunnen we de Main.Zillow-module herschrijven:

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

Over het algemeen is de conventie: als u een functie van een andere toepassing wilt aanroepen, moet u dit doen via de "interface" -module.

Deze aanpak maakt nog steeds eenvoudige ontwikkeling en testen mogelijk, maar creëert een aantal eenvoudige regels die de code beschermen tegen nauwe koppeling en een basis vormt voor toekomstige schaling!

Schalen naar gedistribueerd systeem

Interface-applicaties

Stel je voor dat gegevensverwerking tijdrovend wordt, dus besluiten we om modellen op een afzonderlijk knooppunt uit te voeren. We moeten dus de afhankelijkheid van {: models, in_umbrella: true} verwijderen en die toepassing op een ander knooppunt uitvoeren.

Als u de Elixir-console (iex -S-mix) uitvoert vanuit de hoofdtoepassingsmap, hebt u geen toegang meer tot de toepassingsmodules van modellen:

iex (1)> Models.Interfaces.Rf.fit_model (“data”)
** (UndefinedFunctionError) functie Models.Interfaces.Rf.fit_model / 1 is undefined (module Models.Interfaces.Rf is niet beschikbaar)

De code van de modellenapplicatie bevindt zich nog steeds in het overkoepelende project, maar wordt niet uitgevoerd met de hoofdtoepassing en is dus niet toegankelijk. De modulemodules en -functies bestaan ​​alleen op een ander knooppunt waarop deze toepassing wordt uitgevoerd.

Maar weet u, BEAM VM is ontworpen voor de gedistribueerde applicaties, dus er zijn veel manieren om toegang te krijgen tot de code die op een andere machine wordt uitgevoerd.

: rpc

Het is eenvoudig om een ​​functie op een extern knooppunt uit te voeren met de Erlang: rpc-module. : rpc gebruikt Erlang Distribution Protocol voor de communicatie tussen knooppunten.

Men kan een eenvoudig experiment reproduceren: voer het hoofdproject uit met --sname hoofdoptie op één terminaltabblad

iex - heet hoofd -S mix

en modellen projecteren op een ander tabblad:

iex --sname modellen -S mix

Nu kunt u berekeningen uitvoeren:

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”}

Dus welke veranderingen moeten we in ons project aanbrengen om deze aanpak te gebruiken?

Het idee is heel eenvoudig, we moeten nog een applicatie toevoegen aan ons project dat communicatielogica implementeert - models_interface.

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

Dit is een zeer dunne laag die de belangrijkste toegang biedt tot de modellen. Interface-functies. Er zijn een paar kleine modules die alleen functies van Interfaces-modules dupliceren:

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

Deze module roept alleen de functie Models.Interfaces.Rf.fit_model / 1 aan. De implementatie van remote_call bevindt zich in de ModelsInterface-module:

defmodule Modellen Interface doen
  def remote_call (module, fun, args, env \\ Mix.env) doen
    do_remote_call ({module, fun, args}, env)
  einde

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

  defp do_remote_call ({module, fun, args},: test) do
    toepassen (module, plezier, args)
  einde
  
  defp do_remote_call ({module, fun, args}, _) do
    : rpc.call (remote_node (), module, fun, args)
  einde
einde

De module haalt de knooppuntlocatie op uit de configuratie en voert een externe procedureaanroep uit. Mogelijk ziet u omgevingsspecifieke implementatie van do_remote_call, dit maakt het mogelijk om het testproces te vereenvoudigen, we zullen dit later bespreken.

De volgende snelle refactoring: vervang gewoon Models.Interfaces door ModelsInterface en we zijn klaar! Vergeet niet de toepassing models_interface toe te voegen aan de afhankelijkheden van de hoofdtoepassing.

defp deps doen
  [
    {: datasets, in_umbrella: true},
    {: models, in_umbrella: true, only: [: test]},
    {: models_interface, in_umbrella: true},
    {: utils, in_umbrella: true},
    {: espec, "1.4.6", alleen:: test}
  ]
einde

Nogmaals, ik verliet de afhankelijkheid van modellen, maar alleen in de testomgeving. Dit maakt het mogelijk om rechtstreeks naar de applicatie te bellen in een testomgeving.

Dat is het. Nee, we hebben toegang tot modellen via iex console:

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

Laten we het samenvatten! De enige wijziging die we hebben aangebracht, is een nieuwe eenvoudige interface-applicatie. We hebben nog steeds alle code op één plaats en we hebben nog steeds alle tests doorstaan!

Gedistribueerde taken

Directe procedureoproepen op afstand zijn handig als u een eenvoudige synchrone interface met een andere toepassing nodig hebt. Maar als u asynchrone code op het externe knooppunt effectief wilt uitvoeren, kunt u beter Gedistribueerde taken kiezen.

Elixir heeft een specifieke Task.Supervisor die kan worden gebruikt om taken dynamisch te begeleiden. Deze supervisor start in de externe toepassing en houdt toezicht op taken die code uitvoeren. Laten we Gedistribueerde taken gebruiken voor toegang tot de datasets-applicatie!

Allereerst moeten we Task.Supervisor toevoegen aan kinderen van de supervisor van de datasets-applicatie:

defmodule Datasets. Toepassing wel
  @moduledoc false

  gebruik applicatie
  import Supervisor.Spec

  def start (_type, _args) do
    kinderen = [
      supervisor (Task.Supervisor,
        [[naam: Datasets.Task.Supervisor]],
        [herstart:: tijdelijk, afsluiten: 10000])
    ]

    opts = [strategie:: one_for_one, naam: Datasets.Supervisor]
    Supervisor.start_link (kinderen, opts)
  einde
einde

De DatasetsInterface-module (wat de afzonderlijke interface-applicatie is):

defmodule Datasets Interface doen
  def spawn_task (module, fun, args, env \\ Mix.env) doen
    do_spawn_task ({module, fun, args}, env)
  einde

  defp do_spawn_task ({module, fun, args},: test) do
    toepassen (module, plezier, args)
  einde

  defp do_spawn_task ({module, fun, args}, _) do
    Task.Supervisor.async (remote_supervisor (), module, fun, args)
    |> Task.await
  einde

  defp remote_supervisor doen
    {
      Application.get_env (: datasets_interface,: task_supervisor),
      Application.get_env (: datasets_interface,: node)
    }
  einde
einde

Dus we gebruiken hier async / await patroon. Het verschil is: taken worden voortgebracht op het externe knooppunt en worden bewaakt door externe supervisor. De naam en locatie van de supervisor worden ingesteld in het configuratiebestand:

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

En nogmaals, er is dezelfde truc met testomgeving!

Andere protocollen

RPC en gedistribueerde taken zijn ingebouwde Erlang / Elixir-abstracties waarmee communicatie met de Elixir-term mogelijk is zonder extra serialisatie en deserialisatie. Maar als u moet communiceren met toepassingen die niet in Elixir zijn geschreven, hebt u een meer algemene aanpak nodig, zoals het HTTP-protocol.

Laten we als voorbeeld een eenvoudige HTTP-interface implementeren voor onze utils-applicatie. Nogmaals, het eerste wat we nodig hebben is een nieuwe utils_interface-applicatie:

UtilsInterface-module heeft dezelfde structuur als ModelsInterface, maar de do_remote_call / 2 ziet er als volgt uit:

defp do_remote_call ({module, fun, args}, _) do
  {: ok, resp} = HTTPoison.post (remote_url (),
                               serialiseren ({module, fun, args}))
  deserialize (resp.body)
einde

Voor dit voorbeeld heb ik eenvoudige Erlang term_to_binary en binary_to_term serialisatie gebruikt:

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

Het utils-project heeft een HTTP-server nodig om te luisteren naar externe aanvragen. Ik heb hiervoor cowboy met plug gebruikt

defp deps doen
  [
    {: cowboy, "~> 1.0.0"},
    {: plug, "~> 1.0"},
    {: espec, "1.4.6", alleen:: test}
  ]
einde

De plug-module die verantwoordelijk is voor de afhandeling van aanvragen:

defmodule Utils.Interfaces.Plug do
  gebruik Plug.Router

  plug: match
  plug: verzending

  post "/ remote" doen
    {: ok, body, conn} = Plug.Conn.read_body (conn)
    {module, fun, args} = deserialize (body)
    resultaat = toepassen (module, plezier, args)
    send_resp (conn, 200, serialize (resultaat))
  einde
einde

Het deserialiseert gewoon {module, fun, args} tuple, doet functieaanroep en stuurt een resultaat terug naar de client.

En vergeet niet de "plug" te starten via de cowboy-server in de utils-applicatie

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

Houd er rekening mee dat het geen goede gewoonte is om functies rechtstreeks vanuit gedeserialiseerde gegevens aan te roepen. Ik deed het alleen om het voorbeeld te vereenvoudigen. In de echte wereld heeft u een meer geavanceerde aanpak nodig!

Beperking van concurrency met poolboy

Met de laatste functie die ik in de post wil beschrijven, kun je je applicatie en de bronnen ervan beschermen tegen "overlopen". Stel je bijvoorbeeld voor dat de modellenapplicatie behoorlijk wat geheugen gebruikt voor het aanpassen van modellen. Daarom willen we het aantal clients beperken dat toegang wil hebben tot de modellenapplicatie. Om dit te doen zullen we een beperkte pool van werkprocessen op het interfaceniveau creëren met behulp van de poolboy-bibliotheek.

poolboy moet worden gestart door de supervisor van de toepassing:

defmodule Modellen. Toepassing wel
  gebruik applicatie

  def start (_type, _args) do
    pool_options = [
      name: {: local, Models.Interface},
      werkmodule: Models.Interfaces.Worker,
      maat: 5, max_overflow: 5]

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

    opts = [strategie:: one_for_one, naam: Models.Supervisor]
    Supervisor.start_link (kinderen, opts)
  einde
einde

U ziet hier mogelijk poolboy-opties: naam van supervisor, werkermodule, grootte van een pool en max_overflow.

De werkmodule is een eenvoudige GenServer die alleen de bijbehorende functie aanroept:

defmodule Models.Interfaces.Worker doen
  gebruik GenServer

  def start_link (_opts) doen
    GenServer.start_link (__ MODULE__,: ok, [])
  einde

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

  def handle_call ({module, fun, args}, _from, state) doen
    resultaat = toepassen (module, plezier, args)
    {: antwoord, resultaat, staat}
  einde
einde

En de laatste wijziging zit in de module Models.Interfaces.Rf. In plaats van functiedelegatie wordt het werkproces in de pool uitgezet:

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

  def with_poolboy (args) doen
    worker =: poolboy.checkout (Models.Interface)
    result = GenServer.call (arbeider, args,: oneindig)
    : poolboy.checkin (Models.Interface, werknemer)
    resultaat
  einde
einde

Dat is het! Nu weet u absoluut zeker dat de modellenapplicatie het enige beperkte aantal aanvragen aankan.

Gevolgtrekking

Tot slot wil ik u enkele aanbevelingen geven:

  • Begin met microservices vanaf het begin. Het is heel gemakkelijk om te doen met het Elixir-parapluproject.
  • Gebruik “context” en “implementatie” modules om logica in een applicatie te organiseren.
  • Denk goed na over de interfaces van de applicatie. Sta geen directe oproepen toe naar implementatiefuncties tussen applicaties.
  • Plaats bij het schalen naar een gedistribueerd systeem de "communicatie" -logica in de afzonderlijke toepassing. Gebruik Erlang Distribution Protocol voor communicatie tussen BEAM-applicaties

Ik hoop dat de benaderingen en abstracties die in dit artikel worden beschreven, je zullen helpen om betere code te schrijven met Elixir!

Druk op als je het artikel leuk vond en aarzel niet om me te contacteren als je vragen of voorstellen hebt!

Fijne week,
Anton