Kompilerbar konfiguration af et distribueret system

I dette indlæg vil vi gerne dele en interessant måde at håndtere konfiguration af et distribueret system på.
Konfigurationen er repræsenteret direkte i Scala-sprog på en typesikker måde. Et eksempel på implementering er beskrevet i detaljer. Forskellige aspekter af forslaget diskuteres, herunder indflydelse på den samlede udviklingsproces.

Kompilerbar konfiguration af et distribueret system

(на русском)

Introduktion

Opbygning af robuste distribuerede systemer kræver brug af korrekt og sammenhængende konfiguration på alle noder. En typisk løsning er at bruge en tekstuel implementeringsbeskrivelse (terraform, ansible eller noget lignende) og automatisk genererede konfigurationsfiler (ofte — dedikeret til hver node/rolle). Vi vil også gerne bruge de samme protokoller af de samme versioner på hver kommunikerende noder (ellers ville vi opleve inkompatibilitetsproblemer). I JVM-verdenen betyder dette, at i det mindste meddelelsesbiblioteket skal være af samme version på alle kommunikerende noder.

Hvad med at teste systemet? Selvfølgelig skal vi have enhedstests for alle komponenter, inden vi kommer til integrationstest. For at kunne ekstrapolere testresultater på runtime, bør vi sørge for, at versionerne af alle biblioteker holdes identiske i både runtime og testmiljøer.

Når du kører integrationstest, er det ofte meget nemmere at have den samme klassesti på alle noder. Vi skal bare sikre os, at den samme klassesti bruges ved udrulning. (Det er muligt at bruge forskellige klassestier på forskellige noder, men det er sværere at repræsentere denne konfiguration og implementere den korrekt.) Så for at holde tingene enkle vil vi kun overveje identiske klassestier på alle noder.

Konfiguration har en tendens til at udvikle sig sammen med softwaren. Vi bruger normalt versioner til at identificere forskellige
stadier af softwareudvikling. Det virker rimeligt at dække konfiguration under versionsstyring og identificere forskellige konfigurationer med nogle etiketter. Hvis der kun er én konfiguration i produktionen, kan vi bruge en enkelt version som en identifikator. Nogle gange kan vi have flere produktionsmiljøer. Og for hvert miljø har vi måske brug for en separat konfigurationsgren. Så konfigurationer kan være mærket med filial og version for entydigt at identificere forskellige konfigurationer. Hver grenlabel og version svarer til en enkelt kombination af distribuerede noder, porte, eksterne ressourcer, klassestibiblioteksversioner på hver node. Her vil vi kun dække den enkelte gren og identificere konfigurationer med en tre-komponent decimalversion (1.2.3), på samme måde som andre artefakter.

I moderne miljøer ændres konfigurationsfiler ikke længere manuelt. Typisk genererer vi
konfigurationsfiler ved installationstidspunktet og rør dem aldrig bagefter. Så man kunne spørge, hvorfor vi stadig bruger tekstformat til konfigurationsfiler? En brugbar mulighed er at placere konfigurationen inde i en kompileringsenhed og drage fordel af konfigurationsvalidering ved kompilering.

I dette indlæg vil vi undersøge ideen om at beholde konfigurationen i den kompilerede artefakt.

Kompilerbar konfiguration

I dette afsnit vil vi diskutere et eksempel på statisk konfiguration. To simple tjenester - ekkotjenesten og ekkotjenestens klient bliver konfigureret og implementeret. Derefter instansieres to forskellige distribuerede systemer med begge tjenester. En er til en enkelt nodekonfiguration og en anden til to nodekonfigurationer.

Et typisk distribueret system består af nogle få noder. Noderne kunne identificeres ved hjælp af en eller anden type:

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

eller bare

case class NodeId(hostName: String)

eller endda

object Singleton
type NodeId = Singleton.type

Disse noder udfører forskellige roller, kører nogle tjenester og skal kunne kommunikere med de andre noder ved hjælp af TCP/HTTP-forbindelser.

Til TCP-forbindelse kræves mindst et portnummer. Vi vil også sikre os, at klient og server taler den samme protokol. For at modellere en forbindelse mellem noder, lad os erklære følgende klasse:

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

hvor Port er bare en Int inden for det tilladte interval:

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

Raffinerede typer

Se raffinerede bibliotek. Kort sagt giver det mulighed for at tilføje kompileringstidsbegrænsninger til andre typer. I dette tilfælde Int er kun tilladt at have 16-bit værdier, der kan repræsentere portnummer. Der er intet krav om at bruge dette bibliotek til denne konfigurationstilgang. Det ser bare ud til at passe meget godt.

Til HTTP (REST)​ har vi muligvis også brug for en sti 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 at identificere protokol under kompilering bruger vi Scala-funktionen til at erklære type-argument Protocol som ikke bruges i klassen. Det er en såkaldt fantom type. Under runtime har vi sjældent brug for en forekomst af protokol-id, det er derfor, vi ikke gemmer det. Under kompilering giver denne fantomtype yderligere typesikkerhed. Vi kan ikke sende port med forkert protokol.

En af de mest udbredte protokoller er REST API med Json-serialisering:

sealed trait JsonHttpRestProtocol[RequestMessage, ResponseMessage]

hvor RequestMessage er basistypen af ​​meddelelser, som klienten kan sende til server og ResponseMessage er svarbeskeden fra serveren. Selvfølgelig kan vi lave andre protokolbeskrivelser, der specificerer kommunikationsprotokollen med den ønskede præcision.

Til formålet med dette indlæg vil vi bruge en enklere version af protokollen:

sealed trait SimpleHttpGetRest[RequestMessage, ResponseMessage]

I denne protokol tilføjes anmodningsmeddelelse til url, og svarmeddelelse returneres som almindelig streng.

En tjenestekonfiguration kan beskrives ved tjenestenavnet, en samling af porte og nogle afhængigheder. Der er et par mulige måder at repræsentere alle disse elementer i Scala på (f.eks. HList, algebraiske datatyper). Til formålet med dette indlæg vil vi bruge kagemønster og repræsentere kombinerbare stykker (moduler) som træk. (Kagemønster er ikke et krav for denne kompilerbare konfigurationstilgang. Det er kun en mulig implementering af ideen.)

Afhængigheder kunne repræsenteres ved hjælp af kagemø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)
  }

Echo service behøver kun en port konfigureret. Og vi erklærer, at denne port understøtter ekkoprotokol. Bemærk, at vi ikke behøver at specificere en bestemt port i øjeblikket, fordi egenskaber tillader abstrakte metodedeklarationer. Hvis vi bruger abstrakte metoder, vil compiler kræve en implementering i en konfigurationsforekomst. Her har vi leveret implementeringen (8081), og den vil blive brugt som standardværdien, hvis vi springer den over i en konkret konfiguration.

Vi kan erklære en afhængighed i konfigurationen af ​​ekkotjenesteklienten:

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

Afhængighed har samme type som echoService. Det kræver især den samme protokol. Derfor kan vi være sikre på, at hvis vi forbinder disse to afhængigheder, vil de fungere korrekt.

Implementering af tjenester

En tjeneste har brug for en funktion for at starte og lukke ned. (Evnen til at lukke en tjeneste er kritisk for testning.) Igen er der et par muligheder for at specificere en sådan funktion for en given konfiguration (for eksempel kunne vi bruge typeklasser). Til dette indlæg vil vi bruge kagemønster igen. Vi kan repræsentere en service vha cats.Resource som allerede giver bracketing og ressourcefrigivelse. For at erhverve en ressource bør vi give en konfiguration og en vis runtime-kontekst. Så servicestartfunktionen kan se sådan ud:

  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 konfiguration, der kræves af denne servicestarter
  • AddressResolver — et runtime-objekt, der har evnen til at opnå rigtige adresser på andre noder (fortsæt med at læse for detaljer).

de andre typer kommer fra cats:

  • F[_] — effekttype (I det enkleste tilfælde F[A] kunne være lige () => A. I dette indlæg vil vi bruge cats.IO.)
  • Reader[A,B] — er mere eller mindre et synonym for en funktion A => B
  • cats.Resource - har måder at erhverve og frigive
  • Timer — giver mulighed for at sove/måle tid
  • ContextShift - analog af ExecutionContext
  • Applicative — indpakning af funktioner i kraft (næsten en monade) (vi kan i sidste ende erstatte den med noget andet)

Ved at bruge denne grænseflade kan vi implementere nogle få tjenester. For eksempel en tjeneste, der ikke gør noget:

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

(Se Kildekode for implementeringer af andre tjenester — ekko service,
ekko klient , livstids controllere.)

En node er et enkelt objekt, der kører nogle få tjenester (start af en kæde af ressourcer er aktiveret af Cake Pattern):

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

Bemærk, at vi i noden angiver den nøjagtige type konfiguration, der er nødvendig for denne node. Compiler vil ikke lade os bygge objektet (Cake) med utilstrækkelig type, fordi hvert servicetræk erklærer en begrænsning på Config type. Vi vil heller ikke være i stand til at starte node uden at levere komplet konfiguration.

Node adresse opløsning

For at etablere en forbindelse har vi brug for en rigtig værtsadresse for hver node. Det kan være kendt senere end andre dele af konfigurationen. Derfor har vi brug for en måde at levere en mapping mellem node-id og dens faktiske adresse. Denne kortlægning er en funktion:

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

Der er et par mulige måder at implementere en sådan funktion på.

  1. Hvis vi kender faktiske adresser før implementering, under instansiering af nodeværter, så kan vi generere Scala-kode med de faktiske adresser og køre build bagefter (som udfører kompileringstidstjek og derefter kører integrationstestsuite). I dette tilfælde er vores kortlægningsfunktion kendt statisk og kan simplificeres til noget som a Map[NodeId, NodeAddress].
  2. Nogle gange får vi først faktiske adresser på et senere tidspunkt, når noden faktisk er startet, eller vi har ikke adresser på noder, der ikke er startet endnu. I dette tilfælde har vi muligvis en opdagelsestjeneste, der startes før alle andre knudepunkter, og hver knude kan annoncere sin adresse i den tjeneste og abonnere på afhængigheder.
  3. Hvis vi kan ændre /etc/hosts, kan vi bruge foruddefinerede værtsnavne (som my-project-main-node , echo-backend) og tilknyt blot dette navn til ip-adressen ved implementeringstidspunktet.

I dette indlæg dækker vi ikke disse sager mere detaljeret. Faktisk vil alle noder i vores legetøjseksempel have den samme IP-adresse — 127.0.0.1.

I dette indlæg vil vi overveje to distribuerede systemlayouts:

  1. Enkelt node layout, hvor alle tjenester er placeret på den enkelte node.
  2. To node layout, hvor service og klient er på forskellige noder.

Konfigurationen for en enkelt knudepunkt layout er som følger:

Enkelt node konfiguration

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 opretter vi en enkelt konfiguration, der udvider både server- og klientkonfiguration. Vi konfigurerer også en livscykluscontroller, der normalt vil afslutte klient og server efter lifetime interval passerer.

Det samme sæt af serviceimplementeringer og konfigurationer kan bruges til at skabe et systems layout med to separate noder. Vi skal bare skabe to separate nodekonfigurationer med de relevante tjenester:

To noder konfiguration

  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 angiver afhængigheden. Vi nævner den anden nodes leverede service som en afhængighed af den aktuelle node. Typen af ​​afhængighed kontrolleres, fordi den indeholder fantomtype, der beskriver protokol. Og på runtime vil vi have det korrekte node-id. Dette er et af de vigtige aspekter af den foreslåede konfigurationstilgang. Det giver os mulighed for kun at indstille port én gang og sikre, at vi henviser til den korrekte port.

Implementering af to noder

Til denne konfiguration bruger vi nøjagtig de samme serviceimplementeringer. Ingen ændringer overhovedet. Vi opretter dog to forskellige nodeimplementeringer, der indeholder forskellige sæt 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 node implementerer server, og den behøver kun serversidekonfiguration. Den anden node implementerer klienten og har brug for en anden del af konfigurationen. Begge noder kræver en vis levetidsspecifikation. Med henblik på denne postservice vil node have uendelig levetid, der kan afsluttes ved hjælp af SIGTERM, mens echo-klienten vil afslutte efter den konfigurerede endelige varighed. Se den starter ansøgning for yderligere oplysninger.

Overordnet udviklingsproces

Lad os se, hvordan denne tilgang ændrer den måde, vi arbejder med konfiguration på.

Konfigurationen som kode vil blive kompileret og producerer en artefakt. Det virker rimeligt at adskille konfigurationsartefakter fra andre kodeartefakter. Ofte kan vi have et væld af konfigurationer på den samme kodebase. Og selvfølgelig kan vi have flere versioner af forskellige konfigurationsgrene. I en konfiguration kan vi vælge bestemte versioner af biblioteker, og dette vil forblive konstant, når vi implementerer denne konfiguration.

En konfigurationsændring bliver til kodeændring. Så det bør være omfattet af den samme kvalitetssikringsproces:

Ticket -> PR -> gennemgang -> fletning -> kontinuerlig integration -> kontinuerlig implementering

Der er følgende konsekvenser af tilgangen:

  1. Konfigurationen er sammenhængende for et bestemt systems instans. Det ser ud til, at der ikke er nogen måde at have forkert forbindelse mellem noder.
  2. Det er ikke let at ændre konfigurationen kun i én node. Det virker urimeligt at logge ind og ændre nogle tekstfiler. Så konfigurationsdrift bliver mindre mulig.
  3. Små konfigurationsændringer er ikke nemme at lave.
  4. De fleste af konfigurationsændringerne vil følge den samme udviklingsproces, og den vil gennemgå en vis gennemgang.

Har vi brug for et separat lager til produktionskonfiguration? Produktionskonfigurationen kan indeholde følsomme oplysninger, som vi gerne vil holde uden for rækkevidde af mange mennesker. Så det kan være værd at beholde et separat lager med begrænset adgang, som vil indeholde produktionskonfigurationen. Vi kan opdele konfigurationen i to dele - en der indeholder de mest åbne produktionsparametre og en der indeholder den hemmelige del af konfigurationen. Dette ville give de fleste af udviklerne adgang til langt de fleste parametre, mens det begrænser adgangen til virkelig følsomme ting. Det er nemt at opnå dette ved at bruge mellemliggende træk med standardparameterværdier.

Variationer

Lad os se fordele og ulemper ved den foreslåede tilgang sammenlignet med de andre konfigurationsstyringsteknikker.

Først og fremmest vil vi liste nogle få alternativer til de forskellige aspekter af den foreslåede måde at håndtere konfiguration på:

  1. Tekstfil på målmaskinen.
  2. Centraliseret nøgleværdi-lagring (som etcd/zookeeper).
  3. Underproceskomponenter, der kunne omkonfigureres/genstartes uden at genstarte processen.
  4. Konfiguration uden for artefakt og versionskontrol.

Tekstfil giver en vis fleksibilitet med hensyn til ad hoc rettelser. Et systems administrator kan logge ind på målknuden, foretage en ændring og blot genstarte tjenesten. Dette er måske ikke så godt for større systemer. Der er ingen spor efter ændringen. Ændringen bliver ikke gennemgået af et andet par øjne. Det kan være svært at finde ud af, hvad der har forårsaget ændringen. Det er ikke blevet testet. Fra et distribueret systems perspektiv kan en administrator simpelthen glemme at opdatere konfigurationen i en af ​​de andre noder.

(Btw, hvis der til sidst bliver et behov for at begynde at bruge tekstkonfigurationsfiler, skal vi kun tilføje parser + validator, der kunne producere det samme Config type, og det ville være nok til at begynde at bruge tekstkonfigurationer. Dette viser også, at kompleksiteten af ​​kompileringstidskonfiguration er lidt mindre end kompleksiteten af ​​tekstbaserede konfigurationer, fordi vi i en tekstbaseret version har brug for noget ekstra kode.)

Centraliseret nøgleværdilagring er en god mekanisme til at distribuere applikationsmetaparametre. Her skal vi tænke over, hvad vi anser for at være konfigurationsværdier, og hvad der blot er data. Givet en funktion C => A => B vi normalt kalder sjældent skiftende værdier C "konfiguration", mens hyppigt ændrede data A - bare indtast data. Konfiguration skal leveres til funktionen tidligere end dataene A. Givet denne idé kan vi sige, at det er den forventede hyppighed af ændringer, der kunne bruges til at skelne konfigurationsdata fra kun data. Også data kommer typisk fra én kilde (bruger), og konfiguration kommer fra en anden kilde (admin). Håndtering af parametre, der kan ændres efter initialiseringsprocessen, fører til en forøgelse af applikationskompleksiteten. For sådanne parametre bliver vi nødt til at håndtere deres leveringsmekanisme, parsing og validering og håndtere forkerte værdier. Derfor, for at reducere programkompleksiteten, må vi hellere reducere antallet af parametre, der kan ændres under kørsel (eller endda helt eliminere dem).

Fra dette indlægs perspektiv bør vi skelne mellem statiske og dynamiske parametre. Hvis servicelogik kræver sjælden ændring af nogle parametre under kørsel, kan vi kalde dem dynamiske parametre. Ellers er de statiske og kunne konfigureres ved hjælp af den foreslåede tilgang. Til dynamisk omkonfiguration kan andre tilgange være nødvendige. For eksempel kan dele af systemet genstartes med de nye konfigurationsparametre på samme måde som genstart af separate processer i et distribueret system.
(Min ydmyge mening er at undgå runtime rekonfiguration, fordi det øger kompleksiteten af ​​systemet.
Det kan være mere ligetil bare at stole på OS-understøttelse til genstart af processer. Selvom det måske ikke altid er muligt.)

Et vigtigt aspekt ved at bruge statisk konfiguration, der nogle gange får folk til at overveje dynamisk konfiguration (uden andre årsager), er nedetid for tjenesten under konfigurationsopdatering. Faktisk, hvis vi skal foretage ændringer i den statiske konfiguration, skal vi genstarte systemet, så nye værdier bliver effektive. Kravene til nedetid varierer for forskellige systemer, så det er måske ikke så kritisk. Hvis det er kritisk, så er vi nødt til at planlægge på forhånd for enhver systemgenstart. For eksempel kunne vi implementere AWS ELB tilslutningsdræning. I dette scenarie, når vi skal genstarte systemet, starter vi en ny forekomst af systemet parallelt, og skifter derefter ELB til det, mens vi lader det gamle system fuldføre serviceringen af ​​eksisterende forbindelser.

Hvad med at beholde konfigurationen inde i versionerede artefakter eller udenfor? At holde konfigurationen inde i en artefakt betyder i de fleste tilfælde, at denne konfiguration har bestået den samme kvalitetssikringsproces som andre artefakter. Så man kan være sikker på, at konfigurationen er af god kvalitet og pålidelig. Tværtimod betyder konfiguration i en separat fil, at der ikke er spor af, hvem og hvorfor der har foretaget ændringer i den fil. Er dette vigtigt? Vi mener, at det for de fleste produktionssystemer er bedre at have en stabil konfiguration af høj kvalitet.

Version af artefakten giver mulighed for at finde ud af, hvornår den blev oprettet, hvilke værdier den indeholder, hvilke funktioner der er aktiveret/deaktiveret, hvem der var ansvarlig for at foretage hver ændring i konfigurationen. Det kan kræve en indsats at holde konfigurationen inde i en artefakt, og det er et designvalg at træffe.

For og imod

Her vil vi gerne fremhæve nogle fordele og diskutere nogle ulemper ved den foreslåede tilgang.

Fordele

Funktioner af den kompilerbare konfiguration af et komplet distribueret system:

  1. Statisk kontrol af konfiguration. Dette giver en høj grad af sikkerhed, at konfigurationen er korrekt givet type begrænsninger.
  2. Rigt konfigurationssprog. Typisk er andre konfigurationstilgange begrænset til højst variabel substitution.
    Ved at bruge Scala kan man bruge en lang række sprogfunktioner for at gøre konfigurationen bedre. For eksempel kan vi bruge egenskaber til at give standardværdier, objekter til at angive forskelligt omfang, vi kan henvise til vals kun defineret én gang i det ydre omfang (DRY). Det er muligt at bruge bogstavelige sekvenser eller forekomster af bestemte klasser (Seq, MapOsv.).
  3. DSL. Scala har anstændig support til DSL-skrivere. Man kan bruge disse funktioner til at etablere et konfigurationssprog, der er mere bekvemt og slutbrugervenligt, så den endelige konfiguration i det mindste kan læses af domænebrugere.
  4. Integritet og sammenhæng på tværs af noder. En af fordelene ved at have konfiguration for hele det distribuerede system på ét sted er, at alle værdier defineres strengt én gang og derefter genbruges alle steder, hvor vi har brug for dem. Indtast også sikker port-erklæringer, der sikrer, at i alle mulige korrekte konfigurationer vil systemets noder tale samme sprog. Der er eksplicitte afhængigheder mellem noder, hvilket gør det svært at glemme at levere nogle tjenester.
  5. Høj kvalitet af ændringer. Den overordnede tilgang til at overføre konfigurationsændringer gennem normal PR-proces etablerer høje kvalitetsstandarder også i konfiguration.
  6. Samtidige konfigurationsændringer. Når vi foretager ændringer i konfigurationen, sikrer automatisk implementering, at alle noder bliver opdateret.
  7. Ansøgningsforenkling. Applikationen behøver ikke at parse og validere konfiguration og håndtere forkerte konfigurationsværdier. Dette forenkler den overordnede anvendelse. (En vis kompleksitetsforøgelse er i selve konfigurationen, men det er en bevidst afvejning i retning af sikkerhed.) Det er ret ligetil at vende tilbage til almindelig konfiguration - bare tilføje de manglende stykker. Det er nemmere at komme i gang med kompileret konfiguration og udskyde implementeringen af ​​yderligere stykker til nogle senere tidspunkter.
  8. Versioneret konfiguration. På grund af det faktum, at konfigurationsændringer følger den samme udviklingsproces, får vi som et resultat et artefakt med unik version. Det giver os mulighed for at skifte konfiguration tilbage, hvis det er nødvendigt. Vi kan endda implementere en konfiguration, der blev brugt for et år siden, og den vil fungere på nøjagtig samme måde. Stabil konfiguration forbedrer forudsigeligheden og pålideligheden af ​​det distribuerede system. Konfigurationen er fast på kompileringstidspunktet og kan ikke let ændres på et produktionssystem.
  9. Modularitet. Den foreslåede ramme er modulopbygget, og moduler kan kombineres på forskellige måder
    understøtte forskellige konfigurationer (opsætninger/layouts). Især er det muligt at have et enkelt nodelayout i lille skala og en multinodeindstilling i stor skala. Det er rimeligt at have flere produktionslayouts.
  10. Afprøvning. Til testformål kan man implementere en mock-tjeneste og bruge den som en afhængighed på en typesikker måde. Et par forskellige testlayouts med forskellige dele erstattet af mocks kunne opretholdes samtidigt.
  11. Integrationstest. Nogle gange i distribuerede systemer er det svært at køre integrationstest. Ved at bruge den beskrevne tilgang til typesikker konfiguration af det komplette distribuerede system, kan vi køre alle distribuerede dele på en enkelt server på en kontrollerbar måde. Det er nemt at efterligne situationen
    når en af ​​tjenesterne bliver utilgængelige.

Ulemper

Den kompilerede konfigurationstilgang er forskellig fra "normal" konfiguration, og den passer muligvis ikke til alle behov. Her er nogle af ulemperne ved den kompilerede konfiguration:

  1. Statisk konfiguration. Det er muligvis ikke egnet til alle applikationer. I nogle tilfælde er der behov for hurtigt at rette konfigurationen i produktionen uden om alle sikkerhedsforanstaltninger. Denne tilgang gør det vanskeligere. Kompileringen og omplaceringen er påkrævet efter at have foretaget enhver ændring i konfigurationen. Dette er både funktionen og byrden.
  2. Generering af konfiguration. Når config er genereret af et eller andet automatiseringsværktøj, kræver denne tilgang efterfølgende kompilering (som igen kan mislykkes). Det kan kræve yderligere indsats at integrere dette ekstra trin i byggesystemet.
  3. Instrumenter. Der er masser af værktøjer i brug i dag, der er afhængige af tekstbaserede konfigurationer. Nogle af dem
    vil ikke være gældende, når konfigurationen er kompileret.
  4. Der er brug for et mindsetskifte. Udviklere og DevOps er fortrolige med tekstkonfigurationsfiler. Ideen om at kompilere konfiguration kan virke mærkelig for dem.
  5. Før du introducerer kompilerbar konfiguration, kræves en softwareudviklingsproces af høj kvalitet.

Der er nogle begrænsninger ved det implementerede eksempel:

  1. Hvis vi leverer ekstra konfiguration, som ikke kræves af nodeimplementeringen, hjælper compileren os ikke med at opdage den fraværende implementering. Dette kan løses ved at bruge HList eller ADT'er (case-klasser) til nodekonfiguration i stedet for træk og kagemønster.
  2. Vi er nødt til at levere en kedelplade i config-filen: (package, import, object erklæringer;
    override def's for parametre, der har standardværdier). Dette kan delvist løses ved hjælp af en DSL.
  3. I dette indlæg dækker vi ikke dynamisk rekonfiguration af klynger af lignende noder.

Konklusion

I dette indlæg har vi diskuteret ideen om at repræsentere konfiguration direkte i kildekoden på en typesikker måde. Fremgangsmåden kan bruges i mange applikationer som en erstatning for xml- og andre tekstbaserede konfigurationer. På trods af at vores eksempel er blevet implementeret i Scala, kan det også oversættes til andre kompilerbare sprog (som Kotlin, C#, Swift osv.). Man kunne prøve denne tilgang i et nyt projekt og, hvis det ikke passer godt, skifte til den gammeldags måde.

Selvfølgelig kræver kompilerbar konfiguration en udviklingsproces af høj kvalitet. Til gengæld lover den at levere en robust konfiguration af lige så høj kvalitet.

Denne tilgang kan udvides på forskellige måder:

  1. Man kunne bruge makroer til at udføre konfigurationsvalidering og fejle på kompileringstidspunktet i tilfælde af fejl i forretningslogiske begrænsninger.
  2. En DSL kunne implementeres til at repræsentere konfiguration på en domænebrugervenlig måde.
  3. Dynamisk ressourcestyring med automatiske konfigurationsjusteringer. For eksempel, når vi justerer antallet af klynge noder, vil vi måske have (1) noderne for at opnå en let ændret konfiguration; (2) klyngemanager for at modtage nye knudepunkter.

Tak

Jeg vil gerne sige tak til Andrey Saksonov, Pavel Popov, Anton Nehaev for at give inspirerende feedback på udkastet til dette indlæg, der hjalp mig med at gøre det klarere.

Kilde: www.habr.com