Kompileret distribueret systemkonfiguration

Jeg vil gerne fortælle dig en interessant mekanisme til at arbejde med konfigurationen af ​​et distribueret system. Konfigurationen er repræsenteret direkte i et kompileret sprog (Scala) ved hjælp af sikre typer. Dette indlæg giver et eksempel på en sådan konfiguration og diskuterer forskellige aspekter af implementering af en kompileret konfiguration i den overordnede udviklingsproces.

Kompileret distribueret systemkonfiguration

(engelsk)

Indledning

Opbygning af et pålideligt distribueret system betyder, at alle noder bruger den korrekte konfiguration, synkroniseret med andre noder. DevOps-teknologier (terraform, ansible eller noget lignende) bruges normalt til automatisk at generere konfigurationsfiler (ofte specifikke for hver node). Vi vil også gerne være sikre på, at alle kommunikerende noder bruger identiske protokoller (inklusive den samme version). Ellers vil inkompatibilitet blive indbygget i vores distribuerede system. I JVM-verdenen er en konsekvens af dette krav, at den samme version af biblioteket, der indeholder protokolmeddelelserne, skal bruges overalt.

Hvad med at teste et distribueret system? Vi antager selvfølgelig, at alle komponenter har enhedstest, før vi går videre til integrationstest. (For at vi kan ekstrapolere testresultater til runtime, skal vi også levere et identisk sæt biblioteker på teststadiet og på runtime.)

Når man arbejder med integrationstest, er det ofte nemmere at bruge den samme klassesti overalt på alle noder. Alt, hvad vi skal gøre, er at sikre, at den samme klassesti bruges under kørsel. (Selvom det er fuldt ud muligt at køre forskellige noder med forskellige klassestier, tilføjer dette kompleksitet til den overordnede konfiguration og vanskeligheder med implementering og integrationstest.) I forbindelse med dette indlæg antager vi, at alle noder vil bruge den samme klassesti.

Konfigurationen udvikler sig med applikationen. Vi bruger versioner til at identificere forskellige stadier af programudvikling. Det virker logisk også at identificere forskellige versioner af konfigurationer. Og placer selve konfigurationen i versionskontrolsystemet. Hvis der kun er én konfiguration i produktionen, så kan vi blot bruge versionsnummeret. Hvis vi bruger mange produktionsinstanser, så skal vi bruge flere
konfigurationsgrene og en ekstra etiket ud over versionen (f.eks. navnet på filialen). På denne måde kan vi tydeligt identificere den nøjagtige konfiguration. Hver konfigurationsidentifikator svarer unikt til en specifik kombination af distribuerede noder, porte, eksterne ressourcer og biblioteksversioner. I forbindelse med dette indlæg vil vi antage, at der kun er én gren, og vi kan identificere konfigurationen på den sædvanlige måde ved hjælp af tre tal adskilt af en prik (1.2.3).

I moderne miljøer oprettes konfigurationsfiler sjældent manuelt. Oftere genereres de under implementering og berøres ikke længere (så at ikke bryde noget). Et naturligt spørgsmål opstår: hvorfor bruger vi stadig tekstformat til at gemme konfiguration? Et levedygtigt alternativ synes at være muligheden for at bruge almindelig kode til konfiguration og drage fordel af kompileringstidskontrol.

I dette indlæg vil vi udforske ideen om at repræsentere en konfiguration inde i en kompileret artefakt.

Kompileret konfiguration

Dette afsnit giver et eksempel på en statisk kompileret konfiguration. To simple tjenester er implementeret - ekkotjenesten og ekkotjenesteklienten. Baseret på disse to tjenester er to systemmuligheder samlet. I en mulighed er begge tjenester placeret på den samme node, i en anden mulighed - på forskellige noder.

Et distribueret system indeholder typisk flere noder. Du kan identificere noder ved hjælp af værdier af en eller anden 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 udfører forskellige roller, de kører tjenester, og der kan etableres TCP/HTTP-forbindelser mellem dem.

For at beskrive en TCP-forbindelse har vi brug for mindst et portnummer. Vi vil også gerne afspejle den protokol, der understøttes på den port for at sikre, at både klienten og serveren bruger den samme protokol. Vi vil beskrive forbindelsen ved hjælp af følgende klasse:

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

где Port - kun et heltal Int angiver intervallet af acceptable værdier:

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

Raffinerede typer

Se bibliotek raffinerede и min rapport. Kort sagt giver biblioteket dig mulighed for at tilføje begrænsninger til typer, der kontrolleres på kompileringstidspunktet. I dette tilfælde er gyldige portnummerværdier 16-bit heltal. For en kompileret konfiguration er det ikke obligatorisk at bruge det raffinerede bibliotek, men det forbedrer compilerens mulighed for at kontrollere konfigurationen.

For HTTP (REST) ​​protokoller kan vi ud over portnummeret også have brug for stien til tjenesten:

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

Fantomtyper

For at identificere protokollen på kompileringstidspunktet bruger vi en typeparameter, der ikke bruges i klassen. Denne beslutning skyldes det faktum, at vi ikke bruger en protokolinstans under kørsel, men vi vil gerne have compileren til at kontrollere protokolkompatibilitet. Ved at specificere protokollen vil vi ikke være i stand til at videregive en upassende tjeneste som en afhængighed.

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

sealed trait JsonHttpRestProtocol[RequestMessage, ResponseMessage]

где RequestMessage - anmodningstype, ResponseMessage — svartype.
Selvfølgelig kan vi bruge andre protokolbeskrivelser, der giver den nøjagtighed af beskrivelsen, vi kræver.

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

sealed trait SimpleHttpGetRest[RequestMessage, ResponseMessage]

Her er anmodningen en streng tilføjet til url'en, og svaret er den returnerede streng i HTTP-svarets brødtekst.

Tjenestekonfigurationen er beskrevet af tjenestens navn, porte og afhængigheder. Disse elementer kan repræsenteres i Scala på flere måder (f.eks. HList-s, algebraiske datatyper). I forbindelse med dette indlæg vil vi bruge kagemønsteret og repræsentere moduler, der bruger trait'ov. (Kagemønsteret er ikke et påkrævet element i denne tilgang. Det er simpelthen en mulig implementering.)

Afhængigheder mellem tjenester kan repræsenteres som metoder, der returnerer porte EndPoint's af 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 at oprette en ekkotjeneste behøver du blot et portnummer og en indikation af, at porten understøtter ekkoprotokollen. Vi angiver muligvis ikke en specifik port, fordi... egenskaber giver dig mulighed for at erklære metoder uden implementering (abstrakte metoder). I dette tilfælde, når du opretter en konkret konfiguration, ville compileren kræve, at vi leverer en implementering af den abstrakte metode og angiver et portnummer. Da vi har implementeret metoden, vil vi muligvis ikke angive en anden port, når vi opretter en specifik konfiguration. Standardværdien vil blive brugt.

I klientkonfigurationen erklærer vi en afhængighed af ekkotjenesten:

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

Afhængigheden er af samme type som den eksporterede tjeneste echoService. Især i echo-klienten kræver vi den samme protokol. Når vi forbinder to tjenester, kan vi derfor være sikre på, at alt fungerer korrekt.

Implementering af services

Der kræves en funktion for at starte og stoppe tjenesten. (Evnen til at stoppe tjenesten er afgørende for test.) Igen er der flere muligheder for at implementere en sådan funktion (for eksempel kunne vi bruge typeklasser baseret på konfigurationstypen). Til formålet med dette indlæg vil vi bruge kagemønsteret. Vi vil repræsentere tjenesten ved hjælp af en klasse cats.Resource, fordi Denne klasse giver allerede midler til sikkert at garantere frigivelse af ressourcer i tilfælde af problemer. For at få en ressource skal vi levere konfiguration og en færdiglavet runtime-kontekst. 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]
  }

где

  • Config — konfigurationstype for denne tjeneste
  • AddressResolver — et runtime-objekt, der giver dig mulighed for at finde ud af adresserne på andre noder (se nedenfor)

og andre typer fra biblioteket cats:

  • F[_] — effekttype (i det enkleste tilfælde F[A] kunne bare være en funktion () => A. I dette indlæg vil vi bruge cats.IO.)
  • Reader[A,B] - mere eller mindre synonymt med funktion A => B
  • cats.Resource - en ressource, der kan skaffes og frigives
  • Timer — timer (giver dig mulighed for at falde i søvn et stykke tid og måle tidsintervaller)
  • ContextShift - analog ExecutionContext
  • Applicative — en effekttypeklasse, der giver dig mulighed for at kombinere individuelle effekter (næsten en monade). I mere komplekse applikationer virker det bedre at bruge Monad/ConcurrentEffect.

Ved at bruge denne funktionssignatur kan vi implementere flere 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](()))
  }

(Cm. kildekode, hvor andre tjenester er implementeret - ekko service, ekko klient
и livstids controllere.)

En node er et objekt, der kan lancere flere tjenester (lanceringen af ​​en kæde af ressourcer sikres af kagemønsteret):

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

Bemærk venligst, at vi angiver den nøjagtige type konfiguration, der kræves for denne node. Hvis vi glemmer at angive en af ​​de konfigurationstyper, der kræves af en bestemt tjeneste, vil der være en kompileringsfejl. Vi vil heller ikke være i stand til at starte en node, medmindre vi giver et eller andet objekt af den passende type alle de nødvendige data.

Opløsning af værtsnavn

For at oprette forbindelse til en fjernvært har vi brug for en rigtig IP-adresse. Det er muligt, at adressen bliver kendt senere end resten af ​​konfigurationen. Så vi har brug for en funktion, der kortlægger node-id'et til en adresse:

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

Der er flere måder at implementere denne funktion på:

  1. Hvis adresserne bliver kendt for os før implementering, så kan vi generere Scala-kode med
    adresser og kør derefter build. Dette vil kompilere og køre test.
    I dette tilfælde vil funktionen være kendt statisk og kan repræsenteres i kode som en mapping Map[NodeId, NodeAddress].
  2. I nogle tilfælde kendes den faktiske adresse først, efter at noden er startet.
    I dette tilfælde kan vi implementere en "opdagelsestjeneste", der kører før andre noder, og alle noder vil registrere sig med denne tjeneste og anmode om adresserne på andre noder.
  3. Hvis vi kan ændre /etc/hosts, så kan du bruge foruddefinerede værtsnavne (som my-project-main-node и echo-backend) og tilknyt blot disse navne
    med IP-adresser under implementering.

I dette indlæg vil vi ikke overveje disse sager mere detaljeret. For vores
i et legetøjseksempel vil alle noder have den samme IP-adresse - 127.0.0.1.

Dernæst overvejer vi to muligheder for et distribueret system:

  1. At placere alle tjenester på én node.
  2. Og hosting af ekkotjenesten og ekkoklienten på forskellige noder.

Konfiguration til én node:

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

Objektet implementerer konfigurationen af ​​både klienten og serveren. En time-to-live-konfiguration bruges også, således at efter intervallet lifetime afslutte programmet. (Ctrl-C fungerer også og frigør alle ressourcer korrekt.)

Det samme sæt af konfigurations- og implementeringstræk kan bruges til at skabe et system bestående af to separate noder:

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

Vigtig! Læg mærke til, hvordan tjenesterne er forbundet. Vi specificerer en service implementeret af en node som en implementering af en anden nodes afhængighedsmetode. Afhængighedstypen kontrolleres af compileren, fordi indeholder protokoltypen. Når den køres, vil afhængigheden indeholde det korrekte målknude-id. Takket være denne ordning angiver vi portnummeret nøjagtigt én gang og er altid garanteret at henvise til den korrekte port.

Implementering af to systemknudepunkter

Til denne konfiguration bruger vi de samme serviceimplementeringer uden ændringer. Den eneste forskel er, at vi nu har to objekter, der implementerer 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 serveren og behøver kun serverkonfiguration. Den anden node implementerer klienten og bruger en anden del af konfigurationen. Begge noder har også brug for livstidsstyring. Servernoden kører på ubestemt tid, indtil den stoppes SIGTERM'om, og klientnoden afsluttes efter nogen tid. Cm. launcher app.

Generel udviklingsproces

Lad os se, hvordan denne konfigurationstilgang påvirker den overordnede udviklingsproces.

Konfigurationen vil blive kompileret sammen med resten af ​​koden og en artefakt (.jar) vil blive genereret. Det ser ud til at give mening at placere konfigurationen i en separat artefakt. Dette skyldes, at vi kan have flere konfigurationer baseret på den samme kode. Igen er det muligt at generere artefakter svarende til forskellige konfigurationsgrene. Afhængigheder af specifikke versioner af biblioteker gemmes sammen med konfigurationen, og disse versioner gemmes for evigt, hver gang vi beslutter at implementere den version af konfigurationen.

Enhver konfigurationsændring bliver til en kodeændring. Og derfor hver især
ændringen vil være omfattet af den normale kvalitetssikringsproces:

Billet i fejlsporingen -> PR -> anmeldelse -> flet med relevante filialer ->
integration -> udrulning

De vigtigste konsekvenser af at implementere en kompileret konfiguration er:

  1. Konfigurationen vil være konsistent på tværs af alle noder i det distribuerede system. På grund af det faktum, at alle noder modtager den samme konfiguration fra en enkelt kilde.

  2. Det er problematisk kun at ændre konfigurationen i én af noderne. Derfor er "konfigurationsdrift" usandsynlig.

  3. Det bliver sværere at lave små ændringer i konfigurationen.

  4. De fleste konfigurationsændringer vil ske som en del af den overordnede udviklingsproces og vil blive gennemgået.

Har jeg brug for et separat lager for at gemme produktionskonfigurationen? Denne konfiguration kan indeholde adgangskoder og andre følsomme oplysninger, som vi gerne vil begrænse adgangen til. Baseret på dette ser det ud til at give mening at gemme den endelige konfiguration i et separat lager. Du kan opdele konfigurationen i to dele – en med offentligt tilgængelige konfigurationsindstillinger og en med begrænsede indstillinger. Dette vil give de fleste udviklere adgang til almindelige indstillinger. Denne adskillelse er let at opnå ved at bruge mellemliggende træk, der indeholder standardværdier.

Mulige variationer

Lad os prøve at sammenligne den kompilerede konfiguration med nogle almindelige alternativer:

  1. Tekstfil på målmaskinen.
  2. Centraliseret nøgleværdilager (etcd/zookeeper).
  3. Proceskomponenter, der kan rekonfigureres/genstartes uden at genstarte processen.
  4. Lagring af konfiguration uden for artefakt og versionskontrol.

Tekstfiler giver betydelig fleksibilitet med hensyn til små ændringer. Systemadministratoren kan logge på den eksterne node, foretage ændringer i de relevante filer og genstarte tjenesten. For store systemer kan en sådan fleksibilitet imidlertid ikke være ønskelig. Ændringerne efterlader ingen spor i andre systemer. Ingen gennemgår ændringerne. Det er svært at afgøre, hvem der præcist har foretaget ændringerne og af hvilken grund. Ændringer testes ikke. Hvis systemet er distribueret, kan administratoren glemme at foretage den tilsvarende ændring på andre noder.

(Det skal også bemærkes, at brug af en kompileret konfiguration ikke lukker muligheden for at bruge tekstfiler i fremtiden. Det vil være nok at tilføje en parser og validator, der producerer samme type som output Config, og du kan bruge tekstfiler. Det følger umiddelbart, at kompleksiteten af ​​et system med en kompileret konfiguration er noget mindre end kompleksiteten af ​​et system, der bruger tekstfiler, fordi tekstfiler kræver yderligere kode.)

Et centraliseret nøgleværdilager er en god mekanisme til at distribuere metaparametre for en distribueret applikation. Vi skal beslutte, hvad der er konfigurationsparametre, og hvad der kun er data. Lad os have en funktion C => A => Bog parametrene C sjældent ændringer, og data A - tit. I dette tilfælde kan vi sige det C - konfigurationsparametre, og A - data. Det ser ud til, at konfigurationsparametre adskiller sig fra data ved, at de generelt ændres sjældnere end data. Desuden kommer data normalt fra én kilde (fra brugeren) og konfigurationsparametre fra en anden (fra systemadministratoren).

Hvis sjældent skiftende parametre skal opdateres uden at genstarte programmet, så kan dette ofte føre til komplikation af programmet, fordi vi på en eller anden måde skal levere parametre, gemme, parse og kontrollere og behandle forkerte værdier. Derfor, ud fra et synspunkt om at reducere kompleksiteten af ​​programmet, er det fornuftigt at reducere antallet af parametre, der kan ændres under programdrift (eller slet ikke understøtter sådanne parametre).

Med henblik på dette indlæg vil vi skelne mellem statiske og dynamiske parametre. Hvis logikken i tjenesten kræver ændring af parametre under driften af ​​programmet, kalder vi sådanne parametre dynamiske. Ellers er indstillingerne statiske og kan konfigureres ved hjælp af den kompilerede konfiguration. Til dynamisk rekonfiguration har vi muligvis brug for en mekanisme til at genstarte dele af programmet med nye parametre, svarende til hvordan operativsystemets processer genstartes. (Efter vores mening er det tilrådeligt at undgå omkonfiguration i realtid, da dette øger systemets kompleksitet. Hvis det er muligt, er det bedre at bruge standard OS-funktionerne til genstart af processer.)

Et vigtigt aspekt ved at bruge statisk konfiguration, der får folk til at overveje dynamisk rekonfiguration, er den tid, det tager for systemet at genstarte efter en konfigurationsopdatering (nedetid). Faktisk, hvis vi skal foretage ændringer i den statiske konfiguration, bliver vi nødt til at genstarte systemet for at de nye værdier træder i kraft. Nedetidsproblemet varierer i sværhedsgrad for forskellige systemer. I nogle tilfælde kan du planlægge en genstart på et tidspunkt, hvor belastningen er minimal. Hvis du har brug for at yde kontinuerlig service, kan du implementere AWS ELB tilslutningsdræning. På samme tid, når vi skal genstarte systemet, starter vi en parallel forekomst af dette system, skifter balanceren til det og venter på, at de gamle forbindelser er færdige. Efter at alle gamle forbindelser er afsluttet, lukker vi den gamle instans af systemet ned.

Lad os nu overveje spørgsmålet om at gemme konfigurationen i eller uden for artefakten. Hvis vi gemmer konfigurationen inde i en artefakt, så havde vi i det mindste mulighed for at verificere rigtigheden af ​​konfigurationen under samlingen af ​​artefakten. Hvis konfigurationen er uden for den kontrollerede artefakt, er det svært at spore, hvem der har foretaget ændringer i denne fil og hvorfor. Hvor vigtigt er det? Efter vores mening er det for mange produktionssystemer vigtigt at have en stabil konfiguration af høj kvalitet.

Versionen af ​​en artefakt giver dig mulighed for at bestemme, hvornår den blev oprettet, hvilke værdier den indeholder, hvilke funktioner der er aktiveret/deaktiveret, og hvem der er ansvarlig for enhver ændring i konfigurationen. Selvfølgelig kræver det en vis indsats at gemme konfigurationen inde i en artefakt, så du skal træffe en informeret beslutning.

Fordele og ulemper

Jeg vil gerne dvæle ved fordele og ulemper ved den foreslåede teknologi.

Fordele

Nedenfor er en liste over hovedfunktionerne i en kompileret distribueret systemkonfiguration:

  1. Statisk konfigurationskontrol. Giver dig mulighed for at være sikker på det
    konfigurationen er korrekt.
  2. Rigt konfigurationssprog. Typisk er andre konfigurationsmetoder højst begrænset til strengvariabel substitution. Når du bruger Scala, er en lang række sprogfunktioner tilgængelige for at forbedre din konfiguration. Vi kan f.eks. bruge
    træk for standardværdier, ved at bruge objekter til at gruppere parametre, kan vi henvise til værdier, der kun er erklæret én gang (DRY) i det omsluttende omfang. Du kan instansiere alle klasser direkte inde i konfigurationen (Seq, Map, tilpassede klasser).
  3. DSL. Scala har en række sprogfunktioner, der gør det nemmere at oprette en DSL. Det er muligt at udnytte disse funktioner og implementere et konfigurationssprog, der er mere bekvemt for målgruppen af ​​brugere, så konfigurationen i det mindste er læsbar af domæneeksperter. Specialister kan for eksempel deltage i konfigurationsgennemgangsprocessen.
  4. Integritet og synkronisering mellem noder. En af fordelene ved at have konfigurationen af ​​et helt distribueret system gemt på et enkelt punkt er, at alle værdier erklæres nøjagtigt én gang og derefter genbruges, hvor end de er nødvendige. Brug af fantomtyper til at deklarere porte sikrer, at noder bruger kompatible protokoller i alle korrekte systemkonfigurationer. At have eksplicitte obligatoriske afhængigheder mellem noder sikrer, at alle tjenester er forbundet.
  5. Ændringer af høj kvalitet. Ændringer i konfigurationen ved hjælp af en fælles udviklingsproces gør det muligt at opnå høje kvalitetsstandarder også for konfigurationen.
  6. Samtidig konfigurationsopdatering. Automatisk systemimplementering efter konfigurationsændringer sikrer, at alle noder er opdateret.
  7. Forenkling af applikationen. Applikationen behøver ikke parsing, konfigurationskontrol eller håndtering af forkerte værdier. Dette reducerer kompleksiteten af ​​applikationen. (Noget af konfigurationskompleksiteten, der observeres i vores eksempel, er ikke en egenskab ved den kompilerede konfiguration, men kun en bevidst beslutning drevet af ønsket om at give større typesikkerhed.) Det er ret nemt at vende tilbage til den sædvanlige konfiguration - bare implementer det manglende dele. Derfor kan du for eksempel starte med en kompileret konfiguration og udskyde implementeringen af ​​unødvendige dele til det tidspunkt, hvor det virkelig er nødvendigt.
  8. Verificeret konfiguration. Da konfigurationsændringer følger den sædvanlige skæbne for alle andre ændringer, er det output, vi får, et artefakt med en unik version. Dette giver os for eksempel mulighed for at vende tilbage til en tidligere version af konfigurationen, hvis det er nødvendigt. Vi kan endda bruge konfigurationen fra et år siden, og systemet vil fungere nøjagtigt det samme. En stabil konfiguration forbedrer forudsigeligheden og pålideligheden af ​​et distribueret system. Da konfigurationen er rettet på kompileringsstadiet, er det ret svært at forfalske den i produktionen.
  9. Modularitet. Den foreslåede ramme er modulopbygget, og modulerne kan kombineres på forskellige måder for at skabe forskellige systemer. Især kan du konfigurere systemet til at køre på en enkelt node i én udførelsesform og på flere noder i en anden. Du kan oprette flere konfigurationer til produktionsforekomster af systemet.
  10. Afprøvning. Ved at erstatte individuelle tjenester med mock-objekter kan du få flere versioner af systemet, der er praktiske at teste.
  11. Integrationstest. At have en enkelt konfiguration for hele det distribuerede system gør det muligt at køre alle komponenter i et kontrolleret miljø som en del af integrationstest. Det er nemt at efterligne for eksempel en situation, hvor nogle noder bliver tilgængelige.

Ulemper og begrænsninger

Den kompilerede konfiguration adskiller sig fra andre konfigurationstilgange og er muligvis ikke egnet til nogle applikationer. Nedenfor er nogle ulemper:

  1. Statisk konfiguration. Nogle gange skal du hurtigt rette konfigurationen i produktionen ved at omgå alle beskyttelsesmekanismer. Med denne tilgang kan det være sværere. I det mindste vil kompilering og automatisk implementering stadig være påkrævet. Dette er både en nyttig egenskab ved tilgangen og en ulempe i nogle tilfælde.
  2. Generering af konfiguration. Hvis konfigurationsfilen er genereret af et automatisk værktøj, kan der være behov for yderligere indsats for at integrere build-scriptet.
  3. Værktøjer. I øjeblikket er værktøjer og teknikker designet til at arbejde med konfiguration baseret på tekstfiler. Ikke alle sådanne hjælpeprogrammer/teknikker vil være tilgængelige i en kompileret konfiguration.
  4. En holdningsændring er påkrævet. Udviklere og DevOps er vant til tekstfiler. Selve ideen med at kompilere en konfiguration kan være noget uventet og usædvanlig og forårsage afvisning.
  5. En udviklingsproces af høj kvalitet er påkrævet. For komfortabelt at bruge den kompilerede konfiguration er fuld automatisering af processen med at bygge og implementere applikationen (CI/CD) nødvendig. Ellers vil det være ret ubelejligt.

Lad os også dvæle ved en række begrænsninger af det betragtede eksempel, som ikke er relateret til ideen om en kompileret konfiguration:

  1. Hvis vi leverer unødvendige konfigurationsoplysninger, som ikke bruges af noden, vil compileren ikke hjælpe os med at opdage den manglende implementering. Dette problem kan løses ved at opgive kagemønsteret og bruge mere stive typer, f.eks. HList eller algebraiske datatyper (casusklasser) for at repræsentere konfiguration.
  2. Der er linjer i konfigurationsfilen, der ikke er relateret til selve konfigurationen: (package, import,objekterklæringer; override def's for parametre, der har standardværdier). Dette kan delvist undgås, hvis du implementerer din egen DSL. Derudover pålægger andre typer konfiguration (for eksempel XML) også visse begrænsninger på filstrukturen.
  3. Med henblik på dette indlæg overvejer vi ikke dynamisk rekonfiguration af en klynge af lignende noder.

Konklusion

I dette indlæg undersøgte vi ideen om at repræsentere konfiguration i kildekode ved hjælp af de avancerede funktioner i Scala-systemet. Denne tilgang kan bruges i forskellige applikationer som en erstatning for traditionelle konfigurationsmetoder baseret på xml- eller tekstfiler. Selvom vores eksempel er implementeret i Scala, kan de samme ideer overføres til andre kompilerede sprog (såsom Kotlin, C#, Swift, ...). Du kan prøve denne tilgang i et af følgende projekter, og hvis det ikke virker, gå videre til tekstfilen og tilføje de manglende dele.

En kompileret konfiguration kræver naturligvis en udviklingsproces af høj kvalitet. Til gengæld er høj kvalitet og pålidelighed af konfigurationer sikret.

Den overvejede tilgang kan udvides:

  1. Du kan bruge makroer til at udføre kontrol af kompileringstid.
  2. Du kan implementere en DSL for at præsentere konfigurationen på en måde, der er tilgængelig for slutbrugere.
  3. Du kan implementere dynamisk ressourcestyring med automatisk konfigurationsjustering. For eksempel kræver ændring af antallet af noder i en klynge, at (1) hver node modtager en lidt anderledes konfiguration; (2) klyngelederen modtog information om nye noder.

Tak

Jeg vil gerne takke Andrei Saksonov, Pavel Popov og Anton Nekhaev for deres konstruktive kritik af artikeludkastet.

Kilde: www.habr.com

Tilføj en kommentar