Konfigurimi i kompilueshëm i një sistemi të shpërndarë

Dëshiroj të flas për një mekanizëm interesant të punës me konfigurimin e një sistemi të shpërndarë. Konfigurimi paraqitet drejtpërdrejt në një gjuhë të kompilueshme (Scala) duke përdorur lloje të sigurta. Në këtë post, synohet të shqyrtohet një shembull i tillë konfigurimi dhe të shqyrtohen aspekte të ndryshme të integrimit të konfigurimit të kompilueshëm në procesin e zhvillimit të përgjithshëm.

Konfigurimi i kompilueshëm i një sistemi të shpërndarë

(english)

Hyrje

Ndërtimi i një sistemi të besueshëm të shpërndarë nënkupton se në të gjitha nyjet përdoret një konfigurim i saktë, i sinkronizuar me nyjet e tjera. Zakonisht përdoren teknologjitë DevOps (terraform, ansible ose diçka e ngjashme) për gjenerimin automatik të skedarëve të konfigurimit (shpesh specifikë për çdo nyje). Ne gjithashtu duam të jemi të sigurt se në të gjitha nyjet që ndërveprojnë përdoren protokolle identike (përfshirë versionin e njëjtë). Në të kundërt, do të ketë një moskompatibilitet në sistemin tonë të shpërndarë. Në botën e JVM-së, një nga pasojat e një kërkese të tillë është nevoja për të përdorur gjithandej versionin e njëjtë të bibliotekës që përmban mesazhet e protokollit.

ÇfarĂ« mendoni pĂ«r testimin e sistemit tĂ« shpĂ«rndarĂ«? Natyrisht, ne supozojmĂ« se pĂ«r tĂ« gjitha komponentĂ«t janĂ« parashikuar teste unitare, para se tĂ« kalojmĂ« nĂ« testimin integrues. (QĂ« tĂ« mund tĂ« ekstrapolojmĂ« rezultatet e testimit nĂ« runtime, ne gjithashtu duhet tĂ« sigurojmĂ« njĂ« set identik bibliotekash gjatĂ« testimit dhe nĂ« runtime.)

Kur punojmë me teste integruese, shpesh është më e lehtë të përdorim një classpath të njëjtë në të gjitha nyjet. Na mbetet vetëm të sigurojmë që e njëjta classpath të përdoret edhe në runtime. (Megjithatë, është krejtësisht e mundur të ekzekutoni nyje të ndryshme me classpath të ndryshme, por kjo krijon komplikime në të gjithë konfigurimin dhe vështirësi në implementimin dhe testet integruese.) Në këtë post, ne supozojmë se të gjitha nyjet do të përdorin të njëjtën classpath.

Konfigurimi zhvillohet së bashku me aplikacionin. Për të identifikuar faza të ndryshme të evolucionit të programeve, ne përdorim versionet. Duket logjike gjithashtu të identifikojmë versionet e ndryshme të konfigurimeve. Po ashtu, konfigurimin e vendosim në një sistem kontrolli versionesh. Nëse në production ekziston një konfigurim i vetëm, mund të përdorim thjesht numrin e versionit. Nëse përdoren shumë instanca produksioni, do na duhen disa
dega konfigurimi dhe njĂ« etiketĂ« shtesĂ« pĂ«rveç versionit (pĂ«r shembull, emri i degĂ«s). KĂ«shtu do tĂ« jemi nĂ« gjendje tĂ« identifikojmĂ« me saktĂ«si konfigurimin e saktĂ«. Çdo identifikues konfigurimi pĂ«rputhet saktĂ«sisht me njĂ« kombinim tĂ« caktuar tĂ« nyjeve tĂ« shpĂ«rndara, porteve, burimeve tĂ« jashtme, versioneve tĂ« bibliotekave. NĂ« kuadĂ«r tĂ« kĂ«tij postimi, ne do ta konsiderojmĂ« se ka vetĂ«m njĂ« degĂ« dhe mund ta identifikojmĂ« konfigurimin nĂ« mĂ«nyrĂ« tĂ« zakonshme duke pĂ«rdorur tre numra tĂ« ndarĂ« me pikĂ« (1.2.3).

Në ambientet moderne, skedarët e konfigurimit krijohen manualisht mjaft rrallë. Më shpesh ata gjenerohen gjatë shpërndarjes dhe zakonisht nuk preken më (që të mos prishet asgjë). Lind një pyetje logjike, pse ende e përdorim formatin tekstual për ruajtjen e konfigurimit? Një alternativë e qëndrueshme duket të jetë përdorimi i kodit të zakonshëm për konfigurim dhe të përfitosh përparësitë nga verifikimet gjatë kompilim.

Në këtë postim ne do të shqyrtojmë pikërisht idenë e paraqitjes së konfigurimit brenda artefaktit të kompiliuar.

Konfigurimi i kompiliuar

NĂ« kĂ«tĂ« seksion do tĂ« shqyrtojmĂ« njĂ« shembull tĂ« konfigurimit statik tĂ« kompiluar. Realizohen dy shĂ«rbime tĂ« thjeshta — shĂ«rbimi echo dhe klienti i shĂ«rbimit echo. NĂ« bazĂ« tĂ« kĂ«tyre dy shĂ«rbimeve ndĂ«rtohen dy variante tĂ« sistemit. NĂ« njĂ« variant tĂ« dy shĂ«rbimet ndodhen nĂ« njĂ« nyje, ndĂ«rsa nĂ« variantin tjetĂ«r — nĂ« nyje tĂ« ndryshme.

Zakonisht një sistem i shpërndarë përmban disa nyje. Nyjet mund të identifikohen me vlera të një lloji të caktuar NodeId:

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

ose

case class NodeId(hostName: String)

apo madje

object Singleton
type NodeId = Singleton.type

Nyjet kryejnë role të ndryshme, ato kanë shërbime të instaluara dhe midis tyre mund të ketë lidhje TCP/HTTP.

Për të përshkruar lidhjen TCP, nevojitet të paktën numri i portit. Ne gjithashtu do të donim të reflektornim protokollin që mbështetet në këtë port, për të garantuar që si klienti ashtu edhe serveri përdorin të njëjtin protokoll. Do të përshkruajmë lidhjen me klasën e tillë:

kasa klasa TcpEndPoint[Protocol](node: NodeId, port: Port[Protocol])

ku Port — vetĂ«m njĂ« numĂ«r tĂ« plotĂ« Int me tregimin e intervaleve tĂ« vlerave tĂ« lejuara:

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

Llojet e sqaruara

Shiko bibliotekën refinuar dhe im referatin. Për arsye të shkurtër, biblioteka lejon të shtohen kufizime në lloje, të cilat kontrollohen në fazën e kompilimit. Në këtë rast, vlerat e lejuara të numrit të portit janë numra të plotë 16-bitë. Për konfigurimin e kompiluar, përdorimi i bibliotekës refined nuk është detyrueshëm, por përmirëson mundësitë e kompiluesit për kontrollin e konfigurimit.

Për protokollet HTTP (REST) përveç numrit të portit, mund të na nevojitet gjithashtu një rrugë drejt shërbimit:

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

Llojet fantazmë

Për identifikimin e protokollit gjatë fazës së kompilimit, ne përdorim një parametr të llojit, që nuk përdoret brenda klasës. Kjo zgjidhje lidhet me faktin se në runtime ne nuk përdorim instancën e protokollit, por do të donim që kompiluesi të kontrollonte compatibilitetin e protokolleve. Përmes specifikimit të protokollit, ne nuk mund të dërgojmë një shërbim të papërshtatshëm si varësi.

Një nga protokollet më të zakonshme është REST API me serializimin Json:

sealed trait JsonHttpRestProtocol[RequestMessage, ResponseMessage]

ku RequestMessage — lloji i kĂ«rkesĂ«s, ResponseMessage — lloji i pĂ«rgjigjes.
Natyrisht, mund të përdoren edhe përshkrime të tjera të protokolleve, që sigurojnë saktësinë e kërkuar të përshkrimit.

Për qëllimet e këtij postimi, ne do të përdorim një version të thjeshtuar të protokollit:

sealed trait SimpleHttpGetRest[RequestMessage, ResponseMessage]

KĂ«tu kĂ«rkesa pĂ«rfaqĂ«son njĂ« varg, i cili shtohet nĂ« url, dhe pĂ«rgjigjja — vargu qĂ« kthehet nĂ« trupin e pĂ«rgjigjes HTTP.

Konfigurimi i shĂ«rbimit pĂ«rshkruhet nga emri i shĂ«rbimit, portet dhe varĂ«sitĂ«. KĂ«to elemente mund tĂ« paraqiten nĂ« Scala nĂ« disa mĂ«nyra (p.sh., HList-a, lloje algebrike tĂ« tĂ« dhĂ«nave). PĂ«r qĂ«llimet e kĂ«tij postimi, ne do tĂ« pĂ«rdorim Cake Pattern dhe do tĂ« paraqesim modulet me trait‘at. (Cake Pattern nuk Ă«shtĂ« njĂ« element i detyrueshĂ«m i qasjes sĂ« pĂ«rshkruar. Kjo Ă«shtĂ« thjesht njĂ« nga realizimet e mundshme.)

VarĂ«sitĂ« midis shĂ«rbimeve mund tĂ« paraqiten si metoda, qĂ« kthejnĂ« portet EndPoint‘a tĂ« nyjeve tĂ« tjera:

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

PĂ«r tĂ« krijuar njĂ« shĂ«rbim echo, mjafton tĂ« kemi numrin e portit dhe tĂ« specifikojmĂ« se ky port mbĂ«shtet protokollin echo. Mund tĂ« mos e specifikojmĂ« portin konkret, pasi trait’ët lejojnĂ« shpalljen e metodave pa realizim (metoda abstrakte). NĂ« kĂ«tĂ« rast, kur krijonim njĂ« konfigurim konkret, kompajleri do tĂ« kĂ«rkonte nga ne tĂ« siguronim realizimin e metodĂ«s abstrakte dhe tĂ« ofronim numrin e portit. Tani qĂ« kemi realizuar metodĂ«n, kur krijojmĂ« njĂ« konfigurim konkret nuk kemi nevojĂ« tĂ« specifikojmĂ« ndonjĂ« port tjetĂ«r. Do tĂ« pĂ«rdoret vlera e paracaktuar.

Në konfigurimin e klientit shpallim varësinë nga shërbimi echo:

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

Varësia ka të njëjtin tip si shërbimi i eksportuar shërbimi echo. Në veçanti, në klientin echo kërkojmë të njëjtin protokoll. Prandaj, kur lidhen dy shërbime, mund të jemi të sigurt se gjithçka do të funksionojë siç duhet.

Implementimi i shërbimeve

Për të nisur dhe ndaluar shërbimin kërkohet një funksion. (Mundësia e ndalimit të shërbimit është kritikisht e rëndësishme për testimin.) Përsëri, ka disa mundësi për implementimin e një funksioni të tillë (p.sh., mund të përdorim klasa tipesh në bazë të tipit të konfigurimit). Për qëllimet e këtij posti do të përdorim Cake Pattern. Do ta paraqesim shërbimin përmes një klase cats.Resource, pasi në këtë klasë janë parashikuar mjetet për çlirimin e sigurt dhe të garantuar të burimeve në rast probleme. Për të marrë burimin, na nevojitet konfigurimi dhe konteksti i gatshëm runtime. Funksioni i nisjes së shërbimit mund të ketë pamjen e mëposhtme:

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

ku

  • Konfigurimi — tipi i konfigurimit pĂ«r kĂ«tĂ« shĂ«rbim
  • AddressResolver — objekti i kohĂ«s sĂ« ekzekutimit, i cili lejon tĂ« njohim adresat e nyjave tĂ« tjera (shih mĂ« poshtĂ«)

dhe tipet e tjera nga biblioteka cats:

  • F[_] — tipi i efektit (nĂ« rastin mĂ« tĂ« thjeshtĂ« F[A] mund tĂ« jetĂ« thjesht njĂ« funksion () => A. NĂ« kĂ«tĂ« post do tĂ« pĂ«rdorim cats.IO.)
  • Reader[A,B] — mĂ« shumĂ« ose pak sinonim i funksionit A => B
  • cats.Resource — burimi, i cili mund tĂ« merret dhe çlirohet
  • Timer — timer (lejon tĂ« flejĂ« pĂ«r njĂ«farĂ« kohe dhe mat intervalet e kohĂ«s)
  • ContextShift — ekuivalent ExecutionContext
  • Applicative — klasa e tipit tĂ« efektit, e cila lejon bashkimin e efekteve tĂ« veçanta (gati monada). NĂ« aplikacione mĂ« komplekse, duket se Ă«shtĂ« mĂ« mirĂ« tĂ« pĂ«rdoret Monad/ConcurrentEffect.

Duke përdorur këtë nënshkrim funksioni, mund të implementojmë disa shërbime. Për shembull, një shërbim që nuk bën asgjë:

  trait ZeroServiceImpl[F[_]] extends ServiceImpl[F] {
    type Config  Resource.pure[F, Unit](()))
  }

(Shih. kodin burimor, ku janĂ« implementuar shĂ«rbime tĂ« tjera — shĂ«rbimi echo, klienti echo
dhe kontrolluesit e jetëgjatësisë.)

Nod është një objekt që mund të nisë disa shërbime (nisja e zinxhirit të burimeve sigurohet falë Cake Pattern):

objekti SingleNodeImpl extend ZeroServiceImpl[IO]
  me EchoServiceService
  me EchoClientService
  me FiniteDurationLifecycleServiceImpl
{
  type Config = EchoConfig[String] me EchoClientConfig[String] me FiniteDurationLifecycleConfig
}

Kujdes, ne përcaktojmë llojin e saktë të konfigurimit që kërkohet për këtë nod. Nëse harrojmë të specifikojmë ndonjë nga llojet e konfigurimit të nevojshme për një shërbim të caktuar, do të ketë një gabim kompilimi. Gjithashtu, nuk do të mund të nisim nodin nëse nuk ofrojmë një objekt me një lloj të përshtatshëm me të gjitha të dhënat e nevojshme.

Zgjidhja e emrave të nodit

Për të u lidhur me një nod të largët, na nevojitet një adresë IP reale. Ka të ngjarë që adresa të bëhet e njohur më vonë se pjesët e tjera të konfigurimit. Prandaj, na nevojitet një funksion që shndërron identifikuesin e nodit në adresë:

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

Mund të sugjerojmë disa mënyra për të implementuar një funksion të tillë:

  1. Nëse adresat bëhen të njohura para deploy-imit, mund të gjenerojmë kod Scala me
    adresat dhe pastaj të ekzekutojmë ndërtimin. Në këtë rast, do të kryhet kompilimi dhe do të ekzekutohen testet.
    Në këtë rast, funksioni do të jetë i njohur statikisht dhe mund të paraqitet në kod si një shndërrim Map[NodeId, NodeAddress].
  2. Në disa raste, adresa e vlefshme bëhet e njohur vetëm pas nisjes së nodit.
    Në këtë rast, mund të implementojmë një «shërbim zbulimi» (discovery), i cili niset para nodave të tjera dhe të gjithë nodet regjistrohen në këtë shërbim dhe kërkojnë adresat e nodave të tjera.
  3. Nëse mund ta modifikojmë /etc/hosts, mund të përdorim emra hostesh të paracaktuar (siç janë nyja-kryesore-mia dhe echo-backend) dhe thjesht t'i lidhim këta emra
    me adresat IP gjatë deploy-imit.

Në këtë postim nuk do të shqyrtojmë këto raste më në detaje. Për shembullin tonë
lojĂ«, tĂ« gjithĂ« nodet do tĂ« kenĂ« njĂ« adresĂ« IP — 127.0.0.1.

Tani do të shqyrtojmë dy variante të një sistemi të shpërndarë:

  1. Vendosja e të gjithë shërbimeve në një nod.
  2. Dhe vendosja e shërbimit echo dhe klientit echo në nodet të ndryshme.

Konfigurimi për një nod:

Konfigurimi për një nod

object SingleNodeConfig extends EchoConfig[String] 
  with EchoClientConfig[String] with FiniteDurationLifecycleConfig
{
  case object Singleton \/\/ identifikuesi i nodit të vetëm 
  \/\/ konfigurimi i serverit
  type NodeId = Singleton.type
  def nodeId = Singleton

  \/** Specifikimi i portit të shërbimit të sigurt. *\/ 
  override def portNumber: PortNumber = 8088

  \/\/ konfigurimi i klientit

  \/** Ne do të përdorim shërbimin që ofrohet nga hosti të njëjtë. *\/ 
  def echoServiceDependency = echoService

  override def testMessage: UrlPathElement = "hello"

  def pollInterval: FiniteDuration = 1.second

  \/\/ konfigurimi i kontrolluesit të ciklit të jetës
  def lifetime: FiniteDuration = 10500.milliseconds \/\/ 0.5 sekonda të tjera në mënyrë që të ketë 10 kërkesa, jo 9.
}

Objekti implementon konfigurimin e klientit dhe serverit. Gjithashtu përdoret konfigurimi i jetëgjatësisë, për të përfunduar programin pas një intervalu jetës shkëputësi gjithashtu funksionon dhe çliron në mënyrë korrekte të gjithë burimet.)

TĂ« njĂ«jtin grup trait’esh konfigurimi dhe implementimesh mund tĂ« pĂ«rdorim pĂ«r tĂ« krijuar njĂ« sistem qĂ« pĂ«rbĂ«het nga dy nyje tĂ« veçanta:

Konfigurimi për dy nyje

  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! specifikimi i varësisë
    def echoServiceDependency = NodeServerConfig.echoService

    def pollInterval: FiniteDuration = 1.second

    def lifetime: FiniteDuration = 10500.milliseconds \/\/ 0.5 sekonda të tjera në mënyrë që të ketë 10 kërkesa, jo 9.

    def testMessage: String = "dolly"
  }

ËshtĂ« e rĂ«ndĂ«sishme! Vini re se si kryhet lidhja e shĂ«rbimeve. Ne specifikojmĂ« shĂ«rbimin e implementuar nga njĂ« nyje si implementim tĂ« metodĂ«s sĂ« varĂ«sisĂ« sĂ« njĂ« nyje tjetĂ«r. Lloji i varĂ«sisĂ« kontrollohet nga kompilatori, pasi pĂ«rmban llojin e protokollit. Kur nisemi, varĂ«sia do tĂ« pĂ«rmbajĂ« identifikuesin e saktĂ« tĂ« nyjes cible. FalĂ« kĂ«saj skeme, ne specifikojmĂ« numrin e portit saktĂ«sisht njĂ« herĂ« dhe pĂ«rherĂ« drejtohemi nĂ« portin e duhur.

Implementimi i dy nyjeve të sistemit

Për këtë konfigurim, ne përdorim të njëjtat implementime shërbimesh pa ndryshime. E vetmja dallim është se tani kemi dy objekte që implementojnë grupe të ndryshme shërbimesh:

  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
  }

Nyja e parë implementon serverin dhe ka nevojë vetëm për konfigurimin e serverit. Nyja e dytë implementon klientin dhe përdor një pjesë tjetër të konfigurimit. Gjithashtu, të dy nyjet kanë nevojë për menaxhimin e jetëgjatësisë. Nyja e serverit funksionon pa kufizime derisa të ndalet SIGTERMndërsa nyja e klientit përfundon pas një kohe. Shih. aplikacionin e nisjes.

Procesi i përgjithshëm i zhvillimit

Le të shohim se si ky qasje për konfigurimin ndikon në procesin e përgjithshëm të zhvillimit.

Konfigurimi do të kompilojë së bashku me kodin tjetër dhe do të gjenerohet një artefakt (.jar). Ka kuptim të vendosim konfigurimin në një artefakt të veçantë. Kjo është për shkak se mund të ketë shumë konfigurime të bazuara në të njëjtin kod. Përsëri, mund të gjenerohen artefakte që korrespondrojnë me degë të ndryshme konfigurimi. Së bashku me konfigurimin ruhen varësitë nga versionet specifike të bibliotekave dhe këto versione ruhen përgjithmonë, kurdo që ne të vendosim të zbatojmë këtë version të konfigurimit.

Çdo ndryshim i konfigurimit kthehet nĂ« njĂ« ndryshim kodesh. Prandaj, çdo njĂ«
ndryshim do të mbulohet nga procesi i zakonshëm i garantimit të cilësisë:

Ticket në bug-tracker -> PR -> rishikim -> bashkimi me degët përkatëse ->
integrimi -> përhapja

Pasojat kryesore të implementimit të konfigurimit që mund të kompilohen:

  1. Konfigurimi do të pajtohet në të gjitha nyjet e sistemit të shpërndarë. Për shkak se të gjitha nyjet marrin të njëjtën konfigurim nga një burim të vetëm.

  2. ËshtĂ« problematike tĂ« ndryshosh konfigurimin vetĂ«m nĂ« njĂ« nga nyjet. Prandaj, "çrregullimi i konfigurimit" (configuration drift) Ă«shtĂ« shumĂ« i pamundur.

  3. Bëhet më e vështirë të bëhen ndryshime të vogla në konfigurim.

  4. Shumica e ndryshimeve në konfigurim do të ndodhin brenda procesit të përgjithshëm të zhvillimit dhe do të jenë subjekt i rishikimit.

A është e nevojshme një depo e veçantë për ruajtjen e konfigurimit të prodhimit? Në një konfigurim të tillë mund të përfshihen fjalëkalime dhe informacion tjetër sekret, për të cilin do të doja të kufizohej qasja. Duke u bazuar në këtë, duket se ka kuptim të ruhet konfigurimi përfundimtar në një depo të veçantë. Mund të ndahet konfigurimi në dy pjesë - njëra që përmban parametra të hapur dhe tjetra që përmban parametra të kufizuar. Kjo do t'u lejonte shumicës së zhvilluesve të kishin qasje në parametrat e zakonshëm. Një ndarje e tillë nuk është e vështirë për t'u arritur, duke përdorur trait-e ndërmjetësorë që përmbajnë vlera standarde.

Variacionet e mundshme

Le të përpiqemi të krahasojmë konfigurimin e kompiluar me disa alternativa të njohura:

  1. Një skedar tekstual në makinën e synuar.
  2. Një depoqitë qendrore çelës-vlerë (etcd/zookeeper).
  3. Komponentët e procesit, të cilët mund të konfigurohen/rinisem pa rinisur procesin.
  4. Ruajtja e konfigurimit jashtë artefaktit dhe kontrollit të versioneve.

SkedarĂ«t tekstualĂ« ofrojnĂ« fleksibilitet tĂ« konsiderueshĂ«m nĂ« aspektin e ndryshimeve tĂ« vogla. AdministratorĂ«t e sistemit mund tĂ« hyjnĂ« nĂ« nyjĂ«n e largĂ«t, tĂ« bĂ«jnĂ« ndryshime nĂ« skedarĂ«t pĂ«rkatĂ«s dhe tĂ« rinstalin shĂ«rbimin. PĂ«r sistemet e mĂ«dha, megjithatĂ«, kjo fleksibilitet mund tĂ« jetĂ« e padĂ«shiruar. Ndryshimet e bĂ«ra nuk lĂ«nĂ« gjurmĂ« nĂ« sistemet e tjera. Askush nuk kryen rishikimin e ndryshimeve. ËshtĂ« e vĂ«shtirĂ« tĂ« pĂ«rcaktohet kush nĂ« tĂ« vĂ«rtetĂ« bĂ«ri ndryshimet dhe pĂ«r çfarĂ« arsye. Ndryshimet nuk testohet. NĂ«se sistemi Ă«shtĂ« i shpĂ«rndarĂ«, administratori mund tĂ« harrojĂ« tĂ« bĂ«jĂ« ndryshimin pĂ«rkatĂ«s nĂ« nyjet e tjera.

(Po ashtu, duhet theksuar se përdorimi i konfigurimit të kompiliuar nuk e mbyll mundësinë e përdorimit të skedarëve tekstorë në të ardhmen. Mjafton të shtohet një parser dhe një validues, duke dhënë si rezultat të njëjtin tip Konfigurimi, dhe mund të përdoren skedarë tekstorë. Kështu që, rezulton se kompleksiteti i sistemit me konfigurim të kompiliuar është paksa më i vogël se ai i sistemit që përdor skedarë tekstorë, pasi për skedarët tekstorë kërkohet kod shtesë.)

NjĂ« depo qendrore çelĂ«s-vlerĂ« Ă«shtĂ« njĂ« mekanizĂ«m i mirĂ« pĂ«r shpĂ«rndarjen e meta-parametrave tĂ« njĂ« aplikacioni tĂ« shpĂ«rndarĂ«. Duhet tĂ« pĂ«rcaktojmĂ« se çfarĂ« janĂ« parametrat e konfigurimit dhe çfarĂ« janĂ« vetĂ«m tĂ« dhĂ«na. Le tĂ« kemi njĂ« funksion C => A => B, dhe parametrat C rrallĂ« ndryshojnĂ«, ndĂ«rsa tĂ« dhĂ«nat A — shpesh. NĂ« kĂ«tĂ« rast, mund tĂ« themi se C — parametrat e konfigurimit, ndĂ«rsa A — tĂ« dhĂ«nĂ«. Duket se parametrat e konfigurimit dallojnĂ« nga tĂ« dhĂ«nat nĂ« kuptimin se ata ndryshojnĂ« nĂ« pĂ«rgjithĂ«si mĂ« rrallĂ« se sa tĂ« dhĂ«nat. Gjithashtu, tĂ« dhĂ«nat zakonisht vijnĂ« nga njĂ« burim (nga pĂ«rdoruesi), ndĂ«rsa parametrat e konfigurimit nga njĂ« tjetĂ«r (nga administrator i sistemit).

Nëse parametrat që ndryshojnë rrallë kërkojnë përditësim pa rinisjen e programit, shpeshherë kjo mund të çojë në ndërlikimin e programit, sepse na nevojitet ndonjë mënyrë për të shpërndarë parametrat, për t'i ruajtur, për t'i analizuar dhe verifikuar, si dhe për të trajtuar vlerat e papërshtatshme. Prandaj, nga pikëpamja e zvogëlimit të kompleksitetit të programit, ka kuptim të reduktohet numri i parametrave që mund të ndryshojnë gjatë funksionimit të programit (ose të mos mbahen fare të tillë).

Nga pikëpamja e këtij postimi, ne do të bëjmë ndarjen ndërmjet parametrave statikë dhe dinamikë. Nëse logjika e funksionimit të shërbimit kërkon ndryshimin e parametrave gjatë ekzekutimit të programit, atëherë do t'i quajmë këta parametra dinamikë. Përndryshe, parametrat janë statikë dhe mund të konfigurohen duke përdorur konfigurimin e kompilueshëm. Për rikonfigurimin dinamik mund të na nevojitet një mekanizëm për rinisjen e pjesëve të programit me parametra të rinj, ashtu siç ndodh me rinisjen e proceseve të sistemit operativ. (Sipas mendimit tonë, është e dëshirueshme që të shmangim rikonfigurimin në kohë reale, pasi kjo rrit kompleksitetin e sistemit. Nëse është e mundur, është më mirë të përdoren mundësitë standarde të OS për rinisjen e proceseve.)

Një nga aspektet e rëndësishme të përdorimit të konfigurimit statik, i cili i bën njerëzit të shqyrtojnë rikonstrukcionin dinamik, është koha që shfrytëzohet nga sistemi për të rinisur pas përditësimit të konfigurimit (downtime). Në të vërtetë, nëse na duhet të bëjmë ndryshime në konfigurimin statik, do të duhet të rinisim sistemin për të zbatuar vlerat e reja. Problemi i downtime-t ka intensitet të ndryshëm për sisteme të ndryshme. Në disa raste, mund të planifikohet rinisja në një kohë kur ngarkesa është minimale. Në rastin kur kërkohet të sigurohet shërbim i vazhdueshëm, mund të realizohet «drenazhi i lidhjeve» (AWS ELB connection draining). Në këtë mënyrë, kur na nevojitet të rinisim sistemin, ne fillojmë një instancë paralele të këtij sistemi, kalojmë balancuesin tek ajo, dhe presim derisa të përfundojnë lidhjet e vjetra. Pasi të përfundojnë të gjitha lidhjet e vjetra, ne fikim instancën e vjetër të sistemit.

Tani, le të shqyrtojmë çështjen e ruajtjes së konfiguracionit brenda artefaktit ose jashtë tij. Nëse e ruajmë konfigurimin brenda artefaktit, atëherë, të paktën, kemi pasur mundësinë gjatë ndërtimit të artefaktit të sigurohemi për saktësinë e konfiguracionit. Në rast se konfigurimi është jashtë artefaktit të kontrolluar, është e vështirë të ndjekim se kush dhe për çfarë arsyeje ka bërë ndryshime në këtë skedar. Sa e rëndësishme është kjo? Në mendimin tonë, për shumë sisteme në prodhim është e rëndësishme të kemi një konfiguracion stabil dhe me cilësi të lartë.

Versioni e artefaktit lejon të përcaktojë kur është krijuar, çfarë vlerash përmban, cilat funksione janë të aktivizuara/dizaktivuara dhe kush është përgjegjës për ndryshimet në konfigurim. Natyrisht, ruajtja e konfigurimit brenda artefaktit kërkon disa përpjekje, kështu që duhet të merrni një vendim të informuar.

Për dhe kundra

Dëshiroj të ndalem te përparësitë dhe disavantazhet e teknologjisë së propozuar.

Përfitimet

Më poshtë është lista e mundësive kryesore të konfigurimit të kompaktuar të sistemit të shpërndarë:

  1. Kontrolli statik i konfigurimit. Lejon të sigurohemi se
    konfigurimi është i saktë.
  2. Një gjuhë e pasur konfigurimi. Zakonisht, mënyrat e tjera të konfigurimit kufizohen maksimumi në zëvendësimin e variablave të strinjave. Me përdorimin e Scala-s, bëhet e arritshme një gamë e gjerë mundësish gjuhësore për të përmirësuar konfigurimin. Për shembull, ne mund të përdorim
    trait' për vlerat e paracaktuara, përmes objekteve grupojmë parametrat, mund të referohemi në val' të shpallura një herë (DRY) në fushën përfshirëse. Mund të instancojmë drejpërdrejt brenda konfigurimit çdo klasë (Sekuencë, Hartë, klasat e përdoruesve).
  3. DSL. Në Scala ka një sërë mundësish gjuhësore që lehtësojnë krijimin e DSL. Mund të shfrytëzoni këto mundësi dhe të realizoni një gjuhë konfigurimi, e cila do të ishte më e lehtë për grupin e synuar të përdoruesve, për sa kohë që konfigurimi do të ishte së paku i lexueshëm nga specialistët e fushës. Specialistët mund, për shembull, të marrin pjesë në procesin e rishikimit të konfigurimit.
  4. Integriteti dhe sinkronizimi midis nyjeve. Një nga përfitimet që ka konfigurimi i tërë një sistemi të shpërndarë duke u ruajtur në një pikë të vetme është se të gjitha vlerat shpallen një herë dhe më pas ripërdoren kudo ku janë të nevojshme. Përdorimi i tipeve fantazmë për shpalljen e porteve garanton që në të gjitha konfigurimet e sakta të sistemit nyjet të përdorin protokolle të përputhshme. Prania e varësive të qarta e detyrueshme midis nyjeve garanton që të gjitha shërbimet do të jenë të lidhura me njëra-tjetrën.
  5. Cilësi e lartë e ndryshimeve. Ndryshimi i konfiguracionit, duke përdorur një proces të zakonshëm zhvillimi, e bën të mundur që standardet e larta të cilësisë të jenë të disponueshme gjithashtu për konfigurimin.
  6. Përditësimi i njëkohshëm i konfiguracionit. Zbatimi automatik i sistemit pas ndryshimeve në konfiguracion garanton që të gjitha nyjat do të përditësohen.
  7. Thjeshtimi i aplikacionit. Aplikacioni nuk ka nevojĂ« pĂ«r parsimim, kontroll tĂ« konfiguracionit dhe pĂ«rpunim tĂ« vlerave tĂ« pasakta. KĂ«shtu, kompleksiteti i aplikacionit zvogĂ«lohet. (Disa komplikime tĂ« konfiguracionit qĂ« vĂ«rehen nĂ« shembullin tonĂ« nuk janĂ« njĂ« atribut i konfiguracionit tĂ« kompiluara, por njĂ« vendim i vetĂ«dijshĂ«m qĂ« buron nga dĂ«shira pĂ«r tĂ« siguruar njĂ« siguri mĂ« tĂ« madhe tĂ« tipave.) ËshtĂ« mjaft e lehtĂ« tĂ« kthehesh nĂ« konfiguracionin e zakonshĂ«m — mjafton tĂ« realizosh pjesĂ«t e mungesĂ«s. Prandaj, mund tĂ« fillosh, pĂ«r shembull, me konfiguracionin e kompiluara, duke e lĂ«nĂ« zbatimin e pjesĂ«ve tĂ« tepĂ«rta pĂ«r njĂ« kohĂ« kur kjo nĂ« tĂ« vĂ«rtetĂ« do tĂ« nevojitet.
  8. Konfiguracioni me versionim. Ndërsa ndryshimet në konfiguracion ndjekin fatin e zakonshëm të çdo ndryshimi tjetër, rezultati është një artifakt me një version unik. Kjo na lejon p.sh. të kthehemi në versionin e mëparshëm të konfiguracionit në rast nevoje. Ne madje mund të përdorim konfiguracionin e një viti më parë dhe në të njëjtën kohë sistemi do të punojë ashtu siç duhet. Konfiguracioni stabil përmirëson parashikueshmërinë dhe besueshmërinë e një sistemi të shpërndarë. Pasi konfiguracioni është i fiksuar në fazën e kompilimit, është mjaft e vështirë ta falsifikosh në prodhim.
  9. Modulariteti. Çdo kornizĂ« e propozuar Ă«shtĂ« modular dhe modulet mund tĂ« kombinohen nĂ« variante tĂ« ndryshme pĂ«r tĂ« krijuar sisteme tĂ« ndryshme. NĂ« veçanti, nĂ« njĂ« variant mund tĂ« konfigurohet sistemi pĂ«r tĂ« funksionuar nĂ« njĂ« nyje, ndĂ«rsa nĂ« variantin tjetĂ«r — nĂ« disa nyje. Mund tĂ« krijohen disa konfiguracione pĂ«r instanca prodhimi tĂ« sistemit.
  10. Testimi. Duke zëvendësuar shërbimet e veçanta me objekte mock, mund të krijohen disa versione të sistemit që janë të përshtatshme për testim.
  11. Testimi integrues. Prania e një konfigurimi të vetëm për të gjithë sistemin e shpërndarë siguron mundësinë e aktivizimit të të gjithë komponentëve në një mjedis të kontrolluar brenda kuadrit të testimit integrues. E lehtë për të imituar, për shembull, situatën kur disa nyje bëhen të paarritshme.

Disavantazhet dhe kufizimet

Konfigurimi i kompiluara ndryshon nga qasje të tjera ndaj konfigurimit dhe për disa aplikacione mund të mos jetë i përshtatshëm. Më poshtë janë disa disavantazhe:

  1. Konfigurimi statik. Ndonjëherë është e nevojshme të korrigjosh shpejt konfigurimin në prodhim, duke anashkaluar të gjitha mekanizmat mbrojtës. Në kuadër të këtij afati, mund të jetë më e ndërlikuar. Të paktën, kompilimi dhe deploy automatik përsëri do të kërkohen. Kjo është njëkohësisht një tipar i dobishëm i qasjes dhe një disavantazh në disa raste.
  2. Gjenerimi i konfigurimit. Në rastin kur skedari i konfigurimit gjenerohet nga një mjet automatik, mund të kërkohen përpjekje të mëtejshme për integrimin e skriptit të ndërtimit.
  3. Mjetet. Aktualisht, utilitetet dhe metodat e destinuara për të punuar me konfigurimin janë të bazuara në skedarë tekstorë. Jo të gjitha këto utilitete/metoda do të jenë të aksesueshme në rastin e konfigurimit të kompiluara.
  4. Kërkohet ndryshimi i perspektivës. Zhvilluesit dhe DevOps janë mësuar me skedarë tekstorë. Ideja e kompilimit të konfigurimit mund të jetë disi befasuese dhe e pazakontë dhe të shkaktojë refuzim.
  5. Kërkohet një proces zhvillimi i cilësisë së lartë. Për të përdorur me rehati konfigurimin e kompiluara, kërkohet automatizim i plotë i procesit të ndërtimit dhe deploy të aplikacionit (CI/CD). Ndryshe, do të jetë mjaft e pakëndshme.

Le të ndalemi gjithashtu në disa kufizime të shembujve të shqyrtuara, të papara nga ideja e konfigurimit të kompiluara:

  1. Nëse ne ofrojmë informacion të tepërt konfigurimi, i cili nuk përdoret nga nyja, atëhere kompilatori nuk do të na ndihmojë të zbulojmë mungesën e implementimit. Ky problem mund të zgjidhet, nëse heqim dorë nga Cake Pattern dhe përdorim tipe më të forta, për shembull, HList apo tipe algebraike të dhënash (klasa rastesh) për të paraqitur konfigurimin.
  2. NĂ« skedarin e konfigurimit ka rreshta, tĂ« cilat nuk i pĂ«rkasin saktĂ«sisht konfigurimit: (paketĂ«, import, shpalljet e objekteve; tĂ« anuloj def‘ pĂ«r parametrat, tĂ« cilĂ«t kanĂ« vlera me parazgjedhje). PjesĂ«risht mund tĂ« shmanget kjo, nĂ«se implementoni njĂ« DSL tĂ« vetĂ«. PĂ«r mĂ« tepĂ«r, lloje tĂ« tjera konfigurimi (pĂ«r shembull, XML), gjithashtu imponojnĂ« kufizime tĂ« caktuara nĂ« strukturĂ«n e skedarit.
  3. Brenda këtij posti ne nuk po shqyrtojmë rinovimin dinamik të një klustër të nodeve të ngjashme.

Përfundim

Në këtë post, ne shqyrtuam idenë e paraqitjes së konfigurimit në kodin burimor duke përdorur mundësitë e avancuara të sistemit të tipave Scala. Ky qasje mund të gjejë aplikim në aplikacione të ndryshme si një zëvendësim për metodat tradicionale të konfigurimit bazuar në skedarë me xml ose tekst. Megjithëse shembulli ynë është implementuar në Scala, idetë e njëjta mund të transferohen në gjuhë të tjera kompiluese (si Kotlin, C#, Swift, ...). Ky qasje mund të testohet në njërin nga projektet e ardhshme, dhe në rast se nuk përshtatet, mund të kaloni në skedarë tekstual, duke shtuar detajet e munguar.

Natyrisht, konfigurimi i compiluar kërkon një proces zhvillimi të cilësisë së lartë. Në këmbim, sigurohet cilësi e lartë dhe besueshmëri e konfigurimeve.

Qasja e shqyrtuar mund të jetë e zgjerueshme:

  1. Mund të përdoren makros për të kryer kontrolle gjatë kompilimit.
  2. Mund të implementohet një DSL për të paraqitur konfigurimin në një format të aksesueshëm për përdoruesit final.
  3. Mund të implementohet menaxhimi dinamik i burimeve me rregullimin automatik të konfigurimit. Për shembull, kur ndryshon numri i nodeve në kluster, është e nevojshme që (1) çdo node të marrë një konfigurim pak më të ndryshëm; (2) menaxheri i klusterit të marrë informacion për node të rinj.

Faleminderit

Dëshiroj të falënderoj Andrei Saksonov, Pavel Popov dhe Anton Nehaev për kritikën konstruktive të draftit të artikullit.

Burimi: habr.com

Blini hosting tĂ« besueshĂ«m pĂ«r faqe interneti me mbrojtje nga DDoS, serverĂ« VPS VDS đŸ”„ Blini hosting tĂ« besueshĂ«m pĂ«r faqe interneti me mbrojtje nga DDoS, serverĂ« VPS VDS | ProHoster