Kompilerbar konfiguration av ett distribuerat system

I det här inlägget vill vi dela med oss ​​av ett intressant sätt att hantera konfigurationen av ett distribuerat system.
Konfigurationen representeras direkt i Scala-språket på ett typsäkert sätt. Ett exempel på implementering beskrivs i detalj. Olika aspekter av förslaget diskuteras, bland annat påverkan på den övergripande utvecklingsprocessen.

Kompilerbar konfiguration av ett distribuerat system

(på ryska)

Beskrivning

Att bygga robusta distribuerade system kräver användning av korrekt och sammanhängande konfiguration på alla noder. En typisk lösning är att använda en textuell distributionsbeskrivning (terraform, ansible eller något liknande) och automatiskt genererade konfigurationsfiler (ofta — dedikerade för varje nod/roll). Vi skulle också vilja använda samma protokoll av samma versioner på varje kommunicerande nod (annars skulle vi uppleva inkompatibilitetsproblem). I JVM-världen betyder detta att åtminstone meddelandebiblioteket bör vara av samma version på alla kommunicerande noder.

Hur är det med att testa systemet? Självklart bör vi ha enhetstester för alla komponenter innan vi kommer till integrationstester. För att kunna extrapolera testresultat på runtime bör vi se till att versionerna av alla bibliotek hålls identiska i både runtime och testmiljöer.

När man kör integrationstester är det ofta mycket lättare att ha samma klassväg på alla noder. Vi behöver bara se till att samma klassväg används vid distribution. (Det är möjligt att använda olika klassvägar på olika noder, men det är svårare att representera den här konfigurationen och distribuera den korrekt.) Så för att göra saker enkelt kommer vi bara att överväga identiska klassvägar på alla noder.

Konfiguration tenderar att utvecklas tillsammans med programvaran. Vi använder vanligtvis versioner för att identifiera olika
stadier av mjukvaruutvecklingen. Det verkar rimligt att täcka in konfiguration under versionshantering och identifiera olika konfigurationer med vissa etiketter. Om det bara finns en konfiguration i produktionen kan vi använda en version som identifierare. Ibland kan vi ha flera produktionsmiljöer. Och för varje miljö kan vi behöva en separat gren av konfigurationen. Så konfigurationer kan märkas med filial och version för att unikt identifiera olika konfigurationer. Varje grenetikett och version motsvarar en enda kombination av distribuerade noder, portar, externa resurser, klassvägsbiblioteksversioner på varje nod. Här kommer vi bara att täcka den enda grenen och identifiera konfigurationer med en trekomponents decimalversion (1.2.3), på samma sätt som andra artefakter.

I moderna miljöer ändras inte konfigurationsfiler manuellt längre. Vanligtvis genererar vi
konfigurationsfiler vid driftsättning och rör dem aldrig efteråt. Så man kan fråga sig varför vi fortfarande använder textformat för konfigurationsfiler? Ett genomförbart alternativ är att placera konfigurationen i en kompileringsenhet och dra nytta av konfigurationsvalidering vid kompilering.

I det här inlägget kommer vi att undersöka idén om att behålla konfigurationen i den kompilerade artefakten.

Kompilerbar konfiguration

I det här avsnittet kommer vi att diskutera ett exempel på statisk konfiguration. Två enkla tjänster - ekotjänst och ekotjänstens klient håller på att konfigureras och implementeras. Sedan instansieras två olika distribuerade system med båda tjänsterna. En är för en enda nodkonfiguration och en annan för två nodkonfigurationer.

Ett typiskt distribuerat system består av ett fåtal noder. Noderna kan identifieras med någon typ:

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

eller bara

case class NodeId(hostName: String)

eller till och med

object Singleton
type NodeId = Singleton.type

Dessa noder utför olika roller, kör vissa tjänster och ska kunna kommunicera med de andra noderna med hjälp av TCP/HTTP-anslutningar.

För TCP-anslutning krävs minst ett portnummer. Vi vill också se till att klient och server talar samma protokoll. För att modellera en koppling mellan noder låt oss deklarera följande klass:

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

var Port är bara en Int inom det tillåtna intervallet:

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

Förfinade typer

Se raffinerade bibliotek. Kort sagt tillåter det att lägga till kompileringstidsbegränsningar till andra typer. I detta fall Int får endast ha 16-bitars värden som kan representera portnummer. Det finns inget krav på att använda det här biblioteket för denna konfigurationsmetod. Det verkar bara passa väldigt bra.

För HTTP (REST) ​​kan vi också behöva en sökväg till tjänsten:

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

Fantom typ

För att identifiera protokoll under kompilering använder vi Scala-funktionen för att deklarera typargument Protocol som inte används i klassen. Det är en så kallad fantom typ. Under körning behöver vi sällan en instans av protokollidentifierare, det är därför vi inte lagrar den. Under kompileringen ger denna fantomtyp ytterligare typsäkerhet. Vi kan inte skicka port med felaktigt protokoll.

Ett av de mest använda protokollen är REST API med Json-serialisering:

sealed trait JsonHttpRestProtocol[RequestMessage, ResponseMessage]

var RequestMessage är bastypen av meddelanden som klienten kan skicka till server och ResponseMessage är svarsmeddelandet från servern. Naturligtvis kan vi skapa andra protokollbeskrivningar som anger kommunikationsprotokollet med önskad precision.

För detta inlägg använder vi en enklare version av protokollet:

sealed trait SimpleHttpGetRest[RequestMessage, ResponseMessage]

I detta protokoll läggs förfrågningsmeddelande till url och svarsmeddelande returneras som vanlig sträng.

En tjänstkonfiguration kan beskrivas av tjänstens namn, en samling portar och vissa beroenden. Det finns några möjliga sätt att representera alla dessa element i Scala (till exempel, HList, algebraiska datatyper). I detta inlägg kommer vi att använda Cake Pattern och representera kombinerbara delar (moduler) som egenskaper. (Cake Pattern är inte ett krav för denna kompilerbara konfigurationsmetod. Det är bara en möjlig implementering av idén.)

Beroenden kan representeras med hjälp av Cake Pattern som slutpunkter för 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)
  }

Echo-tjänsten behöver bara en port konfigurerad. Och vi förklarar att den här porten stöder ekoprotokoll. Observera att vi inte behöver specificera en viss port just nu, eftersom egenskaper tillåter abstrakta metoddeklarationer. Om vi ​​använder abstrakta metoder kommer kompilatorn att kräva en implementering i en konfigurationsinstans. Här har vi tillhandahållit implementeringen (8081) och det kommer att användas som standardvärde om vi hoppar över det i en konkret konfiguration.

Vi kan deklarera ett beroende i konfigurationen av ekotjänstklienten:

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

Beroende har samma typ som echoService. I synnerhet kräver det samma protokoll. Därför kan vi vara säkra på att om vi kopplar ihop dessa två beroenden kommer de att fungera korrekt.

Implementering av tjänster

En tjänst behöver en funktion för att starta och graciöst stänga av. (Förmågan att stänga av en tjänst är avgörande för testning.) Återigen finns det några alternativ för att specificera en sådan funktion för en given konfiguration (till exempel kan vi använda typklasser). För det här inlägget kommer vi att använda Cake Pattern igen. Vi kan representera en tjänst med hjälp av cats.Resource som redan tillhandahåller bracketing och resurssläpp. För att skaffa en resurs bör vi tillhandahålla en konfiguration och något körtidskontext. Så tjänstens startfunktion 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]
  }

var

  • Config — typ av konfiguration som krävs av denna servicestartare
  • AddressResolver — ett körtidsobjekt som har förmågan att erhålla riktiga adresser för andra noder (fortsätt läsa för detaljer).

de andra typerna kommer från cats:

  • F[_] — effekttyp (I det enklaste fallet F[A] kan vara bara () => A. I det här inlägget kommer vi att använda cats.IO.)
  • Reader[A,B] — är mer eller mindre en synonym för en funktion A => B
  • cats.Resource — har sätt att förvärva och frigöra
  • Timer — gör det möjligt att sova/mäta tid
  • ContextShift - analog av ExecutionContext
  • Applicative — omslag av funktioner som är i kraft (nästan en monad) (vi kan så småningom ersätta den med något annat)

Med det här gränssnittet kan vi implementera några 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](()))
  }

(Se Källkod för implementeringar av andra tjänster — ekotjänst,
echo klient och livstidskontroller.)

En nod är ett enda objekt som kör några tjänster (att starta en kedja av resurser aktiveras 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 i noden anger vi den exakta typen av konfiguration som behövs för denna nod. Kompilatorn låter oss inte bygga objektet (Cake) med otillräcklig typ, eftersom varje tjänstegenskap deklarerar en begränsning på Config typ. Vi kommer inte heller att kunna starta noden utan att tillhandahålla fullständig konfiguration.

Nodadressupplösning

För att upprätta en anslutning behöver vi en riktig värdadress för varje nod. Det kan vara känt senare än andra delar av konfigurationen. Därför behöver vi ett sätt att tillhandahålla en mappning mellan nod-id och dess faktiska adress. Denna mappning är en funktion:

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

Det finns några möjliga sätt att implementera en sådan funktion.

  1. Om vi ​​känner till faktiska adresser före utplacering, under instansiering av nodvärdar, kan vi generera Scala-kod med de faktiska adresserna och köra bygget efteråt (som utför kompileringstidskontroller och sedan kör integrationstestsviten). I det här fallet är vår mappningsfunktion känd statiskt och kan förenklas till något som a Map[NodeId, NodeAddress].
  2. Ibland får vi faktiska adresser först vid en senare tidpunkt när noden faktiskt startas, eller så har vi inga adresser till noder som inte har startats ännu. I det här fallet kan vi ha en upptäcktstjänst som startas före alla andra noder och varje nod kan annonsera sin adress i den tjänsten och prenumerera på beroenden.
  3. Om vi ​​kan ändra /etc/hosts, kan vi använda fördefinierade värdnamn (som my-project-main-node och echo-backend) och associera bara detta namn med ip-adressen vid implementeringstidpunkten.

I det här inlägget täcker vi inte dessa fall mer detaljerat. Faktum är att i vårt leksaksexempel kommer alla noder att ha samma IP-adress — 127.0.0.1.

I det här inlägget kommer vi att överväga två distribuerade systemlayouter:

  1. Enstaka nodlayout, där alla tjänster placeras på den enskilda noden.
  2. Två noder layout, där tjänst och klient finns på olika noder.

Konfigurationen för en enda nod layouten är som följer:

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

Här skapar vi en enda konfiguration som utökar både server- och klientkonfigurationen. Vi konfigurerar också en livscykelkontroller som normalt avslutar klient och server efter lifetime intervallpass.

Samma uppsättning serviceimplementationer och konfigurationer kan användas för att skapa ett systems layout med två separata noder. Vi behöver bara skapa två separata nodkonfigurationer med lämpliga tjänster:

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

Se hur vi anger beroendet. Vi nämner den andra nodens tillhandahållna tjänst som ett beroende av den aktuella noden. Typen av beroende kontrolleras eftersom den innehåller fantomtyp som beskriver protokoll. Och vid körning kommer vi att ha rätt nod-id. Detta är en av de viktiga aspekterna av den föreslagna konfigurationsmetoden. Det ger oss möjligheten att endast ställa in port en gång och se till att vi refererar till rätt port.

Implementering av två noder

För denna konfiguration använder vi exakt samma tjänsteimplementationer. Inga förändringar alls. Men vi skapar två olika nodimplementeringar som innehåller olika uppsättning 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 server och den behöver bara konfiguration på serversidan. Den andra noden implementerar klienten och behöver en annan del av konfigurationen. Båda noderna kräver viss livstidsspecifikation. För ändamålen med denna posttjänst kommer noden att ha oändlig livslängd som kan avslutas med SIGTERM, medan echo-klienten kommer att avslutas efter den konfigurerade ändliga varaktigheten. Se den startapplikation för mer information.

Övergripande utvecklingsprocess

Låt oss se hur detta tillvägagångssätt förändrar vårt sätt att arbeta med konfiguration.

Konfigurationen som kod kommer att kompileras och producerar en artefakt. Det verkar rimligt att separera konfigurationsartefakter från andra kodartefakter. Ofta kan vi ha en mängd konfigurationer på samma kodbas. Och naturligtvis kan vi ha flera versioner av olika konfigurationsgrenar. I en konfiguration kan vi välja särskilda versioner av bibliotek och detta kommer att förbli konstant när vi distribuerar denna konfiguration.

En konfigurationsändring blir kodändring. Så det bör omfattas av samma kvalitetssäkringsprocess:

Ticket -> PR -> granskning -> sammanfoga -> kontinuerlig integration -> kontinuerlig driftsättning

Det finns följande konsekvenser av tillvägagångssättet:

  1. Konfigurationen är koherent för ett visst systems instans. Det verkar som att det inte finns något sätt att ha felaktig koppling mellan noder.
  2. Det är inte lätt att ändra konfiguration bara i en nod. Det verkar orimligt att logga in och ändra vissa textfiler. Så konfigurationsdrift blir mindre möjlig.
  3. Små konfigurationsändringar är inte lätta att göra.
  4. De flesta av konfigurationsändringarna kommer att följa samma utvecklingsprocess, och det kommer att passera en del granskning.

Behöver vi ett separat förråd för produktionskonfiguration? Produktionskonfigurationen kan innehålla känslig information som vi skulle vilja hålla utom räckhåll för många människor. Så det kan vara värt att ha ett separat arkiv med begränsad åtkomst som kommer att innehålla produktionskonfigurationen. Vi kan dela upp konfigurationen i två delar - en som innehåller de mest öppna produktionsparametrarna och en som innehåller den hemliga delen av konfigurationen. Detta skulle ge de flesta utvecklarna tillgång till de allra flesta parametrar samtidigt som tillgången till riktigt känsliga saker begränsas. Det är lätt att åstadkomma detta med hjälp av mellanliggande egenskaper med standardparametervärden.

Variationer

Låt oss se för- och nackdelar med det föreslagna tillvägagångssättet jämfört med de andra teknikerna för konfigurationshantering.

Först och främst kommer vi att lista några alternativ till de olika aspekterna av det föreslagna sättet att hantera konfigurationen:

  1. Textfil på målmaskinen.
  2. Centraliserad nyckel-värdelagring (som etcd/zookeeper).
  3. Delprocesskomponenter som skulle kunna konfigureras om/startas om utan att starta om processen.
  4. Konfiguration utanför artefakt och versionskontroll.

Textfil ger viss flexibilitet när det gäller ad-hoc-fixar. En systemadministratör kan logga in på målnoden, göra en ändring och helt enkelt starta om tjänsten. Detta kanske inte är lika bra för större system. Inga spår lämnas efter förändringen. Förändringen granskas inte av ett annat par ögon. Det kan vara svårt att ta reda på vad som har orsakat förändringen. Det har inte testats. Ur distribuerade systemperspektiv kan en administratör helt enkelt glömma att uppdatera konfigurationen i en av de andra noderna.

(Btw, om det så småningom kommer att finnas ett behov av att börja använda textkonfigurationsfiler, behöver vi bara lägga till parser + validator som kan producera samma Config typ och det skulle vara tillräckligt för att börja använda textkonfigurationer. Detta visar också att komplexiteten för kompileringstidskonfiguration är lite mindre än komplexiteten för textbaserade konfigurationer, eftersom vi i textbaserad version behöver lite extra kod.)

Centraliserad nyckel-värdelagring är en bra mekanism för att distribuera applikationsmetaparametrar. Här måste vi tänka på vad vi anser vara konfigurationsvärden och vad som bara är data. Givet en funktion C => A => B vi brukar kalla sällan förändrade värden C "konfiguration", medan data ofta ändras A - bara mata in data. Konfiguration bör tillhandahållas till funktionen tidigare än data A. Med tanke på denna idé kan vi säga att det är förväntad frekvens av ändringar som kan användas för att skilja konfigurationsdata från bara data. Även data kommer vanligtvis från en källa (användare) och konfigurationen kommer från en annan källa (admin). Att hantera parametrar som kan ändras efter initieringsprocessen leder till en ökning av applikationskomplexiteten. För sådana parametrar måste vi hantera deras leveransmekanism, parsning och validering och hantera felaktiga värden. Därför, för att minska programmets komplexitet, är det bättre att minska antalet parametrar som kan ändras under körning (eller till och med eliminera dem helt).

Ur perspektivet av detta inlägg bör vi göra en skillnad mellan statiska och dynamiska parametrar. Om tjänstelogik kräver sällsynt ändring av vissa parametrar under körning, kan vi kalla dem dynamiska parametrar. Annars är de statiska och skulle kunna konfigureras med den föreslagna metoden. För dynamisk omkonfiguration kan andra tillvägagångssätt behövas. Till exempel kan delar av systemet startas om med de nya konfigurationsparametrarna på ett liknande sätt som att starta om separata processer i ett distribuerat system.
(Min ödmjuka åsikt är att undvika omkonfigurering av runtime eftersom det ökar systemets komplexitet.
Det kan vara enklare att bara lita på OS-stöd för att starta om processer. Men det kanske inte alltid är möjligt.)

En viktig aspekt av att använda statisk konfiguration som ibland får människor att överväga dynamisk konfiguration (utan andra skäl) är tjänsteavbrott under konfigurationsuppdatering. Faktum är att om vi måste göra ändringar i statisk konfiguration måste vi starta om systemet så att nya värden blir effektiva. Kraven på stillestånd varierar för olika system, så det kanske inte är så kritiskt. Om det är kritiskt måste vi planera i förväg för eventuella omstarter av systemet. Vi skulle till exempel kunna implementera AWS ELB anslutning dränering. I det här scenariot när vi behöver starta om systemet, startar vi en ny instans av systemet parallellt, växlar sedan ELB till det, samtidigt som vi låter det gamla systemet utföra service på befintliga anslutningar.

Vad sägs om att behålla konfigurationen inuti versionerad artefakt eller utanför? Att behålla konfigurationen inuti en artefakt innebär i de flesta fall att denna konfiguration har klarat samma kvalitetssäkringsprocess som andra artefakter. Så man kan vara säker på att konfigurationen är av god kvalitet och pålitlig. Tvärtom innebär konfigurationen i en separat fil att det inte finns några spår av vem och varför som gjort ändringar i den filen. Är detta viktigt? Vi tror att det för de flesta produktionssystem är bättre att ha en stabil och högkvalitativ konfiguration.

Version av artefakten gör det möjligt att ta reda på när den skapades, vilka värden den innehåller, vilka funktioner som är aktiverade/inaktiverade, vem som var ansvarig för att göra varje ändring i konfigurationen. Det kan kräva lite ansträngning att hålla konfigurationen inuti en artefakt och det är ett designval att göra.

Fördelar nackdelar

Här vill vi lyfta fram några fördelar och diskutera några nackdelar med det föreslagna tillvägagångssättet.

Fördelar

Funktioner för den kompilerbara konfigurationen av ett komplett distribuerat system:

  1. Statisk kontroll av konfigurationen. Detta ger en hög nivå av förtroende, att konfigurationen är korrekt givet typbegränsningar.
  2. Riktigt konfigurationsspråk. Typiskt är andra konfigurationsmetoder begränsade till som mest variabel substitution.
    Genom att använda Scala kan man använda ett brett utbud av språkfunktioner för att göra konfigurationen bättre. Till exempel kan vi använda egenskaper för att tillhandahålla standardvärden, objekt för att ställa in olika omfattning, vi kan hänvisa till vals definieras endast en gång i det yttre omfånget (DRY). Det är möjligt att använda bokstavliga sekvenser, eller instanser av vissa klasser (Seq, Map, Etc.).
  3. DSL. Scala har bra stöd för DSL-skrivare. Man kan använda dessa funktioner för att etablera ett konfigurationsspråk som är mer bekvämt och slutanvändarvänligt, så att den slutliga konfigurationen åtminstone är läsbar för domänanvändare.
  4. Integritet och koherens över noder. En av fördelarna med att ha konfiguration för hela det distribuerade systemet på ett ställe är att alla värden definieras strikt en gång och sedan återanvänds på alla platser där vi behöver dem. Skriv även säker port-deklarationer för att i alla möjliga korrekta konfigurationer kommer systemets noder att tala samma språk. Det finns explicita beroenden mellan noder vilket gör det svårt att glömma att tillhandahålla vissa tjänster.
  5. Hög kvalitet på förändringar. Det övergripande tillvägagångssättet att skicka konfigurationsändringar genom normal PR-process fastställer höga kvalitetsstandarder även i konfiguration.
  6. Samtidiga konfigurationsändringar. Varje gång vi gör några ändringar i konfigurationen säkerställer automatisk distribution att alla noder uppdateras.
  7. Applikationsförenkling. Applikationen behöver inte analysera och validera konfiguration och hantera felaktiga konfigurationsvärden. Detta förenklar den övergripande tillämpningen. (En viss komplexitetsökning ligger i själva konfigurationen, men det är en medveten avvägning mot säkerhet.) Det är ganska enkelt att återgå till den vanliga konfigurationen – lägg bara till de delar som saknas. Det är lättare att komma igång med kompilerad konfiguration och skjuta upp implementeringen av ytterligare delar till några senare tider.
  8. Versionerad konfiguration. På grund av att konfigurationsändringar följer samma utvecklingsprocess får vi som ett resultat en artefakt med unik version. Det tillåter oss att byta tillbaka konfigurationen om det behövs. Vi kan till och med distribuera en konfiguration som användes för ett år sedan och den kommer att fungera på exakt samma sätt. Stabil konfiguration förbättrar förutsägbarheten och tillförlitligheten hos det distribuerade systemet. Konfigurationen är fixerad vid kompilering och kan inte lätt manipuleras på ett produktionssystem.
  9. Modularitet. Det föreslagna ramverket är modulärt och moduler kan kombineras på olika sätt för att
    stöder olika konfigurationer (inställningar/layouter). I synnerhet är det möjligt att ha en liten skallig enkel nodlayout och en storskalig multinodsinställning. Det är rimligt att ha flera produktionslayouter.
  10. Testning. För teständamål kan man implementera en låtsastjänst och använda den som ett beroende på ett typsäkert sätt. Några olika testlayouter med olika delar ersatta av hånar kan bibehållas samtidigt.
  11. Integrationstestning. Ibland i distribuerade system är det svårt att köra integrationstester. Genom att använda det beskrivna tillvägagångssättet för typsäker konfiguration av det kompletta distribuerade systemet, kan vi köra alla distribuerade delar på en enda server på ett kontrollerbart sätt. Det är lätt att efterlikna situationen
    när någon av tjänsterna blir otillgänglig.

Nackdelar

Den kompilerade konfigurationsmetoden skiljer sig från "normal" konfiguration och den kanske inte passar alla behov. Här är några av nackdelarna med den kompilerade konfigurationen:

  1. Statisk konfiguration. Det kanske inte är lämpligt för alla applikationer. I vissa fall finns det ett behov av att snabbt fixa konfigurationen i produktionen utan att alla säkerhetsåtgärder går förbi. Detta tillvägagångssätt gör det svårare. Kompileringen och omdistribueringen krävs efter att du har gjort ändringar i konfigurationen. Detta är både funktionen och bördan.
  2. Generering av konfigurationer. När config genereras av något automatiseringsverktyg kräver detta tillvägagångssätt efterföljande kompilering (som i sin tur kan misslyckas). Det kan kräva ytterligare ansträngningar att integrera detta ytterligare steg i byggsystemet.
  3. Instrument. Det finns många verktyg som används idag som är beroende av textbaserade konfigurationer. Några av dem
    kommer inte att vara tillämpligt när konfigurationen kompileras.
  4. En förändring i tankesättet behövs. Utvecklare och DevOps är bekanta med textkonfigurationsfiler. Tanken på att kompilera konfiguration kan verka konstigt för dem.
  5. Innan kompilerbar konfiguration introduceras krävs en högkvalitativ mjukvaruutvecklingsprocess.

Det finns några begränsningar för det implementerade exemplet:

  1. Om vi ​​tillhandahåller extra konfiguration som inte krävs av nodimplementeringen, hjälper kompilatorn oss inte att upptäcka den frånvarande implementeringen. Detta kan åtgärdas genom att använda HList eller ADTs (case-klasser) för nodkonfiguration istället för egenskaper och Cake Pattern.
  2. Vi måste tillhandahålla lite pannplatta i konfigurationsfilen: (package, import, object deklarationer;
    override defför parametrar som har standardvärden). Detta kan delvis åtgärdas med en DSL.
  3. I det här inlägget täcker vi inte dynamisk omkonfiguration av kluster av liknande noder.

Slutsats

I det här inlägget har vi diskuterat idén om att representera konfiguration direkt i källkoden på ett typsäkert sätt. Tillvägagångssättet skulle kunna användas i många applikationer som en ersättning till xml- och andra textbaserade konfigurationer. Trots att vårt exempel har implementerats i Scala kan det också översättas till andra kompilerbara språk (som Kotlin, C#, Swift, etc.). Man skulle kunna pröva detta tillvägagångssätt i ett nytt projekt och, om det inte passar bra, byta till det gammalmodiga sättet.

Naturligtvis kräver kompilerbar konfiguration högkvalitativ utvecklingsprocess. I gengäld lovar den att ge en robust konfiguration av lika hög kvalitet.

Detta tillvägagångssätt kan utvidgas på olika sätt:

  1. Man kan använda makron för att utföra konfigurationsvalidering och misslyckas vid kompilering i händelse av misslyckanden med affärslogiska begränsningar.
  2. En DSL skulle kunna implementeras för att representera konfigurationen på ett domänanvändarvänligt sätt.
  3. Dynamisk resurshantering med automatiska konfigurationsjusteringar. Till exempel, när vi justerar antalet klusternoder kanske vi vill (1) att noderna ska få något modifierad konfiguration; (2) klusterhanterare för att ta emot ny nodinformation.

Tack

Jag skulle vilja säga tack till Andrey Saksonov, Pavel Popov, Anton Nehaev för att du gav inspirerande feedback på utkastet till detta inlägg som hjälpte mig att göra det tydligare.

Källa: will.com