Kompilerbar konfigurasjon av et distribuert system

I dette innlegget vil vi dele en interessant måte å håndtere konfigurasjon av et distribuert system på.
Konfigurasjonen er representert direkte i Scala-språket på en typesikker måte. Et eksempel på implementering er beskrevet i detalj. Ulike sider ved forslaget diskuteres, herunder påvirkning på den samlede utviklingsprosessen.

Kompilerbar konfigurasjon av et distribuert system

(på russisk)

Introduksjon

Å bygge robuste distribuerte systemer krever bruk av korrekt og sammenhengende konfigurasjon på alle noder. En typisk løsning er å bruke en tekstlig distribusjonsbeskrivelse (terraform, ansible eller noe lignende) og automatisk genererte konfigurasjonsfiler (ofte - dedikert for hver node/rolle). Vi ønsker også å bruke de samme protokollene av de samme versjonene på hver kommuniserende node (ellers ville vi oppleve inkompatibilitetsproblemer). I JVM-verden betyr dette at minst meldingsbiblioteket bør være av samme versjon på alle kommuniserende noder.

Hva med å teste systemet? Selvfølgelig bør vi ha enhetstester for alle komponenter før vi kommer til integrasjonstester. For å kunne ekstrapolere testresultater på kjøretid, bør vi sørge for at versjonene av alle biblioteker holdes identiske i både kjøretids- og testmiljøer.

Når du kjører integrasjonstester, er det ofte mye enklere å ha samme klassebane på alle noder. Vi trenger bare å sørge for at den samme klassebanen brukes ved distribusjon. (Det er mulig å bruke forskjellige klassebaner på forskjellige noder, men det er vanskeligere å representere denne konfigurasjonen og distribuere den riktig.) Så for å holde ting enkelt vil vi kun vurdere identiske klassebaner på alle noder.

Konfigurasjon har en tendens til å utvikle seg sammen med programvaren. Vi bruker vanligvis versjoner for å identifisere ulike
stadier av programvareutvikling. Det virker rimelig å dekke konfigurasjon under versjonsadministrasjon og identifisere forskjellige konfigurasjoner med noen etiketter. Hvis det bare er én konfigurasjon i produksjon, kan vi bruke enkeltversjon som identifikator. Noen ganger kan vi ha flere produksjonsmiljøer. Og for hvert miljø trenger vi kanskje en egen gren av konfigurasjon. Så konfigurasjoner kan merkes med gren og versjon for å identifisere forskjellige konfigurasjoner unikt. Hver grenetikett og versjon tilsvarer en enkelt kombinasjon av distribuerte noder, porter, eksterne ressurser, klassebanebibliotekversjoner på hver node. Her skal vi bare dekke enkeltgrenen og identifisere konfigurasjoner med en tre-komponent desimalversjon (1.2.3), på samme måte som andre artefakter.

I moderne miljøer endres ikke konfigurasjonsfiler manuelt lenger. Vanligvis genererer vi
konfigurasjonsfiler ved distribusjonstidspunkt og aldri rør dem etterpå. Så man kan spørre hvorfor vi fortsatt bruker tekstformat for konfigurasjonsfiler? Et levedyktig alternativ er å plassere konfigurasjonen i en kompileringsenhet og dra nytte av konfigurasjonsvalidering under kompilering.

I dette innlegget vil vi undersøke ideen om å beholde konfigurasjonen i den kompilerte artefakten.

Kompilerbar konfigurasjon

I denne delen vil vi diskutere et eksempel på statisk konfigurasjon. To enkle tjenester - ekkotjeneste og klienten til ekkotjenesten blir konfigurert og implementert. Deretter instansieres to forskjellige distribuerte systemer med begge tjenester. En er for en enkelt nodekonfigurasjon og en annen for to nodekonfigurasjon.

Et typisk distribuert system består av noen få noder. Nodene kan identifiseres ved å bruke en eller annen type:

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

eller bare

case class NodeId(hostName: String)

eller enda

object Singleton
type NodeId = Singleton.type

Disse nodene utfører ulike roller, kjører noen tjenester og skal kunne kommunisere med de andre nodene ved hjelp av TCP/HTTP-forbindelser.

For TCP-tilkobling kreves minst et portnummer. Vi vil også sørge for at klient og server snakker samme protokoll. For å modellere en forbindelse mellom noder, la oss erklære følgende klasse:

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

hvor Port er bare en Int innenfor det tillatte området:

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

Raffinerte typer

Se raffinert bibliotek. Kort sagt, det lar deg legge til kompileringstidsbegrensninger til andre typer. I dette tilfellet Int er kun tillatt å ha 16-bits verdier som kan representere portnummer. Det er ingen krav om å bruke dette biblioteket for denne konfigurasjonsmetoden. Det ser bare ut til å passe veldig bra.

For HTTP (REST) ​​kan vi også trenge en bane til tjenesten:

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

Fantom type

For å identifisere protokoll under kompilering bruker vi Scala-funksjonen for å deklarere typeargument Protocol som ikke brukes i klassen. Det er en såkalt fantomtype. Under kjøring trenger vi sjelden en forekomst av protokollidentifikator, det er derfor vi ikke lagrer den. Under kompilering gir denne fantomtypen ekstra typesikkerhet. Vi kan ikke sende port med feil protokoll.

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

sealed trait JsonHttpRestProtocol[RequestMessage, ResponseMessage]

hvor RequestMessage er basistypen for meldinger som klienten kan sende til server og ResponseMessage er svarmeldingen fra serveren. Selvfølgelig kan vi lage andre protokollbeskrivelser som spesifiserer kommunikasjonsprotokollen med ønsket presisjon.

For formålet med dette innlegget bruker vi en enklere versjon av protokollen:

sealed trait SimpleHttpGetRest[RequestMessage, ResponseMessage]

I denne protokollen legges forespørselsmeldingen til url og svarmeldingen returneres som vanlig streng.

En tjenestekonfigurasjon kan beskrives av tjenestenavnet, en samling porter og noen avhengigheter. Det er noen mulige måter å representere alle disse elementene på i Scala (for eksempel, HList, algebraiske datatyper). For formålet med dette innlegget vil vi bruke kakemønster og representere kombinerbare deler (moduler) som egenskaper. (Cake Pattern er ikke et krav for denne kompilerbare konfigurasjonsmetoden. Det er bare en mulig implementering av ideen.)

Avhengigheter kan representeres ved å bruke kakemønsteret som endepunkter for 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)
  }

Ekkotjeneste trenger bare en port konfigurert. Og vi erklærer at denne porten støtter ekkoprotokoll. Merk at vi ikke trenger å spesifisere en bestemt port for øyeblikket, fordi egenskapene tillater abstrakte metodedeklarasjoner. Hvis vi bruker abstrakte metoder, vil kompilatoren kreve en implementering i en konfigurasjonsforekomst. Her har vi gitt implementeringen (8081) og den vil bli brukt som standardverdi hvis vi hopper over den i en konkret konfigurasjon.

Vi kan erklære en avhengighet i konfigurasjonen av ekkotjenesteklienten:

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

Avhengighet har samme type som echoService. Spesielt krever den samme protokoll. Derfor kan vi være sikre på at hvis vi kobler disse to avhengighetene vil de fungere riktig.

Tjenester implementering

En tjeneste trenger en funksjon for å starte og stenge ned. (Mulighet til å stenge en tjeneste er kritisk for testing.) Igjen er det noen få alternativer for å spesifisere en slik funksjon for en gitt konfigurasjon (for eksempel kan vi bruke typeklasser). For dette innlegget bruker vi Cake Pattern igjen. Vi kan representere en tjeneste ved hjelp av cats.Resource som allerede gir bracketing og ressursfrigjøring. For å skaffe en ressurs bør vi gi en konfigurasjon og litt kjøretidskontekst. Så tjenestestartfunksjonen 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]
  }

hvor

  • Config — type konfigurasjon som kreves av denne servicestarteren
  • AddressResolver — et kjøretidsobjekt som har muligheten til å få reelle adresser til andre noder (fortsett å lese for detaljer).

de andre typene kommer fra cats:

  • F[_] — effekttype (I det enkleste tilfellet F[A] kan være rettferdig () => A. I dette innlegget skal vi bruke cats.IO.)
  • Reader[A,B] — er mer eller mindre et synonym for en funksjon A => B
  • cats.Resource — har måter å erverve og frigjøre
  • Timer — lar deg sove/måle tid
  • ContextShift - analog av ExecutionContext
  • Applicative - innpakning av funksjoner i kraft (nesten en monad) (vi kan til slutt erstatte den med noe annet)

Ved å bruke dette grensesnittet kan vi implementere noen få 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](()))
  }

(Se Kildekode for implementering av andre tjenester - ekkotjeneste,
ekko klient og livstidskontrollere.)

En node er et enkelt objekt som kjører noen få tjenester (start av en ressurskjede aktiveres av Cake Pattern):

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

Merk at i noden spesifiserer vi den eksakte typen konfigurasjon som er nødvendig for denne noden. Kompilatoren lar oss ikke bygge objektet (Cake) med utilstrekkelig type, fordi hver tjenesteegenskap erklærer en begrensning på Config type. Vi vil heller ikke kunne starte noden uten å gi fullstendig konfigurasjon.

Nodeadresseoppløsning

For å etablere en forbindelse trenger vi en reell vertsadresse for hver node. Det kan bli kjent senere enn andre deler av konfigurasjonen. Derfor trenger vi en måte å levere en kartlegging mellom node-ID og den faktiske adressen. Denne kartleggingen er en funksjon:

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

Det er noen mulige måter å implementere en slik funksjon på.

  1. Hvis vi kjenner faktiske adresser før utrulling, under instansiering av nodeverter, kan vi generere Scala-kode med de faktiske adressene og kjøre byggingen etterpå (som utfører kompileringstidskontroller og deretter kjører integrasjonstestsuite). I dette tilfellet er kartfunksjonen vår kjent statisk og kan forenkles til noe som a Map[NodeId, NodeAddress].
  2. Noen ganger får vi faktiske adresser først på et senere tidspunkt når noden faktisk er startet, eller vi har ikke adresser til noder som ikke er startet ennå. I dette tilfellet kan vi ha en oppdagelsestjeneste som startes før alle andre noder, og hver node kan annonsere sin adresse i den tjenesten og abonnere på avhengigheter.
  3. Hvis vi kan endre /etc/hosts, kan vi bruke forhåndsdefinerte vertsnavn (som my-project-main-node og echo-backend) og bare assosier dette navnet med ip-adressen ved distribusjonstidspunktet.

I dette innlegget dekker vi ikke disse tilfellene mer detaljert. Faktisk i lekeeksemplet vårt vil alle noder ha samme IP-adresse – 127.0.0.1.

I dette innlegget vil vi vurdere to distribuerte systemoppsett:

  1. Enkelt node layout, hvor alle tjenester er plassert på enkelt node.
  2. To noder layout, hvor tjeneste og klient er på forskjellige noder.

Konfigurasjonen for en enkelt node layout er som følger:

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

Her lager vi en enkelt konfigurasjon som utvider både server- og klientkonfigurasjon. Vi konfigurerer også en livssykluskontroller som normalt vil avslutte klient og server etterpå lifetime intervall passerer.

Det samme settet med tjenesteimplementeringer og konfigurasjoner kan brukes til å lage et systems layout med to separate noder. Vi trenger bare å skape to separate nodekonfigurasjoner med passende tjenester:

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

Se hvordan vi spesifiserer avhengigheten. Vi nevner den andre nodens leverte tjeneste som en avhengighet av gjeldende node. Typen avhengighet kontrolleres fordi den inneholder fantomtype som beskriver protokollen. Og under kjøretid vil vi ha riktig node-ID. Dette er et av de viktige aspektene ved den foreslåtte konfigurasjonsmetoden. Det gir oss muligheten til å angi port kun én gang og sørge for at vi refererer til riktig port.

Implementering av to noder

For denne konfigurasjonen bruker vi nøyaktig de samme tjenesteimplementeringene. Ingen endringer i det hele tatt. Imidlertid lager vi to forskjellige nodeimplementeringer som inneholder 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 server og den trenger bare serversidekonfigurasjon. Den andre noden implementerer klienten og trenger en annen del av konfigurasjonen. Begge nodene krever en viss levetidsspesifikasjon. For formålene med denne posttjenestenden vil noden ha uendelig levetid som kan avsluttes med SIGTERM, mens ekkoklienten vil avsluttes etter den konfigurerte begrensede varigheten. Se startapplikasjon for mer informasjon.

Overordnet utviklingsprosess

La oss se hvordan denne tilnærmingen endrer måten vi jobber med konfigurasjon på.

Konfigurasjonen som kode vil bli kompilert og produserer en artefakt. Det virker rimelig å skille konfigurasjonsartefakter fra andre kodeartefakter. Ofte kan vi ha en mengde konfigurasjoner på samme kodebase. Og selvfølgelig kan vi ha flere versjoner av ulike konfigurasjonsgrener. I en konfigurasjon kan vi velge bestemte versjoner av biblioteker, og dette vil forbli konstant når vi distribuerer denne konfigurasjonen.

En konfigurasjonsendring blir kodeendring. Så det bør dekkes av samme kvalitetssikringsprosess:

Ticket -> PR -> gjennomgang -> flette -> kontinuerlig integrasjon -> kontinuerlig distribusjon

Det er følgende konsekvenser av tilnærmingen:

  1. Konfigurasjonen er sammenhengende for et bestemt systems forekomst. Det ser ut til at det ikke er mulig å ha feil forbindelse mellom noder.
  2. Det er ikke lett å endre konfigurasjon bare i én node. Det virker urimelig å logge på og endre enkelte tekstfiler. Så konfigurasjonsdrift blir mindre mulig.
  3. Små konfigurasjonsendringer er ikke enkle å gjøre.
  4. De fleste av konfigurasjonsendringene vil følge den samme utviklingsprosessen, og den vil bestå en viss gjennomgang.

Trenger vi et eget depot for produksjonskonfigurasjon? Produksjonskonfigurasjonen kan inneholde sensitiv informasjon som vi ønsker å holde utenfor rekkevidde for mange mennesker. Så det kan være verdt å beholde et eget depot med begrenset tilgang som vil inneholde produksjonskonfigurasjonen. Vi kan dele opp konfigurasjonen i to deler - en som inneholder de mest åpne parametrene for produksjon og en som inneholder den hemmelige delen av konfigurasjonen. Dette vil gi de fleste utviklerne tilgang til de aller fleste parametere, samtidig som de begrenser tilgangen til virkelig sensitive ting. Det er enkelt å oppnå dette ved å bruke mellomliggende egenskaper med standard parameterverdier.

Variasjoner

La oss se fordeler og ulemper ved den foreslåtte tilnærmingen sammenlignet med de andre konfigurasjonsadministrasjonsteknikkene.

Først av alt vil vi liste noen få alternativer til de forskjellige aspektene ved den foreslåtte måten å håndtere konfigurasjon på:

  1. Tekstfil på målmaskinen.
  2. Sentralisert nøkkelverdilagring (som etcd/zookeeper).
  3. Delprosesskomponenter som kan rekonfigureres/startes på nytt uten å starte prosessen på nytt.
  4. Konfigurasjon utenfor artefakt og versjonskontroll.

Tekstfil gir en viss fleksibilitet når det gjelder ad-hoc-fikser. Et systemadministrator kan logge på målnoden, gjøre en endring og ganske enkelt starte tjenesten på nytt. Dette er kanskje ikke like bra for større systemer. Det er ingen spor etter endringen. Endringen blir ikke vurdert av et annet par øyne. Det kan være vanskelig å finne ut hva som har forårsaket endringen. Det er ikke testet. Fra distribuert systems perspektiv kan en administrator ganske enkelt glemme å oppdatere konfigurasjonen i en av de andre nodene.

(Btw, hvis det til slutt blir behov for å begynne å bruke tekstkonfigurasjonsfiler, trenger vi bare å legge til parser + validator som kan produsere det samme Config type og det ville være nok til å begynne å bruke tekstkonfigurasjoner. Dette viser også at kompleksiteten til kompileringstidskonfigurasjon er litt mindre enn kompleksiteten til tekstbaserte konfigurasjoner, fordi i tekstbasert versjon trenger vi litt ekstra kode.)

Sentralisert nøkkelverdilagring er en god mekanisme for å distribuere applikasjonsmetaparametere. Her må vi tenke på hva vi anser som konfigurasjonsverdier og hva som bare er data. Gitt en funksjon C => A => B vi vanligvis kaller sjeldent skiftende verdier C "konfigurasjon", mens data ofte endres A - bare skriv inn data. Konfigurasjon bør gis til funksjonen tidligere enn dataene A. Gitt denne ideen kan vi si at det er forventet frekvens av endringer som kan brukes til å skille konfigurasjonsdata fra bare data. Også data kommer vanligvis fra én kilde (bruker) og konfigurasjon kommer fra en annen kilde (admin). Håndtering av parametere som kan endres etter initialiseringsprosessen fører til en økning av applikasjonskompleksiteten. For slike parametere må vi håndtere leveringsmekanismen deres, parsing og validering, og håndtere feil verdier. Derfor, for å redusere programkompleksiteten, bør vi redusere antallet parametere som kan endres under kjøring (eller til og med eliminere dem helt).

Fra perspektivet til dette innlegget bør vi skille mellom statiske og dynamiske parametere. Hvis tjenestelogikk krever sjelden endring av noen parametere under kjøring, kan vi kalle dem dynamiske parametere. Ellers er de statiske og kan konfigureres ved hjelp av den foreslåtte tilnærmingen. For dynamisk rekonfigurering kan andre tilnærminger være nødvendig. For eksempel kan deler av systemet startes på nytt med de nye konfigurasjonsparametrene på en lignende måte som omstart av separate prosesser i et distribuert system.
(Min ydmyke mening er å unngå omkonfigurering av kjøretid fordi det øker kompleksiteten til systemet.
Det kan være enklere å bare stole på OS-støtte for omstart av prosesser. Selv om det kanskje ikke alltid er mulig.)

Et viktig aspekt ved bruk av statisk konfigurasjon som noen ganger får folk til å vurdere dynamisk konfigurasjon (uten andre grunner), er nedetid for tjenesten under konfigurasjonsoppdatering. Faktisk, hvis vi må gjøre endringer i statisk konfigurasjon, må vi starte systemet på nytt slik at nye verdier blir effektive. Kravene til nedetid varierer for ulike systemer, så det er kanskje ikke så kritisk. Hvis det er kritisk, må vi planlegge på forhånd for eventuell omstart av systemet. For eksempel kan vi implementere AWS ELB tilkobling drenering. I dette scenariet når vi trenger å starte systemet på nytt, starter vi en ny forekomst av systemet parallelt, og bytter deretter ELB til det, mens vi lar det gamle systemet fullføre service på eksisterende tilkoblinger.

Hva med å beholde konfigurasjonen inne i versjonert artefakt eller utenfor? Å beholde konfigurasjonen inne i en artefakt betyr i de fleste tilfeller at denne konfigurasjonen har bestått samme kvalitetssikringsprosess som andre artefakter. Så man kan være sikker på at konfigurasjonen er av god kvalitet og pålitelig. Tvert imot betyr konfigurasjon i en egen fil at det ikke er spor av hvem og hvorfor som har gjort endringer i den filen. Er dette viktig? Vi tror at for de fleste produksjonssystemer er det bedre å ha en stabil konfigurasjon av høy kvalitet.

Versjon av artefakten lar deg finne ut når den ble opprettet, hvilke verdier den inneholder, hvilke funksjoner som er aktivert/deaktivert, hvem som var ansvarlig for å gjøre hver endring i konfigurasjonen. Det kan kreve litt innsats å holde konfigurasjonen inne i en artefakt, og det er et designvalg å ta.

Fordeler ulemper

Her vil vi fremheve noen fordeler og diskutere noen ulemper ved den foreslåtte tilnærmingen.

Fordeler

Funksjoner ved den kompilerbare konfigurasjonen til et komplett distribuert system:

  1. Statisk sjekk av konfigurasjonen. Dette gir en høy grad av sikkerhet, at konfigurasjonen er riktig gitt typebegrensninger.
  2. Rikt konfigurasjonsspråk. Vanligvis er andre konfigurasjonstilnærminger begrenset til høyst variabel substitusjon.
    Ved å bruke Scala kan man bruke et bredt spekter av språkfunksjoner for å gjøre konfigurasjonen bedre. For eksempel kan vi bruke egenskaper for å gi standardverdier, objekter for å angi forskjellig omfang, vi kan referere til vals definert kun én gang i det ytre omfanget (DRY). Det er mulig å bruke bokstavelige sekvenser, eller forekomster av visse klasser (Seq, Map, Osv.).
  3. DSL. Scala har grei støtte for DSL-skrivere. Man kan bruke disse funksjonene til å etablere et konfigurasjonsspråk som er mer praktisk og sluttbrukervennlig, slik at den endelige konfigurasjonen i det minste er lesbar for domenebrukere.
  4. Integritet og sammenheng på tvers av noder. En av fordelene med å ha konfigurasjon for hele det distribuerte systemet på ett sted er at alle verdier blir definert strengt én gang og deretter gjenbrukt alle steder der vi trenger dem. Skriv også safe port-erklæringer for at i alle mulige korrekte konfigurasjoner vil systemets noder snakke samme språk. Det er eksplisitte avhengigheter mellom noder som gjør det vanskelig å glemme å tilby noen tjenester.
  5. Høy kvalitet på endringer. Den generelle tilnærmingen med å sende konfigurasjonsendringer gjennom normal PR-prosess etablerer høye kvalitetsstandarder også i konfigurasjon.
  6. Samtidige konfigurasjonsendringer. Hver gang vi gjør endringer i konfigurasjonen sørger automatisk distribusjon for at alle noder blir oppdatert.
  7. Søknadsforenkling. Applikasjonen trenger ikke å analysere og validere konfigurasjon og håndtere feil konfigurasjonsverdier. Dette forenkler den generelle applikasjonen. (Noe kompleksitetsøkning er i selve konfigurasjonen, men det er en bevisst avveining mot sikkerhet.) Det er ganske enkelt å gå tilbake til vanlig konfigurasjon – bare legg til de manglende delene. Det er lettere å komme i gang med kompilert konfigurasjon og utsette implementeringen av flere deler til noen senere tider.
  8. Versjonert konfigurasjon. På grunn av det faktum at konfigurasjonsendringer følger samme utviklingsprosess, får vi som et resultat en artefakt med unik versjon. Det lar oss bytte konfigurasjon tilbake om nødvendig. Vi kan til og med distribuere en konfigurasjon som ble brukt for et år siden, og den vil fungere nøyaktig på samme måte. Stabil konfigurasjon forbedrer forutsigbarheten og påliteligheten til det distribuerte systemet. Konfigurasjonen er fast på kompileringstidspunktet og kan ikke enkelt tukles på et produksjonssystem.
  9. Modularitet. Det foreslåtte rammeverket er modulært og moduler kan kombineres på ulike måter for å
    støtte ulike konfigurasjoner (oppsett/oppsett). Spesielt er det mulig å ha en liten skala enkelt node layout og en stor skala multi node innstilling. Det er rimelig å ha flere produksjonsoppsett.
  10. Testing. For testformål kan man implementere en mock-tjeneste og bruke den som en avhengighet på en typesikker måte. Noen få forskjellige testoppsett med forskjellige deler erstattet av håner kan opprettholdes samtidig.
  11. Integrasjonstesting. Noen ganger i distribuerte systemer er det vanskelig å kjøre integrasjonstester. Ved å bruke den beskrevne tilnærmingen til type sikker konfigurasjon av det komplette distribuerte systemet, kan vi kjøre alle distribuerte deler på en enkelt server på en kontrollerbar måte. Det er lett å etterligne situasjonen
    når en av tjenestene blir utilgjengelig.

Ulemper

Den kompilerte konfigurasjonstilnærmingen er forskjellig fra "normal" konfigurasjon, og den passer kanskje ikke alle behov. Her er noen av ulempene med den kompilerte konfigurasjonen:

  1. Statisk konfigurasjon. Det er kanskje ikke egnet for alle applikasjoner. I noen tilfeller er det behov for å raskt fikse konfigurasjonen i produksjonen utenom alle sikkerhetstiltak. Denne tilnærmingen gjør det vanskeligere. Kompileringen og omdistribueringen er nødvendig etter å ha gjort endringer i konfigurasjonen. Dette er både funksjonen og byrden.
  2. Konfigurasjonsgenerering. Når konfigurasjon genereres av et automatiseringsverktøy krever denne tilnærmingen påfølgende kompilering (som igjen kan mislykkes). Det kan kreve ekstra innsats for å integrere dette ekstra trinnet i byggesystemet.
  3. Instrumenter. Det er mange verktøy i bruk i dag som er avhengige av tekstbaserte konfigurasjoner. Noen av dem
    vil ikke være aktuelt når konfigurasjonen er kompilert.
  4. En endring i tankesett er nødvendig. Utviklere og DevOps er kjent med tekstkonfigurasjonsfiler. Ideen om å kompilere konfigurasjon kan virke merkelig for dem.
  5. Før du introduserer kompilerbar konfigurasjon, kreves det en høykvalitets programvareutviklingsprosess.

Det er noen begrensninger ved det implementerte eksemplet:

  1. Hvis vi gir ekstra konfigurasjon som ikke kreves av nodeimplementeringen, vil ikke kompilatoren hjelpe oss med å oppdage den fraværende implementeringen. Dette kan løses ved å bruke HList eller ADT-er (case-klasser) for nodekonfigurasjon i stedet for egenskaper og kakemønster.
  2. Vi må gi noen kjeleplate i konfigurasjonsfilen: (package, import, object erklæringer;
    override def's for parametere som har standardverdier). Dette kan delvis løses ved hjelp av en DSL.
  3. I dette innlegget dekker vi ikke dynamisk rekonfigurasjon av klynger av lignende noder.

konklusjonen

I dette innlegget har vi diskutert ideen om å representere konfigurasjon direkte i kildekoden på en typesikker måte. Tilnærmingen kan brukes i mange applikasjoner som en erstatning for xml- og andre tekstbaserte konfigurasjoner. Til tross for at eksemplet vårt er implementert i Scala, kan det også oversettes til andre kompilbare språk (som Kotlin, C#, Swift, etc.). Man kan prøve denne tilnærmingen i et nytt prosjekt og, i tilfelle det ikke passer godt, bytte til den gamle måten.

Selvfølgelig krever kompilerbar konfigurasjon en utviklingsprosess av høy kvalitet. Til gjengjeld lover den å gi robust konfigurasjon av like høy kvalitet.

Denne tilnærmingen kan utvides på forskjellige måter:

  1. Man kan bruke makroer for å utføre konfigurasjonsvalidering og mislykkes ved kompilering i tilfelle feil med forretningslogiske begrensninger.
  2. En DSL kan implementeres for å representere konfigurasjon på en domenebrukervennlig måte.
  3. Dynamisk ressursstyring med automatiske konfigurasjonsjusteringer. For eksempel, når vi justerer antallet klyngenoder, vil vi kanskje ha (1) nodene for å få en litt modifisert konfigurasjon; (2) klyngeleder for å motta informasjon om nye noder.

Takk

Jeg vil gjerne si takk til Andrey Saksonov, Pavel Popov, Anton Nehaev for å gi inspirerende tilbakemeldinger på utkastet til dette innlegget som hjalp meg med å gjøre det klarere.

Kilde: www.habr.com