Kompilovaná konfigurácia distribuovaného systému

Rád by som vám povedal jeden zaujímavý mechanizmus na prácu s konfiguráciou distribuovaného systému. Konfigurácia je reprezentovaná priamo v kompilovanom jazyku (Scala) pomocou bezpečných typov. Tento príspevok poskytuje príklad takejto konfigurácie a rozoberá rôzne aspekty implementácie skompilovanej konfigurácie do celkového procesu vývoja.

Kompilovaná konfigurácia distribuovaného systému

(Angličtina)

Úvod

Vybudovanie spoľahlivého distribuovaného systému znamená, že všetky uzly používajú správnu konfiguráciu, synchronizovanú s ostatnými uzlami. Technológie DevOps (terraform, ansible alebo niečo podobné) sa zvyčajne používajú na automatické generovanie konfiguračných súborov (často špecifických pre každý uzol). Tiež by sme chceli mať istotu, že všetky komunikujúce uzly používajú identické protokoly (vrátane rovnakej verzie). V opačnom prípade bude do nášho distribuovaného systému zabudovaná nekompatibilita. Vo svete JVM je jedným z dôsledkov tejto požiadavky, že všade sa musí používať rovnaká verzia knižnice obsahujúcej protokolové správy.

A čo testovanie distribuovaného systému? Samozrejme, predpokladáme, že všetky komponenty majú unit testy predtým, ako prejdeme k integračnému testovaniu. (Aby sme mohli extrapolovať výsledky testov do runtime, musíme tiež poskytnúť identickú sadu knižníc v testovacej fáze a v runtime.)

Pri práci s integračnými testami je často jednoduchšie použiť rovnakú cestu triedy všade na všetkých uzloch. Všetko, čo musíme urobiť, je zabezpečiť, aby sa pri spustení používala rovnaká cesta triedy. (Aj keď je úplne možné prevádzkovať rôzne uzly s rôznymi cestami k triedam, pridáva to na zložitosti celkovej konfigurácii a ťažkostiam s testovaním nasadenia a integrácie.) Na účely tohto príspevku predpokladáme, že všetky uzly budú používať rovnakú cestu k triede.

Konfigurácia sa vyvíja spolu s aplikáciou. Verzie používame na identifikáciu rôznych štádií vývoja programu. Zdá sa logické identifikovať aj rôzne verzie konfigurácií. A umiestnite samotnú konfiguráciu do systému správy verzií. Ak je vo výrobe iba jedna konfigurácia, potom môžeme jednoducho použiť číslo verzie. Ak použijeme veľa produkčných inštancií, budeme ich potrebovať niekoľko
konfiguračné vetvy a dodatočný štítok okrem verzie (napríklad názov pobočky). Takto môžeme jasne identifikovať presnú konfiguráciu. Každý identifikátor konfigurácie jednoznačne zodpovedá špecifickej kombinácii distribuovaných uzlov, portov, externých zdrojov a verzií knižníc. Pre účely tohto príspevku budeme predpokladať, že existuje iba jedna vetva a konfiguráciu môžeme identifikovať bežným spôsobom pomocou troch čísel oddelených bodkou (1.2.3).

V moderných prostrediach sa konfiguračné súbory zriedka vytvárajú manuálne. Častejšie sa generujú počas nasadenia a už sa ich nedotýka (takže nič nepokaz). Vynára sa prirodzená otázka: prečo stále používame textový formát na ukladanie konfigurácie? Ako životaschopná alternatíva sa javí možnosť používať bežný kód na konfiguráciu a využívať výhody kontrol počas kompilácie.

V tomto príspevku preskúmame myšlienku reprezentácie konfigurácie vo vnútri skompilovaného artefaktu.

Kompilovaná konfigurácia

Táto časť poskytuje príklad statickej kompilovanej konfigurácie. Implementované sú dve jednoduché služby – služba echo a klient služby echo. Na základe týchto dvoch služieb sú zostavené dve možnosti systému. V jednej možnosti sú obe služby umiestnené na rovnakom uzle, v inej možnosti - na rôznych uzloch.

Distribuovaný systém zvyčajne obsahuje niekoľko uzlov. Uzly môžete identifikovať pomocou hodnôt určitého typu NodeId:

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

alebo

case class NodeId(hostName: String)

alebo

object Singleton
type NodeId = Singleton.type

Uzly plnia rôzne úlohy, spúšťajú služby a možno medzi nimi nadviazať TCP/HTTP spojenie.

Na popis TCP spojenia potrebujeme aspoň číslo portu. Chceli by sme tiež zohľadniť protokol, ktorý je podporovaný na tomto porte, aby sme zabezpečili, že klient aj server používajú rovnaký protokol. Zapojenie popíšeme pomocou nasledujúcej triedy:

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

kde Port - iba celé číslo Int s uvedením rozsahu prijateľných hodnôt:

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

Rafinované typy

Pozri knižnicu rafinovaný и môj správa. Stručne povedané, knižnica vám umožňuje pridať obmedzenia k typom, ktoré sa kontrolujú v čase kompilácie. V tomto prípade sú platné hodnoty čísla portu 16-bitové celé čísla. Pre kompilovanú konfiguráciu nie je používanie vylepšenej knižnice povinné, ale zlepšuje schopnosť kompilátora kontrolovať konfiguráciu.

Pre HTTP (REST) ​​protokoly môžeme okrem čísla portu potrebovať aj cestu k službe:

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

Fantómové typy

Na identifikáciu protokolu v čase kompilácie používame parameter typu, ktorý sa v triede nepoužíva. Toto rozhodnutie je spôsobené tým, že za behu nepoužívame inštanciu protokolu, ale chceli by sme, aby kompilátor skontroloval kompatibilitu protokolu. Uvedením protokolu nebudeme môcť odovzdať nevhodnú službu ako závislosť.

Jedným z bežných protokolov je REST API so serializáciou Json:

sealed trait JsonHttpRestProtocol[RequestMessage, ResponseMessage]

kde RequestMessage - typ žiadosti, ResponseMessage — typ odpovede.
Samozrejme, môžeme použiť iné popisy protokolov, ktoré poskytujú presnosť popisu, ktorú požadujeme.

Na účely tohto príspevku budeme používať zjednodušenú verziu protokolu:

sealed trait SimpleHttpGetRest[RequestMessage, ResponseMessage]

Tu je požiadavka reťazec pripojený k adrese URL a odpoveď je vrátený reťazec v tele odpovede HTTP.

Konfigurácia služby je opísaná názvom služby, portami a závislosťami. Tieto prvky môžu byť v Scale reprezentované niekoľkými spôsobmi (napr. HList-s, algebraické dátové typy). Na účely tohto príspevku použijeme Cake Pattern a predstavíme pomocou modulov trait'ov. (Vzor koláča nie je povinným prvkom tohto prístupu. Je to jednoducho jedna z možných implementácií.)

Závislosti medzi službami môžu byť reprezentované ako metódy, ktoré vracajú porty EndPoint's ďalších uzlov:

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

Na vytvorenie služby echo potrebujete iba číslo portu a označenie, že port podporuje protokol echo. Možno neuvedieme konkrétny port, pretože... vlastnosti umožňujú deklarovať metódy bez implementácie (abstraktné metódy). V tomto prípade by pri vytváraní konkrétnej konfigurácie kompilátor vyžadoval, aby sme poskytli implementáciu abstraktnej metódy a poskytli číslo portu. Keďže sme metódu implementovali, pri vytváraní špecifickej konfigurácie nemusíme špecifikovať iný port. Použije sa predvolená hodnota.

V konfigurácii klienta deklarujeme závislosť od služby echo:

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

Závislosť je rovnakého typu ako exportovaná služba echoService. Najmä v klientovi echo požadujeme rovnaký protokol. Pri prepojení dvoch služieb si teda môžeme byť istí, že všetko bude fungovať správne.

Realizácia služieb

Na spustenie a zastavenie služby je potrebná funkcia. (Schopnosť zastaviť službu je pre testovanie kritická.) Opäť existuje niekoľko možností na implementáciu takejto funkcie (napríklad by sme mohli použiť triedy typu založené na type konfigurácie). Na účely tohto príspevku použijeme Vzor koláča. Službu budeme reprezentovať pomocou triedy cats.Resource, pretože Táto trieda už poskytuje prostriedky na bezpečné zaručenie uvoľnenia prostriedkov v prípade problémov. Aby sme získali zdroj, musíme poskytnúť konfiguráciu a pripravený kontext runtime. Funkcia spustenia služby môže vyzerať takto:

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

kde

  • Config — typ konfigurácie pre túto službu
  • AddressResolver — runtime objekt, ktorý vám umožňuje zistiť adresy iných uzlov (pozri nižšie)

a iné typy z knižnice cats:

  • F[_] — typ účinku (v najjednoduchšom prípade F[A] môže to byť len funkcia () => A. V tomto príspevku použijeme cats.IO.)
  • Reader[A,B] - viac-menej synonymum funkcie A => B
  • cats.Resource - zdroj, ktorý je možné získať a uvoľniť
  • Timer — časovač (umožňuje na chvíľu zaspať a merať časové intervaly)
  • ContextShift - analógový ExecutionContext
  • Applicative — trieda typu efektu, ktorá vám umožňuje kombinovať jednotlivé efekty (takmer monáda). V zložitejších aplikáciách sa zdá lepšie použiť Monad/ConcurrentEffect.

Pomocou tohto podpisu funkcie môžeme implementovať niekoľko služieb. Napríklad služba, ktorá nič nerobí:

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

(Cm. zdroj, v ktorej sú implementované ďalšie služby - echo službu, echo klienta
и doživotné ovládače.)

Uzol je objekt, ktorý môže spúšťať viacero služieb (spustenie reťazca zdrojov zabezpečuje Cake Pattern):

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

Upozorňujeme, že špecifikujeme presný typ konfigurácie, ktorý je potrebný pre tento uzol. Ak zabudneme zadať jeden z typov konfigurácie požadovaných konkrétnou službou, dôjde k chybe kompilácie. Taktiež nebudeme môcť spustiť uzol, pokiaľ neposkytneme nejakému objektu vhodného typu všetky potrebné údaje.

Rozlíšenie názvu hostiteľa

Na pripojenie k vzdialenému hostiteľovi potrebujeme skutočnú IP adresu. Je možné, že adresa bude známa neskôr ako zvyšok konfigurácie. Potrebujeme teda funkciu, ktorá mapuje ID uzla na adresu:

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

Existuje niekoľko spôsobov, ako implementovať túto funkciu:

  1. Ak sú adresy známe pred nasadením, môžeme vygenerovať kód Scala pomocou
    adresy a potom spustite zostavenie. Tým sa skompilujú a spustia testy.
    V tomto prípade bude funkcia známa staticky a môže byť reprezentovaná v kóde ako mapovanie Map[NodeId, NodeAddress].
  2. V niektorých prípadoch je skutočná adresa známa až po spustení uzla.
    V tomto prípade môžeme implementovať „discovery service“, ktorá beží pred ostatnými uzlami a všetky uzly sa zaregistrujú do tejto služby a vyžiadajú si adresy iných uzlov.
  3. Ak môžeme upraviť /etc/hosts, potom môžete použiť preddefinované názvy hostiteľov (napr my-project-main-node и echo-backend) a jednoducho tieto mená prepojte
    s IP adresami počas nasadenia.

V tomto príspevku sa nebudeme podrobnejšie zaoberať týmito prípadmi. Pre naše
v príklade hračiek budú mať všetky uzly rovnakú IP adresu - 127.0.0.1.

Ďalej zvážime dve možnosti pre distribuovaný systém:

  1. Umiestnenie všetkých služieb na jeden uzol.
  2. A hosťovanie služby echo a klienta echo na rôznych uzloch.

Konfigurácia pre jeden uzol:

Konfigurácia jedného uzla

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

Objekt implementuje konfiguráciu klienta aj servera. Používa sa aj konfigurácia doby životnosti, takže po intervale lifetime ukončiť program. (Ctrl-C tiež funguje a uvoľňuje všetky zdroje správne.)

Rovnakú sadu konfiguračných a implementačných vlastností možno použiť na vytvorenie systému pozostávajúceho z dva samostatné uzly:

Konfigurácia dvoch uzlov

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

Dôležité! Všimnite si, ako sú služby prepojené. Službu implementovanú jedným uzlom špecifikujeme ako implementáciu metódy závislosti iného uzla. Typ závislosti kontroluje kompilátor, pretože obsahuje typ protokolu. Po spustení bude závislosť obsahovať správne ID cieľového uzla. Vďaka tejto schéme zadáme číslo portu presne raz a vždy zaručene odkazujeme na správny port.

Implementácia dvoch systémových uzlov

Pre túto konfiguráciu používame rovnaké implementácie služieb bez zmien. Jediný rozdiel je v tom, že teraz máme dva objekty, ktoré implementujú rôzne sady služieb:

  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
  }

Prvý uzol implementuje server a potrebuje iba konfiguráciu servera. Druhý uzol implementuje klienta a používa inú časť konfigurácie. Oba uzly tiež potrebujú správu životnosti. Serverový uzol beží na dobu neurčitú, kým sa nezastaví SIGTERM'om a klientsky uzol sa po určitom čase ukončí. Cm. spúšťacia aplikácia.

Všeobecný vývojový proces

Pozrime sa, ako tento konfiguračný prístup ovplyvňuje celkový proces vývoja.

Konfigurácia sa skompiluje spolu so zvyškom kódu a vygeneruje sa artefakt (.jar). Zdá sa, že má zmysel umiestniť konfiguráciu do samostatného artefaktu. Je to preto, že môžeme mať viacero konfigurácií založených na rovnakom kóde. Opäť je možné generovať artefakty zodpovedajúce rôznym vetvám konfigurácie. Závislosti na konkrétnych verziách knižníc sa uložia spolu s konfiguráciou a tieto verzie sa uložia navždy vždy, keď sa rozhodneme nasadiť danú verziu konfigurácie.

Akákoľvek zmena konfigurácie sa zmení na zmenu kódu. A preto každý
zmena bude pokrytá bežným procesom zabezpečenia kvality:

Lístok v nástroji na sledovanie chýb -> PR -> recenzia -> zlúčenie s príslušnými pobočkami ->
integrácia -> nasadenie

Hlavné dôsledky implementácie skompilovanej konfigurácie sú:

  1. Konfigurácia bude konzistentná vo všetkých uzloch distribuovaného systému. Vzhľadom na skutočnosť, že všetky uzly dostávajú rovnakú konfiguráciu z jedného zdroja.

  2. Je problematické zmeniť konfiguráciu iba v jednom z uzlov. Preto je „posun konfigurácie“ nepravdepodobný.

  3. Je ťažšie vykonať malé zmeny v konfigurácii.

  4. Väčšina konfiguračných zmien nastane ako súčasť celkového procesu vývoja a bude predmetom kontroly.

Potrebujem samostatné úložisko na uloženie produkčnej konfigurácie? Táto konfigurácia môže obsahovať heslá a iné citlivé informácie, ku ktorým by sme chceli obmedziť prístup. Na základe toho sa zdá, že má zmysel uložiť konečnú konfiguráciu do samostatného úložiska. Konfiguráciu môžete rozdeliť na dve časti – jedna obsahuje verejne prístupné konfiguračné nastavenia a druhá obsahuje obmedzené nastavenia. To umožní väčšine vývojárov prístup k bežným nastaveniam. Toto oddelenie sa dá ľahko dosiahnuť pomocou prechodných znakov obsahujúcich predvolené hodnoty.

Možné variácie

Skúsme porovnať skompilovanú konfiguráciu s niektorými bežnými alternatívami:

  1. Textový súbor na cieľovom počítači.
  2. Centralizovaný obchod s pármi kľúč – hodnota (etcd/zookeeper).
  3. Komponenty procesu, ktoré je možné prekonfigurovať/reštartovať bez reštartovania procesu.
  4. Ukladanie konfigurácie mimo kontroly artefaktov a verzií.

Textové súbory poskytujú značnú flexibilitu z hľadiska malých zmien. Správca systému sa môže prihlásiť do vzdialeného uzla, vykonať zmeny v príslušných súboroch a reštartovať službu. Pre veľké systémy však takáto flexibilita nemusí byť žiaduca. Vykonané zmeny nezanechajú žiadne stopy v iných systémoch. Zmeny nikto nekontroluje. Je ťažké určiť, kto presne zmeny vykonal a z akého dôvodu. Zmeny nie sú testované. Ak je systém distribuovaný, správca môže zabudnúť vykonať zodpovedajúcu zmenu na iných uzloch.

(Treba tiež poznamenať, že použitím skompilovanej konfigurácie sa nezatvára možnosť použitia textových súborov v budúcnosti. Bude stačiť pridať parser a validátor, ktorý produkuje rovnaký typ ako výstup Configa môžete použiť textové súbory. Okamžite z toho vyplýva, že zložitosť systému s skompilovanou konfiguráciou je o niečo menšia ako zložitosť systému používajúceho textové súbory, pretože textové súbory vyžadujú dodatočný kód.)

Centralizované úložisko kľúč – hodnota je dobrým mechanizmom na distribúciu meta parametrov distribuovanej aplikácie. Musíme sa rozhodnúť, čo sú konfiguračné parametre a čo sú len údaje. Dajme si funkciu C => A => Ba parametre C zriedkavo zmeny a údaje A - často. V tomto prípade to môžeme povedať C - konfiguračné parametre a A - údaje. Zdá sa, že konfiguračné parametre sa líšia od údajov v tom, že sa vo všeobecnosti menia menej často ako údaje. Údaje zvyčajne pochádzajú z jedného zdroja (od používateľa) a konfiguračné parametre z iného (od správcu systému).

Ak je potrebné aktualizovať zriedkavo sa meniace parametre bez reštartovania programu, môže to často viesť ku komplikáciám programu, pretože budeme musieť nejakým spôsobom dodať parametre, uložiť, analyzovať a skontrolovať a spracovať nesprávne hodnoty. Preto z hľadiska zníženia zložitosti programu má zmysel znížiť počet parametrov, ktoré sa môžu počas prevádzky programu meniť (alebo takéto parametre vôbec nepodporovať).

Pre účely tohto príspevku budeme rozlišovať medzi statickými a dynamickými parametrami. Ak logika služby vyžaduje zmenu parametrov počas prevádzky programu, potom budeme takéto parametre nazývať dynamické. V opačnom prípade sú možnosti statické a možno ich nakonfigurovať pomocou zostavenej konfigurácie. Pre dynamickú rekonfiguráciu možno budeme potrebovať mechanizmus na reštartovanie častí programu s novými parametrami, podobne ako sa reštartujú procesy operačného systému. (Podľa nášho názoru je vhodné vyhnúť sa rekonfigurácii v reálnom čase, pretože to zvyšuje zložitosť systému. Ak je to možné, je lepšie použiť štandardné možnosti OS na reštartovanie procesov.)

Jedným z dôležitých aspektov používania statickej konfigurácie, ktorý núti ľudí zvážiť dynamickú rekonfiguráciu, je čas potrebný na reštartovanie systému po aktualizácii konfigurácie (odstávka). V skutočnosti, ak potrebujeme vykonať zmeny v statickej konfigurácii, budeme musieť reštartovať systém, aby sa nové hodnoty prejavili. Problém prestojov sa v rôznych systémoch líši v závažnosti. V niektorých prípadoch môžete naplánovať reštart v čase, keď je zaťaženie minimálne. Ak potrebujete poskytovať nepretržitú službu, môžete ju implementovať Vypúšťanie prípojky AWS ELB. Zároveň, keď potrebujeme reštartovať systém, spustíme paralelnú inštanciu tohto systému, prepneme naň balancer a počkáme na dokončenie starých pripojení. Po ukončení všetkých starých pripojení vypneme starú inštanciu systému.

Pozrime sa teraz na otázku uloženia konfigurácie vnútri alebo mimo artefaktu. Ak konfiguráciu uložíme do vnútra artefaktu, tak sme aspoň mali možnosť overiť si správnosť konfigurácie pri montáži artefaktu. Ak je konfigurácia mimo kontrolovaného artefaktu, je ťažké sledovať, kto a prečo vykonal zmeny v tomto súbore. Nakoľko je to dôležité? Podľa nášho názoru je pre mnohé výrobné systémy dôležité mať stabilnú a kvalitnú konfiguráciu.

Verzia artefaktu vám umožňuje určiť, kedy bol vytvorený, aké hodnoty obsahuje, aké funkcie sú povolené/deaktivované a kto je zodpovedný za akúkoľvek zmenu v konfigurácii. Samozrejme, uloženie konfigurácie do artefaktu si vyžaduje určité úsilie, takže musíte urobiť informované rozhodnutie.

Klady a zápory

Rád by som sa pozastavil nad výhodami a nevýhodami navrhovanej technológie.

Výhody

Nižšie je uvedený zoznam hlavných funkcií zostavenej konfigurácie distribuovaného systému:

  1. Kontrola statickej konfigurácie. Umožňuje vám to mať istotu
    konfigurácia je správna.
  2. Bohatý konfiguračný jazyk. Iné konfiguračné metódy sú zvyčajne obmedzené nanajvýš na substitúciu reťazcových premenných. Pri používaní Scala je k dispozícii široká škála jazykových funkcií na zlepšenie vašej konfigurácie. Napríklad môžeme použiť
    vlastnosti pre predvolené hodnoty, pomocou objektov na zoskupenie parametrov, môžeme odkazovať na hodnoty deklarované iba raz (DRY) v priloženom rozsahu. Môžete vytvoriť inštanciu ľubovoľných tried priamo v konfigurácii (Seq, Map, vlastné triedy).
  3. DSL. Scala má množstvo jazykových funkcií, ktoré uľahčujú vytvorenie DSL. Je možné využiť tieto vlastnosti a implementovať konfiguračný jazyk, ktorý je vhodnejší pre cieľovú skupinu používateľov, aby bola konfigurácia aspoň čitateľná pre doménových expertov. Špecialisti sa môžu napríklad zúčastniť procesu kontroly konfigurácie.
  4. Integrita a synchronizácia medzi uzlami. Jednou z výhod uloženia konfigurácie celého distribuovaného systému v jednom bode je, že všetky hodnoty sú deklarované presne raz a potom sa znova použijú, kedykoľvek sú potrebné. Použitie fantómových typov na deklarovanie portov zaisťuje, že uzly používajú kompatibilné protokoly vo všetkých správnych systémových konfiguráciách. Výslovné povinné závislosti medzi uzlami zaisťujú, že všetky služby sú prepojené.
  5. Vysoká kvalita zmien. Vykonávanie zmien konfigurácie pomocou spoločného vývojového procesu umožňuje dosiahnuť vysoké štandardy kvality aj pre konfiguráciu.
  6. Súčasná aktualizácia konfigurácie. Automatické nasadenie systému po zmenách konfigurácie zaisťuje aktualizáciu všetkých uzlov.
  7. Zjednodušenie aplikácie. Aplikácia nepotrebuje analýzu, kontrolu konfigurácie alebo spracovanie nesprávnych hodnôt. Tým sa znižuje zložitosť aplikácie. (Časť zložitosti konfigurácie pozorovanej v našom príklade nie je atribútom skompilovanej konfigurácie, ale iba vedomým rozhodnutím poháňaným túžbou poskytnúť väčšiu bezpečnosť typu.) Je celkom jednoduché vrátiť sa k bežnej konfigurácii – stačí implementovať chýbajúce časti. Preto môžete napríklad začať s skompilovanou konfiguráciou, pričom implementáciu nepotrebných častí odložíte na čas, kedy to bude skutočne potrebné.
  8. Overená konfigurácia. Keďže zmeny konfigurácie sledujú zvyčajný osud akýchkoľvek iných zmien, výstupom, ktorý dostaneme, je artefakt s jedinečnou verziou. To nám napríklad umožňuje vrátiť sa v prípade potreby k predchádzajúcej verzii konfigurácie. Dokonca môžeme použiť konfiguráciu spred roka a systém bude fungovať úplne rovnako. Stabilná konfigurácia zlepšuje predvídateľnosť a spoľahlivosť distribuovaného systému. Keďže konfigurácia je fixovaná vo fáze kompilácie, je dosť ťažké ju sfalšovať vo výrobe.
  9. Modularita. Navrhovaný rámec je modulárny a moduly je možné rôznymi spôsobmi kombinovať a vytvárať tak rôzne systémy. Konkrétne môžete systém nakonfigurovať tak, aby bežal na jednom uzle v jednom uskutočnení a na viacerých uzloch v inom. Môžete vytvoriť niekoľko konfigurácií pre produkčné inštancie systému.
  10. Testovanie. Nahradením jednotlivých služieb falošnými objektmi môžete získať niekoľko verzií systému, ktoré sú vhodné na testovanie.
  11. Integračné testovanie. Jednotná konfigurácia pre celý distribuovaný systém umožňuje spúšťať všetky komponenty v kontrolovanom prostredí v rámci integračného testovania. Je ľahké napodobniť napríklad situáciu, keď sa niektoré uzly stanú prístupnými.

Nevýhody a obmedzenia

Kompilovaná konfigurácia sa líši od iných konfiguračných prístupov a nemusí byť vhodná pre niektoré aplikácie. Nižšie sú uvedené niektoré nevýhody:

  1. Statická konfigurácia. Niekedy je potrebné rýchlo opraviť konfiguráciu vo výrobe a obísť všetky ochranné mechanizmy. S týmto prístupom to môže byť ťažšie. Prinajmenšom bude stále potrebná kompilácia a automatické nasadenie. Toto je užitočná vlastnosť prístupu a v niektorých prípadoch aj nevýhoda.
  2. Generovanie konfigurácie. V prípade, že konfiguračný súbor generuje automatický nástroj, môže byť potrebné ďalšie úsilie na integráciu skriptu zostavenia.
  3. Nástroje. V súčasnosti sú nástroje a techniky navrhnuté na prácu s konfiguráciou založené na textových súboroch. Nie všetky takéto nástroje/techniky budú dostupné v zostavenej konfigurácii.
  4. Je potrebná zmena postojov. Vývojári a DevOps sú zvyknutí na textové súbory. Samotná myšlienka zostavenia konfigurácie môže byť trochu neočakávaná a nezvyčajná a spôsobiť odmietnutie.
  5. Vyžaduje sa vysokokvalitný vývojový proces. Pre pohodlné používanie zostavenej konfigurácie je potrebná plná automatizácia procesu budovania a nasadzovania aplikácie (CI/CD). Inak to bude dosť nepohodlné.

Pozrime sa tiež na niekoľko obmedzení uvažovaného príkladu, ktoré nesúvisia s myšlienkou skompilovanej konfigurácie:

  1. Ak poskytneme nepotrebné konfiguračné informácie, ktoré uzol nevyužíva, potom nám kompilátor nepomôže odhaliť chýbajúcu implementáciu. Tento problém sa dá vyriešiť opustením tortového vzoru a použitím pevnejších typov, napr. HList alebo algebraické dátové typy (triedy prípadov) na reprezentáciu konfigurácie.
  2. V konfiguračnom súbore sú riadky, ktoré nesúvisia so samotnou konfiguráciou: (package, import,deklarácie objektu; override def's pre parametre, ktoré majú predvolené hodnoty). Tomu sa dá čiastočne predísť, ak implementujete svoje vlastné DSL. Okrem toho iné typy konfigurácie (napríklad XML) tiež ukladajú určité obmedzenia na štruktúru súborov.
  3. Pre účely tohto príspevku neuvažujeme o dynamickej rekonfigurácii zhluku podobných uzlov.

Záver

V tomto príspevku sme skúmali myšlienku reprezentácie konfigurácie v zdrojovom kóde pomocou pokročilých možností systému typu Scala. Tento prístup môže byť použitý v rôznych aplikáciách ako náhrada za tradičné konfiguračné metódy založené na xml alebo textových súboroch. Aj keď je náš príklad implementovaný v Scale, rovnaké nápady je možné preniesť do iných kompilovaných jazykov (ako Kotlin, C#, Swift, ...). Tento prístup môžete vyskúšať v jednom z nasledujúcich projektov a ak to nefunguje, prejdite na textový súbor a pridajte chýbajúce časti.

Prirodzene, skompilovaná konfigurácia vyžaduje vysokokvalitný vývojový proces. Na oplátku je zabezpečená vysoká kvalita a spoľahlivosť konfigurácií.

Uvažovaný prístup možno rozšíriť:

  1. Na vykonanie kontrol počas kompilácie môžete použiť makrá.
  2. Môžete implementovať DSL na prezentáciu konfigurácie spôsobom, ktorý je prístupný koncovým používateľom.
  3. Môžete implementovať dynamickú správu prostriedkov s automatickou úpravou konfigurácie. Napríklad zmena počtu uzlov v klastri vyžaduje, aby (1) každý uzol dostal mierne odlišnú konfiguráciu; (2) manažér klastra dostal informácie o nových uzloch.

Poďakovanie

Chcel by som poďakovať Andrei Saksonovovi, Pavlovi Popovovi a Antonovi Nekhaevovi za konštruktívnu kritiku návrhu článku.

Zdroj: hab.com

Pridať komentár