Gecompileerde gedistribueerde systeemconfiguratie

Ik zou u graag een interessant mechanisme willen vertellen voor het werken met de configuratie van een gedistribueerd systeem. De configuratie wordt direct weergegeven in een gecompileerde taal (Scala) met behulp van veilige typen. Dit bericht geeft een voorbeeld van een dergelijke configuratie en bespreekt verschillende aspecten van het implementeren van een gecompileerde configuratie in het algehele ontwikkelingsproces.

Gecompileerde gedistribueerde systeemconfiguratie

(Engels)

Introductie

Het bouwen van een betrouwbaar gedistribueerd systeem betekent dat alle knooppunten de juiste configuratie gebruiken, gesynchroniseerd met andere knooppunten. DevOps-technologieën (terraform, ansible of iets dergelijks) worden meestal gebruikt om automatisch configuratiebestanden te genereren (vaak specifiek voor elk knooppunt). We willen er ook zeker van zijn dat alle communicerende knooppunten identieke protocollen gebruiken (inclusief dezelfde versie). Anders zal incompatibiliteit in ons gedistribueerde systeem worden ingebouwd. In de JVM-wereld is een gevolg van deze vereiste dat overal dezelfde versie van de bibliotheek met de protocolberichten moet worden gebruikt.

Hoe zit het met het testen van een gedistribueerd systeem? Uiteraard gaan we ervan uit dat alle componenten unit-tests hebben voordat we overgaan tot integratietesten. (Om testresultaten naar runtime te kunnen extrapoleren, moeten we ook tijdens de testfase en tijdens runtime een identieke set bibliotheken leveren.)

Bij het werken met integratietests is het vaak eenvoudiger om overal op alle knooppunten hetzelfde klassenpad te gebruiken. Het enige wat we moeten doen is ervoor zorgen dat hetzelfde klassenpad tijdens runtime wordt gebruikt. (Hoewel het heel goed mogelijk is om verschillende knooppunten met verschillende klassenpaden te gebruiken, voegt dit wel complexiteit toe aan de algehele configuratie en problemen met implementatie- en integratietests.) Voor de doeleinden van dit bericht gaan we ervan uit dat alle knooppunten hetzelfde klassenpad zullen gebruiken.

De configuratie evolueert met de applicatie. We gebruiken versies om verschillende stadia van programma-evolutie te identificeren. Het lijkt logisch om ook verschillende versies van configuraties te identificeren. En plaats de configuratie zelf in het versiebeheersysteem. Als er slechts één configuratie in productie is, kunnen we gewoon het versienummer gebruiken. Als we veel productie-instanties gebruiken, hebben we er meerdere nodig
configuratievertakkingen en een extra label naast de versie (bijvoorbeeld de naam van de vertakking). Zo kunnen we de exacte configuratie duidelijk identificeren. Elke configuratie-ID komt op unieke wijze overeen met een specifieke combinatie van gedistribueerde knooppunten, poorten, externe bronnen en bibliotheekversies. Voor de doeleinden van dit bericht gaan we ervan uit dat er maar één vertakking is en kunnen we de configuratie op de gebruikelijke manier identificeren met behulp van drie cijfers gescheiden door een punt (1.2.3).

In moderne omgevingen worden configuratiebestanden zelden handmatig aangemaakt. Vaker worden ze tijdens de inzet gegenereerd en niet meer aangeraakt (zodat breek niets). Er rijst een natuurlijke vraag: waarom gebruiken we nog steeds het tekstformaat om de configuratie op te slaan? Een haalbaar alternatief lijkt de mogelijkheid te zijn om reguliere code te gebruiken voor configuratie en te profiteren van controles tijdens het compileren.

In dit bericht zullen we het idee onderzoeken van het representeren van een configuratie binnen een gecompileerd artefact.

Gecompileerde configuratie

In deze sectie vindt u een voorbeeld van een statisch gecompileerde configuratie. Er worden twee eenvoudige services geïmplementeerd: de echoservice en de echoserviceclient. Op basis van deze twee diensten worden twee systeemopties samengesteld. In één optie bevinden beide services zich op hetzelfde knooppunt, in een andere optie - op verschillende knooppunten.

Normaal gesproken bevat een gedistribueerd systeem meerdere knooppunten. U kunt knooppunten identificeren met behulp van waarden van een bepaald type NodeId:

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

of

case class NodeId(hostName: String)

of

object Singleton
type NodeId = Singleton.type

Knooppunten vervullen verschillende rollen, ze voeren services uit en er kunnen TCP/HTTP-verbindingen tussen worden gemaakt.

Om een ​​TCP-verbinding te beschrijven hebben we minimaal een poortnummer nodig. We willen ook graag het protocol weerspiegelen dat op die poort wordt ondersteund, om ervoor te zorgen dat zowel de client als de server hetzelfde protocol gebruiken. We zullen de verbinding beschrijven met behulp van de volgende klasse:

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

waar Port - gewoon een geheel getal Int geeft het bereik van aanvaardbare waarden aan:

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

Verfijnde soorten

Zie bibliotheek verfijnd и mijn verslag. Kortom, met de bibliotheek kunt u beperkingen toevoegen aan typen die tijdens het compileren worden gecontroleerd. In dit geval zijn geldige poortnummerwaarden 16-bit gehele getallen. Voor een gecompileerde configuratie is het gebruik van de verfijnde bibliotheek niet verplicht, maar het verbetert wel het vermogen van de compiler om de configuratie te controleren.

Voor HTTP (REST)-protocollen hebben we naast het poortnummer mogelijk ook het pad naar de service nodig:

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

Fantoomtypes

Om het protocol tijdens het compileren te identificeren, gebruiken we een typeparameter die niet binnen de klasse wordt gebruikt. Deze beslissing is te wijten aan het feit dat we tijdens runtime geen protocolinstantie gebruiken, maar dat we graag willen dat de compiler de protocolcompatibiliteit controleert. Door het protocol te specificeren, kunnen we een ongepaste service niet als afhankelijkheid doorgeven.

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

sealed trait JsonHttpRestProtocol[RequestMessage, ResponseMessage]

waar RequestMessage - aanvraag type, ResponseMessage — antwoordtype.
Natuurlijk kunnen we andere protocolbeschrijvingen gebruiken die de nauwkeurigheid bieden van de beschrijving die we nodig hebben.

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

sealed trait SimpleHttpGetRest[RequestMessage, ResponseMessage]

Hier is het verzoek een tekenreeks die aan de URL is toegevoegd en het antwoord is de geretourneerde tekenreeks in de hoofdtekst van het HTTP-antwoord.

De serviceconfiguratie wordt beschreven door de servicenaam, poorten en afhankelijkheden. Deze elementen kunnen op verschillende manieren in Scala worden weergegeven (bijvoorbeeld HList-s, algebraïsche gegevenstypen). Voor de doeleinden van dit bericht zullen we het Cake-patroon gebruiken en modules weergeven met behulp van trait'ov. (Het Taartpatroon is geen verplicht onderdeel van deze aanpak. Het is eenvoudigweg een mogelijke implementatie.)

Afhankelijkheden tussen services kunnen worden weergegeven als methoden die poorten retourneren EndPoint's 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)
  }

Om een ​​echoservice aan te maken, heeft u alleen een poortnummer nodig en een indicatie dat de poort het echoprotocol ondersteunt. Mogelijk specificeren we geen specifieke poort, omdat... Met eigenschappen kunt u methoden declareren zonder implementatie (abstracte methoden). In dit geval zou de compiler bij het maken van een concrete configuratie van ons eisen dat we een implementatie van de abstracte methode leveren en een poortnummer opgeven. Omdat we de methode hebben geïmplementeerd, mogen we bij het maken van een specifieke configuratie geen andere poort opgeven. De standaardwaarde wordt gebruikt.

In de clientconfiguratie verklaren we een afhankelijkheid van de echoservice:

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

De afhankelijkheid is van hetzelfde type als de geëxporteerde service echoService. In het bijzonder hebben we in de echoclient hetzelfde protocol nodig. Daarom kunnen we er bij het verbinden van twee services zeker van zijn dat alles correct zal werken.

Implementatie van diensten

Er is een functie vereist om de service te starten en te stoppen. (De mogelijkheid om een ​​service te stoppen is van cruciaal belang voor het testen.) Ook hier zijn er verschillende opties om een ​​dergelijke functie te implementeren (we kunnen bijvoorbeeld typeklassen gebruiken op basis van het configuratietype). Voor de doeleinden van dit bericht gebruiken we het taartpatroon. We vertegenwoordigen de service met behulp van een klasse cats.Resource, omdat Deze klasse biedt al middelen om de vrijgave van hulpbronnen veilig te garanderen in geval van problemen. Om een ​​bron te krijgen, moeten we configuratie en een kant-en-klare runtime-context bieden. De service-opstartfunctie kan er 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 — configuratietype voor deze service
  • AddressResolver — een runtime-object waarmee u de adressen van andere knooppunten kunt achterhalen (zie hieronder)

en andere typen uit de bibliotheek cats:

  • F[_] — soort effect (in het eenvoudigste geval F[A] Het zou zomaar een functie kunnen zijn () => A. In dit bericht zullen we gebruiken cats.IO.)
  • Reader[A,B] - min of meer synoniem met functie A => B
  • cats.Resource - een hulpbron die kan worden verkregen en vrijgegeven
  • Timer — timer (hiermee kunt u een tijdje in slaap vallen en tijdsintervallen meten)
  • ContextShift - analoog ExecutionContext
  • Applicative — een effecttypeklasse waarmee u individuele effecten kunt combineren (bijna een monade). In complexere toepassingen lijkt het beter om te gebruiken Monad/ConcurrentEffect.

Met behulp van deze functiehandtekening kunnen we verschillende services implementeren. Een service die niets doet:

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

(Cm. bron, waarin andere diensten worden geïmplementeerd - echo dienst, echo-klant
и levenslange controllers.)

Een knooppunt is een object dat verschillende services kan starten (de lancering van een keten van bronnen wordt verzekerd door het Cake-patroon):

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

Houd er rekening mee dat we het exacte type configuratie specificeren dat vereist is voor dit knooppunt. Als we vergeten een van de configuratietypen op te geven die voor een bepaalde service vereist zijn, zal er een compilatiefout optreden. We kunnen ook geen knooppunt starten tenzij we een object van het juiste type voorzien van alle benodigde gegevens.

Hostnaamresolutie

Om verbinding te maken met een externe host hebben we een echt IP-adres nodig. Het is mogelijk dat het adres later bekend wordt dan de rest van de configuratie. We hebben dus een functie nodig die de knooppunt-ID aan een adres koppelt:

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

Er zijn verschillende manieren om deze functie te implementeren:

  1. Als de adressen vóór de implementatie bij ons bekend worden, kunnen we er Scala-code mee genereren
    adressen en voer vervolgens de build uit. Hiermee worden tests samengesteld en uitgevoerd.
    In dit geval is de functie statisch bekend en kan deze in code worden weergegeven als een afbeelding Map[NodeId, NodeAddress].
  2. In sommige gevallen is het daadwerkelijke adres pas bekend nadat het knooppunt is gestart.
    In dit geval kunnen we een ‘ontdekkingsservice’ implementeren die vóór andere knooppunten wordt uitgevoerd en alle knooppunten zullen zich bij deze dienst registreren en de adressen van andere knooppunten opvragen.
  3. Als we kunnen wijzigen /etc/hosts, dan kunt u vooraf gedefinieerde hostnamen gebruiken (zoals my-project-main-node и echo-backend) en koppel eenvoudigweg deze namen
    met IP-adressen tijdens de implementatie.

In dit bericht zullen we deze gevallen niet in meer detail bespreken. Voor onze
in een speelgoedvoorbeeld hebben alle knooppunten hetzelfde IP-adres - 127.0.0.1.

Vervolgens beschouwen we twee opties voor een gedistribueerd systeem:

  1. Alle services op één knooppunt plaatsen.
  2. En het hosten van de echoservice en echoclient op verschillende knooppunten.

Configuratie voor één knooppunt:

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

Het object implementeert de configuratie van zowel de client als de server. Er wordt ook gebruik gemaakt van een time-to-live-configuratie, zodat na het interval lifetime beëindig het programma. (Ctrl-C werkt ook en maakt alle bronnen correct vrij.)

Dezelfde set configuratie- en implementatiekenmerken kan worden gebruikt om een ​​systeem te creëren dat bestaat uit: twee afzonderlijke knooppunten:

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

Belangrijk! Merk op hoe de services met elkaar zijn verbonden. We specificeren een dienst die door één knooppunt wordt geïmplementeerd als een implementatie van de afhankelijkheidsmethode van een ander knooppunt. Het afhankelijkheidstype wordt gecontroleerd door de compiler, omdat bevat het protocoltype. Wanneer deze wordt uitgevoerd, bevat de afhankelijkheid de juiste doelknooppunt-ID. Dankzij deze regeling specificeren wij het poortnummer precies één keer en verwijzen we altijd gegarandeerd naar de juiste poort.

Implementatie van twee systeemknooppunten

Voor deze configuratie gebruiken we dezelfde service-implementaties zonder wijzigingen. Het enige verschil is dat we nu twee objecten hebben die verschillende sets services implementeren:

  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 serverconfiguratie nodig. Het tweede knooppunt implementeert de client en gebruikt een ander deel van de configuratie. Ook hebben beide knooppunten levenslange beheer nodig. Het serverknooppunt blijft voor onbepaalde tijd actief totdat het wordt gestopt SIGTERM'om, en het clientknooppunt wordt na enige tijd beëindigd. Cm. launcher-app.

Algemeen ontwikkelingsproces

Laten we eens kijken hoe deze configuratiebenadering het algehele ontwikkelingsproces beïnvloedt.

De configuratie wordt samen met de rest van de code gecompileerd en er wordt een artefact (.jar) gegenereerd. Het lijkt zinvol om de configuratie in een afzonderlijk artefact te plaatsen. Dit komt omdat we meerdere configuraties kunnen hebben op basis van dezelfde code. Ook hier is het mogelijk om artefacten te genereren die overeenkomen met verschillende configuratietakken. Afhankelijkheden van specifieke versies van bibliotheken worden samen met de configuratie opgeslagen, en deze versies worden voor altijd bewaard wanneer we besluiten die versie van de configuratie te implementeren.

Elke configuratiewijziging verandert in een codewijziging. En daarom elk
de wijziging valt onder het normale kwaliteitsborgingsproces:

Ticket in de bugtracker -> PR -> beoordeling -> samenvoegen met relevante branches ->
integratie -> implementatie

De belangrijkste gevolgen van het implementeren van een gecompileerde configuratie zijn:

  1. De configuratie zal consistent zijn op alle knooppunten van het gedistribueerde systeem. Vanwege het feit dat alle knooppunten dezelfde configuratie van één enkele bron ontvangen.

  2. Het is problematisch om de configuratie in slechts één van de knooppunten te wijzigen. Daarom is “configuratiedrift” onwaarschijnlijk.

  3. Het wordt moeilijker om kleine wijzigingen in de configuratie aan te brengen.

  4. De meeste configuratiewijzigingen zullen plaatsvinden als onderdeel van het algehele ontwikkelingsproces en zullen worden herzien.

Heb ik een aparte repository nodig om de productieconfiguratie op te slaan? Deze configuratie kan wachtwoorden en andere gevoelige informatie bevatten waartoe we de toegang willen beperken. Op basis hiervan lijkt het zinvol om de uiteindelijke configuratie in een aparte repository op te slaan. U kunt de configuratie in twee delen opsplitsen: één met openbaar toegankelijke configuratie-instellingen en één met beperkte instellingen. Hierdoor hebben de meeste ontwikkelaars toegang tot algemene instellingen. Deze scheiding is eenvoudig te realiseren met behulp van tussenkenmerken die standaardwaarden bevatten.

Mogelijke variaties

Laten we proberen de gecompileerde configuratie te vergelijken met enkele veelvoorkomende alternatieven:

  1. Tekstbestand op de doelcomputer.
  2. Gecentraliseerd sleutel/waarde-archief (etcd/zookeeper).
  3. Procescomponenten die opnieuw kunnen worden geconfigureerd/opnieuw gestart zonder het proces opnieuw te starten.
  4. Configuratie opslaan buiten artefact- en versiebeheer.

Tekstbestanden bieden aanzienlijke flexibiliteit als het gaat om kleine wijzigingen. De systeembeheerder kan inloggen op het externe knooppunt, wijzigingen aanbrengen in de juiste bestanden en de service opnieuw starten. Voor grote systemen kan een dergelijke flexibiliteit echter niet wenselijk zijn. De aangebrachte wijzigingen laten geen sporen na in andere systemen. Niemand beoordeelt de wijzigingen. Het is lastig vast te stellen wie de wijzigingen precies heeft doorgevoerd en om welke reden. Wijzigingen worden niet getest. Als het systeem gedistribueerd is, kan de beheerder vergeten de overeenkomstige wijziging op andere knooppunten aan te brengen.

(Er moet ook worden opgemerkt dat het gebruik van een gecompileerde configuratie de mogelijkheid om in de toekomst tekstbestanden te gebruiken niet uitsluit. Het zal voldoende zijn om een ​​parser en validator toe te voegen die hetzelfde type produceren als uitvoer Configen u kunt tekstbestanden gebruiken. Hieruit volgt meteen dat de complexiteit van een systeem met een gecompileerde configuratie iets minder is dan de complexiteit van een systeem dat gebruik maakt van tekstbestanden, omdat tekstbestanden vereisen extra code.)

Een gecentraliseerde sleutelwaardeopslag is een goed mechanisme voor het distribueren van metaparameters van een gedistribueerde applicatie. We moeten beslissen wat configuratieparameters zijn en wat alleen maar gegevens zijn. Laten we een functie hebben C => A => Ben de parameters C verandert zelden, en gegevens A - vaak. In dit geval kunnen we dat zeggen C - configuratieparameters, en A - gegevens. Het lijkt erop dat configuratieparameters verschillen van data doordat ze over het algemeen minder vaak veranderen dan data. Bovendien komen gegevens meestal uit de ene bron (van de gebruiker) en configuratieparameters uit een andere (van de systeembeheerder).

Als zelden veranderende parameters moeten worden bijgewerkt zonder het programma opnieuw te starten, kan dit vaak leiden tot complicaties van het programma, omdat we op de een of andere manier parameters moeten aanleveren, onjuiste waarden moeten opslaan, parseren en controleren en verwerken. Daarom is het vanuit het oogpunt van het verminderen van de complexiteit van het programma zinvol om het aantal parameters te verminderen dat tijdens de werking van het programma kan veranderen (of dergelijke parameters helemaal niet ondersteunt).

Voor de doeleinden van dit bericht zullen we onderscheid maken tussen statische en dynamische parameters. Als de logica van de service vereist dat parameters worden gewijzigd tijdens de werking van het programma, dan noemen we dergelijke parameters dynamisch. Anders zijn de opties statisch en kunnen ze worden geconfigureerd met behulp van de gecompileerde configuratie. Voor dynamische herconfiguratie hebben we mogelijk een mechanisme nodig om delen van het programma opnieuw te starten met nieuwe parameters, vergelijkbaar met de manier waarop besturingssysteemprocessen opnieuw worden gestart. (Naar onze mening is het raadzaam om real-time herconfiguratie te vermijden, omdat dit de complexiteit van het systeem vergroot. Indien mogelijk is het beter om de standaard OS-mogelijkheden te gebruiken voor het herstarten van processen.)

Een belangrijk aspect van het gebruik van statische configuratie waardoor mensen dynamische herconfiguratie overwegen, is de tijd die het systeem nodig heeft om opnieuw op te starten na een configuratie-update (downtime). Als we wijzigingen moeten aanbrengen in de statische configuratie, zullen we het systeem zelfs opnieuw moeten opstarten om de nieuwe waarden van kracht te laten worden. Het downtimeprobleem varieert in ernst voor verschillende systemen. In sommige gevallen kunt u een herstart plannen op een moment dat de belasting minimaal is. Als u continue service moet bieden, kunt u dit implementeren AWS ELB-aansluiting aftappen. Tegelijkertijd, wanneer we het systeem opnieuw moeten opstarten, starten we een parallel exemplaar van dit systeem, schakelen de balancer ernaar over en wachten tot de oude verbindingen zijn voltooid. Nadat alle oude verbindingen zijn verbroken, sluiten we het oude exemplaar van het systeem af.

Laten we nu eens kijken naar de kwestie van het opslaan van de configuratie binnen of buiten het artefact. Als we de configuratie in een artefact opslaan, hadden we tenminste de mogelijkheid om de juistheid van de configuratie te verifiëren tijdens de assemblage van het artefact. Als de configuratie buiten het gecontroleerde artefact valt, is het moeilijk na te gaan wie wijzigingen in dit bestand heeft aangebracht en waarom. Hoe belangrijk is het? Naar onze mening is het voor veel productiesystemen belangrijk om een ​​stabiele en hoogwaardige configuratie te hebben.

Met de versie van een artefact kunt u bepalen wanneer het is gemaakt, welke waarden het bevat, welke functies zijn in-/uitgeschakeld en wie verantwoordelijk is voor eventuele wijzigingen in de configuratie. Natuurlijk vergt het opslaan van de configuratie in een artefact enige inspanning, dus u moet een weloverwogen beslissing nemen.

Voors en tegens

Ik wil graag stilstaan ​​bij de voor- en nadelen van de voorgestelde technologie.

Voordelen

Hieronder vindt u een lijst met de belangrijkste kenmerken van een gecompileerde gedistribueerde systeemconfiguratie:

  1. Statische configuratiecontrole. Hiermee kunt u er zeker van zijn
    de configuratie klopt.
  2. Rijke configuratietaal. Normaal gesproken zijn andere configuratiemethoden beperkt tot hoogstens de vervanging van tekenreeksvariabelen. Wanneer u Scala gebruikt, is er een breed scala aan taalfuncties beschikbaar om uw configuratie te verbeteren. Wij kunnen bijvoorbeeld gebruiken
    eigenschappen voor standaardwaarden, door objecten te gebruiken om parameters te groeperen, kunnen we verwijzen naar waarden die slechts één keer zijn gedeclareerd (DRY) in het omsluitende bereik. U kunt alle klassen rechtstreeks binnen de configuratie instantiëren (Seq, Map, aangepaste klassen).
  3. DSL. Scala heeft een aantal taalfuncties die het maken van een DSL eenvoudiger maken. Het is mogelijk om van deze features te profiteren en een configuratietaal te implementeren die handiger is voor de doelgroep van gebruikers, zodat de configuratie in ieder geval leesbaar is voor domeinexperts. Specialisten kunnen bijvoorbeeld deelnemen aan het configuratiereviewproces.
  4. Integriteit en synchronie tussen knooppunten. Een van de voordelen van het opslaan van de configuratie van een volledig gedistribueerd systeem op één punt is dat alle waarden precies één keer worden gedeclareerd en vervolgens hergebruikt waar ze nodig zijn. Het gebruik van fantoomtypen om poorten te declareren zorgt ervoor dat knooppunten compatibele protocollen gebruiken in alle correcte systeemconfiguraties. Het hebben van expliciete verplichte afhankelijkheden tussen knooppunten zorgt ervoor dat alle services met elkaar verbonden zijn.
  5. Veranderingen van hoge kwaliteit. Door wijzigingen in de configuratie aan te brengen via een gemeenschappelijk ontwikkelproces, is het mogelijk om ook voor de configuratie hoge kwaliteitsnormen te realiseren.
  6. Gelijktijdige configuratie-update. Automatische systeemimplementatie na configuratiewijzigingen zorgt ervoor dat alle knooppunten worden bijgewerkt.
  7. Vereenvoudiging van de toepassing. De toepassing hoeft niet te worden geparseerd, de configuratie te controleren of onjuiste waarden te verwerken. Dit vermindert de complexiteit van de applicatie. (Een deel van de configuratiecomplexiteit die in ons voorbeeld wordt waargenomen, is geen attribuut van de gecompileerde configuratie, maar slechts een bewuste beslissing, gedreven door de wens om een ​​grotere typeveiligheid te bieden.) Het is vrij eenvoudig om terug te keren naar de gebruikelijke configuratie - implementeer gewoon de ontbrekende onderdelen. Je kunt daarom bijvoorbeeld beginnen met een gecompileerde configuratie, waarbij je de implementatie van onnodige onderdelen uitstelt tot het moment dat het echt nodig is.
  8. Versifieerde configuratie. Omdat configuratiewijzigingen het gebruikelijke lot van andere wijzigingen volgen, is de uitvoer die we krijgen een artefact met een unieke versie. Hierdoor kunnen wij indien nodig bijvoorbeeld terugkeren naar een eerdere versie van de configuratie. We kunnen zelfs de configuratie van een jaar geleden gebruiken en het systeem zal precies hetzelfde werken. Een stabiele configuratie verbetert de voorspelbaarheid en betrouwbaarheid van een gedistribueerd systeem. Omdat de configuratie tijdens de compilatiefase vastligt, is het vrij moeilijk om deze tijdens de productie te vervalsen.
  9. Modulariteit. Het voorgestelde raamwerk is modulair en de modules kunnen op verschillende manieren worden gecombineerd om verschillende systemen te creëren. In het bijzonder kunt u het systeem configureren om in de ene uitvoeringsvorm op één enkel knooppunt te draaien, en in een andere op meerdere knooppunten. U kunt verschillende configuraties maken voor productie-instanties van het systeem.
  10. Testen. Door individuele services te vervangen door nepobjecten, kunt u verschillende versies van het systeem verkrijgen die handig zijn om te testen.
  11. Integratie testen. Het hebben van één enkele configuratie voor het gehele gedistribueerde systeem maakt het mogelijk om alle componenten in een gecontroleerde omgeving uit te voeren als onderdeel van integratietesten. Het is gemakkelijk om bijvoorbeeld een situatie na te bootsen waarin sommige knooppunten toegankelijk worden.

Nadelen en beperkingen

De gecompileerde configuratie verschilt van andere configuratiebenaderingen en is mogelijk niet geschikt voor sommige toepassingen. Hieronder staan ​​enkele nadelen:

  1. Statische configuratie. Soms moet u de configuratie in de productie snel corrigeren, waarbij u alle beveiligingsmechanismen omzeilt. Met deze aanpak kan het moeilijker zijn. Op zijn minst zullen compilatie en automatische implementatie nog steeds nodig zijn. Dit is zowel een nuttig kenmerk van de aanpak als in sommige gevallen een nadeel.
  2. Configuratie generatie. Als het configuratiebestand door een automatische tool wordt gegenereerd, kunnen er extra inspanningen nodig zijn om het buildscript te integreren.
  3. Hulpmiddelen. Momenteel zijn hulpprogramma's en technieken die zijn ontworpen om met configuratie te werken, gebaseerd op tekstbestanden. Niet al deze hulpprogramma's/technieken zullen beschikbaar zijn in een gecompileerde configuratie.
  4. Er is een verandering in de houding nodig. Ontwikkelaars en DevOps zijn gewend aan tekstbestanden. Het idee alleen al om een ​​configuratie samen te stellen kan enigszins onverwacht en ongebruikelijk zijn en tot afwijzing leiden.
  5. Een ontwikkelingsproces van hoge kwaliteit is vereist. Om de gecompileerde configuratie comfortabel te kunnen gebruiken, is volledige automatisering van het proces van het bouwen en implementeren van de applicatie (CI/CD) noodzakelijk. Anders zal het behoorlijk lastig zijn.

Laten we ook stilstaan ​​bij een aantal beperkingen van het beschouwde voorbeeld die geen verband houden met het idee van een gecompileerde configuratie:

  1. Als we onnodige configuratie-informatie verstrekken die niet door het knooppunt wordt gebruikt, zal de compiler ons niet helpen de ontbrekende implementatie te detecteren. Dit probleem kan worden opgelost door het taartpatroon te verlaten en stijvere typen te gebruiken, bijvoorbeeld HList of algebraïsche gegevenstypen (case-klassen) om de configuratie weer te geven.
  2. Er zijn regels in het configuratiebestand die geen verband houden met de configuratie zelf: (package, import,objectverklaringen; override def's voor parameters die standaardwaarden hebben). Dit kan gedeeltelijk worden vermeden als u uw eigen DSL implementeert. Bovendien leggen andere soorten configuraties (bijvoorbeeld XML) ook bepaalde beperkingen op aan de bestandsstructuur.
  3. Voor de doeleinden van dit bericht overwegen we geen dynamische herconfiguratie van een cluster van vergelijkbare knooppunten.

Conclusie

In dit bericht hebben we het idee onderzocht om de configuratie in de broncode weer te geven met behulp van de geavanceerde mogelijkheden van het Scala-type systeem. Deze aanpak kan in verschillende toepassingen worden gebruikt ter vervanging van traditionele configuratiemethoden op basis van XML- of tekstbestanden. Hoewel ons voorbeeld in Scala is geïmplementeerd, kunnen dezelfde ideeën worden overgedragen naar andere gecompileerde talen (zoals Kotlin, C#, Swift, ...). U kunt deze aanpak uitproberen in een van de volgende projecten, en als het niet werkt, ga dan verder met het tekstbestand en voeg de ontbrekende delen toe.

Uiteraard vereist een samengestelde configuratie een kwalitatief hoogstaand ontwikkelproces. In ruil daarvoor wordt een hoge kwaliteit en betrouwbaarheid van de configuraties gegarandeerd.

De overwogen aanpak kan worden uitgebreid:

  1. U kunt macro's gebruiken om controles tijdens het compileren uit te voeren.
  2. U kunt een DSL implementeren om de configuratie op een voor eindgebruikers toegankelijke manier te presenteren.
  3. U kunt dynamisch resourcebeheer implementeren met automatische configuratie-aanpassing. Het wijzigen van het aantal knooppunten in een cluster vereist bijvoorbeeld dat (1) elk knooppunt een iets andere configuratie krijgt; (2) de clustermanager ontving informatie over nieuwe knooppunten.

Dankbetuigingen

Ik wil Andrei Saksonov, Pavel Popov en Anton Nekhaev bedanken voor de constructieve kritiek op het conceptartikel.

Bron: www.habr.com

Voeg een reactie