Configurație sistem distribuită compilată

Aș dori să vă spun un mecanism interesant de lucru cu configurarea unui sistem distribuit. Configurația este reprezentată direct într-un limbaj compilat (Scala) folosind tipuri sigure. Această postare oferă un exemplu de astfel de configurație și discută diferite aspecte ale implementării unei configurații compilate în procesul general de dezvoltare.

Configurație sistem distribuită compilată

(Engleză)

Introducere

Construirea unui sistem distribuit de încredere înseamnă că toate nodurile folosesc configurația corectă, sincronizată cu alte noduri. Tehnologiile DevOps (terraform, ansible sau ceva de genul acesta) sunt de obicei folosite pentru a genera automat fișiere de configurare (deseori specifice fiecărui nod). De asemenea, am dori să ne asigurăm că toate nodurile care comunică folosesc protocoale identice (inclusiv aceeași versiune). În caz contrar, incompatibilitatea va fi inclusă în sistemul nostru distribuit. În lumea JVM, o consecință a acestei cerințe este aceea că aceeași versiune a bibliotecii care conține mesajele de protocol trebuie utilizată peste tot.

Ce zici de testarea unui sistem distribuit? Desigur, presupunem că toate componentele au teste unitare înainte de a trece la testarea integrării. (Pentru ca noi să extrapolăm rezultatele testelor la timpul de execuție, trebuie să oferim și un set identic de biblioteci în faza de testare și în timpul execuției.)

Când lucrați cu teste de integrare, este adesea mai ușor să utilizați aceeași cale de clasă peste tot pe toate nodurile. Tot ce trebuie să facem este să ne asigurăm că aceeași cale de clasă este utilizată în timpul execuției. (Deși este în întregime posibil să rulați noduri diferite cu căi de clasă diferite, acest lucru adaugă complexitate configurației generale și dificultăți cu testele de implementare și integrare.) În scopul acestei postări, presupunem că toate nodurile vor folosi aceeași cale de clasă.

Configurația evoluează odată cu aplicația. Folosim versiuni pentru a identifica diferitele etape ale evoluției programului. Pare logic să identificăm și diferite versiuni de configurații. Și plasați configurația în sine în sistemul de control al versiunilor. Dacă există o singură configurație în producție, atunci putem folosi pur și simplu numărul versiunii. Dacă folosim mai multe instanțe de producție, atunci vom avea nevoie de mai multe
ramuri de configurare și o etichetă suplimentară în plus față de versiune (de exemplu, numele ramurii). Astfel putem identifica clar configurația exactă. Fiecare identificator de configurare corespunde în mod unic unei combinații specifice de noduri distribuite, porturi, resurse externe și versiuni de bibliotecă. În scopul acestui post vom presupune că există o singură ramură și putem identifica configurația în mod obișnuit folosind trei numere separate printr-un punct (1.2.3).

În mediile moderne, fișierele de configurare sunt rareori create manual. Mai des sunt generate în timpul implementării și nu mai sunt atinse (astfel încât nu rupe nimic). Apare o întrebare firească: de ce folosim în continuare formatul text pentru a stoca configurația? O alternativă viabilă pare să fie abilitatea de a utiliza codul obișnuit pentru configurare și de a beneficia de verificări la timp de compilare.

În această postare vom explora ideea de a reprezenta o configurație în interiorul unui artefact compilat.

Configurație compilată

Această secțiune oferă un exemplu de configurație compilată statică. Sunt implementate două servicii simple - serviciul echo și clientul serviciului echo. Pe baza acestor două servicii, sunt asamblate două opțiuni de sistem. Într-o opțiune, ambele servicii sunt situate pe același nod, în altă opțiune - pe noduri diferite.

De obicei, un sistem distribuit conține mai multe noduri. Puteți identifica nodurile folosind valori de un anumit tip NodeId:

sealed trait NodeId
case object Backend extends NodeId
case object Frontend extends NodeId

sau

case class NodeId(hostName: String)

sau

object Singleton
type NodeId = Singleton.type

Nodurile îndeplinesc diverse roluri, rulează servicii și pot fi stabilite conexiuni TCP/HTTP între ele.

Pentru a descrie o conexiune TCP avem nevoie de cel puțin un număr de port. De asemenea, am dori să reflectăm protocolul care este acceptat pe acel port pentru a ne asigura că atât clientul, cât și serverul folosesc același protocol. Vom descrie conexiunea folosind următoarea clasă:

case class TcpEndPoint[Protocol](node: NodeId, port: Port[Protocol])

unde Port - doar un număr întreg Int indicând intervalul de valori acceptabile:

type PortNumber = Refined[Int, Closed[_0, W.`65535`.T]]

Tipuri rafinate

Vezi biblioteca rafinat и meu raport. Pe scurt, biblioteca vă permite să adăugați constrângeri la tipurile care sunt verificate în timpul compilării. În acest caz, valorile valide ale numărului de port sunt numere întregi de 16 biți. Pentru o configurație compilată, utilizarea bibliotecii rafinate nu este obligatorie, dar îmbunătățește capacitatea compilatorului de a verifica configurația.

Pentru protocoalele HTTP (REST), pe lângă numărul portului, este posibil să avem nevoie și de calea către serviciu:

type UrlPathPrefix = Refined[String, MatchesRegex[W.`"[a-zA-Z_0-9/]*"`.T]]
case class PortWithPrefix[Protocol](portNumber: PortNumber, pathPrefix: UrlPathPrefix)

Tipuri de fantome

Pentru a identifica protocolul în timpul compilării, folosim un parametru de tip care nu este utilizat în cadrul clasei. Această decizie se datorează faptului că nu folosim o instanță de protocol în timpul execuției, dar am dori ca compilatorul să verifice compatibilitatea protocolului. Specificând protocolul, nu vom putea trece un serviciu neadecvat drept dependență.

Unul dintre protocoalele comune este API-ul REST cu serializare Json:

sealed trait JsonHttpRestProtocol[RequestMessage, ResponseMessage]

unde RequestMessage - Tip de solicitare, ResponseMessage - tipul de răspuns.
Desigur, putem folosi alte descrieri de protocol care oferă acuratețea descrierii de care avem nevoie.

În scopul acestei postări, vom folosi o versiune simplificată a protocolului:

sealed trait SimpleHttpGetRest[RequestMessage, ResponseMessage]

Aici cererea este un șir atașat la adresa URL, iar răspunsul este șirul returnat în corpul răspunsului HTTP.

Configurația serviciului este descrisă de numele serviciului, porturi și dependențe. Aceste elemente pot fi reprezentate în Scala în mai multe moduri (de exemplu, HList-s, tipuri de date algebrice). În scopul acestei postări, vom folosi modelul tort și vom reprezenta module folosind trait'ov. (Modelul de tort nu este un element necesar al acestei abordări. Este pur și simplu o implementare posibilă.)

Dependențe între servicii pot fi reprezentate ca metode care returnează porturi EndPointale altor noduri:

  type EchoProtocol[A] = SimpleHttpGetRest[A, A]

  trait EchoConfig[A] extends ServiceConfig {
    def portNumber: PortNumber = 8081
    def echoPort: PortWithPrefix[EchoProtocol[A]] = PortWithPrefix[EchoProtocol[A]](portNumber, "echo")
    def echoService: HttpSimpleGetEndPoint[NodeId, EchoProtocol[A]] = providedSimpleService(echoPort)
  }

Pentru a crea un serviciu echo, tot ce aveți nevoie este un număr de port și o indicație că portul acceptă protocolul echo. S-ar putea să nu specificăm un anumit port, deoarece... trăsăturile vă permit să declarați metode fără implementare (metode abstracte). În acest caz, atunci când se creează o configurație concretă, compilatorul ne-ar cere să furnizăm o implementare a metodei abstracte și să furnizăm un număr de port. Deoarece am implementat metoda, atunci când creăm o anumită configurație, este posibil să nu specificăm un alt port. Se va folosi valoarea implicită.

În configurația clientului declarăm o dependență de serviciul echo:

  trait EchoClientConfig[A] {
    def testMessage: String = "test"
    def pollInterval: FiniteDuration
    def echoServiceDependency: HttpSimpleGetEndPoint[_, EchoProtocol[A]]
  }

Dependența este de același tip ca și serviciul exportat echoService. În special, în clientul echo avem nevoie de același protocol. Prin urmare, atunci când conectăm două servicii, putem fi siguri că totul va funcționa corect.

Implementarea serviciilor

Este necesară o funcție pentru a porni și opri serviciul. (Abilitatea de a opri serviciul este critică pentru testare.) Din nou, există mai multe opțiuni pentru implementarea unei astfel de caracteristici (de exemplu, am putea folosi clase de tip în funcție de tipul de configurare). În scopul acestei postări vom folosi modelul de tort. Vom reprezenta serviciul folosind o clasă cats.Resource, deoarece Această clasă oferă deja mijloace pentru a garanta în siguranță eliberarea resurselor în caz de probleme. Pentru a obține o resursă, trebuie să oferim configurație și un context de rulare gata făcut. Funcția de pornire a serviciului poate arăta astfel:

  type ResourceReader[F[_], Config, A] = Reader[Config, Resource[F, A]]

  trait ServiceImpl[F[_]] {
    type Config
    def resource(
      implicit
      resolver: AddressResolver[F],
      timer: Timer[F],
      contextShift: ContextShift[F],
      ec: ExecutionContext,
      applicative: Applicative[F]
    ): ResourceReader[F, Config, Unit]
  }

unde

  • Config — tip de configurare pentru acest serviciu
  • AddressResolver — un obiect runtime care vă permite să aflați adresele altor noduri (vezi mai jos)

și alte tipuri din bibliotecă cats:

  • F[_] — tipul efectului (în cel mai simplu caz F[A] ar putea fi doar o funcție () => A. În această postare vom folosi cats.IO.)
  • Reader[A,B] - mai mult sau mai puțin sinonim cu funcție A => B
  • cats.Resource - o resursă care poate fi obținută și eliberată
  • Timer — cronometru (vă permite să adormiți o perioadă și să măsurați intervale de timp)
  • ContextShift - analogic ExecutionContext
  • Applicative — o clasă de tip de efect care vă permite să combinați efecte individuale (aproape o monadă). În aplicații mai complexe pare mai bine de utilizat Monad/ConcurrentEffect.

Folosind această semnătură funcție putem implementa mai multe servicii. De exemplu, un serviciu care nu face nimic:

  trait ZeroServiceImpl[F[_]] extends ServiceImpl[F] {
    type Config <: Any
    def resource(...): ResourceReader[F, Config, Unit] =
      Reader(_ => Resource.pure[F, Unit](()))
  }

(Cm. sursă, în care sunt implementate alte servicii - serviciu ecou, client echo
и controlere pe viață.)

Un nod este un obiect care poate lansa mai multe servicii (lansarea unui lanț de resurse este asigurată de Cake Pattern):

object SingleNodeImpl extends ZeroServiceImpl[IO]
  with EchoServiceService
  with EchoClientService
  with FiniteDurationLifecycleServiceImpl
{
  type Config = EchoConfig[String] with EchoClientConfig[String] with FiniteDurationLifecycleConfig
}

Vă rugăm să rețineți că specificăm tipul exact de configurație care este necesar pentru acest nod. Dacă uităm să specificăm unul dintre tipurile de configurare cerute de un anumit serviciu, va apărea o eroare de compilare. De asemenea, nu vom putea porni un nod decât dacă oferim un obiect de tipul corespunzător cu toate datele necesare.

Rezoluție nume gazdă

Pentru a vă conecta la o gazdă la distanță, avem nevoie de o adresă IP reală. Este posibil ca adresa să devină cunoscută mai târziu decât restul configurației. Deci avem nevoie de o funcție care mapează ID-ul nodului la o adresă:

case class NodeAddress[NodeId](host: Uri.Host)
trait AddressResolver[F[_]] {
  def resolve[NodeId](nodeId: NodeId): F[NodeAddress[NodeId]]
}

Există mai multe moduri de a implementa această funcție:

  1. Dacă adresele ne devin cunoscute înainte de implementare, atunci putem genera cod Scala cu
    adrese și apoi rulați build-ul. Aceasta va compila și rula teste.
    În acest caz, funcția va fi cunoscută static și poate fi reprezentată în cod ca o mapare Map[NodeId, NodeAddress].
  2. În unele cazuri, adresa reală este cunoscută numai după ce nodul a pornit.
    În acest caz, putem implementa un „serviciu de descoperire” care rulează înaintea altor noduri și toate nodurile se vor înregistra la acest serviciu și vor solicita adresele altor noduri.
  3. Dacă putem modifica /etc/hosts, apoi puteți utiliza nume de gazdă predefinite (cum ar fi my-project-main-node и echo-backend) și pur și simplu legați aceste nume
    cu adrese IP în timpul implementării.

În această postare nu vom lua în considerare aceste cazuri mai detaliat. Pentru noi
într-un exemplu de jucărie, toate nodurile vor avea aceeași adresă IP - 127.0.0.1.

În continuare, luăm în considerare două opțiuni pentru un sistem distribuit:

  1. Plasarea tuturor serviciilor pe un singur nod.
  2. Și găzduiește serviciul echo și clientul echo pe diferite noduri.

Configurare pentru un singur nod:

Configurație cu un singur nod

object SingleNodeConfig extends EchoConfig[String] 
  with EchoClientConfig[String] with FiniteDurationLifecycleConfig
{
  case object Singleton // identifier of the single node 
  // configuration of server
  type NodeId = Singleton.type
  def nodeId = Singleton

  /** Type safe service port specification. */
  override def portNumber: PortNumber = 8088

  // configuration of client

  /** We'll use the service provided by the same host. */
  def echoServiceDependency = echoService

  override def testMessage: UrlPathElement = "hello"

  def pollInterval: FiniteDuration = 1.second

  // lifecycle controller configuration
  def lifetime: FiniteDuration = 10500.milliseconds // additional 0.5 seconds so that there are 10 requests, not 9.
}

Obiectul implementează configurația atât a clientului, cât și a serverului. Se folosește și o configurație time-to-live, astfel încât după interval lifetime termina programul. (Ctrl-C funcționează și eliberează corect toate resursele.)

Același set de trăsături de configurare și implementare poate fi folosit pentru a crea un sistem format din două noduri separate:

Configurație cu două noduri

  object NodeServerConfig extends EchoConfig[String] with SigTermLifecycleConfig
  {
    type NodeId = NodeIdImpl

    def nodeId = NodeServer

    override def portNumber: PortNumber = 8080
  }

  object NodeClientConfig extends EchoClientConfig[String] with FiniteDurationLifecycleConfig
  {
    // NB! dependency specification
    def echoServiceDependency = NodeServerConfig.echoService

    def pollInterval: FiniteDuration = 1.second

    def lifetime: FiniteDuration = 10500.milliseconds // additional 0.5 seconds so that there are 10 request, not 9.

    def testMessage: String = "dolly"
  }

Important! Observați cum sunt conectate serviciile. Specificăm un serviciu implementat de un nod ca implementare a metodei de dependență a altui nod. Tipul de dependență este verificat de compilator, deoarece conţine tipul de protocol. Când este rulată, dependența va conține ID-ul corect al nodului țintă. Datorită acestei scheme, specificăm numărul portului exact o dată și avem întotdeauna garanția că ne referim la portul corect.

Implementarea a două noduri de sistem

Pentru această configurație, folosim aceleași implementări de servicii fără modificări. Singura diferență este că acum avem două obiecte care implementează seturi diferite de servicii:

  object TwoJvmNodeServerImpl extends ZeroServiceImpl[IO] with EchoServiceService with SigIntLifecycleServiceImpl {
    type Config = EchoConfig[String] with SigTermLifecycleConfig
  }

  object TwoJvmNodeClientImpl extends ZeroServiceImpl[IO] with EchoClientService with FiniteDurationLifecycleServiceImpl {
    type Config = EchoClientConfig[String] with FiniteDurationLifecycleConfig
  }

Primul nod implementează serverul și are nevoie doar de configurarea serverului. Al doilea nod implementează clientul și folosește o parte diferită a configurației. De asemenea, ambele noduri au nevoie de management pe durata vieții. Nodul server rulează pe termen nelimitat până când este oprit SIGTERM'om, iar nodul client se termină după ceva timp. Cm. aplicația de lansare.

Procesul general de dezvoltare

Să vedem cum această abordare de configurare afectează procesul general de dezvoltare.

Configurația va fi compilată împreună cu restul codului și va fi generat un artefact (.jar). Se pare că are sens să punem configurația într-un artefact separat. Acest lucru se datorează faptului că putem avea mai multe configurații bazate pe același cod. Din nou, este posibil să se genereze artefacte corespunzătoare diferitelor ramuri de configurare. Dependențele de versiuni specifice ale bibliotecilor sunt salvate împreună cu configurația, iar aceste versiuni sunt salvate pentru totdeauna ori de câte ori decidem să implementăm acea versiune a configurației.

Orice modificare a configurației se transformă într-o schimbare de cod. Și, prin urmare, fiecare
modificarea va fi acoperită de procesul normal de asigurare a calității:

Ticket în bug tracker -> PR -> revizuire -> îmbinare cu ramurile relevante ->
integrare -> implementare

Principalele consecințe ale implementării unei configurații compilate sunt:

  1. Configurația va fi consecventă în toate nodurile sistemului distribuit. Datorită faptului că toate nodurile primesc aceeași configurație dintr-o singură sursă.

  2. Este problematică schimbarea configurației doar într-unul dintre noduri. Prin urmare, „deriva de configurare” este puțin probabilă.

  3. Devine mai dificil să faci mici modificări la configurație.

  4. Majoritatea modificărilor de configurare vor avea loc ca parte a procesului general de dezvoltare și vor fi supuse revizuirii.

Am nevoie de un depozit separat pentru a stoca configurația de producție? Această configurație poate conține parole și alte informații sensibile la care am dori să restricționăm accesul. Pe baza acestui lucru, pare să aibă sens să stocați configurația finală într-un depozit separat. Puteți împărți configurația în două părți — una care conține setări de configurare accesibile publicului și una care conține setări restricționate. Acest lucru va permite majorității dezvoltatorilor să aibă acces la setările comune. Această separare este ușor de realizat folosind trăsături intermediare care conțin valori implicite.

Posibile variații

Să încercăm să comparăm configurația compilată cu câteva alternative comune:

  1. Fișier text pe mașina țintă.
  2. Magazin centralizat cheie-valoare (etcd/zookeeper).
  3. Componente de proces care pot fi reconfigurate/repornite fără a reporni procesul.
  4. Stocarea configurației în afara controlului artefactului și al versiunilor.

Fișierele text oferă o flexibilitate semnificativă în ceea ce privește micile modificări. Administratorul de sistem se poate conecta la nodul de la distanță, poate face modificări la fișierele corespunzătoare și poate reporni serviciul. Pentru sistemele mari, totuși, o astfel de flexibilitate poate să nu fie de dorit. Modificările efectuate nu lasă urme în alte sisteme. Nimeni nu revizuiește modificările. Este dificil de stabilit cine anume a făcut modificările și din ce motiv. Modificările nu sunt testate. Dacă sistemul este distribuit, atunci administratorul poate uita să facă modificarea corespunzătoare pe alte noduri.

(De asemenea, trebuie remarcat faptul că utilizarea unei configurații compilate nu închide posibilitatea utilizării fișierelor text în viitor. Va fi suficient să adăugați un parser și un validator care să producă același tip ca rezultat Config, și puteți utiliza fișiere text. Rezultă imediat că complexitatea unui sistem cu o configurație compilată este ceva mai mică decât complexitatea unui sistem care utilizează fișiere text, deoarece fișierele text necesită cod suplimentar.)

Un depozit centralizat cheie-valoare este un mecanism bun pentru distribuirea meta-parametrilor unei aplicații distribuite. Trebuie să decidem care sunt parametrii de configurare și ce sunt doar date. Să avem o funcție C => A => B, și parametrii C rareori modificări și date A - de multe ori. În acest caz putem spune că C - parametrii de configurare și A - date. Se pare că parametrii de configurare diferă de date prin faptul că, în general, se modifică mai rar decât datele. De asemenea, datele provin de obicei dintr-o sursă (de la utilizator), iar parametrii de configurare din alta (de la administratorul de sistem).

Dacă parametrii care se schimbă rareori trebuie actualizați fără a reporni programul, atunci acest lucru poate duce adesea la complicarea programului, deoarece va trebui să livrăm cumva parametri, să stocăm, să analizăm și să verificăm și să procesăm valori incorecte. Prin urmare, din punctul de vedere al reducerii complexității programului, este logic să se reducă numărul de parametri care se pot modifica în timpul funcționării programului (sau nu acceptă deloc astfel de parametri).

În scopul acestei postări, vom face diferența între parametrii statici și dinamici. Dacă logica serviciului necesită modificarea parametrilor în timpul funcționării programului, atunci vom numi astfel de parametri dinamici. În caz contrar, opțiunile sunt statice și pot fi configurate folosind configurația compilată. Pentru reconfigurarea dinamică, este posibil să avem nevoie de un mecanism pentru a reporni părți ale programului cu parametri noi, similar cu modul în care procesele sistemului de operare sunt repornite. (În opinia noastră, este recomandabil să evitați reconfigurarea în timp real, deoarece aceasta crește complexitatea sistemului. Dacă este posibil, este mai bine să utilizați capabilitățile standard ale sistemului de operare pentru repornirea proceselor.)

Un aspect important al utilizării configurației statice care îi face pe oameni să ia în considerare reconfigurarea dinamică este timpul necesar pentru repornirea sistemului după o actualizare a configurației (timp de nefuncționare). De fapt, dacă trebuie să facem modificări la configurația statică, va trebui să repornim sistemul pentru ca noile valori să intre în vigoare. Problema timpului de nefuncționare variază ca severitate pentru diferite sisteme. În unele cazuri, puteți programa o repornire într-un moment în care încărcarea este minimă. Dacă aveți nevoie să oferiți servicii continue, puteți implementa Scurgerea conexiunii AWS ELB. În același timp, când trebuie să repornim sistemul, lansăm o instanță paralelă a acestui sistem, comutăm echilibrul la acesta și așteptăm ca vechile conexiuni să se finalizeze. După ce toate conexiunile vechi s-au încheiat, închidem vechea instanță a sistemului.

Să luăm acum în considerare problema stocării configurației în interiorul sau în afara artefactului. Dacă stocăm configurația în interiorul unui artefact, atunci cel puțin am avut ocazia să verificăm corectitudinea configurației în timpul asamblarii artefactului. Dacă configurația este în afara artefactului controlat, este dificil de urmărit cine a făcut modificări acestui fișier și de ce. Cât de important este? În opinia noastră, pentru multe sisteme de producție este important să existe o configurație stabilă și de înaltă calitate.

Versiunea unui artefact vă permite să determinați când a fost creat, ce valori conține, ce funcții sunt activate/dezactivate și cine este responsabil pentru orice modificare a configurației. Desigur, stocarea configurației în interiorul unui artefact necesită un efort, așa că trebuie să iei o decizie în cunoștință de cauză.

Pro și contra

Aș dori să mă opresc asupra avantajelor și dezavantajelor tehnologiei propuse.

Avantaje

Mai jos este o listă a principalelor caracteristici ale unei configurații de sistem distribuite compilate:

  1. Verificarea configurației statice. Vă permite să fiți sigur că
    configuratia este corecta.
  2. Limbă de configurare bogată. În mod obișnuit, alte metode de configurare sunt limitate la înlocuirea variabilei șirului cel mult. Când utilizați Scala, sunt disponibile o gamă largă de funcții de limbă pentru a vă îmbunătăți configurația. De exemplu, putem folosi
    trăsături pentru valorile implicite, folosind obiecte pentru a grupa parametrii, ne putem referi la valorile declarate o singură dată (DRY) în domeniul de aplicare. Puteți instanția orice clase direct în configurație (Seq, Map, clase personalizate).
  3. DSL. Scala are o serie de caracteristici lingvistice care facilitează crearea unui DSL. Este posibil să profitați de aceste caracteristici și să implementați un limbaj de configurare care este mai convenabil pentru grupul țintă de utilizatori, astfel încât configurația să fie cel puțin lizibilă de către experții în domeniu. Specialiștii pot, de exemplu, să participe la procesul de revizuire a configurației.
  4. Integritate și sincronie între noduri. Unul dintre avantajele de a avea configurația unui întreg sistem distribuit stocată într-un singur punct este că toate valorile sunt declarate exact o dată și apoi reutilizate oriunde sunt necesare. Utilizarea tipurilor fantomă pentru a declara porturile asigură că nodurile utilizează protocoale compatibile în toate configurațiile corecte ale sistemului. Având dependențe obligatorii explicite între noduri asigură că toate serviciile sunt conectate.
  5. Modificări de înaltă calitate. Modificarea configurației folosind un proces comun de dezvoltare face posibilă atingerea unor standarde de calitate înalte și pentru configurație.
  6. Actualizare simultană a configurației. Implementarea automată a sistemului după modificările de configurare asigură că toate nodurile sunt actualizate.
  7. Simplificarea aplicației. Aplicația nu are nevoie de analiza, verificarea configurației sau gestionarea valorilor incorecte. Acest lucru reduce complexitatea aplicației. (O parte din complexitatea configurației observată în exemplul nostru nu este un atribut al configurației compilate, ci doar o decizie conștientă condusă de dorința de a oferi o siguranță mai mare a tipului.) Este destul de ușor să reveniți la configurația obișnuită - doar implementați cea care lipsește. părți. Prin urmare, puteți, de exemplu, să începeți cu o configurație compilată, amânând implementarea părților inutile până la momentul în care este cu adevărat necesară.
  8. Configurație verificată. Deoarece modificările de configurare urmează soarta obișnuită a oricăror alte modificări, rezultatul pe care îl obținem este un artefact cu o versiune unică. Acest lucru ne permite, de exemplu, să revenim la o versiune anterioară a configurației dacă este necesar. Putem folosi chiar și configurația de acum un an și sistemul va funcționa exact la fel. O configurație stabilă îmbunătățește predictibilitatea și fiabilitatea unui sistem distribuit. Deoarece configurația este fixată în etapa de compilare, este destul de dificil să o falsăm în producție.
  9. Modularitate. Cadrul propus este modular, iar modulele pot fi combinate în moduri diferite pentru a crea sisteme diferite. În special, puteți configura sistemul să ruleze pe un singur nod într-o variantă de realizare și pe mai multe noduri în alta. Puteți crea mai multe configurații pentru instanțe de producție ale sistemului.
  10. Testare. Prin înlocuirea serviciilor individuale cu obiecte simulate, puteți obține mai multe versiuni ale sistemului care sunt convenabile pentru testare.
  11. Testarea integrării. Având o singură configurație pentru întregul sistem distribuit, face posibilă rularea tuturor componentelor într-un mediu controlat, ca parte a testării integrării. Este ușor de emulat, de exemplu, o situație în care unele noduri devin accesibile.

Dezavantaje și limitări

Configurația compilată diferă de alte abordări de configurare și poate să nu fie potrivită pentru unele aplicații. Mai jos sunt câteva dezavantaje:

  1. Configurație statică. Uneori trebuie să corectați rapid configurația în producție, ocolind toate mecanismele de protecție. Cu această abordare poate fi mai dificil. Cel puțin, compilarea și implementarea automată vor fi în continuare necesare. Aceasta este atât o caracteristică utilă a abordării, cât și un dezavantaj în unele cazuri.
  2. Generarea configurației. În cazul în care fișierul de configurare este generat de un instrument automat, pot fi necesare eforturi suplimentare pentru a integra scriptul de compilare.
  3. Instrumente. În prezent, utilitățile și tehnicile concepute pentru a funcționa cu configurația se bazează pe fișiere text. Nu toate aceste utilități/tehnici vor fi disponibile într-o configurație compilată.
  4. Este necesară o schimbare de atitudine. Dezvoltatorii și DevOps sunt obișnuiți cu fișierele text. Însăși ideea de a compila o configurație poate fi oarecum neașteptată și neobișnuită și poate provoca respingere.
  5. Este necesar un proces de dezvoltare de înaltă calitate. Pentru a utiliza confortabil configurația compilată, este necesară automatizarea completă a procesului de construire și implementare a aplicației (CI/CD). În caz contrar, va fi destul de incomod.

Să ne oprim și pe o serie de limitări ale exemplului considerat care nu sunt legate de ideea unei configurații compilate:

  1. Dacă furnizăm informații de configurare inutile care nu sunt utilizate de nod, atunci compilatorul nu ne va ajuta să detectăm implementarea lipsă. Această problemă poate fi rezolvată prin abandonarea modelului de tort și utilizarea unor tipuri mai rigide, de exemplu, HList sau tipuri de date algebrice (clase de caz) pentru a reprezenta configurația.
  2. Există linii în fișierul de configurare care nu au legătură cu configurația în sine: (package, import,declarații de obiect; override def's pentru parametrii care au valori implicite). Acest lucru poate fi parțial evitat dacă implementați propriul DSL. În plus, alte tipuri de configurare (de exemplu, XML) impun și anumite restricții asupra structurii fișierelor.
  3. În scopul acestei postări, nu luăm în considerare reconfigurarea dinamică a unui grup de noduri similare.

Concluzie

În această postare, am explorat ideea de a reprezenta configurația în codul sursă folosind capabilitățile avansate ale sistemului de tip Scala. Această abordare poate fi utilizată în diferite aplicații ca înlocuitor pentru metodele tradiționale de configurare bazate pe fișiere xml sau text. Chiar dacă exemplul nostru este implementat în Scala, aceleași idei pot fi transferate în alte limbaje compilate (cum ar fi Kotlin, C#, Swift, ...). Puteți încerca această abordare într-unul dintre următoarele proiecte și, dacă nu funcționează, treceți la fișierul text, adăugând părțile lipsă.

Desigur, o configurație compilată necesită un proces de dezvoltare de înaltă calitate. În schimb, se asigură calitatea înaltă și fiabilitatea configurațiilor.

Abordarea luată în considerare poate fi extinsă:

  1. Puteți utiliza macrocomenzi pentru a efectua verificări la timp de compilare.
  2. Puteți implementa un DSL pentru a prezenta configurația într-un mod care este accesibil utilizatorilor finali.
  3. Puteți implementa managementul dinamic al resurselor cu ajustarea automată a configurației. De exemplu, schimbarea numărului de noduri dintr-un cluster necesită ca (1) fiecare nod să primească o configurație ușor diferită; (2) managerul cluster-ului a primit informații despre noduri noi.

Mulțumiri

Aș dori să le mulțumesc lui Andrei Saksonov, Pavel Popov și Anton Nekhaev pentru critica constructivă față de proiectul articolului.

Sursa: www.habr.com

Adauga un comentariu