Kompilerad distribuerad systemkonfiguration

Jag skulle vilja berätta för dig en intressant mekanism för att arbeta med konfigurationen av ett distribuerat system. Konfigurationen representeras direkt i ett kompilerat språk (Scala) med hjälp av säkra typer. Det här inlägget ger ett exempel på en sådan konfiguration och diskuterar olika aspekter av att implementera en kompilerad konfiguration i den övergripande utvecklingsprocessen.

Kompilerad distribuerad systemkonfiguration

(Engelska)

Inledning

Att bygga ett tillförlitligt distribuerat system innebär att alla noder använder rätt konfiguration, synkroniserat med andra noder. DevOps-teknologier (terraform, ansible eller något liknande) används vanligtvis för att automatiskt generera konfigurationsfiler (ofta specifika för varje nod). Vi vill också vara säkra på att alla kommunicerande noder använder identiska protokoll (inklusive samma version). Annars kommer inkompatibilitet att byggas in i vårt distribuerade system. I JVM-världen är en konsekvens av detta krav att samma version av biblioteket som innehåller protokollmeddelandena måste användas överallt.

Hur är det med att testa ett distribuerat system? Vi förutsätter givetvis att alla komponenter har enhetstester innan vi går vidare till integrationstestning. (För att vi ska kunna extrapolera testresultat till körning måste vi också tillhandahålla en identisk uppsättning bibliotek vid teststadiet och vid körning.)

När man arbetar med integrationstester är det ofta lättare att använda samma klassväg överallt på alla noder. Allt vi behöver göra är att se till att samma klassväg används vid körning. (Även om det är fullt möjligt att köra olika noder med olika klassvägar, tillför detta komplexitet till den övergripande konfigurationen och svårigheter med driftsättning och integrationstester.) För detta inläggs syften antar vi att alla noder kommer att använda samma klassväg.

Konfigurationen utvecklas med applikationen. Vi använder versioner för att identifiera olika stadier av programutveckling. Det verkar logiskt att också identifiera olika versioner av konfigurationer. Och placera själva konfigurationen i versionskontrollsystemet. Om det bara finns en konfiguration i produktionen kan vi helt enkelt använda versionsnumret. Om vi ​​använder många produktionsinstanser kommer vi att behöva flera
konfigurationsgrenar och en extra etikett utöver versionen (till exempel namnet på filialen). På så sätt kan vi tydligt identifiera den exakta konfigurationen. Varje konfigurationsidentifierare motsvarar unikt en specifik kombination av distribuerade noder, portar, externa resurser och biblioteksversioner. För detta inlägg kommer vi att anta att det bara finns en gren och vi kan identifiera konfigurationen på vanligt sätt med hjälp av tre siffror separerade med en punkt (1.2.3).

I moderna miljöer skapas sällan konfigurationsfiler manuellt. Oftare genereras de under driftsättning och berörs inte längre (så att bryt inget). En naturlig fråga uppstår: varför använder vi fortfarande textformat för att lagra konfiguration? Ett genomförbart alternativ verkar vara möjligheten att använda vanlig kod för konfiguration och dra nytta av kompileringskontroller.

I det här inlägget kommer vi att utforska idén om att representera en konfiguration inuti en kompilerad artefakt.

Kompilerad konfiguration

Det här avsnittet ger ett exempel på en statisk kompilerad konfiguration. Två enkla tjänster implementeras - ekotjänsten och ekotjänstklienten. Baserat på dessa två tjänster sätts två systemalternativ samman. I ett alternativ är båda tjänsterna placerade på samma nod, i ett annat alternativ - på olika noder.

Vanligtvis innehåller ett distribuerat system flera noder. Du kan identifiera noder med hjälp av värden av någon typ 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ör olika roller, de kör tjänster och TCP/HTTP-anslutningar kan upprättas mellan dem.

För att beskriva en TCP-anslutning behöver vi åtminstone ett portnummer. Vi skulle också vilja återspegla protokollet som stöds på den porten för att säkerställa att både klienten och servern använder samma protokoll. Vi kommer att beskriva anslutningen med följande klass:

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

där Port - bara ett heltal Int anger intervallet för acceptabla värden:

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

Förfinade typer

Se bibliotek raffinerade и min Rapportera. Kort sagt låter biblioteket dig lägga till begränsningar för typer som kontrolleras vid kompilering. I det här fallet är giltiga portnummervärden 16-bitars heltal. För en kompilerad konfiguration är det inte obligatoriskt att använda det förfinade biblioteket, men det förbättrar kompilatorns förmåga att kontrollera konfigurationen.

För HTTP (REST)-protokoll kan vi, förutom portnumret, också behöva sökvägen till tjänsten:

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

Fantomtyper

För att identifiera protokollet vid kompilering använder vi en typparameter som inte används inom klassen. Detta beslut beror på det faktum att vi inte använder en protokollinstans vid körning, men vi skulle vilja att kompilatorn kontrollerar protokollkompatibiliteten. Genom att specificera protokollet kommer vi inte att kunna skicka en olämplig tjänst som ett beroende.

Ett av de vanliga protokollen är REST API med Json-serialisering:

sealed trait JsonHttpRestProtocol[RequestMessage, ResponseMessage]

där RequestMessage - typ av begäran, ResponseMessage — svarstyp.
Naturligtvis kan vi använda andra protokollbeskrivningar som ger den exakta beskrivningen vi kräver.

För detta inlägg kommer vi att använda en förenklad version av protokollet:

sealed trait SimpleHttpGetRest[RequestMessage, ResponseMessage]

Här är begäran en sträng som läggs till webbadressen och svaret är den returnerade strängen i HTTP-svarets brödtext.

Tjänstens konfiguration beskrivs av tjänstens namn, portar och beroenden. Dessa element kan representeras i Scala på flera sätt (t.ex. HList-s, algebraiska datatyper). För detta inlägg kommer vi att använda tårtmönstret och representera moduler som använder trait'ov. (Tårtmönstret är inte ett obligatoriskt inslag i detta tillvägagångssätt. Det är helt enkelt en möjlig implementering.)

Beroenden mellan tjänster kan representeras som metoder som returnerar portar EndPoints av andra 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)
  }

För att skapa en ekotjänst behöver du bara ett portnummer och en indikation på att porten stöder ekoprotokollet. Vi kanske inte anger en specifik port, eftersom... egenskaper låter dig deklarera metoder utan implementering (abstrakta metoder). I det här fallet, när du skapar en konkret konfiguration, skulle kompilatorn kräva att vi tillhandahåller en implementering av den abstrakta metoden och tillhandahåller ett portnummer. Eftersom vi har implementerat metoden, när vi skapar en specifik konfiguration, kanske vi inte specificerar en annan port. Standardvärdet kommer att användas.

I klientkonfigurationen förklarar vi ett beroende av ekotjänsten:

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

Beroendet är av samma typ som den exporterade tjänsten echoService. I synnerhet kräver vi samma protokoll i echo-klienten. När vi ansluter två tjänster kan vi därför vara säkra på att allt kommer att fungera korrekt.

Implementering av tjänster

En funktion krävs för att starta och stoppa tjänsten. (Möjligheten att stoppa tjänsten är avgörande för testning.) Återigen finns det flera alternativ för att implementera en sådan funktion (till exempel kan vi använda typklasser baserade på konfigurationstypen). För detta inlägg kommer vi att använda tårtmönstret. Vi kommer att representera tjänsten med hjälp av en klass cats.Resource, därför att Den här klassen tillhandahåller redan medel för att säkert garantera frigöring av resurser vid problem. För att få en resurs måste vi tillhandahålla konfiguration och ett färdigt körtidskontext. Servicestartfunktionen kan se ut så här:

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

där

  • Config — konfigurationstyp för denna tjänst
  • AddressResolver — ett runtime-objekt som låter dig ta reda på adresserna till andra noder (se nedan)

och andra typer från biblioteket cats:

  • F[_] — typ av effekt (i det enklaste fallet F[A] kan bara vara en funktion () => A. I det här inlägget kommer vi att använda cats.IO.)
  • Reader[A,B] - mer eller mindre synonymt med funktion A => B
  • cats.Resource - en resurs som kan erhållas och frigöras
  • Timer — timer (låter dig somna en stund och mäta tidsintervaller)
  • ContextShift - analog ExecutionContext
  • Applicative — en effekttypsklass som låter dig kombinera individuella effekter (nästan en monad). I mer komplexa applikationer verkar det bättre att använda Monad/ConcurrentEffect.

Med hjälp av denna funktionssignatur kan vi implementera flera tjänster. Till exempel en tjänst som inte gör något:

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

(Centimeter. källkod, där andra tjänster implementeras - ekotjänst, echo klient
и livstidskontroller.)

En nod är ett objekt som kan lansera flera tjänster (lanseringen av en kedja av resurser säkerställs av Cake Pattern):

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

Observera att vi specificerar den exakta typen av konfiguration som krävs för denna nod. Om vi ​​glömmer att ange en av de konfigurationstyper som krävs av en viss tjänst, kommer det att uppstå ett kompileringsfel. Vi kommer inte heller att kunna starta en nod om vi inte tillhandahåller något objekt av lämplig typ med all nödvändig data.

Värdnamnsupplösning

För att ansluta till en fjärrvärd behöver vi en riktig IP-adress. Det är möjligt att adressen blir känd senare än resten av konfigurationen. Så vi behöver en funktion som mappar nod-ID:t till en adress:

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

Det finns flera sätt att implementera den här funktionen:

  1. Om adresserna blir kända för oss innan distributionen kan vi generera Scala-kod med
    adresser och kör sedan bygget. Detta kommer att kompilera och köra tester.
    I detta fall kommer funktionen att vara känd statiskt och kan representeras i kod som en mappning Map[NodeId, NodeAddress].
  2. I vissa fall är den faktiska adressen känd först efter att noden har startat.
    I det här fallet kan vi implementera en "upptäcktstjänst" som körs före andra noder och alla noder kommer att registrera sig hos denna tjänst och begära adresserna till andra noder.
  3. Om vi ​​kan ändra /etc/hosts, då kan du använda fördefinierade värdnamn (som my-project-main-node и echo-backend) och länka helt enkelt dessa namn
    med IP-adresser under driftsättning.

I det här inlägget kommer vi inte att överväga dessa fall mer i detalj. För vår
i ett leksaksexempel kommer alla noder att ha samma IP-adress - 127.0.0.1.

Därefter överväger vi två alternativ för ett distribuerat system:

  1. Placera alla tjänster på en nod.
  2. Och att vara värd för ekotjänsten och ekoklienten på olika noder.

Konfiguration för en nod:

Konfiguration av en nod

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

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

  // configuration of client

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

  override def testMessage: UrlPathElement = "hello"

  def pollInterval: FiniteDuration = 1.second

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

Objektet implementerar konfigurationen av både klienten och servern. En time-to-live-konfiguration används också så att efter intervallet lifetime avsluta programmet. (Ctrl-C fungerar också och frigör alla resurser korrekt.)

Samma uppsättning konfigurations- och implementeringsegenskaper kan användas för att skapa ett system som består av två separata noder:

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

Viktig! Lägg märke till hur tjänsterna är länkade. Vi specificerar en tjänst implementerad av en nod som en implementering av en annan nods beroendemetod. Beroendetypen kontrolleras av kompilatorn, eftersom innehåller protokolltypen. När det körs kommer beroendet att innehålla rätt målnod-ID. Tack vare detta schema anger vi portnumret exakt en gång och är alltid garanterade att referera till rätt port.

Implementering av två systemnoder

För den här konfigurationen använder vi samma tjänsteimplementationer utan ändringar. Den enda skillnaden är att vi nu har två objekt som implementerar olika uppsättningar tjänster:

  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örsta noden implementerar servern och behöver bara serverkonfiguration. Den andra noden implementerar klienten och använder en annan del av konfigurationen. Båda noderna behöver också livstidshantering. Servernoden körs på obestämd tid tills den stoppas SIGTERM'om, och klientnoden avslutas efter en tid. Centimeter. startapp.

Allmän utvecklingsprocess

Låt oss se hur denna konfigurationsmetod påverkar den övergripande utvecklingsprocessen.

Konfigurationen kommer att kompileras tillsammans med resten av koden och en artefakt (.jar) kommer att genereras. Det verkar vara vettigt att placera konfigurationen i en separat artefakt. Detta beror på att vi kan ha flera konfigurationer baserade på samma kod. Återigen är det möjligt att generera artefakter som motsvarar olika konfigurationsgrenar. Beroenden på specifika versioner av bibliotek sparas tillsammans med konfigurationen, och dessa versioner sparas för alltid när vi bestämmer oss för att distribuera den versionen av konfigurationen.

Varje konfigurationsändring förvandlas till en kodändring. Och därför var och en
ändringen kommer att omfattas av den normala kvalitetssäkringsprocessen:

Biljett i buggspåraren -> PR -> recension -> slå samman med relevanta grenar ->
integration -> distribution

De viktigaste konsekvenserna av att implementera en kompilerad konfiguration är:

  1. Konfigurationen kommer att vara konsekvent över alla noder i det distribuerade systemet. På grund av det faktum att alla noder får samma konfiguration från en enda källa.

  2. Det är problematiskt att ändra konfigurationen i endast en av noderna. Därför är "konfigurationsavvikelse" osannolik.

  3. Det blir svårare att göra små ändringar i konfigurationen.

  4. De flesta konfigurationsändringar kommer att ske som en del av den övergripande utvecklingsprocessen och kommer att bli föremål för granskning.

Behöver jag ett separat arkiv för att lagra produktionskonfigurationen? Denna konfiguration kan innehålla lösenord och annan känslig information som vi vill begränsa åtkomsten till. Baserat på detta verkar det vara vettigt att lagra den slutliga konfigurationen i ett separat arkiv. Du kan dela upp konfigurationen i två delar – en som innehåller allmänt tillgängliga konfigurationsinställningar och en som innehåller begränsade inställningar. Detta gör att de flesta utvecklare får tillgång till vanliga inställningar. Denna separation är lätt att uppnå med hjälp av mellanliggande egenskaper som innehåller standardvärden.

Möjliga variationer

Låt oss försöka jämföra den kompilerade konfigurationen med några vanliga alternativ:

  1. Textfil på målmaskinen.
  2. Centraliserad nyckel-värde butik (etcd/zookeeper).
  3. Processkomponenter som kan konfigureras om/startas om utan att starta om processen.
  4. Lagring av konfiguration utanför artefakt och versionskontroll.

Textfiler ger betydande flexibilitet när det gäller små ändringar. Systemadministratören kan logga in på fjärrnoden, göra ändringar i lämpliga filer och starta om tjänsten. För stora system kanske en sådan flexibilitet inte är önskvärd. De ändringar som gjorts lämnar inga spår i andra system. Ingen granskar förändringarna. Det är svårt att avgöra exakt vem som gjort ändringarna och av vilken anledning. Ändringar testas inte. Om systemet är distribuerat kan administratören glömma att göra motsvarande ändring på andra noder.

(Det bör också noteras att användning av en kompilerad konfiguration inte stänger möjligheten att använda textfiler i framtiden. Det räcker med att lägga till en parser och validator som producerar samma typ som utdata Config, och du kan använda textfiler. Det följer omedelbart att komplexiteten hos ett system med en kompilerad konfiguration är något mindre än komplexiteten hos ett system som använder textfiler, eftersom textfiler kräver ytterligare kod.)

Ett centraliserat nyckel-värdelager är en bra mekanism för att distribuera metaparametrar för en distribuerad applikation. Vi måste bestämma vad som är konfigurationsparametrar och vad som bara är data. Låt oss ha en funktion C => A => Boch parametrarna C ändras sällan, och data A - ofta. I det här fallet kan vi säga det C - konfigurationsparametrar, och A - data. Det verkar som om konfigurationsparametrar skiljer sig från data genom att de i allmänhet ändras mer sällan än data. Dessutom kommer data vanligtvis från en källa (från användaren) och konfigurationsparametrar från en annan (från systemadministratören).

Om sällan ändrade parametrar behöver uppdateras utan att starta om programmet, kan detta ofta leda till programmets komplikation, eftersom vi på något sätt kommer att behöva leverera parametrar, lagra, analysera och kontrollera och bearbeta felaktiga värden. Därför, med tanke på att minska programmets komplexitet, är det vettigt att minska antalet parametrar som kan ändras under programdrift (eller inte stödja sådana parametrar alls).

I detta inlägg kommer vi att skilja mellan statiska och dynamiska parametrar. Om tjänstens logik kräver att parametrar ändras under programmets drift, kommer vi att kalla sådana parametrar dynamiska. Annars är alternativen statiska och kan konfigureras med den kompilerade konfigurationen. För dynamisk omkonfigurering kan vi behöva en mekanism för att starta om delar av programmet med nya parametrar, liknande hur operativsystemsprocesser startas om. (Enligt vår åsikt är det tillrådligt att undvika omkonfigurering i realtid, eftersom detta ökar systemets komplexitet. Om möjligt är det bättre att använda standardoperativsystemets funktioner för att starta om processer.)

En viktig aspekt av att använda statisk konfiguration som får människor att överväga dynamisk omkonfiguration är den tid det tar för systemet att starta om efter en konfigurationsuppdatering (stopptid). Faktum är att om vi behöver göra ändringar i den statiska konfigurationen måste vi starta om systemet för att de nya värdena ska träda i kraft. Problemet med stillestånd varierar i svårighetsgrad för olika system. I vissa fall kan du schemalägga en omstart vid en tidpunkt då belastningen är minimal. Om du behöver ge kontinuerlig service kan du implementera AWS ELB anslutning dränering. Samtidigt, när vi behöver starta om systemet, startar vi en parallell instans av detta system, byter balanseraren till den och väntar på att de gamla anslutningarna ska slutföras. Efter att alla gamla anslutningar har avslutats stänger vi av den gamla instansen av systemet.

Låt oss nu överväga frågan om att lagra konfigurationen inuti eller utanför artefakten. Om vi ​​lagrar konfigurationen inuti en artefakt, så hade vi åtminstone möjlighet att verifiera konfigurationens korrekthet under monteringen av artefakten. Om konfigurationen ligger utanför den kontrollerade artefakten är det svårt att spåra vem som gjort ändringar i den här filen och varför. Hur viktigt är det? Enligt vår mening är det för många produktionssystem viktigt att ha en stabil och högkvalitativ konfiguration.

Versionen av en artefakt låter dig bestämma när den skapades, vilka värden den innehåller, vilka funktioner som är aktiverade/inaktiverade och vem som är ansvarig för eventuella ändringar i konfigurationen. Naturligtvis kräver lagring av konfigurationen inuti en artefakt en del ansträngning, så du måste fatta ett välgrundat beslut.

Fördelar och nackdelar

Jag skulle vilja uppehålla mig vid för- och nackdelarna med den föreslagna tekniken.

Fördelar

Nedan är en lista över huvudfunktionerna i en kompilerad distribuerad systemkonfiguration:

  1. Statisk konfigurationskontroll. Låter dig vara säker på det
    konfigurationen är korrekt.
  2. Riktigt konfigurationsspråk. Vanligtvis är andra konfigurationsmetoder begränsade till strängvariabelsubstitution som mest. När du använder Scala finns ett brett utbud av språkfunktioner tillgängliga för att förbättra din konfiguration. Till exempel kan vi använda
    egenskaper för standardvärden, med hjälp av objekt för att gruppera parametrar, kan vi hänvisa till värden som endast deklareras en gång (DRY) i det omslutande omfånget. Du kan instansiera alla klasser direkt i konfigurationen (Seq, Map, anpassade klasser).
  3. DSL. Scala har ett antal språkfunktioner som gör det lättare att skapa en DSL. Det är möjligt att dra nytta av dessa funktioner och implementera ett konfigurationsspråk som är mer bekvämt för målgruppen av användare, så att konfigurationen åtminstone är läsbar av domänexperter. Specialister kan till exempel delta i konfigurationsgranskningen.
  4. Integritet och synkronisering mellan noder. En av fördelarna med att ha konfigurationen av ett helt distribuerat system lagrat på en enda punkt är att alla värden deklareras exakt en gång och sedan återanvänds varhelst de behövs. Att använda fantomtyper för att deklarera portar säkerställer att noder använder kompatibla protokoll i alla korrekta systemkonfigurationer. Att ha explicita obligatoriska beroenden mellan noder säkerställer att alla tjänster är anslutna.
  5. Förändringar av hög kvalitet. Genom att göra ändringar i konfigurationen med en gemensam utvecklingsprocess är det möjligt att uppnå höga kvalitetsstandarder även för konfigurationen.
  6. Samtidig konfigurationsuppdatering. Automatisk systemdistribution efter konfigurationsändringar säkerställer att alla noder uppdateras.
  7. Förenkla applikationen. Applikationen behöver inte analysera, kontrollera konfigurationen eller hantera felaktiga värden. Detta minskar applikationens komplexitet. (En del av konfigurationskomplexiteten som observeras i vårt exempel är inte ett attribut för den kompilerade konfigurationen, utan bara ett medvetet beslut som drivs av önskan att ge större typsäkerhet.) Det är ganska lätt att återgå till den vanliga konfigurationen - implementera bara det som saknas. delar. Därför kan du till exempel börja med en kompilerad konfiguration, skjuta upp implementeringen av onödiga delar till den tidpunkt då det verkligen behövs.
  8. Verifierad konfiguration. Eftersom konfigurationsändringar följer det vanliga ödet för alla andra ändringar, är utdata vi får en artefakt med en unik version. Detta gör att vi till exempel kan återgå till en tidigare version av konfigurationen vid behov. Vi kan till och med använda konfigurationen från ett år sedan och systemet kommer att fungera exakt likadant. En stabil konfiguration förbättrar förutsägbarheten och tillförlitligheten hos ett distribuerat system. Eftersom konfigurationen är fixerad vid kompileringsstadiet är det ganska svårt att fejka den i produktionen.
  9. Modularitet. Det föreslagna ramverket är modulärt och modulerna kan kombineras på olika sätt för att skapa olika system. I synnerhet kan du konfigurera systemet att köras på en enda nod i en utföringsform och på flera noder i en annan. Du kan skapa flera konfigurationer för produktionsinstanser av systemet.
  10. Testning. Genom att ersätta enskilda tjänster med skenobjekt kan du få flera versioner av systemet som är bekväma att testa.
  11. Integrationstestning. Att ha en enda konfiguration för hela det distribuerade systemet gör det möjligt att köra alla komponenter i en kontrollerad miljö som en del av integrationstestning. Det är lätt att efterlikna till exempel en situation där vissa noder blir tillgängliga.

Nackdelar och begränsningar

Kompilerad konfiguration skiljer sig från andra konfigurationsmetoder och kanske inte är lämplig för vissa applikationer. Nedan följer några nackdelar:

  1. Statisk konfiguration. Ibland måste du snabbt korrigera konfigurationen i produktionen och kringgå alla skyddsmekanismer. Med detta tillvägagångssätt kan det bli svårare. Åtminstone kommer kompilering och automatisk distribution fortfarande att krävas. Detta är både ett användbart inslag i tillvägagångssättet och en nackdel i vissa fall.
  2. Generering av konfiguration. Om konfigurationsfilen genereras av ett automatiskt verktyg, kan ytterligare ansträngningar krävas för att integrera byggskriptet.
  3. Verktyg. För närvarande är verktyg och tekniker utformade för att fungera med konfiguration baserade på textfiler. Inte alla sådana verktyg/tekniker kommer att vara tillgängliga i en kompilerad konfiguration.
  4. Det krävs en attitydförändring. Utvecklare och DevOps är vana vid textfiler. Själva idén med att kompilera en konfiguration kan vara något oväntad och ovanlig och orsaka avslag.
  5. En utvecklingsprocess av hög kvalitet krävs. För att bekvämt kunna använda den kompilerade konfigurationen krävs full automatisering av processen för att bygga och distribuera applikationen (CI/CD). Annars blir det ganska obekvämt.

Låt oss också uppehålla oss vid ett antal begränsningar av det övervägda exemplet som inte är relaterade till idén om en kompilerad konfiguration:

  1. Om vi ​​tillhandahåller onödig konfigurationsinformation som inte används av noden, kommer kompilatorn inte att hjälpa oss att upptäcka den saknade implementeringen. Detta problem kan lösas genom att överge tårtmönstret och använda styvare typer, till exempel, HList eller algebraiska datatyper (caseklasser) för att representera konfiguration.
  2. Det finns rader i konfigurationsfilen som inte är relaterade till själva konfigurationen: (package, import,objektdeklarationer; override defför parametrar som har standardvärden). Detta kan delvis undvikas om du implementerar din egen DSL. Dessutom lägger andra typer av konfigurationer (till exempel XML) vissa begränsningar på filstrukturen.
  3. För detta inläggs syften överväger vi inte dynamisk omkonfiguration av ett kluster av liknande noder.

Slutsats

I det här inlägget utforskade vi idén om att representera konfiguration i källkod med hjälp av de avancerade funktionerna i systemet av typen Scala. Detta tillvägagångssätt kan användas i olika applikationer som en ersättning för traditionella konfigurationsmetoder baserade på xml- eller textfiler. Även om vårt exempel är implementerat i Scala, kan samma idéer överföras till andra kompilerade språk (som Kotlin, C#, Swift, ...). Du kan prova detta tillvägagångssätt i något av följande projekt, och om det inte fungerar, gå vidare till textfilen och lägg till de delar som saknas.

Naturligtvis kräver en kompilerad konfiguration en högkvalitativ utvecklingsprocess. I gengäld säkerställs hög kvalitet och tillförlitlighet av konfigurationer.

Det övervägda tillvägagångssättet kan utökas:

  1. Du kan använda makron för att utföra kontroller av kompileringstid.
  2. Du kan implementera en DSL för att presentera konfigurationen på ett sätt som är tillgängligt för slutanvändare.
  3. Du kan implementera dynamisk resurshantering med automatisk konfigurationsjustering. Att till exempel ändra antalet noder i ett kluster kräver att (1) varje nod får en något annorlunda konfiguration; (2) klusterchefen fick information om nya noder.

Kvitteringar

Jag skulle vilja tacka Andrei Saksonov, Pavel Popov och Anton Nekhaev för deras konstruktiva kritik av utkastet till artikel.

Källa: will.com

Lägg en kommentar