Vorrei raccontare un interessante meccanismo di lavoro con la configurazione di un sistema distribuito. La configurazione è rappresentata direttamente in un linguaggio compilato (Scala) utilizzando tipi sicuri. In questo post esamineremo un esempio di tale configurazione e discuteremo vari aspetti dell'implementazione della configurazione compilata nel processo di sviluppo complessivo.

()
Introduzione
Costruire un sistema distribuito affidabile implica che tutti i nodi utilizzino una configurazione corretta, sincronizzata con gli altri nodi. Di solito si utilizzano tecnologie DevOps (terraform, ansible o simili) per generare automaticamente i file di configurazione (spesso personalizzati per ogni nodo). Vogliamo anche essere certi che su tutti i nodi interagenti vengano utilizzati protocolli identici (inclusa la versione identica). In caso contrario, ci sarà incompatibilità nel nostro sistema distribuito. Nel mondo JVM, una delle conseguenze di tale requisito è la necessità di utilizzare ovunque la stessa versione della libreria contenente i messaggi del protocollo.
Cosa ne pensi dei test di un sistema distribuito? Naturalmente, prevediamo che siano disponibili test unitari per tutti i componenti, prima di passare ai test di integrazione. (Per poter estrapolare i risultati dei test durante il runtime, dobbiamo anche garantire un insieme identico di librerie sia in fase di test che durante il runtime.)
Quando si lavora con i test di integrazione, è spesso più semplice utilizzare un classpath uniforme su tutti i nodi. Dobbiamo solo assicurarci che lo stesso classpath venga utilizzato anche in runtime. (Sebbene sia possibile eseguire nodi diversi con classpath differenti, questo complica l'intera configurazione e crea difficoltà nel dispiegamento e nei test di integrazione.) In questo post, presupponiamo che sarà usato un classpath identico su tutti i nodi.
La configurazione evolve insieme all'applicazione. Per identificare le diverse fasi dell'evoluzione dei software, utilizziamo le versioni. È logico anche identificare le diverse versioni delle configurazioni. La configurazione stessa può essere inserita in un sistema di controllo versione. Se in produzione esiste una sola configurazione, possiamo utilizzare semplicemente il numero di versione. Se sono presenti più istanze in produzione, avremo bisogno di più
rami di configurazione e di un'etichetta aggiuntiva oltre alla versione (ad esempio, il nome del ramo). In questo modo, potremo identificare in modo univoco la configurazione esatta. Ogni identificatore di configurazione corrisponde in modo univoco a una determinata combinazione di nodi distribuiti, porte, risorse esterne, versioni di librerie. In questo post presupporremo che ci sia solo un ramo e che possiamo identificare la configurazione nel modo consueto utilizzando tre numeri separati da punti (1.2.3).
Nei moderni ambienti, i file di configurazione vengono creati manualmente piuttosto raramente. Più frequentemente, vengono generati durante il processo di deployment e non vengono più toccati (per non ). Si pone quindi la domanda: perché utilizziamo ancora un formato testuale per memorizzare le configurazioni? Un'alternativa praticabile sembra essere la possibilità di utilizzare codice convenzionale per la configurazione e ottenere vantaggi grazie ai controlli durante la compilazione.
In questo post esploriamo proprio l'idea di rappresentare la configurazione all'interno di un artefatto compilato.
Configurazione compilabile
In questa sezione viene presentato un esempio di configurazione compilabile statica. Vengono implementati due semplici servizi: un servizio echo e un client del servizio echo. Sulla base di questi due servizi vengono costruiti due varianti del sistema. In una variante, entrambi i servizi si trovano su un nodo, mentre nell'altra variante sono su nodi diversi.
Di solito, un sistema distribuito contiene più nodi. I nodi possono essere identificati tramite valori di un certo tipo NodeId:
sealed trait NodeId
case object Backend extends NodeId
case object Frontend extends NodeIdo
case class NodeId(hostName: String)o addirittura
object Singleton
type NodeId = Singleton.typeI nodi ricoprono vari ruoli, su di essi sono attivi servizi e possono essere stabilite connessioni TCP/HTTP tra di essi.
Per descrivere una connessione TCP, ci serve almeno un numero di porta. Vorremmo anche riflettere il protocollo supportato su questa porta, per garantire che sia il client sia il server utilizzino lo stesso protocollo. Descriveremo la connessione utilizzando una classe del genere:
case class TcpEndPoint[Protocol](node: NodeId, port: Port[Protocol])dove Porto — semplicemente un numero intero Int con indicazione dell'intervallo di valori consentiti:
type PortNumber = Refined[Int, Closed[_0, W.`65535`.T]]Tipi specificati
Vedi la libreria e . In breve, la libreria consente di aggiungere delle restrizioni ai tipi, verificate al momento della compilazione. In questo caso, i valori consentiti per il numero di porta sono numeri interi a 16 bit. Per la configurazione compilata, l'uso della libreria refined non è obbligatorio, ma migliora le capacità del compilatore nel verificare la configurazione.
Per i protocolli HTTP (REST), oltre al numero di porta, potrebbe esserci anche bisogno del percorso del servizio:
type UrlPathPrefix = Refined[String, MatchesRegex[W.`"[a-zA-Z_0-9/]*"`.T]]
case class PortWithPrefix[Protocol](portNumber: PortNumber, pathPrefix: UrlPathPrefix)Tipi fittizi
Per identificare il protocollo nella fase di compilazione, utilizziamo un parametro tipo che non viene impiegato all'interno della classe. Questa soluzione è legata al fatto che durante il runtime non utilizziamo un'istanza del protocollo, ma vorremmo che il compilatore verificasse la compatibilità dei protocolli. Specificando il protocollo, non saremo in grado di passare un servizio inadeguato come dipendenza.
Uno dei protocolli più comuni è il REST API con serializzazione Json:
sealed trait JsonHttpRestProtocol[RequestMessage, ResponseMessage]dove RequestMessage — tipo di richiesta, ResponseMessage — tipo di risposta.
Naturalmente, è possibile utilizzare altre descrizioni dei protocolli che forniscono la necessaria precisione nella loro descrizione.
Per i fini di questo post, utilizzeremo una versione semplificata del protocollo:
sealed trait SimpleHttpGetRest[RequestMessage, ResponseMessage]In questo caso, la richiesta è una stringa che si aggiunge all'url, mentre la risposta è la stringa restituita nel corpo della risposta HTTP.
La configurazione del servizio è descritta dal nome del servizio, dalle porte e dalle dipendenze. Questi elementi possono essere rappresentati in Scala in vari modi (ad esempio, HList- tipi algebrici). Per i fini di questo post, useremo il Cake Pattern e rappresenteremo i moduli tramite trait‘ov. (Il Cake Pattern non è un elemento obbligatorio dell'approccio descritto. È solo una possibile implementazione.)
Le dipendenze tra i servizi possono essere rappresentate come metodi che restituiscono porte EndPoint‘di altri nodi:
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)
}Per creare un servizio echo è sufficiente solo il numero di porta e l'indicazione che questa porta supporta il protocollo echo. Potremmo anche non specificare una porta concreta, poiché i trait consentono di dichiarare metodi senza implementazione (metodi astratti). In questo caso, al momento della creazione di una configurazione concreta, il compilatore ci richiederebbe di fornire un'implementazione del metodo astratto e di fornire il numero di porta. Poiché abbiamo implementato il metodo, possiamo non specificare un'altra porta nella creazione di una configurazione concreta. Sarà utilizzato il valore predefinito.
Nella configurazione del client dichiariamo la dipendenza dal servizio echo:
trait EchoClientConfig[A] {
def testMessage: String = "test"
def pollInterval: FiniteDuration
def echoServiceDependency: HttpSimpleGetEndPoint[_, EchoProtocol[A]]
}La dipendenza ha lo stesso tipo del servizio esportato echoService. In particolare, nel client echo richiediamo lo stesso protocollo. Pertanto, quando colleghiamo due servizi, possiamo essere certi che tutto funzionerà correttamente.
Implementazione dei servizi
Per avviare e fermare un servizio è necessaria una funzione. (La possibilità di fermare il servizio è critica per i test.) Anche in questo caso esistono diverse opzioni per realizzare tale funzione (ad esempio, potremmo utilizzare classi di tipi basate sul tipo di configurazione). Ai fini di questo post, utilizzeremo il Cake Pattern. Rappresenteremo il servizio tramite una classe cats.Resource, poiché in questa classe sono già previsti strumenti per garantire in modo sicuro e affidabile il rilascio delle risorse in caso di problemi. Per ottenere una risorsa, dobbiamo fornire la configurazione e un contesto di runtime pronto. La funzione di avvio del servizio potrebbe avere la seguente forma:
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]
}dove
Configurazione— tipo di configurazione per questo servizioAddressResolver— oggetto di runtime che consente di conoscere gli indirizzi di altri nodi (vedi oltre)
e altri tipi dalla libreria cats:
F[_]— tipo di effetto (nel caso più sempliceF[A]può essere semplicemente una funzione() => A. In questo post utilizzeremocats.IO.)Reader[A,B]— più o meno un sinonimo di funzioneA => Bcats.Resource— risorsa che può essere acquisita e rilasciataTimer— timer (consente di sospendersi per un certo periodo e misurare intervalli di tempo)ContextShift— analogoExecutionContextApplicative— una classe di tipo effetto che consente di combinare effetti separati (quasi una monade). In applicazioni più complesse, sembra migliore utilizzareMonade/EffettoConcurrente.
Utilizzando questa firma di funzione possiamo implementare diversi servizi. Ad esempio, un servizio che non fa nulla:
trait ZeroServiceImpl[F[_]] extends ServiceImpl[F] {
type Config Resource.pure[F, Unit](()))
}(Vedi , in cui sono implementati altri servizi — ,
e .)
Un nodo è un oggetto che può avviare diversi servizi (l'avvio della catena di risorse è garantito dal Cake Pattern):
object SingleNodeImpl extends ZeroServiceImpl[IO]
with EchoServiceService
with EchoClientService
with FiniteDurationLifecycleServiceImpl
{
type Config = EchoConfig[String] with EchoClientConfig[String] with FiniteDurationLifecycleConfig
}Si prega di notare che specifichiamo il tipo esatto di configurazione necessario per questo nodo. Se dimentichiamo di specificare uno dei tipi di configurazione necessari per un servizio specifico, si verificherà un errore di compilazione. Inoltre, non saremo in grado di avviare il nodo se non forniamo un oggetto con un tipo appropriato contenente tutti i dati necessari.
Risoluzione dei nomi dei nodi
Per connettersi a un nodo remoto, abbiamo bisogno di un indirizzo IP reale. È possibile che l'indirizzo diventi noto dopo le altre parti della configurazione. Pertanto, abbiamo bisogno di una funzione che mappa l'identificatore del nodo all'indirizzo:
case class NodeAddress[NodeId](host: Uri.Host)
trait AddressResolver[F[_]] {
def resolve[NodeId](nodeId: NodeId): F[NodeAddress[NodeId]]
}Possiamo suggerire diversi modi per implementare questa funzione:
- Se gli indirizzi ci sono noti prima del deployment, possiamo generare codice Scala con
gli indirizzi e poi eseguire la build. In questo caso, verrà eseguita la compilazione e i test.
In tal caso, la funzione sarà nota staticamente e potrà essere rappresentata nel codice come una mappaturaMap[NodeId, NodeAddress]. - In alcuni casi, l'indirizzo reale diventa noto solo dopo l'avvio del nodo.
In questo caso, possiamo implementare un 'servizio di discovery' che si avvia prima degli altri nodi e tutti i nodi si registreranno in questo servizio per richiedere gli indirizzi degli altri nodi. - Se possiamo modificare
/etc/hosts, possiamo utilizzare nomi host predefiniti (comemy-project-main-nodeeecho-backend) e semplicemente associare questi nomi
agli indirizzi IP durante il deployment.
Nell'ambito di questo post, non esamineremo ulteriormente questi casi. Per il nostro
esempio didattico, tutti i nodi avranno un unico indirizzo IP — 127.0.0.1.
Ora esaminiamo due varianti del sistema distribuito:
- Tutti i servizi sono ospitati su un unico nodo.
- E l'hosting del servizio echo e del client echo su nodi separati.
Configurazione per :
Configurazione per un nodo
object SingleNodeConfig extends EchoConfig[String]
with EchoClientConfig[String] with FiniteDurationLifecycleConfig
{
case object Singleton // identificatore del nodo singolo
// configurazione del server
type NodeId = Singleton.type
def nodeId = Singleton
/** Specifica del porto del servizio in modo type safe. */
override def portNumber: PortNumber = 8088
// configurazione del client
/** Utilizzeremo il servizio fornito dallo stesso host. */
def echoServiceDependency = echoService
override def testMessage: UrlPathElement = "hello"
def pollInterval: FiniteDuration = 1.second
// configurazione del controllore del ciclo di vita
def lifetime: FiniteDuration = 10500.milliseconds // 0.5 secondi aggiuntivi affinché ci siano 10 richieste, non 9.
}L'oggetto implementa sia la configurazione che il client e il server. Viene utilizzata anche una configurazione del tempo di vita, in modo che dopo un intervallo si possa la durata terminare l'esecuzione del programma. (Ctrl-C funziona anche correttamente e libera tutte le risorse.)
Lo stesso insieme di trait della configurazione e delle implementazioni può essere utilizzato per creare un sistema composto da :
Configurazione per due nodi
oggetto NodeServerConfig estende EchoConfig[String] con SigTermLifecycleConfig
{
tipo NodeId = NodeIdImpl
def nodeId = NodeServer
override def portNumber: PortNumber = 8080
}
oggetto NodeClientConfig estende EchoClientConfig[String] con FiniteDurationLifecycleConfig
{
// NB! specifica delle dipendenze
def echoServiceDependency = NodeServerConfig.echoService
def pollInterval: FiniteDuration = 1.second
def lifetime: FiniteDuration = 10500.milliseconds // ulteriori 0.5 secondi in modo da effettuare 10 richieste, non 9.
def testMessage: String = "dolly"
}Importante! Presta attenzione a come viene eseguita la connessione dei servizi. Indichiamo il servizio implementato da un nodo come realizzazione del metodo di dipendenza di un altro nodo. Il tipo di dipendenza viene controllato dal compilatore, poiché contiene il tipo di protocollo. Durante l'esecuzione, la dipendenza avrà un identificatore corretto per il nodo di destinazione. Grazie a questo schema, indichiamo il numero di porta solo una volta e ci riferiamo sempre alla porta corretta.
Implementazione di due nodi di sistema
Per questa configurazione utilizziamo le stesse implementazioni dei servizi senza modifiche. L'unica differenza è che ora abbiamo due oggetti che implementano diversi set di servizi:
oggetto TwoJvmNodeServerImpl estende ZeroServiceImpl[IO] con EchoServiceService e SigIntLifecycleServiceImpl {
tipo Config = EchoConfig[String] con SigTermLifecycleConfig
}
oggetto TwoJvmNodeClientImpl estende ZeroServiceImpl[IO] con EchoClientService e FiniteDurationLifecycleServiceImpl {
tipo Config = EchoClientConfig[String] con FiniteDurationLifecycleConfig
}Il primo nodo implementa il server e necessita solo della configurazione del server. Il secondo nodo implementa il client e utilizza un'altra parte della configurazione. Entrambi i nodi richiedono anche una gestione della durata. Il nodo server funziona indefinitamente fino a quando non viene arrestato SIGTERMmentre il nodo client si interrompe dopo un certo periodo. Vedi. .
Processo di sviluppo complessivo
Analizziamo come questo approccio alla configurazione influisce sul processo di sviluppo generale.
La configurazione verrà compilata insieme al resto del codice e verrà generato un artefatto (.jar). È sensato, evidentemente, mettere la configurazione in un artefatto separato. Questo è perché potremmo avere molte configurazioni basate sullo stesso codice. Inoltre, è possibile generare artefatti corrispondenti a diversi rami di configurazione. Insieme alla configurazione, vengono memorizzate le dipendenze dalle versioni specifiche delle librerie e tali versioni rimangono permanentemente memorizzate, ogni volta che decidiamo di distribuire questa versione della configurazione.
Qualsiasi modifica alla configurazione si traduce in una modifica del codice. Pertanto, ogni tale
la modifica sarà coperta dal normale processo di assicurazione della qualità:
Ticket nel bug tracker -> PR -> revisione -> fusione con i rami appropriati ->
integrazione -> distribuzione
Principali conseguenze dell'implementazione di una configurazione compilabile:
La configurazione sarà concordata su tutti i nodi del sistema distribuito. Poiché tutti i nodi ricevono la stessa configurazione da un'unica fonte.
È problematico modificare la configurazione solo in uno dei nodi. Pertanto, è improbabile una 'dissincronizzazione della configurazione' (configuration drift).
Diventa più difficile apportare piccole modifiche alla configurazione.
La maggior parte delle modifiche alla configurazione avverrà nell'ambito del processo di sviluppo generale e sarà sottoposta a revisione.
È necessario un repository separato per la configurazione di produzione? In tale configurazione potrebbero esserci password e altre informazioni riservate, per le quali vorremmo limitare l'accesso. Pertanto, sembra sensato conservare la configurazione finale in un repository separato. È possibile suddividere la configurazione in due parti: una contenente parametri di configurazione pubblici e l'altra con parametri di accesso limitato. Questo permetterà alla maggior parte degli sviluppatori di avere accesso ai parametri comuni. Tale suddivisione può essere facilmente ottenuta utilizzando trait intermedi contenenti valori predefiniti.
Varianti possibili
Cerchiamo di confrontare la configurazione compilata con alcune alternative comuni:
- File di testo sulla macchina target.
- Archiviazione centralizzata chiave-valore (
etcd/zookeeper). - Componenti del processo che possono essere riconfigurati/ripristinati senza riavviare il processo.
- Conservare la configurazione al di fuori dell'artefatto e del controllo versioni.
I file di testo offrono una notevole flessibilità per piccole modifiche. L'amministratore di sistema può accedere a un nodo remoto, apportare modifiche ai file pertinenti e riavviare il servizio. Tuttavia, per i sistemi di grandi dimensioni, tale flessibilità potrebbe essere indesiderabile. Le modifiche apportate non lasciano tracce in altri sistemi. Nessuno effettua una revisione delle modifiche. È difficile stabilire chi abbia apportato le modifiche e per quale motivo. Le modifiche non vengono testate. Se il sistema è distribuito, l'amministratore potrebbe dimenticare di apportare le modifiche appropriate su altri nodi.
(Va anche notato che l'uso di una configurazione compilabile non preclude l'uso di file di testo in futuro. Sarà sufficiente aggiungere un parser e un validatore che producano lo stesso tipo Configurazione, e sarà possibile utilizzare i file di testo. Da ciò segue direttamente che la complessità del sistema con configurazione compilabile è leggermente inferiore rispetto alla complessità del sistema che utilizza file di testo, poiché per i file di testo è necessario codice aggiuntivo.)
Il deposito centralizzato chiave-valore è un buon meccanismo per la distribuzione dei metaparametri di un'applicazione distribuita. Dobbiamo stabilire cosa siano i parametri di configurazione e cosa siano semplicemente i dati. Supponiamo di avere una funzione C => A => B, dove i parametri C cambiano raramente, mentre i dati A sono frequenti. In questo caso possiamo dire che C sono parametri di configurazione e che A sono dati. Sembra che i parametri di configurazione differiscano dai dati in quanto, in generale, cambiano meno frequentemente rispetto ai dati. Inoltre, i dati provengono solitamente da una sola fonte (dall'utente), mentre i parametri di configurazione provengono da un'altra (dall'amministratore di sistema).
Se i parametri che cambiano raramente devono essere aggiornati senza riavviare il programma, ciò può spesso portare a complicare il programma, poiché dovremo in qualche modo fornire i parametri, memorizzarli, analizzarli, verificarli e gestire i valori non corretti. Pertanto, dal punto di vista della riduzione della complessità del programma, ha senso ridurre il numero di parametri che possono cambiare durante l'esecuzione del programma (o non supportare affatto tali parametri).
In riferimento a questo post, distingueremo tra parametri statici e dinamici. Se la logica di funzionamento del servizio richiede la modifica dei parametri durante l'esecuzione del programma, li chiameremo parametri dinamici. In caso contrario, i parametri sono statici e possono essere configurati utilizzando una configurazione compilabile. Per la riconfigurazione dinamica, potrebbe essere necessario un meccanismo per riavviare parti del programma con nuovi parametri, similmente a come avviene il riavvio dei processi del sistema operativo. (Riteniamo sia preferibile evitare la riconfigurazione in tempo reale, poiché ciò aumenta la complessità del sistema. Se possibile, è meglio utilizzare le opzioni standard del sistema operativo per il riavvio dei processi.)
Uno degli aspetti importanti dell'uso di una configurazione statica, che porta le persone a considerare la riconfigurazione dinamica, è il tempo necessario al sistema per riavviarsi dopo un aggiornamento della configurazione (downtime). Infatti, se dobbiamo apportare modifiche alla configurazione statica, dovremo riavviare il sistema affinché i nuovi valori abbiano effetto. Il problema del downtime ha diverse intensità a seconda dei sistemi. In alcuni casi, è possibile pianificare il riavvio in un momento in cui il carico è minimo. Nel caso in cui sia necessario garantire un servizio continuo, è possibile implementare . In questo modo, quando dobbiamo riavviare il sistema, avviamo un'istanza parallela di questo sistema, commutiamo il bilanciatore su di essa e aspettiamo che tutte le vecchie connessioni si completino. Dopo che tutte le vecchie connessioni sono terminate, spegniamo la vecchia istanza del sistema.
Consideriamo ora la questione dell'archiviazione della configurazione all'interno dell'artefatto o all'esterno. Se archiviamo la configurazione all'interno dell'artefatto, almeno abbiamo avuto la possibilità di assicurarci della correttezza della configurazione durante la creazione dell'artefatto. Nel caso in cui la configurazione si trovi al di fuori dell'artefatto controllato, è difficile rintracciare chi e perché ha apportato modifiche a quel file. Quanto è importante? A nostro avviso, per molti sistemi di produzione è fondamentale avere una configurazione stabile e di alta qualità.
La versione dell'artefatto consente di determinare quando è stato creato, quali valori contiene, quali funzioni sono abilitate/disable, e chi è responsabile per qualsiasi modifica nella configurazione. Naturalmente, memorizzare la configurazione all'interno dell'artefatto richiede alcuni sforzi, quindi è necessario prendere una decisione consapevole.
Pro e contro
Ci piacerebbe soffermarci sui pro e contro della tecnologia proposta.
Vantaggi
Di seguito è elencato un elenco delle principali funzionalità della configurazione compilata di un sistema distribuito:
- Controllo statico della configurazione. Consente di essere certi che
la configurazione sia corretta. - Un linguaggio di configurazione ricco. Di solito, altri metodi di configurazione sono limitati al massimo alla sostituzione di variabili di stringa. Utilizzando Scala, si apre una vasta gamma di possibilità linguistiche per migliorare la configurazione. Ad esempio, possiamo utilizzare
trait per valori di default, raggruppare parametri tramite oggetti, e fare riferimento a val dichiarati una sola volta (DRY) nell'ambito di visibilità. È possibile istanziare direttamente qualsiasi classe all'interno della configurazione (Seq,Map, classi personalizzate). - DSL. In Scala esistono diverse funzionalità linguistiche che facilitano la creazione di DSL. Possiamo sfruttare queste funzionalità e implementare un linguaggio di configurazione più adatto al gruppo target di utenti, in modo che la configurazione sia almeno leggibile per gli esperti del settore. Gli esperti possono, ad esempio, partecipare al processo di revisione della configurazione.
- Integrità e sincronia tra i nodi. Uno dei vantaggi di avere la configurazione di un intero sistema distribuito memorizzata in un unico punto è che tutti i valori vengono dichiarati esattamente una volta e poi riutilizzati ovunque siano necessari. L'uso di tipi fantasmi per dichiarare le porte consente di garantire che in tutte le configurazioni corrette del sistema i nodi utilizzino protocolli compatibili. La presenza di dipendenze obbligatorie esplicite tra i nodi garantisce che tutti i servizi siano interconnessi.
- Elevata qualità nelle modifiche. Effettuare modifiche alla configurazione, utilizzando un processo di sviluppo comune, rende disponibili alti standard di qualità anche per la configurazione.
- Aggiornamento simultaneo della configurazione. Il deployment automatico del sistema dopo le modifiche alla configurazione garantisce che tutti i nodi vengano aggiornati.
- Semplificazione dell'applicazione. L'applicazione non richiede parsing, verifica della configurazione e gestione di valori non validi. In questo modo, la complessità dell'applicazione diminuisce. (Alcune complicazioni nella configurazione che osserviamo nel nostro esempio non sono attributi della configurazione compilata, ma solo una decisione consapevole, motivata dal desiderio di garantire una maggiore sicurezza dei tipi.) È piuttosto facile tornare alla configurazione abituale: basta implementare le parti mancanti. Pertanto, si può, ad esempio, iniziare con una configurazione compilata, rimandando l'implementazione delle parti superflue a quando sarà davvero necessario.
- Configurazione versionata. Poiché le modifiche alla configurazione seguono il corso abituale di qualsiasi altra modifica, otteniamo un artefatto con una versione unica. Questo ci consente, ad esempio, di tornare a una versione precedente della configurazione se necessario. Possiamo anche utilizzare una configurazione di un anno fa e il sistema funzionerà esattamente allo stesso modo. Una configurazione stabile migliora la prevedibilità e l'affidabilità del sistema distribuito. Poiché la configurazione è fissata al momento della compilazione, è piuttosto difficile falsificarla in produzione.
- Modularità. Il framework proposto è modulare e i moduli possono essere combinati in diverse varianti per ottenere sistemi diversi. In particolare, è possibile configurare un sistema per funzionare su un singolo nodo in un'opzione e su più nodi in un'altra. È possibile creare più configurazioni per le istanze di produzione del sistema.
- Test. Sostituendo singoli servizi con oggetti mock, è possibile ottenere diverse versioni del sistema, utili per i test.
- Testing di integrazione. La presenza di una configurazione unificata per l'intero sistema distribuito consente di avviare tutti i componenti in un ambiente controllato nell'ambito del testing di integrazione. È facile emulare, ad esempio, situazioni in cui alcuni nodi diventano non disponibili.
Svantaggi e limitazioni
La configurazione compilabile si distingue da altri approcci alla configurazione e potrebbe non essere adatta per alcune applicazioni. Di seguito sono riportati alcuni svantaggi:
- Configurazione statica. Talvolta è necessario modificare rapidamente la configurazione in produzione, bypassando tutti i meccanismi di sicurezza. Con questo approccio, potrebbe essere più complicato. Quantomeno, la compilazione e il deployment automatico saranno comunque necessari. Questa è al tempo stesso una caratteristica utile e uno svantaggio in alcuni casi.
- Generazione della configurazione. Se il file di configurazione viene generato da uno strumento automatico, potrebbero essere necessari ulteriori sforzi per integrare lo script di build.
- Strumenti. Attualmente, gli strumenti e le metodologie destinati a lavorare con la configurazione si basano su file di testo. Non tutti questi strumenti/metodologie saranno disponibili nel caso di una configurazione compilata.
- È necessario cambiare prospettiva. Gli sviluppatori e i DevOps sono abituati ai file di testo. L'idea stessa di compilare la configurazione può sembrare inaspettata e poco familiari, generando resistenza.
- Serve un processo di sviluppo di alta qualità. Per utilizzare con successo una configurazione compilata è necessaria una completa automazione del processo di build e deployment dell'applicazione (CI/CD). Altrimenti, l'esperienza può risultare piuttosto scomoda.
Focalizziamoci anche su alcune limitazioni dell'esempio considerato, non collegate all'idea di configurazione compilata:
- Se forniamo informazioni di configurazione superflue che non vengono utilizzate dal nodo, il compilatore non ci aiuterà a individuare la mancanza di implementazione. Questo problema può essere risolto abbandonando il Cake Pattern e utilizzando tipi più rigorosi, come ad esempio,
HListo tipi algebrici (case class) per rappresentare la configurazione. - Nel file di configurazione sono presenti righe non relative propriamente alla configurazione: (
pacchetto,importa, dichiarazioni di oggetti;override def‘e per parametri con valori predefiniti). In parte, questo può essere evitato implementando un proprio DSL. Inoltre, altri tipi di configurazione (ad esempio, XML) impongono anch'essi determinate restrizioni sulla struttura del file. - In questo post non trattiamo la riconfigurazione dinamica di un cluster di nodi simili.
Conclusione
In questo post abbiamo esaminato l'idea di rappresentare la configurazione nel codice sorgente utilizzando le avanzate funzionalità del sistema di tipi di Scala. Questo approccio può trovare applicazione in diverse applicazioni come sostituto dei metodi tradizionali di configurazione basati su file xml o di testo. Anche se il nostro esempio è realizzato in Scala, le stesse idee possono essere trasferite ad altri linguaggi compilati (come Kotlin, C#, Swift, ...). Questo approccio può essere testato in uno dei prossimi progetti e, nel caso in cui non risulti adeguato, si può passare ai file di testo, aggiungendo i dettagli mancanti.
Naturalmente, una configurazione compilata richiede un processo di sviluppo di alta qualità. In cambio, si garantisce un'elevata qualità e affidabilità delle configurazioni.
L'approccio considerato può essere ampliato:
- Possono essere utilizzati macro per effettuare controlli in fase di compilazione.
- È possibile implementare un DSL per presentare la configurazione in una forma accessibile agli utenti finali.
- Si può realizzare una gestione dinamica delle risorse con adattamento automatico della configurazione. Ad esempio, quando cambia il numero di nodi nel cluster, è necessario che (1) ogni nodo riceva una configurazione leggermente diversa; (2) il gestore del cluster riceva informazioni sui nuovi nodi.
Ringraziamenti
Desidero ringraziare Andrey Saksonov, Pavel Popov e Anton Nekhaev per le critiche costruttive al progetto dell'articolo.
Fonte: habr.com
