Jag skulle vilja berÀtta om en intressant mekanism för arbetet med konfigurationen av ett distribuerat system. Konfigurationen presenteras direkt i ett kompilert sprÄk (Scala) med hjÀlp av sÀkra typer. I detta inlÀgg analyseras ett exempel pÄ en sÄdan konfiguration och olika aspekter av att integrera kompilérbar konfiguration i den övergripande utvecklingsprocessen.

()
Introduktion
Att bygga ett pÄlitligt distribuerat system innebÀr att korrekt konfiguration anvÀnds pÄ alla noder, synkroniserad med andra noder. Vanligtvis anvÀnds DevOps-teknologier (terraform, ansible eller liknande) för automatisk generering av konfigurationsfiler (ofta specifika för varje nod). Vi vill ocksÄ vara sÀkra pÄ att identiska protokoll anvÀnds pÄ alla interagerande noder (inklusive samma version). Annars kommer det att finnas inkompatibilitet i vÄrt distribuerade system. I JVM-vÀrlden Àr en konsekvens av detta krav att samma version av biblioteket som innehÄller protokollmeddelanden mÄste anvÀndas överallt.
Vad sÀgs om testning av det distribuerade systemet? SjÀlvklart förutsÀtter vi att alla komponenter har enhetstester innan vi gÄr vidare till integrationstester. (För att vi ska kunna extrapolera testresultaten till runtime mÄste vi ocksÄ sÀkerstÀlla att samma uppsÀttning bibliotek anvÀnds i bÄde test- och runtime.)
NĂ€r vi arbetar med integrationstester Ă€r det ofta enklare att anvĂ€nda en enhetlig classpath pĂ„ alla noder. Vi behöver bara se till att samma classpath anvĂ€nds Ă€ven i runtime. (Ăven om det Ă€r möjligt att köra olika noder med olika classpaths, leder det till en komplicerad konfiguration och svĂ„righeter med distribution och integrationstester.) I detta inlĂ€gg utgĂ„r vi ifrĂ„n att samma classpath kommer att anvĂ€ndas pĂ„ alla noder.
Konfigurationen utvecklas tillsammans med applikationen. För att identifiera olika stadier i programmens utveckling anvÀnder vi versioner. Det verkar Àven logiskt att identifiera olika versioner av konfigurationer. SjÀlva konfigurationen kan placeras i ett versionshanteringssystem. Om det i produktionen finns en enda konfiguration kan vi helt enkelt anvÀnda versionsnumret. Men om flera produktionsinstanser anvÀnds behöver vi flera
konfigurationsgrenar och en extra mÀrkning utöver versionen (till exempel grenens namn). PÄ sÄ sÀtt kan vi entydigt identifiera den exakta konfigurationen. Varje konfigurationsidentifierare motsvarar entydigt en viss kombination av distribuerade noder, portar, externa resurser och versioner av bibliotek. Inom ramen för detta inlÀgg utgÄr vi frÄn att det bara finns en gren och att vi kan identifiera konfigurationen pÄ vanligt sÀtt med tre siffror separerade med punkter (1.2.3).
I moderna miljöer skapas konfigurationsfiler manuellt ganska sÀllan. Oftare genereras de under distributionen och rörs knappt vid igen (för att ). Det uppstÄr en legitim frÄga: varför anvÀnder vi fortfarande textformat för att lagra konfiguration? Ett helt livskraftigt alternativ ser ut att vara att anvÀnda vanlig kod för konfiguration och fÄ fördelar genom kontroller vid kompilering.
I detta inlÀgg utforskar vi idén om att representera konfigurationen inuti det kompilerade artefaktet.
Kompilerad konfiguration
I detta avsnitt beskrivs ett exempel pÄ statisk kompilerad konfiguration. TvÄ enkla tjÀnster implementeras - en echo-tjÀnst och en klient för echo-tjÀnsten. Baserat pÄ dessa tvÄ tjÀnster byggs tvÄ alternativ av systemet. I det ena alternativet ligger bÄda tjÀnsterna pÄ en nod, i det andra alternativet - pÄ olika noder.
En distribuerad tjÀnst innehÄller vanligtvis flera noder. Noder kan identifieras med hjÀlp av vÀrden av en viss typ NodeId:
sealed trait NodeId
case object Backend extends NodeId
case object Frontend extends NodeIdeller
case class NodeId(hostName: String)eller till och med
object Singleton
type NodeId = Singleton.typeNoder utför olika roller, tjÀnster körs pÄ dem och TCP/HTTP-förbindelser kan etableras mellan dem.
För att beskriva TCP-anslutningen behöver vi Ätminstone ett portnummer. Vi vill ocksÄ Äterspegla den protokoll som stöds pÄ denna port för att sÀkerstÀlla att bÄde klienten och servern anvÀnder samma protokoll. Vi kommer att beskriva anslutningen med hjÀlp av en sÄdan klass:
case class TcpEndPoint[Protocol](node: NodeId, port: Port[Protocol])w Port â bara ett heltal Int med angivande av ett intervall av tillĂ„tna vĂ€rden:
type PortNumber = Refined[Int, Closed[_0, W.`65535`.T]]Förfinade typer
Se biblioteket och . Kort sagt, biblioteket gör det möjligt att lÀgga till begrÀnsningar till typer, som kontrolleras vid kompileringen. I detta fall Àr de tillÄtna vÀrdena för portnumret hela 16-bitarsnummer. För den kompilerade konfigurationen Àr det inte obligatoriskt att anvÀnda biblioteket refined, men det förbÀttrar kompilatorns förmÄga att kontrollera konfigurationen.
För HTTP (REST) protokoll, förutom portnumret kan vi ocksÄ behöva sökvÀgen till tjÀnsten:
type UrlPathPrefix = Refined[String, MatchesRegex[W.`"[a-zA-Z_0-9\/]*"`.T]]
case class PortWithPrefix[Protocol](portNumber: PortNumber, pathPrefix: UrlPathPrefix)Fantomtyper
För att identifiera protokollet vid kompilering anvÀnder vi en typparameter som inte anvÀnds inom klassen. Denna lösning beror pÄ att vi inte anvÀnder en instans av protokollet vid runtime, men vi vill att kompilatorn ska kontrollera protokollens kompatibilitet. Genom att ange protokollet kan vi inte överföra en olÀmplig tjÀnst som ett beroende.
Ett av de vanligaste protokollen Àr REST API med JSON-serielisering:
sealed trait JsonHttpRestProtocol[RequestMessage, ResponseMessage]w RequestMessage â begĂ€rningstyp, ResponseMessage â svarstyp.
SjÀlvklart kan andra protokollbeskrivningar anvÀndas som ger den nödvÀndiga noggrannheten i beskrivningen.
För syftet av detta inlÀgg kommer vi att anvÀnda en förenklad version av protokollet:
sealed trait SimpleHttpGetRest[RequestMessage, ResponseMessage]HÀr representerar begÀran en strÀng som lÀggs till i URL:en, och svaret Àr den strÀng som returneras i kroppen av HTTP-svaret.
TjÀnstekonfigurationen beskrivs av tjÀnstens namn, portar och beroenden. Dessa element kan representeras i Scala pÄ flera sÀtt (till exempel HList-ar, algebraiska datatyper). För syftet av detta inlÀgg kommer vi att anvÀnda Cake Pattern och representera moduler med hjÀlp av trait(Cake Pattern Àr inte en obligatorisk del av den beskrivna metoden. Det Àr bara en av de potentiella implementationerna.)
Beroenden mellan tjÀnster kan representeras som metoder som returnerar portar EndPointför andra noder:
type EchoProtocol[A] = SimpleHttpGetRest[A, A]
trait EchoConfig[A] extends ServiceConfig {
def portNumber: PortNumber = 8081
def echoPort: PortWithPrefix[EchoProtocol[A]] = PortWithPrefix[EchoProtocol[A]](portNumber, "echo")
def echoService: HttpSimpleGetEndPoint[NodeId, EchoProtocol[A]] = providedSimpleService(echoPort)
}För att skapa en eko-tjÀnst krÀvs endast portnumret och angivelsen att denna port stöder eko-protokollet. Vi skulle kunna avstÄ frÄn att ange en specifik port, eftersom traits tillÄter oss att deklarera metoder utan implementation (abstrakta metoder). I sÄ fall skulle kompilatorn krÀva att vi tillhandahÄller en implementation av den abstrakta metoden och ange portnumret i den specifika konfigurationen. Eftersom vi har implementerat metoden kan vi vid skapandet av den specifika konfigurationen avstÄ frÄn att ange en annan port. StandardvÀrdet kommer att anvÀndas.
I klientkonfigurationen deklarerar vi beroendet av eko-tjÀnsten:
trait EchoClientConfig[A] {
def testMessage: String = "test"
def pollInterval: FiniteDuration
def echoServiceDependency: HttpSimpleGetEndPoint[_, EchoProtocol[A]]
}Beroendet har samma typ som den exporterade tjÀnsten echoService. SÀrskilt i eko-klienten krÀver vi samma protokoll. SÄ vid sammankoppling av tvÄ tjÀnster kan vi vara sÀkra pÄ att allt kommer att fungera korrekt.
Implementering av tjÀnster
För att starta och stoppa tjĂ€nsten krĂ€vs en funktion. (Möjligheten att stoppa tjĂ€nsten Ă€r avgörande för testning.) Ă
terigen finns det flera alternativ för att implementera en sÄdan funktion (till exempel skulle vi kunna anvÀnda typklasser baserat pÄ konfigurationstypen). För ÀndamÄlet med detta inlÀgg kommer vi att anvÀnda Cake Pattern. Vi kommer att representera tjÀnsten med hjÀlp av klassen cats.Resource, eftersom denna klass redan har medel för sÀker och garanterad frigöring av resurser vid problem. För att fÄ resursen mÄste vi tillhandahÄlla konfigurationen och en fÀrdig runtime-kontext. Funktionen för att starta tjÀnsten kan se ut pÄ följande sÀtt:
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]
}w
Konfigureraâ konfigurationstyp för denna tjĂ€nstAddressResolverâ runtime-objekt som möjliggör att ta reda pĂ„ adresserna till andra noder (se nedan)
och andra typer frÄn biblioteket cats:
F[_]â effektens typ (i enklaste fallF[A]kan vara en enkel funktion() => A. I detta inlĂ€gg kommer vi att anvĂ€ndacats.IO.)Reader[A,B]â mer eller mindre en synonym för funktionenA => Bcats.Resourceâ en resurs som kan hĂ€mtas och frigörasTimerâ timer (möjliggör att sova en viss tid och mĂ€ta tidsintervall)ContextShiftâ analogExecutionContextApplicativeâ effektens klass som möjliggör att kombinera individuella effekter (nĂ€stan en monad). I mer komplexa tillĂ€mpningar verkar det bĂ€ttre att anvĂ€ndaMonad/ConcurrentEffect.
Genom att anvÀnda denna funktionssignatur kan vi implementera flera tjÀnster. Till exempel en tjÀnst som inte gör nÄgot:
trait ZeroServiceImpl[F[_]] extends ServiceImpl[F] {
type Config Resource.pure[F, Unit](()))
}(Se. , dĂ€r andra tjĂ€nster implementeras â ,
och .)
En nod representerar ett objekt som kan starta flera tjÀnster (start av resurskedjan sÀkerstÀlls genom Cake Pattern):
object SingleNodeImpl extends ZeroServiceImpl[IO]
with EchoServiceService
with EchoClientService
with FiniteDurationLifecycleServiceImpl
{
type Config = EchoConfig[String] with EchoClientConfig[String] with FiniteDurationLifecycleConfig
}Observera att vi anger den exakta konfigurationstypen som krÀvs för denna nod. Om vi glömmer att ange nÄgon av de konfigurationstyper som krÀvs av en separat tjÀnst, kommer det att bli ett kompilationsfel. Vi kan inte heller starta noden om vi inte tillhandahÄller nÄgot objekt med rÀtt typ och all nödvÀndig data.
Nodnamnsupplösning
För att ansluta till en fjÀrrnod behöver vi en faktisk IP-adress. Det Àr möjligt att adressen blir kÀnd senare Àn de andra delarna av konfigurationen. DÀrför behöver vi en funktion som kartlÀgger nodens identifierare till en adress:
case class NodeAddress[NodeId](host: Uri.Host)
trait AddressResolver[F[_]] {
def resolve[NodeId](nodeId: NodeId): F[NodeAddress[NodeId]]
}Det kan föreslÄs flera sÀtt att implementera en sÄdan funktion:
- Om adresserna blir kÀnda för oss före distribution kan vi generera Scala-kod med
adresser och sedan köra bygget. Under detta kommer kompilering att ske och tester att utföras.
I sÄ fall kommer funktionen att vara kÀnd statiskt och kan representeras i koden i form av en avbildningMap[NodeId, NodeAddress]. - I vissa fall blir den verkliga adressen kÀnd först efter att noden har startats.
I detta fall kan vi implementera en "upptÀckstjÀnst" (discovery), som startas före de andra noderna och alla noder kommer att registrera sig i denna tjÀnst och begÀra adresser frÄn andra noder. - Om vi kan modifiera
/etc/hosts, kan vi anvÀnda fördefinierade vÀrdnamn (sommy-project-main-nodeochecho-backend) och helt enkelt koppla dessa namn
till IP-adresser under distributionen.
Inom ramen för detta inlÀgg kommer vi inte att diskutera dessa fall mer ingÄende. För vÄrt
leksaksexempel kommer alla noder att ha en IP-adress â 127.0.0.1.
LÄt oss nu titta pÄ tvÄ varianter av ett distribuerat system:
- Placera alla tjÀnster pÄ en nod.
- Och placera echo-tjÀnsten och echo-klienten pÄ olika noder.
Konfiguration för :
Konfiguration för en nod
object SingleNodeConfig extends EchoConfig[String]
with EchoClientConfig[String] with FiniteDurationLifecycleConfig
{
case object Singleton \/\/ identifier av den enda noden
\/\/ konfiguration av server
type NodeId = Singleton.type
def nodeId = Singleton
\/** Typ-sÀker tjÀnstportsspecifikation. *\/
override def portNumber: PortNumber = 8088
\/\/ konfiguration av klient
\/** Vi kommer att anvÀnda tjÀnsten frÄn samma vÀrd. *\/
def echoServiceDependency = echoService
override def testMessage: UrlPathElement = "hej"
def pollInterval: FiniteDuration = 1.second
\/\/ livscykelkontrollerkonfiguration
def lifetime: FiniteDuration = 10500.milliseconds \/\/ ytterligare 0,5 sekunder sÄ att det blir 10 förfrÄgningar, inte 9.
}Objektet implementerar konfigurationen för bÄde klient och server. Det anvÀnds ocksÄ en konfiguration för livslÀngd för att avsluta programmet efter intervallet lifetime programmet. (Ctrl-C fungerar ocksÄ och frigör alla resurser korrekt.)
Samma uppsÀttning trait:er av konfiguration och implementation kan anvÀndas för att skapa ett system bestÄende av :
Konfiguration för tvÄ noder
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! beroendespecifikation
def echoServiceDependency = NodeServerConfig.echoService
def pollInterval: FiniteDuration = 1.second
def lifetime: FiniteDuration = 10500.milliseconds \/\/ ytterligare 0,5 sekunder sÄ att det blir 10 förfrÄgningar, inte 9.
def testMessage: String = "dolly"
}Viktigt! Observera hur tjÀnster kopplas ihop. Vi anger en tjÀnst som implementeras av en nod som en implementation av metoden för beroende av en annan nod. Typen av beroende kontrolleras av kompilatorn, eftersom den innehÄller typ av protokoll. Vid körning kommer beroendet att innehÄlla den korrekta identifieraren för mÄl-noden. Tack vare detta schema anger vi portnumret endast en gÄng och refererar alltid till rÀtt port.
Implementering av tvÄ noder i systemet
För denna konfiguration anvÀnder vi samma implementationer av tjÀnster utan Àndringar. Den enda skillnaden Àr att vi nu har tvÄ objekt som implementerar olika uppsÀttningar av tjÀnster:
object TwoJvmNodeServerImpl extends ZeroServiceImpl[IO] with EchoServiceService with SigIntLifecycleServiceImpl {
type Config = EchoConfig[String] with SigTermLifecycleConfig
}
object TwoJvmNodeClientImpl extends ZeroServiceImpl[IO] with EchoClientService with FiniteDurationLifecycleServiceImpl {
type Config = EchoClientConfig[String] with FiniteDurationLifecycleConfig
}Den första noden implementerar servern och behöver endast serverkonfigurationen. Den andra noden implementerar klienten och anvÀnder en annan del av konfigurationen. BÄda noderna behöver ocksÄ livscykelhantering. Server-noden körs obegrÀnsat lÀnge tills den stoppas, medan klient-noden avslutas efter en viss tid. Se. SIGTERMatt klient-noden avslutas efter en viss tid. Se. .
Den övergripande utvecklingsprocessen
LÄt oss se hur detta tillvÀgagÄngssÀtt för konfiguration pÄverkar den övergripande utvecklingsprocessen.
Konfigurationen kommer att kompileras tillsammans med resten av koden och en artifact (.jar) kommer att genereras. Det verkar meningsfullt att placera konfigurationen i en separat artifact. Detta beror pÄ att vi kan ha mÄnga konfigurationer baserade pÄ samma kod. à terigen kan vi generera artifacts som motsvarar olika konfigurationsgrenar. Tillsammans med konfigurationen sparas beroenden frÄn specifika versioner av bibliotek, och dessa versioner sparas för alltid, oavsett nÀr vi bestÀmmer oss för att distribuera denna version av konfigurationen.
Varje Àndring av konfigurationen omvandlas till en Àndring av koden. DÀrmed kommer varje sÄdan
Ă€ndring att omfattas av den vanliga kvalitetsprocessen:
En biljett i bugtrackern -> PR -> granskning -> sammanslagning med motsvarande grenar ->
integration -> distribution
De viktigaste följderna av att införa kompilerbar konfiguration:
Konfigurationen kommer att överenskommas pÄ alla noder i det distribuerade systemet. Eftersom alla noder fÄr samma konfiguration frÄn en enhetlig kÀlla.
Det Àr problematiskt att Àndra konfigurationen endast pÄ en av noderna. DÀrför Àr "konfigurationsavvikelse" (configuration drift) osannolik.
Det blir svÄrare att göra smÄ Àndringar i konfigurationen.
Majoriteten av Àndringarna i konfigurationen kommer att ske inom ramen för den gemensamma utvecklingsprocessen och kommer att granskas.
Behövs det ett separat repository för att lagra produktionskonfigurationen? I en sĂ„dan konfiguration kan det finnas lösenord och annan hemlig information som vi skulle vilja begrĂ€nsa Ă„tkomsten till. UtifrĂ„n detta verkar det rimligt att lagra den slutliga konfigurationen i ett separat repository. Man kan dela upp konfigurationen i tvĂ„ delar â en som innehĂ„ller offentliga konfigurationsparametrar och en annan med begrĂ€nsad Ă„tkomst. Detta skulle lĂ„ta de flesta utvecklare fĂ„ tillgĂ„ng till de offentliga parametrarna. SĂ„dan uppdelning Ă€r inte svĂ„r att Ă„stadkomma genom att anvĂ€nda mellanliggande traits som innehĂ„ller standardvĂ€rden.
Möjliga variationer
LÄt oss försöka jÀmföra den kompilera konfigurationen med nÄgra vanliga alternativ:
- Textfil pÄ den mÄlmaskinen.
- Centraliserat nyckel-vÀrde-lager (
i den motsvarande Docker-bilden/zookeeper). - Komponenter i processen som kan rekonfigureras/omstartas utan att processen behöver startas om.
- Lagring av konfiguration utanför artefakten och versionskontroll.
Textfiler ger betydande flexibilitet nĂ€r det gĂ€ller smĂ„ Ă€ndringar. Systemadministratören kan logga in pĂ„ den fjĂ€rrnoden, göra Ă€ndringar i de relevanta filerna och starta om tjĂ€nsten. För större system kan dock sĂ„dan flexibilitet vara oönskad. Ăndringarna lĂ€mnar inga spĂ„r i andra system. Ingen granskar Ă€ndringar. Det Ă€r svĂ„rt att faststĂ€lla vem som gjorde Ă€ndringarna och varför. Ăndringarna testas inte. Om systemet Ă€r distribuerat kan administratören glömma att göra relevanta Ă€ndringar pĂ„ andra noder.
(Det bör ocksÄ noteras att anvÀndningen av en komplierad konfiguration inte stÀnger möjligheten att anvÀnda textfiler i framtiden. Det Àr tillrÀckligt att lÀgga till en parser och validerare som ger samma typ som utdata Konfigurera, och man kan anvÀnda textfiler. DÀrmed följer det direkt att komplexiteten i systemet med komplierad konfiguration Àr nÄgot mindre Àn komplexiteten i systemet som anvÀnder textfiler, eftersom textfiler krÀver extra kod.)
Ett centraliserat nyckel-vÀrde-lager Àr en bra mekanism för att distribuera meta-parametrar i en distribuerad applikation. Vi bör definiera vad som Àr konfigurationsparametrar och vad som bara Àr data. LÄt oss ha en funktion C => A => B, dÀr parametrarna C sÀllan Àndras, och datan A Àndras ofta. I det hÀr fallet kan vi sÀga att C Àr konfigurationsparametrar, medan A Àr data. Det verkar som om konfigurationsparametrar skiljer sig frÄn data genom att de i allmÀnhet Àndras mindre ofta Àn data. Dessutom kommer data vanligtvis frÄn en kÀlla (frÄn anvÀndaren), medan konfigurationsparametrar kommer frÄn en annan (frÄn systemadministratören).
Om sÀllan förÀnderliga parametrar behöver uppdateras utan att programmet startas om, kan detta ofta leda till en ökning av programmets komplexitet, eftersom vi pÄ nÄgot sÀtt behöver överföra parametrar, lagra, parsa och validera samt hantera felaktiga vÀrden. DÀrför, sett ur ett perspektiv av att minska programmets komplexitet, Àr det meningsfullt att minska antalet parametrar som kan förÀndras under programmets gÄng (eller helt enkelt inte stödja sÄdana parametrar).
FrÄn perspektivet av detta inlÀgg kommer vi att sÀrskilja mellan statiska och dynamiska parametrar. Om logiken för tjÀnstens arbete krÀver att parametrarna Àndras under programmets gÄng, kommer vi att kalla dessa parametrar dynamiska. I annat fall Àr parametrarna statiska och kan konfigureras med hjÀlp av en compilable-konfiguration. För dynamisk rekonfiguration kan vi behöva en mekanism för att starta om delar av programmet med nya parametrar, pÄ ett sÀtt som liknar hur operativsystemets processer startas om. (Enligt vÄr mening Àr det önskvÀrt att undvika realtidsrekonfiguration, eftersom det ökar systemets komplexitet. Om möjligt, Àr det bÀttre att anvÀnda standardfunktionerna i operativsystemet för att starta om processer.)
En av de viktiga aspekterna av att anvÀnda statisk konfiguration, som fÄr mÀnniskor att övervÀga dynamisk rekonfiguration, Àr den tid som krÀvs för systemet att starta om efter en konfigurationsuppdatering (downtime). Faktum Àr att om vi behöver göra Àndringar i den statiska konfigurationen, mÄste vi starta om systemet för att de nya vÀrdena ska trÀda i kraft. Problemet med downtime har olika allvarlighetsgrad för olika system. I vissa fall kan omstart schemalÀggas vid en tidpunkt nÀr belastningen Àr minimal. Om det krÀvs att sÀkerstÀlla kontinuerlig service kan vi implementera . NÀr vi behöver starta om systemet, startar vi en parallell instans av systemet, vÀxlar belastningsbalanseraren till den, och vÀntar tills de gamla anslutningarna avslutas. Efter att alla gamla anslutningar har avslutats, stÀnger vi av den gamla instansen av systemet.
LÄt oss nu övervÀga frÄgan om konfigurationslagring antingen inom artefakten eller utanför den. Om vi lagrar konfigurationen inom artefakten har vi Ätminstone möjlighet att bekrÀfta att konfigurationen Àr korrekt vid tidpunkten för artefaktens byggande. Om konfigurationen Àr utanför den kontrollerade artefakten Àr det svÄrt att spÄra vem och varför som gjorde förÀndringar i den filen. Hur viktigt Àr detta? Enligt vÄr Äsikt, för mÄnga produktionssystem Àr det viktigt att ha en stabil och högkvalitativ konfiguration.
Versionsartefakten gör det möjligt att faststÀlla nÀr det skapades, vilka vÀrden det innehÄller, vilka funktioner som Àr aktiverade eller avaktiverade och vem som ansvarar för eventuella Àndringar i konfigurationen. SjÀlvklart krÀver lagring av konfigurationen inom artefakten vissa anstrÀngningar, sÄ ett medvetet beslut mÄste fattas.
För och emot
Jag skulle vilja fokusera pÄ fördelarna och nackdelarna med den föreslagna teknologin.
Fördelar
Nedan följer en lista över de viktigaste möjligheterna med den kompilera konfigurationen av det distribuerade systemet:
- Statisk konfigurationskontroll. Det gör det möjligt att vara sÀker pÄ att
konfigurationen Àr korrekt. - Rikt sprÄk för konfiguration. Vanligtvis Àr andra metoder för konfiguration begrÀnsade till högst substitutionsstrÀngar. Med Scala fÄr vi tillgÄng till ett brett spektrum av sprÄkets möjligheter för att förbÀttra konfigurationen. Till exempel kan vi anvÀnda
traits för standardvÀrden, anvÀnda objekt för att gruppera parametrar, och vi kan referera till val som deklarerats en gÄng (DRY) inom det övergripande synsvinkeln. Vi kan direkt instansiera alla klasser inuti konfigurationen (Seq,Karta, anvÀndardefinierade klasser). - DSL. I Scala finns det flera sprÄkliga möjligheter som underlÀttar skapandet av DSL. Vi kan utnyttja dessa möjligheter och implementera ett konfigurationssprÄk som skulle vara mer anvÀndarvÀnligt för mÄlgruppen, sÄ att konfigurationen skulle vara Ätminstone lÀsbar för fackexperter. Experterna kan till exempel delta i processen för att granska konfigurationen.
- Integritet och synkronisering mellan noder. En av fördelarna med att hela den distribuerade systemkonfigurationen lagras pÄ en enda plats Àr att alla vÀrden deklareras exakt en gÄng och ÄteranvÀnds dÀr de behövs. AnvÀndning av fantomtyper för att deklarera portar gör det möjligt att sÀkerstÀlla att alla korrekta konfigurationer av systemet anvÀnder kompatibla protokoll. NÀrvaron av tydliga nödvÀndiga beroenden mellan noder sÀkerstÀller att alla tjÀnster knyts ihop.
- Hög kvalitet pÄ Àndringar. Att göra Àndringar i konfigurationen med hjÀlp av en gemensam utvecklingsprocess sÀkerstÀller att höga kvalitetsstandarder ocksÄ gÀller för konfigurationen.
- Samtidig uppdatering av konfigurationen. Automatisk distribution av systemet efter Àndringar i konfigurationen garanterar att alla noder uppdateras.
- Förenkling av applikationen. Applikationen behöver inte parse, kontrollera konfigurationen eller hantera ogiltiga vÀrden. Detta minskar kompleksiteten i applikationen. (Viss komplexitet i konfigurationen, som observeras i vÄrt exempel, Àr inte en egenskap hos den kompilerade konfigurationen, utan en medveten beslut som syftar till att sÀkerstÀlla större typ-sÀkerhet.) Det Àr ganska enkelt att ÄtergÄ till en vanlig konfiguration - man behöver bara implementera de saknade delarna. DÀrför kan man till exempel börja med en kompilerad konfiguration och skjuta upp implementeringen av överflödiga delar tills de verkligen behövs.
- Versionshantering av konfigurationen. Eftersom konfigurationsÀndringar följer det vanliga mönstret för alla andra Àndringar fÄr vi en artefakt med en unik version i utdata. Detta gör att vi till exempel kan ÄtergÄ till en tidigare version av konfigurationen vid behov. Vi kan till och med anvÀnda en konfiguration frÄn ett Är sedan, och systemet kommer att fungera precis som tidigare. Stabil konfiguration förbÀttrar förutsÀgbarheten och tillförlitligheten i det distribuerade systemet. Eftersom konfigurationen Àr faststÀlld i kompileringen Àr den ganska svÄr att manipulera i produktion.
- Modularitet. Det föreslagna ramverket Àr modulÀrt, och moduler kan kombineras pÄ olika sÀtt för att skapa olika system. SÀrskilt kan man konfigurera systemet för att köras pÄ en nod i en variant och pÄ flera noder i en annan. Man kan skapa flera konfigurationer för produktionsinstanser av systemet.
- Testning. Genom att ersÀtta enskilda tjÀnster med mock-objekt kan man fÄ flera versioner av systemet som Àr bekvÀma för testning.
- Integreringstestning. En enhetlig konfiguration av hela det distribuerade systemet möjliggör körning av alla komponenter i en kontrollerad miljö inom ramen för integreringstestning. Det Àr lÀtt att emulera, till exempel en situation dÀr vissa noder blir otillgÀngliga.
Nackdelar och begrÀnsningar
Den kompilerade konfigurationen skiljer sig frÄn andra metoder för konfiguration och kanske inte passar för vissa applikationer. Nedan följer nÄgra nackdelar:
- Statisk konfiguration. Ibland krÀvs en snabb Àndring av konfigurationen i produktion, utan att gÄ igenom alla skyddsmekanismer. Inom ramen för denna metod kan det bli svÄrare. à tminstone kommer kompilering och automatisk distribution fortfarande att behövas. Detta Àr bÄde en anvÀndbar funktion och en nackdel i vissa fall.
- Konfigurationsgenerering. Om konfigurationsfilen genereras av ett automatiskt verktyg kan det krÀvas extra anstrÀngningar för att integrera byggskriptet.
- Verktyg. För nÀrvarande bygger verktyg och metoder för att arbeta med konfiguration pÄ textfiler. Inte alla sÄdana verktyg/metoder kommer att vara tillgÀngliga för kompilerad konfiguration.
- Ăndrade perspektiv krĂ€vs. Utvecklare och DevOps Ă€r vana vid textfiler. SjĂ€lva idĂ©n om att kompilera konfiguration kan vara nĂ„got ovĂ€ntad och ovanlig och vĂ€cka aversion.
- En högkvalitativ utvecklingsprocess behövs. För att bekvÀmt anvÀnda kompilerad konfiguration krÀvs full automatisering av bygg- och distributionsprocessen (CI/CD). Annars blir det ganska besvÀrligt.
LÄt oss ocksÄ titta pÄ ett antal begrÀnsningar i det granskade exemplet, som inte Àr relaterade till idén om kompilerad konfiguration:
- Om vi tillhandahÄller överflödig konfigurationsinformation som inte anvÀnds av noden kommer kompilatorn inte att hjÀlpa oss att upptÀcka avsaknaden av implementering. Detta problem kan lösas genom att överge Cake-mönstret och anvÀnda striktare typer, till exempel
HListeller algebraiska datatyper (caseklasser) för att representera konfigurationen. - I konfigurationsfilen finns rader som inte relevanta för sjÀlva konfigurationen: (
paket,import, objektdeklarationer;override defFör parametrar med standardvÀrden kan detta delvis undvikas genom att implementera en egen DSL. Dessutom sÀtter andra typer av konfiguration (t.ex. XML) ocksÄ vissa begrÀnsningar pÄ filens struktur. - I detta inlÀgg kommer vi inte att diskutera dynamisk rekonfiguration av kluster av liknande noder.
Slutsats
I detta inlĂ€gg har vi utforskat idĂ©n att representera konfigurationer i kĂ€llkoden med hjĂ€lp av de avancerade funktionerna i Scala-typ systemet. Detta tillvĂ€gagĂ„ngssĂ€tt kan tillĂ€mpas i olika applikationer som ett alternativ till traditionella konfigurationsmetoder baserade pĂ„ XML- eller textfiler. Ăven om vĂ„rt exempel Ă€r implementerat i Scala kan samma idĂ©er överföras till andra kompilĂ©rbara sprĂ„k (som Kotlin, C#, Swift, ...). Detta tillvĂ€gagĂ„ngssĂ€tt kan prövas i nĂ„got av de kommande projekten, och om det inte fungerar, kan man Ă„tergĂ„ till textfiler och lĂ€gga till de saknade detaljerna.
Naturligtvis krÀver kompilérbar konfiguration en högkvalitativ utvecklingsprocess. I gengÀld sÀkerstÀlls hög kvalitet och pÄlitlighet i konfigurationerna.
Det diskuterade tillvÀgagÄngssÀttet kan utvidgas:
- Makron kan anvÀndas för att utföra kontroller vid kompilering.
- En DSL kan realiseras för att representera konfigurationen i ett format som Àr tillgÀngligt för slutanvÀndare.
- Dynamisk resursadministration med automatisk justering av konfigurationen kan genomföras. Till exempel, vid förÀndringar av antalet noder i klustret krÀvs det att (1) varje nod fÄr en nÄgot annorlunda konfiguration; (2) klusterhandlaren fÄr information om nya noder.
Tack.
Jag vill tacka Andrey Saksonov, Pavel Popov och Anton Nekhaev för den konstruktiva kritiken av artikelutkastet.
KĂ€lla: habr.com
