Kompilert distribuert systemkonfigurasjon

Jeg vil gjerne fortelle deg en interessant mekanisme for å jobbe med konfigurasjonen av et distribuert system. Konfigurasjonen er representert direkte i et kompilert språk (Scala) ved bruk av sikre typer. Dette innlegget gir et eksempel på en slik konfigurasjon og diskuterer ulike aspekter ved å implementere en kompilert konfigurasjon i den generelle utviklingsprosessen.

Kompilert distribuert systemkonfigurasjon

(Engelsk)

Innledning

Å bygge et pålitelig distribuert system betyr at alle noder bruker riktig konfigurasjon, synkronisert med andre noder. DevOps-teknologier (terraform, ansible eller noe sånt) brukes vanligvis til automatisk å generere konfigurasjonsfiler (ofte spesifikke for hver node). Vi vil også være sikre på at alle kommuniserende noder bruker identiske protokoller (inkludert samme versjon). Ellers vil inkompatibilitet bygges inn i vårt distribuerte system. I JVM-verdenen er en konsekvens av dette kravet at den samme versjonen av biblioteket som inneholder protokollmeldingene må brukes overalt.

Hva med å teste et distribuert system? Vi forutsetter selvfølgelig at alle komponenter har enhetstester før vi går over til integrasjonstesting. (For at vi skal ekstrapolere testresultater til kjøretid, må vi også tilby et identisk sett med biblioteker på teststadiet og under kjøretid.)

Når man jobber med integrasjonstester er det ofte lettere å bruke samme klassesti overalt på alle noder. Alt vi trenger å gjøre er å sikre at den samme klassebanen brukes under kjøring. (Selv om det er fullt mulig å kjøre forskjellige noder med forskjellige klassebaner, tilfører dette kompleksitet til den generelle konfigurasjonen og vanskeligheter med distribusjon og integrasjonstester.) For formålet med dette innlegget antar vi at alle noder vil bruke samme klassebane.

Konfigurasjonen utvikler seg med applikasjonen. Vi bruker versjoner for å identifisere ulike stadier av programutviklingen. Det virker logisk å også identifisere ulike versjoner av konfigurasjoner. Og plasser selve konfigurasjonen i versjonskontrollsystemet. Hvis det bare er én konfigurasjon i produksjon, kan vi ganske enkelt bruke versjonsnummeret. Hvis vi bruker mange produksjonsinstanser, vil vi trenge flere
konfigurasjonsgrener og en ekstra etikett i tillegg til versjonen (for eksempel navnet på grenen). På denne måten kan vi tydelig identifisere den nøyaktige konfigurasjonen. Hver konfigurasjonsidentifikator tilsvarer unikt en spesifikk kombinasjon av distribuerte noder, porter, eksterne ressurser og bibliotekversjoner. For formålet med dette innlegget vil vi anta at det bare er én gren, og vi kan identifisere konfigurasjonen på vanlig måte ved å bruke tre tall atskilt med en prikk (1.2.3).

I moderne miljøer lages konfigurasjonsfiler sjelden manuelt. Oftere genereres de under distribusjon og berøres ikke lenger (slik at ikke knekk noe). Et naturlig spørsmål dukker opp: hvorfor bruker vi fortsatt tekstformat for å lagre konfigurasjon? Et levedyktig alternativ ser ut til å være muligheten til å bruke vanlig kode for konfigurasjon og dra nytte av kompileringstidskontroller.

I dette innlegget vil vi utforske ideen om å representere en konfigurasjon i en kompilert artefakt.

Kompilert konfigurasjon

Denne delen gir et eksempel på en statisk kompilert konfigurasjon. To enkle tjenester er implementert - ekkotjenesten og ekkotjenesteklienten. Basert på disse to tjenestene er to systemalternativer satt sammen. I ett alternativ er begge tjenestene plassert på samme node, i et annet alternativ - på forskjellige noder.

Vanligvis inneholder et distribuert system flere noder. Du kan identifisere noder ved å bruke verdier av en eller annen type NodeId:

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

eller

case class NodeId(hostName: String)

eller

object Singleton
type NodeId = Singleton.type

Noder utfører ulike roller, de kjører tjenester og TCP/HTTP-forbindelser kan opprettes mellom dem.

For å beskrive en TCP-tilkobling trenger vi minst et portnummer. Vi ønsker også å gjenspeile protokollen som støttes på den porten for å sikre at både klienten og serveren bruker samme protokoll. Vi vil beskrive forbindelsen ved hjelp av følgende klasse:

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

der Port - bare et heltall Int som angir området for akseptable verdier:

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

Raffinerte typer

Se bibliotek raffinert и min rapportere. Kort sagt lar biblioteket deg legge til begrensninger til typer som kontrolleres ved kompilering. I dette tilfellet er gyldige portnummerverdier 16-bits heltall. For en kompilert konfigurasjon er det ikke obligatorisk å bruke det raffinerte biblioteket, men det forbedrer kompilatorens evne til å sjekke konfigurasjonen.

For HTTP (REST)-protokoller, i tillegg til portnummeret, kan vi også trenge banen til tjenesten:

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

Fantomtyper

For å identifisere protokollen på kompileringstidspunktet bruker vi en typeparameter som ikke brukes i klassen. Denne avgjørelsen skyldes det faktum at vi ikke bruker en protokollforekomst under kjøring, men vi vil at kompilatoren skal sjekke protokollkompatibilitet. Ved å spesifisere protokollen vil vi ikke kunne sende en upassende tjeneste som en avhengighet.

En av de vanlige protokollene er REST API med Json-serialisering:

sealed trait JsonHttpRestProtocol[RequestMessage, ResponseMessage]

der RequestMessage - forespørselstype, ResponseMessage - svartype.
Selvfølgelig kan vi bruke andre protokollbeskrivelser som gir nøyaktigheten av beskrivelsen vi krever.

For formålet med dette innlegget vil vi bruke en forenklet versjon av protokollen:

sealed trait SimpleHttpGetRest[RequestMessage, ResponseMessage]

Her er forespørselen en streng som er lagt til url-en, og svaret er den returnerte strengen i kroppen til HTTP-svaret.

Tjenestekonfigurasjonen er beskrevet av tjenestenavnet, portene og avhengighetene. Disse elementene kan representeres i Scala på flere måter (f.eks. HList-s, algebraiske datatyper). For formålet med dette innlegget vil vi bruke kakemønsteret og representere moduler som bruker trait'ov. (Kakemønsteret er ikke et nødvendig element i denne tilnærmingen. Det er ganske enkelt en mulig implementering.)

Avhengigheter mellom tjenester kan representeres som metoder som returnerer porter EndPoints av andre noder:

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

For å lage en ekkotjeneste trenger du bare et portnummer og en indikasjon på at porten støtter ekkoprotokollen. Det kan hende vi ikke spesifiserer en spesifikk port, fordi... egenskaper lar deg deklarere metoder uten implementering (abstrakte metoder). I dette tilfellet, når du oppretter en konkret konfigurasjon, vil kompilatoren kreve at vi gir en implementering av den abstrakte metoden og oppgir et portnummer. Siden vi har implementert metoden, når vi oppretter en spesifikk konfigurasjon, kan det hende vi ikke spesifiserer en annen port. Standardverdien vil bli brukt.

I klientkonfigurasjonen erklærer vi en avhengighet av ekkotjenesten:

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

Avhengigheten er av samme type som den eksporterte tjenesten echoService. Spesielt i ekkoklienten krever vi den samme protokollen. Derfor, når vi kobler sammen to tjenester, kan vi være sikre på at alt fungerer som det skal.

Implementering av tjenester

Det kreves en funksjon for å starte og stoppe tjenesten. (Muligheten til å stoppe en tjeneste er kritisk for testing.) Igjen er det flere alternativer for å implementere en slik funksjon (for eksempel kan vi bruke typeklasser basert på konfigurasjonstypen). For formålet med dette innlegget vil vi bruke kakemønsteret. Vi vil representere tjenesten ved hjelp av en klasse cats.Resource, fordi Denne klassen gir allerede midler for å trygt garantere frigjøring av ressurser i tilfelle problemer. For å få en ressurs, må vi gi konfigurasjon og en ferdig kjøretidskontekst. Tjenestens oppstartsfunksjon kan se slik ut:

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

der

  • Config — konfigurasjonstype for denne tjenesten
  • AddressResolver - et kjøretidsobjekt som lar deg finne ut adressene til andre noder (se nedenfor)

og andre typer fra biblioteket cats:

  • F[_] — type effekt (i det enkleste tilfellet F[A] kan bare være en funksjon () => A. I dette innlegget skal vi bruke cats.IO.)
  • Reader[A,B] - mer eller mindre synonymt med funksjon A => B
  • cats.Resource - en ressurs som kan skaffes og frigjøres
  • Timer — timer (lar deg sovne en stund og måle tidsintervaller)
  • ContextShift - analog ExecutionContext
  • Applicative — en effekttypeklasse som lar deg kombinere individuelle effekter (nesten en monade). I mer komplekse applikasjoner virker det bedre å bruke Monad/ConcurrentEffect.

Ved å bruke denne funksjonssignaturen kan vi implementere flere tjenester. For eksempel en tjeneste som ikke gjør noe:

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

(Cm. kilde, der andre tjenester er implementert - ekkotjeneste, ekko klient
и livstidskontrollere.)

En node er et objekt som kan lansere flere tjenester (lanseringen av en kjede av ressurser er sikret av kakemønsteret):

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

Vær oppmerksom på at vi spesifiserer den nøyaktige typen konfigurasjon som kreves for denne noden. Hvis vi glemmer å spesifisere en av konfigurasjonstypene som kreves av en bestemt tjeneste, vil det oppstå en kompileringsfeil. Vi vil heller ikke være i stand til å starte en node med mindre vi gir et objekt av passende type med alle nødvendige data.

Oppløsning av vertsnavn

For å koble til en ekstern vert trenger vi en ekte IP-adresse. Det er mulig at adressen blir kjent senere enn resten av konfigurasjonen. Så vi trenger en funksjon som tilordner node-IDen til en adresse:

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

Det er flere måter å implementere denne funksjonen på:

  1. Hvis adressene blir kjent for oss før distribusjon, kan vi generere Scala-kode med
    adresser og kjør deretter bygget. Dette vil kompilere og kjøre tester.
    I dette tilfellet vil funksjonen være statisk kjent og kan representeres i kode som en mapping Map[NodeId, NodeAddress].
  2. I noen tilfeller er den faktiske adressen først kjent etter at noden har startet.
    I dette tilfellet kan vi implementere en "oppdagelsestjeneste" som kjører før andre noder, og alle noder vil registrere seg med denne tjenesten og be om adressene til andre noder.
  3. Hvis vi kan endre /etc/hosts, så kan du bruke forhåndsdefinerte vertsnavn (som my-project-main-node и echo-backend) og bare koble til disse navnene
    med IP-adresser under distribusjon.

I dette innlegget vil vi ikke vurdere disse sakene mer detaljert. For vår
i et lekeeksempel vil alle noder ha samme IP-adresse - 127.0.0.1.

Deretter vurderer vi to alternativer for et distribuert system:

  1. Plassere alle tjenester på én node.
  2. Og vert for ekkotjenesten og ekkoklienten på forskjellige noder.

Konfigurasjon for én node:

Enkel nodekonfigurasjon

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

Objektet implementerer konfigurasjonen av både klienten og serveren. En time-to-live-konfigurasjon brukes også slik at etter intervallet lifetime avslutte programmet. (Ctrl-C fungerer også og frigjør alle ressurser på riktig måte.)

Det samme settet med konfigurasjons- og implementeringsegenskaper kan brukes til å lage et system bestående av to separate noder:

Konfigurasjon av to noder

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

Viktig! Legg merke til hvordan tjenestene er koblet sammen. Vi spesifiserer en tjeneste implementert av en node som en implementering av en annen nodes avhengighetsmetode. Avhengighetstypen kontrolleres av kompilatoren, fordi inneholder protokolltypen. Når den kjøres, vil avhengigheten inneholde riktig målnode-ID. Takket være denne ordningen spesifiserer vi portnummeret nøyaktig én gang og er alltid garantert å referere til riktig port.

Implementering av to systemnoder

For denne konfigurasjonen bruker vi de samme tjenesteimplementeringene uten endringer. Den eneste forskjellen er at vi nå har to objekter som implementerer forskjellige sett med tjenester:

  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
  }

Den første noden implementerer serveren og trenger kun serverkonfigurasjon. Den andre noden implementerer klienten og bruker en annen del av konfigurasjonen. Begge nodene trenger også livstidsstyring. Servernoden kjører på ubestemt tid til den stoppes SIGTERM'om, og klientnoden avsluttes etter en stund. Cm. lanseringsapp.

Generell utviklingsprosess

La oss se hvordan denne konfigurasjonstilnærmingen påvirker den generelle utviklingsprosessen.

Konfigurasjonen vil bli kompilert sammen med resten av koden og en artefakt (.jar) vil bli generert. Det ser ut til å være fornuftig å sette konfigurasjonen i en egen artefakt. Dette er fordi vi kan ha flere konfigurasjoner basert på samme kode. Igjen er det mulig å generere artefakter som tilsvarer forskjellige konfigurasjonsgrener. Avhengigheter av spesifikke versjoner av biblioteker lagres sammen med konfigurasjonen, og disse versjonene lagres for alltid når vi bestemmer oss for å distribuere den versjonen av konfigurasjonen.

Enhver konfigurasjonsendring blir til en kodeendring. Og derfor hver
endringen vil bli dekket av den vanlige kvalitetssikringsprosessen:

Billett i feilsporingen -> PR -> anmeldelse -> slå sammen med relevante grener ->
integrasjon -> utrulling

De viktigste konsekvensene av å implementere en kompilert konfigurasjon er:

  1. Konfigurasjonen vil være konsistent på tvers av alle noder i det distribuerte systemet. På grunn av det faktum at alle noder mottar samme konfigurasjon fra en enkelt kilde.

  2. Det er problematisk å endre konfigurasjonen i kun én av nodene. Derfor er "konfigurasjonsavvik" usannsynlig.

  3. Det blir vanskeligere å gjøre små endringer i konfigurasjonen.

  4. De fleste konfigurasjonsendringer vil skje som en del av den generelle utviklingsprosessen og vil bli gjenstand for vurdering.

Trenger jeg et separat depot for å lagre produksjonskonfigurasjonen? Denne konfigurasjonen kan inneholde passord og annen sensitiv informasjon som vi ønsker å begrense tilgangen til. Basert på dette ser det ut til å være fornuftig å lagre den endelige konfigurasjonen i et eget depot. Du kan dele opp konfigurasjonen i to deler – en som inneholder offentlig tilgjengelige konfigurasjonsinnstillinger og en som inneholder begrensede innstillinger. Dette vil tillate de fleste utviklere å ha tilgang til vanlige innstillinger. Denne separasjonen er enkel å oppnå ved å bruke mellomliggende egenskaper som inneholder standardverdier.

Mulige variasjoner

La oss prøve å sammenligne den kompilerte konfigurasjonen med noen vanlige alternativer:

  1. Tekstfil på målmaskinen.
  2. Sentralisert nøkkelverdi-lager (etcd/zookeeper).
  3. Prosesskomponenter som kan rekonfigureres/startes på nytt uten å starte prosessen på nytt.
  4. Lagring av konfigurasjon utenfor artefakt og versjonskontroll.

Tekstfiler gir betydelig fleksibilitet når det gjelder små endringer. Systemadministratoren kan logge på den eksterne noden, gjøre endringer i de aktuelle filene og starte tjenesten på nytt. For store systemer kan det imidlertid hende at slik fleksibilitet ikke er ønskelig. Endringene etterlater ingen spor i andre systemer. Ingen vurderer endringene. Det er vanskelig å fastslå hvem som har gjort endringene og av hvilken grunn. Endringer testes ikke. Hvis systemet er distribuert, kan administratoren glemme å gjøre den tilsvarende endringen på andre noder.

(Det skal også bemerkes at bruk av en kompilert konfigurasjon ikke lukker muligheten for å bruke tekstfiler i fremtiden. Det vil være nok å legge til en parser og validator som produserer samme type som utdata Config, og du kan bruke tekstfiler. Det følger umiddelbart at kompleksiteten til et system med en kompilert konfigurasjon er noe mindre enn kompleksiteten til et system som bruker tekstfiler, fordi tekstfiler krever tilleggskode.)

Et sentralisert nøkkelverdilager er en god mekanisme for å distribuere metaparametere til en distribuert applikasjon. Vi må bestemme hva som er konfigurasjonsparametere og hva som bare er data. La oss ha en funksjon C => A => B, og parametrene C endres sjelden, og data A - ofte. I dette tilfellet kan vi si det C - konfigurasjonsparametere, og A - data. Det ser ut til at konfigurasjonsparametere skiller seg fra data ved at de generelt endres sjeldnere enn data. Dessuten kommer data vanligvis fra én kilde (fra brukeren), og konfigurasjonsparametere fra en annen (fra systemadministratoren).

Hvis parametere som endrer seg sjelden må oppdateres uten å starte programmet på nytt, kan dette ofte føre til komplikasjoner av programmet, fordi vi på en eller annen måte må levere parametere, lagre, analysere og sjekke og behandle feil verdier. Derfor, fra synspunktet om å redusere kompleksiteten til programmet, er det fornuftig å redusere antall parametere som kan endres under programdrift (eller ikke støtter slike parametere i det hele tatt).

For formålet med dette innlegget vil vi skille mellom statiske og dynamiske parametere. Hvis logikken til tjenesten krever endring av parametere under driften av programmet, vil vi kalle slike parametere dynamiske. Ellers er alternativene statiske og kan konfigureres ved hjelp av den kompilerte konfigurasjonen. For dynamisk rekonfigurering kan det hende vi trenger en mekanisme for å starte deler av programmet på nytt med nye parametere, som ligner på hvordan operativsystemprosesser startes på nytt. (Etter vår mening er det tilrådelig å unngå rekonfigurering i sanntid, siden dette øker kompleksiteten til systemet. Hvis det er mulig, er det bedre å bruke standard OS-funksjoner for omstart av prosesser.)

Et viktig aspekt ved bruk av statisk konfigurasjon som får folk til å vurdere dynamisk rekonfigurasjon, er tiden det tar for systemet å starte på nytt etter en konfigurasjonsoppdatering (nedetid). Faktisk, hvis vi trenger å gjøre endringer i den statiske konfigurasjonen, må vi starte systemet på nytt for at de nye verdiene skal tre i kraft. Nedetidsproblemet varierer i alvorlighetsgrad for ulike systemer. I noen tilfeller kan du planlegge en omstart på et tidspunkt når belastningen er minimal. Hvis du trenger å yte kontinuerlig service, kan du implementere AWS ELB tilkobling drenering. Samtidig, når vi trenger å starte systemet på nytt, starter vi en parallell forekomst av dette systemet, bytter balanseren til det og venter på at de gamle tilkoblingene skal fullføres. Etter at alle gamle tilkoblinger er avsluttet, slår vi av den gamle forekomsten av systemet.

La oss nå vurdere spørsmålet om å lagre konfigurasjonen i eller utenfor artefakten. Hvis vi lagrer konfigurasjonen inne i en artefakt, så hadde vi i det minste muligheten til å verifisere riktigheten av konfigurasjonen under sammenstillingen av artefakten. Hvis konfigurasjonen er utenfor den kontrollerte artefakten, er det vanskelig å spore hvem som har gjort endringer i denne filen og hvorfor. Hvor viktig er det? Etter vår mening er det for mange produksjonssystemer viktig å ha en stabil og høykvalitets konfigurasjon.

Versjonen av en artefakt lar deg bestemme når den ble opprettet, hvilke verdier den inneholder, hvilke funksjoner som er aktivert/deaktivert, og hvem som er ansvarlig for enhver endring i konfigurasjonen. Å lagre konfigurasjonen inne i en artefakt krever selvfølgelig litt innsats, så du må ta en informert beslutning.

Fordeler og ulemper

Jeg vil gjerne dvele ved fordeler og ulemper med den foreslåtte teknologien.

Fordeler

Nedenfor er en liste over hovedtrekkene til en kompilert distribuert systemkonfigurasjon:

  1. Statisk konfigurasjonssjekk. Lar deg være sikker på det
    konfigurasjonen er riktig.
  2. Rikt konfigurasjonsspråk. Vanligvis er andre konfigurasjonsmetoder begrenset til strengvariabelerstatning på det meste. Når du bruker Scala, er et bredt spekter av språkfunksjoner tilgjengelige for å forbedre konfigurasjonen. For eksempel kan vi bruke
    egenskaper for standardverdier, ved å bruke objekter for å gruppere parametere, kan vi referere til verdier som er erklært kun én gang (DRY) i det vedlagte omfanget. Du kan instansiere alle klasser direkte inne i konfigurasjonen (Seq, Map, tilpassede klasser).
  3. DSL. Scala har en rekke språkfunksjoner som gjør det enklere å lage en DSL. Det er mulig å dra nytte av disse funksjonene og implementere et konfigurasjonsspråk som er mer praktisk for målgruppen av brukere, slik at konfigurasjonen i det minste er lesbar av domeneeksperter. Spesialister kan for eksempel delta i konfigurasjonsgjennomgangsprosessen.
  4. Integritet og synkronisering mellom noder. En av fordelene med å ha konfigurasjonen av et helt distribuert system lagret på ett enkelt punkt, er at alle verdier blir deklarert nøyaktig én gang og deretter gjenbrukt der de trengs. Å bruke fantomtyper for å deklarere porter sikrer at noder bruker kompatible protokoller i alle korrekte systemkonfigurasjoner. Å ha eksplisitte obligatoriske avhengigheter mellom noder sikrer at alle tjenester er tilkoblet.
  5. Høykvalitetsendringer. Å gjøre endringer i konfigurasjonen ved hjelp av en felles utviklingsprosess gjør det mulig å oppnå høye kvalitetsstandarder også for konfigurasjonen.
  6. Samtidig konfigurasjonsoppdatering. Automatisk systemimplementering etter konfigurasjonsendringer sikrer at alle noder er oppdatert.
  7. Forenkling av applikasjonen. Applikasjonen trenger ikke parsing, konfigurasjonskontroll eller håndtering av feil verdier. Dette reduserer kompleksiteten til applikasjonen. (Noe av konfigurasjonskompleksiteten observert i vårt eksempel er ikke en egenskap ved den kompilerte konfigurasjonen, men bare en bevisst beslutning drevet av ønsket om å gi større typesikkerhet.) Det er ganske enkelt å gå tilbake til den vanlige konfigurasjonen - bare implementer det som mangler. deler. Derfor kan du for eksempel starte med en kompilert konfigurasjon, og utsette implementeringen av unødvendige deler til tidspunktet da det virkelig er nødvendig.
  8. Verifisert konfigurasjon. Siden konfigurasjonsendringer følger den vanlige skjebnen til alle andre endringer, er utdataene vi får en artefakt med en unik versjon. Dette gjør at vi for eksempel kan gå tilbake til en tidligere versjon av konfigurasjonen om nødvendig. Vi kan til og med bruke konfigurasjonen fra et år siden, og systemet vil fungere nøyaktig det samme. En stabil konfigurasjon forbedrer forutsigbarheten og påliteligheten til et distribuert system. Siden konfigurasjonen er fikset på kompileringsstadiet, er det ganske vanskelig å forfalske den i produksjonen.
  9. Modularitet. Det foreslåtte rammeverket er modulært og modulene kan kombineres på ulike måter for å lage ulike systemer. Spesielt kan du konfigurere systemet til å kjøre på en enkelt node i én utførelse, og på flere noder i en annen. Du kan opprette flere konfigurasjoner for produksjonsforekomster av systemet.
  10. Testing. Ved å erstatte individuelle tjenester med falske objekter, kan du få flere versjoner av systemet som er praktiske å teste.
  11. Integrasjonstesting. Å ha en enkelt konfigurasjon for hele det distribuerte systemet gjør det mulig å kjøre alle komponenter i et kontrollert miljø som en del av integrasjonstesting. Det er lett å etterligne for eksempel en situasjon der noen noder blir tilgjengelige.

Ulemper og begrensninger

Kompilert konfigurasjon skiller seg fra andre konfigurasjonsmetoder og er kanskje ikke egnet for enkelte applikasjoner. Nedenfor er noen ulemper:

  1. Statisk konfigurasjon. Noen ganger må du raskt korrigere konfigurasjonen i produksjonen, omgå alle beskyttelsesmekanismer. Med denne tilnærmingen kan det være vanskeligere. I det minste vil kompilering og automatisk distribusjon fortsatt være nødvendig. Dette er både en nyttig funksjon ved tilnærmingen og en ulempe i noen tilfeller.
  2. Konfigurasjonsgenerering. I tilfelle konfigurasjonsfilen genereres av et automatisk verktøy, kan det kreves ytterligere innsats for å integrere byggeskriptet.
  3. Verktøy. Foreløpig er verktøy og teknikker designet for å fungere med konfigurasjon basert på tekstfiler. Ikke alle slike verktøy/teknikker vil være tilgjengelige i en kompilert konfigurasjon.
  4. En holdningsendring er nødvendig. Utviklere og DevOps er vant til tekstfiler. Selve ideen om å kompilere en konfigurasjon kan være noe uventet og uvanlig og forårsake avvisning.
  5. Det kreves en utviklingsprosess av høy kvalitet. For å komfortabelt bruke den kompilerte konfigurasjonen, er full automatisering av prosessen med å bygge og distribuere applikasjonen (CI/CD) nødvendig. Ellers vil det være ganske upraktisk.

La oss også dvele ved en rekke begrensninger ved det betraktede eksemplet som ikke er relatert til ideen om en kompilert konfigurasjon:

  1. Hvis vi gir unødvendig konfigurasjonsinformasjon som ikke brukes av noden, vil ikke kompilatoren hjelpe oss med å oppdage den manglende implementeringen. Dette problemet kan løses ved å forlate kakemønsteret og bruke mer stive typer, for eksempel, HList eller algebraiske datatyper (casusklasser) for å representere konfigurasjon.
  2. Det er linjer i konfigurasjonsfilen som ikke er relatert til selve konfigurasjonen: (package, import,objekterklæringer; override def's for parametere som har standardverdier). Dette kan delvis unngås hvis du implementerer din egen DSL. I tillegg pålegger andre typer konfigurasjon (for eksempel XML) visse begrensninger på filstrukturen.
  3. For formålet med dette innlegget vurderer vi ikke dynamisk rekonfigurasjon av en klynge med lignende noder.

Konklusjon

I dette innlegget utforsket vi ideen om å representere konfigurasjon i kildekoden ved å bruke de avanserte egenskapene til Scala-systemet. Denne tilnærmingen kan brukes i ulike applikasjoner som en erstatning for tradisjonelle konfigurasjonsmetoder basert på xml- eller tekstfiler. Selv om eksemplet vårt er implementert i Scala, kan de samme ideene overføres til andre kompilerte språk (som Kotlin, C#, Swift, ...). Du kan prøve denne tilnærmingen i ett av de følgende prosjektene, og hvis det ikke fungerer, gå videre til tekstfilen og legg til de manglende delene.

Naturligvis krever en kompilert konfigurasjon en utviklingsprosess av høy kvalitet. Til gjengjeld sikres høy kvalitet og pålitelighet på konfigurasjoner.

Den vurderte tilnærmingen kan utvides:

  1. Du kan bruke makroer til å utføre kompileringstidskontroller.
  2. Du kan implementere en DSL for å presentere konfigurasjonen på en måte som er tilgjengelig for sluttbrukere.
  3. Du kan implementere dynamisk ressursstyring med automatisk konfigurasjonsjustering. For eksempel, endring av antall noder i en klynge krever at (1) hver node mottar en litt forskjellig konfigurasjon; (2) klyngelederen mottok informasjon om nye noder.

Anerkjennelser

Jeg vil gjerne takke Andrei Saksonov, Pavel Popov og Anton Nekhaev for deres konstruktive kritikk av utkastet til artikkelen.

Kilde: www.habr.com

Legg til en kommentar