Compileerbare configuratie van een gedistribueerd systeem

In dit bericht willen we een interessante manier delen om met de configuratie van een gedistribueerd systeem om te gaan.
De configuratie wordt op een typeveilige manier rechtstreeks in Scala-taal weergegeven. Een voorbeeldimplementatie wordt in details beschreven. Verschillende aspecten van het voorstel worden besproken, waaronder de invloed op het totale ontwikkelingsproces.

Compileerbare configuratie van een gedistribueerd systeem

(in het Russisch)

Introductie

Het bouwen van robuuste gedistribueerde systemen vereist het gebruik van correcte en coherente configuratie op alle knooppunten. Een typische oplossing is het gebruik van een tekstuele implementatiebeschrijving (terraform, ansible of iets dergelijks) en automatisch gegenereerde configuratiebestanden (vaak specifiek voor elk knooppunt/rol). We zouden ook dezelfde protocollen van dezelfde versies willen gebruiken op elke communicerende knooppunten (anders zouden we incompatibiliteitsproblemen ervaren). In de JVM-wereld betekent dit dat in ieder geval de berichtenbibliotheek dezelfde versie moet hebben op alle communicerende knooppunten.

Hoe zit het met het testen van het systeem? Natuurlijk moeten we unit-tests hebben voor alle componenten voordat we tot integratietests komen. Om testresultaten tijdens runtime te kunnen extrapoleren, moeten we ervoor zorgen dat de versies van alle bibliotheken identiek blijven in zowel runtime- als testomgevingen.

Bij het uitvoeren van integratietests is het vaak veel eenvoudiger om hetzelfde klassenpad op alle knooppunten te hebben. We moeten er alleen voor zorgen dat hetzelfde klassenpad wordt gebruikt bij de implementatie. (Het is mogelijk om verschillende klassenpaden op verschillende knooppunten te gebruiken, maar het is moeilijker om deze configuratie weer te geven en correct te implementeren.) Om de zaken eenvoudig te houden, zullen we alleen identieke klassenpaden op alle knooppunten overwegen.

Configuratie heeft de neiging mee te evolueren met de software. We gebruiken meestal versies om verschillende te identificeren
stadia van de software-evolutie. Het lijkt redelijk om de configuratie onder versiebeheer te behandelen en verschillende configuraties met enkele labels te identificeren. Als er slechts één configuratie in productie is, kunnen we één versie als identificatie gebruiken. Soms hebben we meerdere productieomgevingen. En voor elke omgeving hebben we mogelijk een aparte configuratietak nodig. Configuraties kunnen dus worden gelabeld met vertakking en versie om verschillende configuraties op unieke wijze te identificeren. Elk vertakkingslabel en elke versie komt overeen met een enkele combinatie van gedistribueerde knooppunten, poorten, externe bronnen en klassenpadbibliotheekversies op elk knooppunt. Hier behandelen we alleen de enkele vertakking en identificeren we configuraties aan de hand van een decimale versie met drie componenten (1.2.3), op dezelfde manier als andere artefacten.

In moderne omgevingen worden configuratiebestanden niet meer handmatig gewijzigd. Meestal genereren wij
config-bestanden tijdens de implementatie en raak ze nooit aan daarna. Je zou je dus kunnen afvragen waarom we nog steeds het tekstformaat gebruiken voor configuratiebestanden? Een haalbare optie is om de configuratie in een compilatie-eenheid te plaatsen en te profiteren van configuratievalidatie tijdens het compileren.

In dit bericht zullen we het idee onderzoeken om de configuratie in het gecompileerde artefact te behouden.

Compileerbare configuratie

In deze sectie bespreken we een voorbeeld van een statische configuratie. Twee eenvoudige services: echoservice en de client van de echoservice worden geconfigureerd en geïmplementeerd. Vervolgens worden twee verschillende gedistribueerde systemen met beide services geïnstantieerd. Eén is voor een configuratie met één knooppunt en een andere voor configuratie met twee knooppunten.

Een typisch gedistribueerd systeem bestaat uit een paar knooppunten. De knooppunten kunnen worden geïdentificeerd met behulp van een bepaald type:

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

of gewoon

case class NodeId(hostName: String)

of

object Singleton
type NodeId = Singleton.type

Deze knooppunten vervullen verschillende rollen, voeren een aantal services uit en moeten via TCP/HTTP-verbindingen met de andere knooppunten kunnen communiceren.

Voor een TCP-verbinding is minimaal een poortnummer vereist. We willen er ook zeker van zijn dat client en server hetzelfde protocol gebruiken. Om een ​​verbinding tussen knooppunten te modelleren, declareren we de volgende klasse:

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

WAAR Port is slechts een Int binnen het toegestane bereik:

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

Verfijnde soorten

Bekijk verfijnd bibliotheek. Kortom, het maakt het mogelijk om compileertijdbeperkingen aan andere typen toe te voegen. In dit geval Int mag alleen 16-bits waarden hebben die het poortnummer kunnen vertegenwoordigen. Er is geen vereiste om deze bibliotheek te gebruiken voor deze configuratiebenadering. Het lijkt gewoon heel goed te passen.

Voor HTTP (REST) ​​hebben we mogelijk ook een pad van de service nodig:

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

Fantoomtype

Om het protocol tijdens het compileren te identificeren, gebruiken we de Scala-functie voor het declareren van type-argumenten Protocol die niet in de klas wordt gebruikt. Het is een zogenaamde fantoom soort. Tijdens runtime hebben we zelden een exemplaar van de protocol-ID nodig, daarom slaan we deze niet op. Tijdens het compileren geeft dit fantoomtype extra typeveiligheid. We kunnen geen poort passeren met een onjuist protocol.

Een van de meest gebruikte protocollen is REST API met Json-serialisatie:

sealed trait JsonHttpRestProtocol[RequestMessage, ResponseMessage]

WAAR RequestMessage is het basistype berichten dat de client naar de server kan sturen ResponseMessage is het antwoordbericht van de server. Uiteraard kunnen we andere protocolbeschrijvingen maken die het communicatieprotocol met de gewenste precisie specificeren.

Voor de doeleinden van dit bericht gebruiken we een eenvoudigere versie van het protocol:

sealed trait SimpleHttpGetRest[RequestMessage, ResponseMessage]

In dit protocol wordt het verzoekbericht aan de URL toegevoegd en wordt het antwoordbericht als gewone tekenreeks geretourneerd.

Een serviceconfiguratie kan worden beschreven aan de hand van de servicenaam, een verzameling poorten en enkele afhankelijkheden. Er zijn een paar mogelijke manieren om al deze elementen in Scala weer te geven (bijvoorbeeld HList, algebraïsche gegevenstypen). Voor de doeleinden van dit bericht gebruiken we Cake Pattern en stellen we combineerbare stukken (modules) voor als eigenschappen. (Cake Pattern is geen vereiste voor deze compileerbare configuratiebenadering. Het is slechts één mogelijke implementatie van het idee.)

Afhankelijkheden kunnen worden weergegeven met behulp van het Cake-patroon als eindpunten van andere knooppunten:

  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 heeft alleen een geconfigureerde poort nodig. En we verklaren dat deze poort het echoprotocol ondersteunt. Merk op dat we op dit moment geen specifieke poort hoeven te specificeren, omdat trait's abstracte methodedeclaraties toestaat. Als we abstracte methoden gebruiken, vereist de compiler een implementatie in een configuratie-instantie. Hier hebben wij de implementatie verzorgd (8081) en zal worden gebruikt als de standaardwaarde als we deze overslaan in een concrete configuratie.

We kunnen een afhankelijkheid declareren in de configuratie van de echoserviceclient:

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

Afhankelijkheid heeft hetzelfde type als de echoService. In het bijzonder vereist het hetzelfde protocol. We kunnen er dus zeker van zijn dat als we deze twee afhankelijkheden met elkaar verbinden, ze correct zullen werken.

Implementatie van diensten

Een service heeft een functie nodig om te starten en netjes af te sluiten. (De mogelijkheid om een ​​service af te sluiten is van cruciaal belang voor het testen.) Ook hier zijn er een paar opties om een ​​dergelijke functie voor een bepaalde configuratie te specificeren (we kunnen bijvoorbeeld typeklassen gebruiken). Voor dit bericht gebruiken we opnieuw het Taartpatroon. We kunnen een dienst vertegenwoordigen met behulp van cats.Resource die al voorziet in bracketing en het vrijgeven van bronnen. Om een ​​bron te verkrijgen, moeten we een configuratie en een runtime-context bieden. De servicestartfunctie kan er dus als volgt uitzien:

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

WAAR

  • Config — type configuratie dat vereist is voor deze servicestarter
  • AddressResolver — een runtime-object dat de mogelijkheid heeft om echte adressen van andere knooppunten te verkrijgen (lees verder voor details).

de andere soorten komen vandaan cats:

  • F[_] — effecttype (in het eenvoudigste geval F[A] zou gewoon kunnen zijn () => A. In dit bericht zullen we gebruiken cats.IO.)
  • Reader[A,B] — is min of meer een synoniem voor een functie A => B
  • cats.Resource - heeft manieren om te verwerven en vrij te geven
  • Timer - maakt het mogelijk om te slapen/de tijd te meten
  • ContextShift - analoog van ExecutionContext
  • Applicative — verpakking van functies in feite (bijna een monade) (we kunnen het uiteindelijk vervangen door iets anders)

Met behulp van deze interface kunnen we een aantal services implementeren. Een dienst die niets doet:

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

(Zie Broncode voor andere dienstenimplementaties — echo dienst,
echo-klant en levenslange controllers.)

Een knooppunt is een enkel object dat een paar services uitvoert (het starten van een keten van bronnen wordt mogelijk gemaakt door Cake Pattern):

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

Merk op dat we in het knooppunt het exacte type configuratie specificeren dat nodig is voor dit knooppunt. Compiler staat ons niet toe het object (Cake) met onvoldoende type te bouwen, omdat elke service-eigenschap een beperking oplegt aan de Config type. We kunnen het knooppunt ook niet starten zonder de volledige configuratie aan te bieden.

Knooppuntadresresolutie

Om een ​​verbinding tot stand te brengen, hebben we voor elk knooppunt een echt hostadres nodig. Het kan later bekend zijn dan andere delen van de configuratie. Daarom hebben we een manier nodig om een ​​afbeelding te leveren tussen de knooppunt-ID en het daadwerkelijke adres ervan. Deze mapping is een functie:

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

Er zijn een paar mogelijke manieren om een ​​dergelijke functie te implementeren.

  1. Als we de daadwerkelijke adressen kennen vóór de implementatie, tijdens de instantiatie van knooppunthosts, kunnen we Scala-code genereren met de daadwerkelijke adressen en daarna de build uitvoeren (die compilatietijdcontroles uitvoert en vervolgens de integratietestsuite uitvoert). In dit geval is onze mappingfunctie statisch bekend en kan deze worden vereenvoudigd tot zoiets als a Map[NodeId, NodeAddress].
  2. Soms verkrijgen we de werkelijke adressen pas op een later tijdstip wanneer het knooppunt daadwerkelijk is gestart, of hebben we geen adressen van knooppunten die nog niet zijn gestart. In dit geval hebben we mogelijk een detectieservice die vóór alle andere knooppunten wordt gestart en elk knooppunt kan zijn adres in die service adverteren en zich abonneren op afhankelijkheden.
  3. Als we kunnen wijzigen /etc/hosts, kunnen we vooraf gedefinieerde hostnamen gebruiken (zoals my-project-main-node en echo-backend) en koppel deze naam gewoon aan het IP-adres tijdens de implementatie.

In dit bericht gaan we niet dieper in op deze gevallen. In ons speelgoedvoorbeeld hebben alle knooppunten hetzelfde IP-adres – 127.0.0.1.

In dit bericht zullen we twee gedistribueerde systeemlay-outs bekijken:

  1. Indeling met één knooppunt, waarbij alle services op één knooppunt worden geplaatst.
  2. Indeling met twee knooppunten, waarbij service en client zich op verschillende knooppunten bevinden.

De configuratie voor een enkele knoop indeling is als volgt:

Configuratie met één knooppunt

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

Hier creëren we een enkele configuratie die zowel de server- als de clientconfiguratie uitbreidt. Ook configureren we een levenscycluscontroller die normaal gesproken de client en server daarna beëindigt lifetime interval verstrijkt.

Dezelfde set service-implementaties en configuraties kan worden gebruikt om de lay-out van een systeem te creëren met twee afzonderlijke knooppunten. We hoeven alleen maar te creëren twee afzonderlijke knooppuntconfiguraties met de juiste diensten:

Configuratie met twee knooppunten

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

Bekijk hoe we de afhankelijkheid specificeren. We vermelden de door het andere knooppunt geleverde service als afhankelijkheid van het huidige knooppunt. Het type afhankelijkheid wordt gecontroleerd omdat het een fantoomtype bevat dat het protocol beschrijft. En tijdens runtime hebben we de juiste knooppunt-ID. Dit is een van de belangrijke aspecten van de voorgestelde configuratiebenadering. Het biedt ons de mogelijkheid om de poort slechts één keer in te stellen en ervoor te zorgen dat we naar de juiste poort verwijzen.

Implementatie van twee knooppunten

Voor deze configuratie gebruiken we exact dezelfde dienstenimplementaties. Er zijn helemaal geen wijzigingen. We maken echter twee verschillende knooppuntimplementaties die verschillende sets services bevatten:

  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
  }

Het eerste knooppunt implementeert de server en heeft alleen configuratie aan de serverzijde nodig. Het tweede knooppunt implementeert de client en heeft een ander deel van de configuratie nodig. Voor beide knooppunten is een bepaalde levensduurspecificatie vereist. Voor de doeleinden van dit postserviceknooppunt zal het een oneindige levensduur hebben die kan worden beëindigd met behulp van SIGTERM, terwijl de echo-client wordt beëindigd na de geconfigureerde eindige duur. Zie de starterstoepassing voor meer info.

Algeheel ontwikkelingsproces

Laten we eens kijken hoe deze aanpak de manier verandert waarop we met configuratie werken.

De configuratie als code wordt gecompileerd en produceert een artefact. Het lijkt redelijk om configuratieartefacten te scheiden van andere codeartefacten. Vaak kunnen we een groot aantal configuraties op dezelfde codebasis hebben. En natuurlijk kunnen we meerdere versies van verschillende configuratietakken hebben. In een configuratie kunnen we bepaalde versies van bibliotheken selecteren en dit blijft constant wanneer we deze configuratie inzetten.

Een configuratiewijziging wordt een codewijziging. Het moet dus onder hetzelfde kwaliteitsborgingsproces vallen:

Ticket -> PR -> beoordeling -> samenvoegen -> continue integratie -> continue implementatie

Er zijn de volgende consequenties van de aanpak:

  1. De configuratie is coherent voor de instantie van een bepaald systeem. Het lijkt erop dat er geen manier is om een ​​onjuiste verbinding tussen knooppunten tot stand te brengen.
  2. Het is niet eenvoudig om de configuratie slechts in één knooppunt te wijzigen. Het lijkt onredelijk om in te loggen en sommige tekstbestanden te wijzigen. Configuratiedrift wordt dus minder mogelijk.
  3. Kleine configuratiewijzigingen zijn niet eenvoudig door te voeren.
  4. De meeste configuratiewijzigingen zullen hetzelfde ontwikkelingsproces volgen en zullen enige beoordeling doorstaan.

Hebben we een aparte repository nodig voor de productieconfiguratie? De productieconfiguratie kan gevoelige informatie bevatten die we graag buiten het bereik van veel mensen willen houden. Het kan dus de moeite waard zijn om een ​​aparte repository met beperkte toegang aan te houden waarin de productieconfiguratie wordt opgeslagen. We kunnen de configuratie in twee delen opsplitsen: één dat de meest open productieparameters bevat en één dat het geheime deel van de configuratie bevat. Dit zou de meeste ontwikkelaars toegang geven tot de overgrote meerderheid van de parameters, terwijl de toegang tot echt gevoelige zaken zou worden beperkt. Dit is eenvoudig te bereiken met behulp van tussenliggende kenmerken met standaard parameterwaarden.

Variaties

Laten we de voor- en nadelen van de voorgestelde aanpak bekijken in vergelijking met de andere configuratiemanagementtechnieken.

Allereerst zullen we een aantal alternatieven opsommen voor de verschillende aspecten van de voorgestelde manier om met configuratie om te gaan:

  1. Tekstbestand op de doelcomputer.
  2. Gecentraliseerde opslag van sleutelwaarden (zoals etcd/zookeeper).
  3. Subprocescomponenten die opnieuw kunnen worden geconfigureerd/opnieuw gestart zonder het proces opnieuw te starten.
  4. Configuratie buiten artefact- en versiebeheer.

Een tekstbestand biedt enige flexibiliteit op het gebied van ad-hocoplossingen. De systeembeheerder kan inloggen op het doelknooppunt, een wijziging aanbrengen en de service eenvoudigweg opnieuw starten. Dit is misschien niet zo goed voor grotere systemen. Er blijven geen sporen achter bij de verandering. De verandering wordt niet beoordeeld door een ander paar ogen. Het kan moeilijk zijn om erachter te komen wat de verandering heeft veroorzaakt. Het is niet getest. Vanuit het perspectief van een gedistribueerd systeem kan een beheerder eenvoudigweg vergeten de configuratie in een van de andere knooppunten bij te werken.

(Trouwens, als het uiteindelijk nodig zal zijn om tekstconfiguratiebestanden te gaan gebruiken, hoeven we alleen maar parser + validator toe te voegen die hetzelfde zou kunnen produceren Config type en dat zou voldoende zijn om tekstconfiguraties te gaan gebruiken. Dit laat ook zien dat de complexiteit van de configuratie tijdens het compileren iets kleiner is dan de complexiteit van op tekst gebaseerde configuraties, omdat we in een op tekst gebaseerde versie wat extra code nodig hebben.)

Gecentraliseerde opslag van sleutelwaarden is een goed mechanisme voor het distribueren van metaparameters van applicaties. Hier moeten we nadenken over wat we beschouwen als configuratiewaarden en wat slechts gegevens zijn. Een functie gegeven C => A => B we noemen dit meestal zelden veranderende waarden C "configuratie", terwijl vaak gewijzigde gegevens A - voer gewoon gegevens in. Configuratie moet eerder aan de functie worden verstrekt dan aan de gegevens A. Gegeven dit idee kunnen we zeggen dat de verwachte frequentie van veranderingen gebruikt kan worden om configuratiegegevens te onderscheiden van louter gegevens. Gegevens komen doorgaans ook van één bron (gebruiker) en de configuratie komt van een andere bron (beheerder). Het omgaan met parameters die na het initialisatieproces kunnen worden gewijzigd, leidt tot een toename van de applicatiecomplexiteit. Voor dergelijke parameters moeten we het leveringsmechanisme, het parseren en valideren, en het omgaan met onjuiste waarden afhandelen. Om de complexiteit van het programma te verminderen, kunnen we daarom beter het aantal parameters verminderen dat tijdens runtime kan veranderen (of ze zelfs helemaal elimineren).

Vanuit het perspectief van dit bericht moeten we onderscheid maken tussen statische en dynamische parameters. Als servicelogica een zeldzame wijziging van sommige parameters tijdens runtime vereist, kunnen we deze dynamische parameters noemen. Anders zijn ze statisch en kunnen ze worden geconfigureerd met behulp van de voorgestelde aanpak. Voor dynamische herconfiguratie kunnen andere benaderingen nodig zijn. Delen van het systeem kunnen bijvoorbeeld opnieuw worden opgestart met de nieuwe configuratieparameters op een vergelijkbare manier als het opnieuw opstarten van afzonderlijke processen van een gedistribueerd systeem.
(Mijn bescheiden mening is om runtime-herconfiguratie te vermijden, omdat dit de complexiteit van het systeem vergroot.
Het kan eenvoudiger zijn om gewoon te vertrouwen op de ondersteuning van het besturingssysteem bij het opnieuw opstarten van processen. Hoewel dat misschien niet altijd mogelijk is.)

Een belangrijk aspect van het gebruik van statische configuratie waardoor mensen soms dynamische configuratie overwegen (zonder andere redenen) is service-downtime tijdens configuratie-updates. Als we wijzigingen moeten aanbrengen in de statische configuratie, moeten we het systeem inderdaad opnieuw opstarten zodat nieuwe waarden van kracht worden. De vereisten voor downtime variëren per systeem, dus het is misschien niet zo belangrijk. Als het kritiek is, moeten we vooruit plannen voor het opnieuw opstarten van het systeem. Wij zouden dit bijvoorbeeld kunnen implementeren AWS ELB-aansluiting aftappen. In dit scenario starten we, wanneer we het systeem opnieuw moeten opstarten, parallel een nieuw exemplaar van het systeem en schakelen vervolgens ELB ernaar over, terwijl we het oude systeem de bestaande verbindingen laten voltooien.

Hoe zit het met het behouden van de configuratie binnen een versie-artefact of daarbuiten? Het binnen een artefact houden van de configuratie betekent in de meeste gevallen dat deze configuratie hetzelfde kwaliteitsborgingsproces heeft doorstaan ​​als andere artefacten. U kunt er dus zeker van zijn dat de configuratie van goede kwaliteit en betrouwbaar is. Integendeel, configuratie in een apart bestand betekent dat er geen sporen zijn van wie en waarom wijzigingen in dat bestand heeft aangebracht. Is dit belangrijk? Wij zijn van mening dat het voor de meeste productiesystemen beter is om een ​​stabiele en hoogwaardige configuratie te hebben.

De versie van het artefact maakt het mogelijk om erachter te komen wanneer het is gemaakt, welke waarden het bevat, welke functies zijn in- of uitgeschakeld, wie verantwoordelijk was voor het aanbrengen van elke wijziging in de configuratie. Het kan enige inspanning vergen om de configuratie binnen een artefact te houden en het is een ontwerpkeuze die moet worden gemaakt.

Voor-en nadelen

Hier willen we enkele voordelen belichten en enkele nadelen van de voorgestelde aanpak bespreken.

voordelen

Kenmerken van de compileerbare configuratie van een compleet gedistribueerd systeem:

  1. Statische controle van de configuratie. Dit geeft een hoge mate van vertrouwen dat de configuratie correct is, gegeven typebeperkingen.
  2. Rijke configuratietaal. Typisch zijn andere configuratiebenaderingen beperkt tot ten hoogste variabele vervanging.
    Met Scala kan men een breed scala aan taalfuncties gebruiken om de configuratie te verbeteren. We kunnen bijvoorbeeld eigenschappen gebruiken om standaardwaarden te bieden, of objecten om een ​​ander bereik in te stellen, waarnaar we kunnen verwijzen vals slechts één keer gedefinieerd in de buitenste scope (DRY). Het is mogelijk om letterlijke reeksen of instanties van bepaalde klassen te gebruiken (Seq, Map, Enz.).
  3. DSL. Scala heeft behoorlijke ondersteuning voor DSL-schrijvers. Men kan deze functies gebruiken om een ​​configuratietaal op te zetten die handiger en eindgebruikervriendelijker is, zodat de uiteindelijke configuratie op zijn minst leesbaar is voor domeingebruikers.
  4. Integriteit en samenhang tussen knooppunten. Een van de voordelen van het hebben van configuratie voor het hele gedistribueerde systeem op één plek is dat alle waarden strikt één keer worden gedefinieerd en vervolgens hergebruikt op alle plaatsen waar we ze nodig hebben. Ook type Safe Port-declaraties zorgen ervoor dat in alle mogelijke correcte configuraties de knooppunten van het systeem dezelfde taal zullen spreken. Er zijn expliciete afhankelijkheden tussen knooppunten, waardoor het moeilijk is om te vergeten bepaalde services te leveren.
  5. Hoge kwaliteit van veranderingen. De algemene aanpak waarbij configuratiewijzigingen via het normale PR-proces worden doorgegeven, zorgt ook voor hoge kwaliteitsnormen voor de configuratie.
  6. Gelijktijdige configuratiewijzigingen. Telkens wanneer we wijzigingen in de configuratie aanbrengen, zorgt de automatische implementatie ervoor dat alle knooppunten worden bijgewerkt.
  7. Vereenvoudiging van toepassingen. De toepassing hoeft de configuratie niet te parseren en valideren en onjuiste configuratiewaarden te verwerken. Dit vereenvoudigt de algehele toepassing. (Een zekere toename van de complexiteit zit in de configuratie zelf, maar het is een bewuste afweging ten opzichte van veiligheid.) Het is vrij eenvoudig om terug te keren naar de gewone configuratie: voeg gewoon de ontbrekende stukjes toe. Het is gemakkelijker om aan de slag te gaan met de gecompileerde configuratie en de implementatie van aanvullende onderdelen uit te stellen naar een later tijdstip.
  8. Versie-configuratie. Omdat configuratiewijzigingen hetzelfde ontwikkelingsproces volgen, krijgen we als resultaat een artefact met een unieke versie. Het stelt ons in staat om indien nodig de configuratie terug te schakelen. We kunnen zelfs een configuratie inzetten die een jaar geleden werd gebruikt en die werkt precies hetzelfde. Stabiele configuratie verbetert de voorspelbaarheid en betrouwbaarheid van het gedistribueerde systeem. De configuratie ligt tijdens het compileren vast en er kan niet gemakkelijk op een productiesysteem mee worden geknoeid.
  9. Modulariteit. Het voorgestelde raamwerk is modulair en modules kunnen op verschillende manieren worden gecombineerd
    ondersteunen verschillende configuraties (opstellingen/lay-outs). In het bijzonder is het mogelijk om een ​​kleinschalige lay-out met één knooppunt en een grootschalige instelling met meerdere knooppunten te hebben. Het is redelijk om meerdere productie-indelingen te hebben.
  10. Testen. Voor testdoeleinden zou men een nepservice kunnen implementeren en deze op een typeveilige manier als afhankelijkheid kunnen gebruiken. Een paar verschillende testlay-outs waarbij verschillende onderdelen door mocks zijn vervangen, kunnen tegelijkertijd worden gehandhaafd.
  11. Integratie testen. Soms is het in gedistribueerde systemen moeilijk om integratietests uit te voeren. Door de beschreven aanpak te gebruiken om een ​​veilige configuratie van het volledige gedistribueerde systeem te realiseren, kunnen we alle gedistribueerde onderdelen op een beheersbare manier op één server draaien. Het is gemakkelijk om de situatie na te bootsen
    wanneer een van de services niet meer beschikbaar is.

Nadelen

De gecompileerde configuratiebenadering verschilt van de “normale” configuratie en is mogelijk niet geschikt voor alle behoeften. Hier zijn enkele nadelen van de gecompileerde configuratie:

  1. Statische configuratie. Het is mogelijk niet voor alle toepassingen geschikt. In sommige gevallen is het nodig om de configuratie snel in de productie te herstellen, waarbij alle veiligheidsmaatregelen worden omzeild. Deze aanpak maakt het moeilijker. De compilatie en herimplementatie zijn vereist na het aanbrengen van een wijziging in de configuratie. Dit is zowel het kenmerk als de last.
  2. Configuratie generatie. Wanneer configuratie wordt gegenereerd door een automatiseringstool, vereist deze aanpak daaropvolgende compilatie (wat op zijn beurt kan mislukken). Het kan extra inspanning vergen om deze extra stap in het bouwsysteem te integreren.
  3. Instrumenten. Er zijn tegenwoordig tal van tools in gebruik die afhankelijk zijn van op tekst gebaseerde configuraties. Sommigen van hen
    is niet van toepassing wanneer de configuratie is gecompileerd.
  4. Er is een mentaliteitsverandering nodig. Ontwikkelaars en DevOps zijn bekend met tekstconfiguratiebestanden. Het idee om de configuratie te compileren kan voor hen vreemd overkomen.
  5. Voordat een compileerbare configuratie kan worden geïntroduceerd, is een softwareontwikkelingsproces van hoge kwaliteit vereist.

Er zijn enkele beperkingen van het geïmplementeerde voorbeeld:

  1. Als we extra configuratie bieden die niet vereist is door de knooppuntimplementatie, zal de compiler ons niet helpen de ontbrekende implementatie te detecteren. Dit zou verholpen kunnen worden door gebruik te maken van HList of ADT's (case-klassen) voor knooppuntconfiguratie in plaats van eigenschappen en Cake Pattern.
  2. We moeten een standaard in het configuratiebestand opgeven: (package, import, object verklaringen;
    override def's voor parameters die standaardwaarden hebben). Dit kan gedeeltelijk worden verholpen door gebruik te maken van DSL.
  3. In dit bericht behandelen we de dynamische herconfiguratie van clusters van vergelijkbare knooppunten niet.

Conclusie

In dit bericht hebben we het idee besproken om de configuratie op een typeveilige manier rechtstreeks in de broncode weer te geven. De aanpak zou in veel toepassingen kunnen worden gebruikt als vervanging voor XML- en andere op tekst gebaseerde configuraties. Ondanks dat ons voorbeeld in Scala is geïmplementeerd, zou het ook vertaald kunnen worden naar andere compileerbare talen (zoals Kotlin, C#, Swift, etc.). Je zou deze aanpak in een nieuw project kunnen proberen en, als het niet goed past, overstappen op de ouderwetse manier.

Uiteraard vereist een compileerbare configuratie een ontwikkelingsproces van hoge kwaliteit. In ruil daarvoor belooft het een robuuste configuratie van even hoge kwaliteit te bieden.

Deze aanpak kan op verschillende manieren worden uitgebreid:

  1. Men zou macro's kunnen gebruiken om configuratievalidatie uit te voeren en tijdens het compileren kunnen mislukken in het geval van fouten in de bedrijfslogische beperkingen.
  2. Er zou een DSL kunnen worden geïmplementeerd om de configuratie op een domeingebruiksvriendelijke manier weer te geven.
  3. Dynamisch resourcebeheer met automatische configuratie-aanpassingen. Als we bijvoorbeeld het aantal clusterknooppunten aanpassen, willen we misschien dat (1) de knooppunten een enigszins gewijzigde configuratie krijgen; (2) clustermanager om nieuwe knooppuntinformatie te ontvangen.

Bedankt

Ik wil Andrey Saksonov, Pavel Popov en Anton Nehaev bedanken voor het geven van inspirerende feedback op het concept van dit bericht, waardoor ik het duidelijker heb kunnen maken.

Bron: www.habr.com