Vorrei raccontare un interessante meccanismo di lavoro con la configurazione di un sistema distribuito. La configurazione è presentata direttamente in un linguaggio compilabile (Scala) utilizzando tipi sicuri. In questo post viene analizzato un esempio di tale configurazione e discussi vari aspetti dell'implementazione della configurazione compilabile nel processo di sviluppo generale.

()
Introduzione
La costruzione di un sistema distribuito affidabile implica che in tutti i nodi venga utilizzata una configurazione corretta, sincronizzata con altri nodi. Solitamente si utilizzano tecnologie DevOps (terraform, ansible o simili) per la generazione automatica di file di configurazione (spesso specifici per ogni nodo). Vorremmo anche essere certi che in tutti i nodi interagenti siano utilizzati protocolli identici (inclusa la stessa versione). In caso contrario, nel nostro sistema distribuito si verificherà incompatibilità. Nel mondo JVM, una delle conseguenze di tale richiesta è la necessità di utilizzare ovunque la stessa versione della libreria che contiene i messaggi di protocollo.
E per quanto riguarda il test di un sistema distribuito? Naturalmente, presupponiamo che per tutti i componenti siano previsti test unitari, prima di passare al testing dell'integrazione. (Per poter estendere i risultati dei test al runtime, dobbiamo anche garantire un set identico di librerie sia in fase di test che durante il runtime.)
Quando si lavora con test di integrazione, spesso è più semplice utilizzare lo stesso classpath su tutti i nodi. Ci rimarrà solo da assicurarci che lo stesso classpath venga utilizzato anche durante il runtime. (Sebbene sia possibile avviare nodi diversi con classpath diversi, questo complica tutta la configurazione e porta a difficoltà nel deployment e nei test di integrazione.) In questo post partiamo dall'idea che su tutti i nodi venga utilizzato lo stesso classpath.
La configurazione si sviluppa insieme all'applicazione. Per identificare le diverse fasi dell'evoluzione dei software utilizziamo le versioni. È logico identificare anche le diverse versioni delle configurazioni. La configurazione stessa dovrebbe essere inserita in un sistema di controllo versioni. Se in production esiste una sola configurazione, possiamo usare semplicemente il numero di versione. Se invece ci sono più istanze in production, avremo bisogno di più
rami di configurazione e di un'etichetta aggiuntiva oltre alla versione (ad esempio, il nome del ramo). In questo modo saremo in grado di identificare in modo univoco la configurazione esatta. Ogni identificatore di configurazione corrisponde in modo univoco a una combinazione specifica di nodi distribuiti, porte, risorse esterne e versioni delle librerie. In questo post assumeremo che ci sia solo un ramo e che possiamo identificare la configurazione nel modo consueto utilizzando tre numeri separati da un punto (1.2.3).
Negli ambienti moderni, i file di configurazione vengono creati manualmente molto raramente. Più spesso vengono generati durante il dispiegamento e non vengono più toccati (perché ). Sorge quindi la legittima domanda: perché continuiamo a utilizzare un formato testuale per memorizzare le configurazioni? Un'alternativa del tutto praticabile sembra essere l'uso di codice normale per la configurazione, beneficiando dei controlli durante la compilazione.
In questo post esploreremo l'idea di rappresentare la configurazione all'interno di un artefatto compilato.
Configurazione compilabile
In questa sezione sarà discusso un esempio di configurazione statica compilabile. Vengono realizzati due servizi semplici: un servizio echo e un client del servizio echo. Basandosi su questi due servizi vengono raccolti due varianti del sistema. In una variante entrambi i servizi sono collocati su un unico nodo, nell'altra variante sono su nodi diversi.
Di solito un sistema distribuito contiene diversi 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 svolgono ruoli diversi, su di essi sono in esecuzione servizi e tra di loro possono essere stabiliti collegamenti TCP/HTTP.
Per descrivere la connessione TCP abbiamo bisogno di almeno un numero di porta. Vorremmo anche riflettere il protocollo supportato su questa porta, per garantire che sia il client che il server utilizzino lo stesso protocollo. Descriveremo la connessione utilizzando questa classe:
case class TcpEndPoint[Protocol](node: NodeId, port: Port[Protocol])dove Porta — un semplice numero intero Int indicando l'intervallo dei valori consentiti:
type PortNumber = Refined[Int, Closed[_0, W.`65535`.T]]Tipi raffinati
Vedi la libreria e . In breve, la libreria consente di aggiungere restrizioni ai tipi, verificate in fase di 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 nella verifica della configurazione.
Per i protocolli HTTP (REST) oltre al numero di porta, potremmo aver bisogno anche di un percorso per il servizio:
type UrlPathPrefix = Refined[String, MatchesRegex[W.`"[a-zA-Z_0-9\/]*"`.T]]
case class PortWithPrefix[Protocol](portNumber: PortNumber, pathPrefix: UrlPathPrefix)Tipi fantasma
Per identificare il protocollo in fase di compilazione usiamo un parametro di tipo che non viene utilizzato all'interno della classe. Questa soluzione è dovuta al fatto che durante il runtime non utilizziamo un'istanza del protocollo, ma desidereremmo che il compilatore verificasse la compatibilità dei protocolli. Grazie all'indicazione del protocollo, non possiamo passare un servizio non compatibile come dipendenza.
Uno dei protocolli più comuni è REST API con serializzazione Json:
sealed trait JsonHttpRestProtocol[RequestMessage, ResponseMessage]dove RequestMessage — tipo di richiesta, ResponseMessage — tipo di risposta.
Naturalmente, è possibile utilizzare anche altre descrizioni di protocolli che forniscono il livello di precisione richiesto.
Per gli scopi di questo post, utilizzeremo una versione semplificata del protocollo:
sealed trait SimpleHttpGetRest[RequestMessage, ResponseMessage]Qui la richiesta è una stringa aggiunta all'url, e la risposta è una 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 diversi modi (ad esempio, HList-ei, tipi di dati algebrici). Per gli scopi di questo post, utilizzeremo il Cake Pattern e rappresenteremo i moduli tramite trait‘ov. (Il Cake Pattern non è un elemento obbligatorio dell'approccio descritto. È semplicemente una delle possibili realizzazioni.)
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 il numero della porta e l'indicazione che questa porta supporta il protocollo echo. Potremmo anche non specificare una porta concreta, poiché i trait permettono di dichiarare metodi senza implementazione (metodi astratti). In questo caso, quando creiamo una configurazione specifica, il compilatore ci richiederebbe di fornire un'implementazione del metodo astratto e di fornire il numero della porta. Poiché abbiamo implementato il metodo, possiamo non specificare un'altra porta nella creazione della configurazione specifica. Verrà 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, collegando 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, ci sono diverse opzioni per implementare tale funzione (ad esempio, potremmo utilizzare classi di tipo basate sul tipo di configurazione). Per gli scopi di questo post utilizzeremo il Cake Pattern. Rappresenteremo il servizio tramite la classe cats.Resource, poiché in questa classe sono già previsti mezzi per garantire in modo sicuro 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 il seguente aspetto:
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 scoprire gli indirizzi di altri nodi (vedi sotto)
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]— sinonimo più o meno della funzioneA => Bcats.Resource— risorsa che può essere acquisita e liberataTimer— timer (consente di sospendere per un certo tempo e misurare intervalli di tempo)ContextShift— equivalenteExecutionContextApplicative— classe di tipo effetto che consente di combinare effetti singoli (quasi una monade). In applicazioni più complesse, è probabilmente meglio utilizzareMonad/ConcurrentEffect.
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. , che implementa altri servizi — ,
e .)
Un nodo rappresenta 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
}Nota che specifichiamo il tipo esatto di configurazione necessario per questo nodo. Se dimentichiamo di specificare uno dei tipi di configurazione richiesti da un servizio, si verificherà un errore di compilazione. Inoltre, non potremo avviare il nodo se non forniamo un oggetto con il tipo corretto e tutte le informazioni necessarie.
Risoluzione dei nomi dei nodi
Per connettersi a un nodo remoto, ci serve un vero indirizzo IP. È possibile che l'indirizzo venga a conoscenza dopo rispetto alle altre parti della configurazione. Pertanto, abbiamo bisogno di una funzione che mappa l'identificativo del nodo all'indirizzo:
case class NodeAddress[NodeId](host: Uri.Host)
trait AddressResolver[F[_]] {
def resolve[NodeId](nodeId: NodeId): F[NodeAddress[NodeId]]
}È possibile proporre diversi modi per implementare tale funzione:
- Se gli indirizzi ci sono noti prima della distribuzione, possiamo generare codice Scala con
indirizzi e poi avviare il processo di build. Durante questo processo verrà effettuata la compilazione e i test.
In questo caso, la funzione sarà conosciuta 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 scoperta» (discovery), che viene avviato prima degli altri nodi e tutti i nodi si registreranno in questo servizio e richiederanno gli indirizzi degli altri nodi. - Se possiamo modificare
/etc/hosts, allora possiamo utilizzare nomi host predefiniti (comemy-project-main-nodeeecho-backend) e semplicemente collegare questi nomi
agli indirizzi IP durante il dispiegamento.
Nel presente post non esamineremo questi casi più nel dettaglio. Per il nostro
esempio pilota, tutti i nodi avranno un solo indirizzo IP — 127.0.0.1.
Consideriamo due varianti di un sistema distribuito:
- Posizionare tutti i servizi su un solo nodo.
- E posizionare il servizio Echo e il client Echo su nodi diversi.
Configurazione per :
Configurazione per un nodo
object SingleNodeConfig extends EchoConfig[String]
with EchoClientConfig[String] with FiniteDurationLifecycleConfig
{
case object Singleton
type NodeId = Singleton.type
def nodeId = Singleton
override def portNumber: PortNumber = 8088
def echoServiceDependency = echoService
override def testMessage: UrlPathElement = "hello"
def pollInterval: FiniteDuration = 1.second
def lifetime: FiniteDuration = 10500.milliseconds
}L'oggetto implementa la configurazione sia del client che del server. Viene anche utilizzata la configurazione della durata della vita, affinché dopo l'intervallo lifetime il programma termini. (Ctrl-C funziona anche e libera correttamente tutte le risorse.)
Lo stesso insieme di trait di configurazione e implementazioni può essere utilizzato per creare un sistema composto da :
Configurazione per due nodi
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
{
def echoServiceDependency = NodeServerConfig.echoService
def pollInterval: FiniteDuration = 1.second
def lifetime: FiniteDuration = 10500.milliseconds
def testMessage: String = "dolly"
}Importante! Presta attenzione a come viene eseguita l'associazione dei servizi. Indichiamo un servizio, implementato da un nodo, come implementazione del metodo dipendente di un altro nodo. Il tipo di dipendenza viene verificato dal compilatore, poiché contiene il tipo di protocollo. All'avvio, la dipendenza conterrà un identificatore corretto per il nodo di destinazione. Grazie a questo schema, indichiamo il numero di porta esattamente una volta e facciamo sempre riferimento alla porta corretta.
Implementazione di due nodi del sistema
Per questa configurazione utilizziamo le stesse implementazioni dei servizi senza modifiche. L'unica differenza è che ora abbiamo due oggetti che implementano set di servizi diversi:
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
}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 necessitano anche di gestione del ciclo di vita. Il nodo server può funzionare indefinitamente fino a quando non viene arrestato SIGTERMmentre il nodo client termina dopo un certo periodo di tempo. Vedi. .
Processo di sviluppo generale
Esaminiamo 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). Evidentemente, ha senso mettere la configurazione in un artefatto separato. Questo perché potremmo avere molte configurazioni basate sullo stesso codice. Ancora una volta, è possibile generare artefatti corrispondenti a diversi rami di configurazione. Insieme alla configurazione, le dipendenze da versioni specifiche di librerie vengono conservate e queste versioni vengono conservate per sempre, quando decidiamo di distribuire questa versione della configurazione.
Qualsiasi modifica della configurazione si traduce in una modifica del codice. Pertanto, ogni tale
modifica sarà coperta dal normale processo di assicurazione della qualità:
Ticket nel bug tracker -> PR -> revisione -> unione con i rami corrispondenti ->
integrazione -> distribuzione
Principali conseguenze dell'implementazione di configurazioni compilabili:
La configurazione sarà sincronizzata su tutti i nodi del sistema distribuito. Poiché tutti i nodi ricevono la stessa configurazione da un'unica fonte.
È problematico modificare la configurazione in uno solo dei nodi. Pertanto, è improbabile che si verifichi una "discrepanza di 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à soggetta a revisione.
È necessario un repository separato per memorizzare la configurazione di produzione? In tale configurazione possono essere contenute password e altre informazioni riservate, l'accesso alle quali vorremmo limitare. Da ciò, sembra sensato memorizzare la configurazione finale in un repository separato. È possibile suddividere la configurazione in due parti: una contenente parametri di configurazione pubblici e l'altra contenente parametri a accesso limitato. Questo consentirebbe alla maggior parte degli sviluppatori di accedere ai parametri comuni. Tale suddivisione è facile da ottenere utilizzando trait intermedi contenenti valori predefiniti.
Varianti possibili
Cerchiamo di confrontare la configurazione compilata con alcune alternative comuni:
- File di testo sulla macchina target.
- Archivio centralizzato chiave-valore (
sono stati resi/zookeeper). - Componenti del processo che possono essere riconfigurati/riavviati senza riavviare il processo.
- Memorizzazione della configurazione al di fuori dell'artefatto e del controllo delle versioni.
I file di testo offrono una notevole flessibilità in termini di piccole modifiche. Un amministratore di sistema può accedere a un nodo remoto, apportare modifiche ai file pertinenti e riavviare il servizio. Tuttavia, per sistemi grandi, tale flessibilità può essere indesiderata. Non ci sono tracce delle modifiche apportate in altri sistemi. Nessuno effettua revisione delle modifiche. È difficile stabilire chi ha apportato le modifiche e per quale motivo. Le modifiche non sono testate. Se il sistema è distribuito, l'amministratore potrebbe dimenticare di apportare la modifica corrispondente sugli altri nodi.
(Va anche notato che l'uso di una configurazione compilata non esclude la possibilità di utilizzare file di testo in futuro. Sarà sufficiente aggiungere un parser e un validatore che producano lo stesso tipo Configurazione, così da poter utilizzare file di testo. Da ciò ne deriva direttamente che la complessità del sistema con configurazione compilata è leggermente minore rispetto a quella di un sistema che utilizza file di testo, poiché per i file di testo è necessario un codice aggiuntivo.)
Un archivio centralizzato chiave-valore è un buon meccanismo per la distribuzione dei metaparametri di un'applicazione distribuita. Dobbiamo chiarire cosa siano i parametri di configurazione e cosa siano invece semplici dati. Consideriamo una funzione C => A => B, dove i parametri C cambiano raramente, mentre i dati A cambiano frequentemente. In questo caso possiamo affermare che C sono parametri di configurazione, mentre A sono dati. Sembra che i parametri di configurazione si differenzino 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 del sistema).
Se i parametri che cambiano raramente devono essere aggiornati senza riavviare il programma, ciò può complicare notevolmente il programma, poiché sarà necessario in qualche modo fornire i parametri, conservarli, analizzarli e convalidarli, e gestire valori non corretti. Pertanto, in un'ottica di riduzione della complessità del programma, ha senso ridurre il numero di parametri che possono cambiare durante l'esecuzione del programma (oppure non supportare affatto tali parametri).
Dal punto di vista del presente 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. Altrimenti, i parametri saranno considerati statici e potranno essere configurati utilizzando una configurazione compilabile. Per la riconfigurazione dinamica, potrebbe essere necessario un meccanismo per riavviare parti del programma con nuovi parametri, simile a come avviene il riavvio dei processi del sistema operativo. (A nostro avviso, è preferibile evitare la riconfigurazione in tempo reale, poiché ciò aumenta la complessità del sistema. Se possibile, è meglio utilizzare le possibilità standard del sistema operativo per il riavvio dei processi.)
Uno degli aspetti importanti dell'uso della configurazione statica che spinge 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 un’intensità diversa a seconda dei sistemi. In alcuni casi è possibile pianificare il riavvio in un momento in cui il carico è minimo. Nel caso 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 attendiamo che le vecchie connessioni vengano terminate. Dopo che tutte le vecchie connessioni sono terminate, spegniamo la vecchia istanza del sistema.
Esaminiamo ora la questione della memorizzazione della configurazione all'interno dell'artefatto o al di fuori di esso. Se memorizziamo la configurazione all'interno dell'artefatto, almeno abbiamo avuto la possibilità, durante la costruzione dell'artefatto, di verificare la correttezza della configurazione. Se la configurazione si trova al di fuori dell'artefatto controllato, è difficile tracciare chi e perché ha apportato modifiche a questo file. Quanto è importante questo? 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 attive/disattive e chi è responsabile di eventuali modifiche alla configurazione. Ovviamente, la memorizzazione della configurazione all'interno dell'artefatto richiede alcuni sforzi, quindi è necessario prendere una decisione consapevole.
Pro e contro
Vorrei soffermarmi sui pro e contro della tecnologia proposta.
Vantaggi
Di seguito è riportato un elenco delle principali funzionalità della configurazione compilata del sistema distribuito:
- Verifica statica della configurazione. Consente di essere certi che
la configurazione sia corretta. - Linguaggio di configurazione ricco. Di solito, altri metodi di configurazione sono limitati al massimo alla sostituzione di variabili stringa. Utilizzando Scala, si ha accesso a una vasta gamma di possibilità del linguaggio per migliorare la configurazione. Ad esempio, possiamo utilizzare
trait per valori predefiniti, raggruppare parametri con oggetti e fare riferimento a val dichiarati una sola volta (DRY) nell'ambito circostante. Possiamo istanziare direttamente all'interno della configurazione qualsiasi classe (Seq,Mappa, classi personalizzate). - DSL. In Scala ci sono diverse funzionalità linguistiche che semplificano la creazione di DSL. Possiamo sfruttare queste possibilità e implementare un linguaggio di configurazione che sia più comodo per il gruppo target di utenti, in modo che la configurazione sia almeno leggibile per gli esperti del dominio. 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 memorizzato in un unico punto è che tutti i valori vengono dichiarati una sola volta e poi riutilizzati ovunque siano necessari. L'uso di tipi fantasma per dichiarare le porte garantisce che in tutte le configurazioni corrette del sistema i nodi utilizzino protocolli compatibili. La presenza di dipendenze esplicite tra i nodi garantisce che tutti i servizi siano collegati tra loro.
- Alta qualità nell'implementazione delle modifiche. L'implementazione delle modifiche nella configurazione, seguendo il processo di sviluppo comune, rende accessibili elevati standard di qualità anche per la configurazione.
- Aggiornamento simultaneo della configurazione. Il deployment automatico del sistema dopo l'implementazione delle modifiche nella configurazione garantisce che tutti i nodi vengano aggiornati.
- Semplificazione dell'applicazione. L'applicazione non necessita di parsing, verifica della configurazione e gestione di valori non validi. Questo riduce così la complessità dell'applicazione. (Una certa complessità della configurazione, osservata nel nostro esempio, non è attribuibile alla configurazione compilata, ma è solo una decisione consapevole, dovuta al desiderio di garantire una maggiore sicurezza dei tipi.) È facile tornare alla configurazione standard: è sufficiente implementare le parti mancanti. Pertanto, si può, ad esempio, iniziare con una configurazione compilata, rinviando l'implementazione delle parti superflue fino a quando non sarà realmente necessario.
- Configurazione versionata. Poiché le modifiche alla configurazione seguono la stessa sorte di qualsiasi altra modifica, otteniamo un artefatto con una versione unica. Questo ci permette, ad esempio, di tornare a una versione precedente della configurazione se necessario. Possiamo persino utilizzare la configurazione di un anno fa e il sistema funzionerà esattamente allo stesso modo. Una configurazione stabile migliora la prevedibilità e l'affidabilità di un sistema distribuito. Poiché la configurazione è fissata al momento della compilazione, risulta 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, si può configurare un sistema per funzionare su un singolo nodo in una variante, e in un'altra su più nodi. È possibile creare diverse configurazioni per le istanze di produzione del sistema.
- Testing. Sostituendo i singoli servizi con oggetti mock, si possono ottenere diverse versioni del sistema, comode per il testing.
- Testing di integrazione. Avere una configurazione unificata dell'intero sistema distribuito consente di avviare tutti i componenti in un ambiente controllato durante il testing di integrazione. È facile, ad esempio, simulare una situazione in cui alcuni nodi diventano non raggiungibili.
Svantaggi e limitazioni
Una configurazione compilata si distingue da altri approcci alla configurazione e per alcune applicazioni potrebbe non essere adatta. Di seguito sono elencati alcuni svantaggi:
- Configurazione statica. A volte è necessario modificare rapidamente la configurazione in produzione, eludendo tutti i meccanismi di protezione. Con questo approccio, potrebbe essere più complicato. In ogni caso, la compilazione e il deployment automatico saranno comunque necessari. Questa è al tempo stesso una caratteristica utile dell'approccio e una limitazione in alcuni casi.
- Generazione della configurazione. Nel caso in cui il file di configurazione sia generato da uno strumento automatico, potrebbero essere richiesti ulteriori sforzi per integrare lo script di build.
- Strumenti. Attualmente, gli strumenti e le metodologie destinate al lavoro con la configurazione si basano su file di testo. Non tutti questi strumenti/metodologie saranno disponibili nel caso di una configurazione compilata.
- Richiesta di un cambiamento di mentalità. Gli sviluppatori e il DevOps sono abituati ai file di testo. L'idea stessa di compilare una configurazione può sembrare un po' inaspettata e poco consueta, suscitando resistenza.
- È necessario un processo di sviluppo di alta qualità. Per utilizzare comodamente una configurazione compilata è necessaria una completa automazione del processo di build e deployment dell'applicazione (CI/CD). In caso contrario, risulterà piuttosto scomodo.
Ci soffermiamo anche su un certo numero di limitazioni dell'esempio considerato, non correlate all'idea della configurazione compilata:
- Se forniamo informazioni di configurazione eccessive, che non vengono utilizzate dal nodo, il compilatore non ci aiuterà a rilevare l'assenza di implementazione. Questo problema può essere risolto rinunciando al Cake Pattern e utilizzando tipi più rigidi, ad esempio,
HListo tipi di dati algebrici (case class) per rappresentare la configurazione. - Nel file di configurazione ci sono righe che non si riferiscono specificamente alla configurazione: (
pacchetto,importa, dichiarazioni di oggetti;override defCiò per le impostazioni che hanno valori predefiniti). In parte è possibile evitarlo implementando il proprio DSL. Inoltre, altri tipi di configurazione (ad esempio, XML) impongono anche determinate restrizioni sulla struttura del file. - In questo post non consideriamo 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ò essere applicato in diverse applicazioni come sostituto dei metodi tradizionali di configurazione basati su file XML o di testo. Anche se il nostro esempio è stato implementato in Scala, le stesse idee possono essere trasferite in altri linguaggi compilati (come Kotlin, C#, Swift, …). Questo approccio può essere testato in uno dei seguenti progetti e, nel caso non si adatti, si può passare ai file di testo aggiungendo i dettagli mancanti.
Naturalmente, la configurazione compilata richiede un processo di sviluppo di alta qualità. In cambio si ottiene alta qualità e affidabilità delle configurazioni.
L'approccio esaminato può essere ampliato:
- Possono essere utilizzati i macro per eseguire controlli durante la compilazione.
- Si può realizzare un DSL per rappresentare la configurazione in un formato accessibile agli utenti finali.
- È possibile implementare una gestione dinamica delle risorse con auto-regolazione 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
Vorrei ringraziare Andrey Saksonov, Pavel Popov e Anton Nekhaev per il loro costruttivo feedback sulla bozza dell'articolo.
Fonte: habr.com
