Configurazione compilabile di un sistema distribuito

In questo post vorremmo condividere un modo interessante di affrontare la configurazione di un sistema distribuito.
La configurazione è rappresentata direttamente in linguaggio Scala in modo sicuro. Un'implementazione di esempio è descritta in dettaglio. Vengono discussi vari aspetti della proposta, inclusa l'influenza sul processo di sviluppo complessivo.

Configurazione compilabile di un sistema distribuito

(на русском)

Introduzione

La costruzione di sistemi distribuiti robusti richiede l'uso di una configurazione corretta e coerente su tutti i nodi. Una soluzione tipica consiste nell'utilizzare una descrizione testuale della distribuzione (terraform, ansible o qualcosa di simile) e file di configurazione generati automaticamente (spesso dedicati a ciascun nodo/ruolo). Vorremmo anche utilizzare gli stessi protocolli delle stesse versioni su ciascun nodo comunicante (altrimenti riscontreremmo problemi di incompatibilità). Nel mondo JVM ciò significa che almeno la libreria di messaggistica dovrebbe essere della stessa versione su tutti i nodi comunicanti.

Che ne dici di testare il sistema? Naturalmente, dovremmo avere test unitari per tutti i componenti prima di arrivare ai test di integrazione. Per poter estrapolare i risultati dei test in fase di runtime, dovremmo assicurarci che le versioni di tutte le librerie siano mantenute identiche sia in runtime che in ambienti di test.

Quando si eseguono test di integrazione, spesso è molto più semplice avere lo stesso classpath su tutti i nodi. Dobbiamo solo assicurarci che durante la distribuzione venga utilizzato lo stesso percorso di classe. (È possibile utilizzare percorsi di classe diversi su nodi diversi, ma è più difficile rappresentare questa configurazione e distribuirla correttamente.) Quindi, per semplificare le cose, considereremo solo percorsi di classe identici su tutti i nodi.

La configurazione tende ad evolversi insieme al software. Di solito utilizziamo le versioni per identificare vari
fasi dell’evoluzione del software. Sembra ragionevole coprire la configurazione sotto la gestione delle versioni e identificare le diverse configurazioni con alcune etichette. Se è presente una sola configurazione in produzione, potremmo utilizzare la versione singola come identificatore. A volte potremmo avere più ambienti di produzione. E per ogni ambiente potremmo aver bisogno di un ramo di configurazione separato. Pertanto le configurazioni potrebbero essere etichettate con ramo e versione per identificare in modo univoco diverse configurazioni. Ogni etichetta e versione di ramo corrisponde a una singola combinazione di nodi distribuiti, porte, risorse esterne e versioni della libreria classpath su ciascun nodo. Qui copriremo solo il singolo ramo e identificheremo le configurazioni tramite una versione decimale a tre componenti (1.2.3), allo stesso modo degli altri artefatti.

Negli ambienti moderni i file di configurazione non vengono più modificati manualmente. In genere generiamo
file di configurazione al momento della distribuzione e non toccarli mai dopo. Quindi ci si potrebbe chiedere perché utilizziamo ancora il formato testo per i file di configurazione? Un'opzione praticabile è posizionare la configurazione all'interno di un'unità di compilazione e trarre vantaggio dalla convalida della configurazione in fase di compilazione.

In questo post esamineremo l'idea di mantenere la configurazione nell'artefatto compilato.

Configurazione compilabile

In questa sezione discuteremo un esempio di configurazione statica. Vengono configurati e implementati due semplici servizi: il servizio echo e il client del servizio echo. Quindi vengono istanziati due diversi sistemi distribuiti con entrambi i servizi. Uno è per la configurazione a nodo singolo e l'altro per la configurazione a due nodi.

Un tipico sistema distribuito è costituito da pochi nodi. I nodi potrebbero essere identificati utilizzando qualche tipo:

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

o solo

case class NodeId(hostName: String)

o addirittura

object Singleton
type NodeId = Singleton.type

Questi nodi svolgono vari ruoli, eseguono alcuni servizi e dovrebbero essere in grado di comunicare con gli altri nodi tramite connessioni TCP/HTTP.

Per la connessione TCP è richiesto almeno un numero di porta. Vogliamo anche assicurarci che client e server parlino dello stesso protocollo. Per modellare una connessione tra nodi dichiariamo la seguente classe:

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

where Port è solo un Int nell'intervallo consentito:

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

Tipi raffinati

See raffinato biblioteca. In breve, consente di aggiungere vincoli di tempo di compilazione ad altri tipi. In questo caso Int è consentito avere solo valori a 16 bit che possano rappresentare il numero di porta. Non è necessario utilizzare questa libreria per questo approccio di configurazione. Sembra che si adatti molto bene.

Per HTTP (REST) ​​potremmo aver bisogno anche di un percorso del servizio:

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

Tipo fantasma

Per identificare il protocollo durante la compilazione utilizziamo la funzionalità Scala di dichiarare l'argomento del tipo Protocol che non viene utilizzato in classe. È un cosiddetto tipo fantasma. In fase di esecuzione raramente abbiamo bisogno di un'istanza dell'identificatore di protocollo, ecco perché non la memorizziamo. Durante la compilazione questo tipo fantasma fornisce ulteriore sicurezza del tipo. Non possiamo passare il porto con un protocollo errato.

Uno dei protocolli più utilizzati è l'API REST con serializzazione Json:

sealed trait JsonHttpRestProtocol[RequestMessage, ResponseMessage]

where RequestMessage è il tipo base di messaggi che il client può inviare al server e ResponseMessage è il messaggio di risposta dal server. Naturalmente possiamo creare altre descrizioni di protocollo che specifichino il protocollo di comunicazione con la precisione desiderata.

Ai fini di questo post utilizzeremo una versione più semplice del protocollo:

sealed trait SimpleHttpGetRest[RequestMessage, ResponseMessage]

In questo protocollo il messaggio di richiesta viene aggiunto all'URL e il messaggio di risposta viene restituito come stringa semplice.

Una configurazione di servizio potrebbe essere descritta dal nome del servizio, da una raccolta di porte e da alcune dipendenze. Esistono alcuni modi possibili per rappresentare tutti questi elementi in Scala (ad esempio, HList, tipi di dati algebrici). Ai fini di questo post utilizzeremo Cake Pattern e rappresenteremo pezzi combinabili (moduli) come tratti. (Cake Pattern non è un requisito per questo approccio di configurazione compilabile. È solo una possibile implementazione dell'idea.)

Le dipendenze potrebbero essere rappresentate utilizzando il Cake Pattern come punti finali 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)
  }

Il servizio Echo necessita solo di una porta configurata. E dichiariamo che questa porta supporta il protocollo echo. Tieni presente che non è necessario specificare una porta particolare in questo momento, poiché i tratti consentono dichiarazioni di metodi astratti. Se utilizziamo metodi astratti, il compilatore richiederà un'implementazione in un'istanza di configurazione. Qui abbiamo fornito l'implementazione (8081) e verrà utilizzato come valore predefinito se lo saltiamo in una configurazione concreta.

Possiamo dichiarare una dipendenza nella configurazione del client del servizio echo:

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

La dipendenza ha lo stesso tipo di echoService. In particolare, richiede lo stesso protocollo. Quindi, possiamo essere sicuri che se colleghiamo queste due dipendenze funzioneranno correttamente.

Implementazione dei servizi

Un servizio necessita di una funzione per essere avviato e arrestato con garbo. (La capacità di arrestare un servizio è fondamentale per il test.) Anche in questo caso ci sono alcune opzioni per specificare tale funzione per una determinata configurazione (ad esempio, potremmo utilizzare le classi di tipo). Per questo post utilizzeremo nuovamente Cake Pattern. Possiamo rappresentare un servizio utilizzando cats.Resource che già fornisce il bracketing e il rilascio delle risorse. Per acquisire una risorsa dovremmo fornire una configurazione e un contesto di runtime. Quindi la funzione di avvio del servizio potrebbe essere simile a:

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

where

  • Config — tipo di configurazione richiesta da questo avviatore di servizio
  • AddressResolver — un oggetto runtime che ha la capacità di ottenere indirizzi reali di altri nodi (continua a leggere per i dettagli).

da cui provengono gli altri tipi cats:

  • F[_] — tipo di effetto (nel caso più semplice F[A] potrebbe essere giusto () => A. In questo post useremo cats.IO.)
  • Reader[A,B] — è più o meno un sinonimo di funzione A => B
  • cats.Resource - ha modi per acquisire e rilasciare
  • Timer — permette di dormire/misurare il tempo
  • ContextShift - analogico di ExecutionContext
  • Applicative — wrapper di funzioni effettive (quasi una monade) (potremmo eventualmente sostituirlo con qualcos'altro)

Utilizzando questa interfaccia possiamo implementare alcuni servizi. Ad esempio, un servizio che non fa nulla:

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

(Vedere Codice sorgente per implementazioni di altri servizi — servizio eco,
cliente dell'eco ed controllori a vita.)

Un nodo è un singolo oggetto che esegue alcuni servizi (l'avvio di una catena di risorse è abilitato da Cake Pattern):

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

Tieni presente che nel nodo specifichiamo il tipo esatto di configurazione necessaria a questo nodo. Il compilatore non ci permetterà di costruire l'oggetto (Cake) con tipo insufficiente, perché ogni tratto del servizio dichiara un vincolo sul Config tipo. Inoltre non saremo in grado di avviare il nodo senza fornire la configurazione completa.

Risoluzione dell'indirizzo del nodo

Per stabilire una connessione abbiamo bisogno di un indirizzo host reale per ciascun nodo. Potrebbe essere noto più tardi rispetto ad altre parti della configurazione. Pertanto, abbiamo bisogno di un modo per fornire una mappatura tra l'ID del nodo e il suo indirizzo effettivo. Questa mappatura è una funzione:

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

Esistono alcuni modi possibili per implementare tale funzione.

  1. Se conosciamo gli indirizzi effettivi prima della distribuzione, durante l'istanziazione degli host del nodo, possiamo generare codice Scala con gli indirizzi effettivi ed eseguire successivamente la compilazione (che esegue controlli in fase di compilazione e quindi esegue la suite di test di integrazione). In questo caso la nostra funzione di mappatura è nota staticamente e può essere semplificata in qualcosa come a Map[NodeId, NodeAddress].
  2. A volte otteniamo gli indirizzi effettivi solo in un secondo momento quando il nodo viene effettivamente avviato, oppure non abbiamo indirizzi di nodi che non sono ancora stati avviati. In questo caso potremmo avere un servizio di rilevamento avviato prima di tutti gli altri nodi e ciascun nodo potrebbe pubblicizzare il proprio indirizzo in quel servizio e sottoscrivere le dipendenze.
  3. Se possiamo modificare /etc/hosts, possiamo utilizzare nomi host predefiniti (come my-project-main-node ed echo-backend) e associare semplicemente questo nome all'indirizzo IP al momento della distribuzione.

In questo post non trattiamo questi casi in modo più dettagliato. Infatti nel nostro esempio giocattolo tutti i nodi avranno lo stesso indirizzo IP — 127.0.0.1.

In questo post considereremo due layout di sistema distribuito:

  1. Layout a nodo singolo, in cui tutti i servizi sono posizionati sul singolo nodo.
  2. Layout a due nodi, in cui servizio e client si trovano su nodi diversi.

La configurazione per a singolo nodo la disposizione è la seguente:

Configurazione a nodo singolo

object SingleNodeConfig extends EchoConfig[String] 
  with EchoClientConfig[String] with FiniteDurationLifecycleConfig
{
  case object Singleton // identifier of the single node 
  // configuration of server
  type NodeId = Singleton.type
  def nodeId = Singleton

  /** Type safe service port specification. */
  override def portNumber: PortNumber = 8088

  // configuration of client

  /** We'll use the service provided by the same host. */
  def echoServiceDependency = echoService

  override def testMessage: UrlPathElement = "hello"

  def pollInterval: FiniteDuration = 1.second

  // lifecycle controller configuration
  def lifetime: FiniteDuration = 10500.milliseconds // additional 0.5 seconds so that there are 10 requests, not 9.
}

Qui creiamo un'unica configurazione che estende sia la configurazione del server che quella del client. Inoltre configuriamo un controller del ciclo di vita che normalmente terminerà client e server successivamente lifetime passa l'intervallo.

Lo stesso insieme di implementazioni e configurazioni del servizio può essere utilizzato per creare il layout di un sistema con due nodi separati. Dobbiamo solo creare due configurazioni di nodi separate con i servizi adeguati:

Configurazione a 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
  {
    // NB! dependency specification
    def echoServiceDependency = NodeServerConfig.echoService

    def pollInterval: FiniteDuration = 1.second

    def lifetime: FiniteDuration = 10500.milliseconds // additional 0.5 seconds so that there are 10 request, not 9.

    def testMessage: String = "dolly"
  }

Guarda come specifichiamo la dipendenza. Menzioniamo il servizio fornito dall'altro nodo come dipendenza del nodo corrente. Il tipo di dipendenza viene controllato perché contiene un tipo fantasma che descrive il protocollo. E in fase di esecuzione avremo l'ID nodo corretto. Questo è uno degli aspetti importanti dell'approccio di configurazione proposto. Ci fornisce la possibilità di impostare la porta solo una volta e assicurarci di fare riferimento alla porta corretta.

Implementazione a due nodi

Per questa configurazione utilizziamo esattamente le stesse implementazioni dei servizi. Nessun cambiamento. Tuttavia, creiamo due diverse implementazioni del nodo che contengono diversi set di servizi:

  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 lato server. Il secondo nodo implementa il client e necessita di un'altra parte della configurazione. Entrambi i nodi richiedono alcune specifiche sulla durata. Ai fini di questo servizio post, il nodo avrà una durata infinita che potrebbe essere terminata utilizzando SIGTERM, mentre echo client terminerà dopo la durata finita configurata. Vedi il applicazione iniziale per i dettagli.

Processo di sviluppo complessivo

Vediamo come questo approccio cambia il modo in cui lavoriamo con la configurazione.

La configurazione come codice verrà compilata e produrrà un artefatto. Sembra ragionevole separare gli artefatti di configurazione dagli altri artefatti del codice. Spesso possiamo avere una moltitudine di configurazioni sulla stessa base di codice. E, naturalmente, possiamo avere più versioni di vari rami di configurazione. In una configurazione possiamo selezionare versioni particolari delle librerie e questo rimarrà costante ogni volta che distribuiremo questa configurazione.

Una modifica della configurazione diventa una modifica del codice. Quindi dovrebbe essere coperto dallo stesso processo di garanzia della qualità:

Ticket -> PR -> revisione -> unisci -> integrazione continua -> distribuzione continua

Le conseguenze dell’approccio sono le seguenti:

  1. La configurazione è coerente per una particolare istanza del sistema. Sembra che non ci sia modo di avere una connessione errata tra i nodi.
  2. Non è facile modificare la configurazione in un solo nodo. Sembra irragionevole accedere e modificare alcuni file di testo. Quindi la deriva della configurazione diventa meno possibile.
  3. Piccole modifiche alla configurazione non sono facili da apportare.
  4. La maggior parte delle modifiche alla configurazione seguiranno lo stesso processo di sviluppo e supereranno alcune revisioni.

Abbiamo bisogno di un repository separato per la configurazione di produzione? La configurazione di produzione potrebbe contenere informazioni sensibili che vorremmo tenere fuori dalla portata di molte persone. Potrebbe quindi valere la pena mantenere un repository separato con accesso limitato che conterrà la configurazione di produzione. Possiamo dividere la configurazione in due parti: una che contiene i parametri di produzione più aperti e l'altra che contiene la parte segreta della configurazione. Ciò consentirebbe l'accesso alla maggior parte degli sviluppatori alla stragrande maggioranza dei parametri, limitando l'accesso a cose veramente sensibili. È facile ottenere questo risultato utilizzando tratti intermedi con valori di parametro predefiniti.

Variazioni

Vediamo pro e contro dell'approccio proposto rispetto alle altre tecniche di gestione della configurazione.

Innanzitutto elencheremo alcune alternative ai diversi aspetti del modo proposto di gestire la configurazione:

  1. File di testo sul computer di destinazione.
  2. Archiviazione centralizzata di valori-chiave (come etcd/zookeeper).
  3. Componenti del sottoprocesso che potrebbero essere riconfigurati/riavviati senza riavviare il processo.
  4. Configurazione esterna agli artefatti e al controllo della versione.

Il file di testo offre una certa flessibilità in termini di correzioni ad hoc. L'amministratore di sistema può accedere al nodo di destinazione, apportare una modifica e riavviare semplicemente il servizio. Questo potrebbe non essere altrettanto valido per i sistemi più grandi. Nessuna traccia è rimasta dietro il cambiamento. Il cambiamento non viene rivisto da un altro paio di occhi. Potrebbe essere difficile scoprire cosa ha causato il cambiamento. Non è stato testato. Dal punto di vista del sistema distribuito un amministratore può semplicemente dimenticare di aggiornare la configurazione in uno degli altri nodi.

(A proposito, se alla fine ci sarà bisogno di iniziare a usare file di configurazione di testo, dovremo solo aggiungere parser + validatore che potrebbero produrre lo stesso Config digitare e questo sarebbe sufficiente per iniziare a utilizzare le configurazioni di testo. Ciò mostra anche che la complessità della configurazione in fase di compilazione è leggermente inferiore alla complessità delle configurazioni basate su testo, perché nella versione basata su testo abbiamo bisogno di codice aggiuntivo.)

L'archiviazione centralizzata dei valori-chiave è un buon meccanismo per la distribuzione dei metaparametri dell'applicazione. Qui dobbiamo pensare a ciò che consideriamo valori di configurazione e cosa sono solo dati. Data una funzione C => A => B di solito chiamiamo valori che cambiano raramente C "configurazione", mentre i dati vengono modificati frequentemente A - basta inserire i dati. La configurazione deve essere fornita alla funzione prima dei dati A. Data questa idea possiamo dire che è prevista la frequenza delle modifiche che potrebbero essere utilizzate per distinguere i dati di configurazione dai semplici dati. Inoltre, i dati in genere provengono da un'origine (utente) e la configurazione proviene da un'origine diversa (amministratore). Gestire parametri che possono essere modificati dopo il processo di inizializzazione porta ad un aumento della complessità dell'applicazione. Per tali parametri dovremo gestire il loro meccanismo di consegna, analisi e validazione, gestendo valori errati. Quindi, per ridurre la complessità del programma, sarebbe meglio ridurre il numero di parametri che possono cambiare in fase di esecuzione (o addirittura eliminarli del tutto).

Dal punto di vista di questo post dovremmo fare una distinzione tra parametri statici e dinamici. Se la logica del servizio richiede una rara modifica di alcuni parametri in fase di esecuzione, allora potremmo chiamarli parametri dinamici. Altrimenti sono statici e potrebbero essere configurati utilizzando l'approccio proposto. Per la riconfigurazione dinamica potrebbero essere necessari altri approcci. Ad esempio, parti del sistema potrebbero essere riavviate con i nuovi parametri di configurazione in modo simile al riavvio di processi separati di un sistema distribuito.
(La mia modesta opinione è quella di evitare la riconfigurazione del runtime perché aumenta la complessità del sistema.
Potrebbe essere più semplice fare affidamento solo sul supporto del sistema operativo per il riavvio dei processi. Tuttavia, potrebbe non essere sempre possibile.)

Un aspetto importante dell'utilizzo della configurazione statica che a volte induce le persone a considerare la configurazione dinamica (senza altri motivi) è il tempo di inattività del servizio durante l'aggiornamento della configurazione. Infatti, se dobbiamo apportare modifiche alla configurazione statica, dobbiamo riavviare il sistema affinché i nuovi valori diventino effettivi. I requisiti per i tempi di inattività variano a seconda dei diversi sistemi, quindi potrebbe non essere così critico. Se è critico, dobbiamo pianificare in anticipo eventuali riavvii del sistema. Ad esempio, potremmo implementare Drenaggio della connessione AWS ELB. In questo scenario, ogni volta che dobbiamo riavviare il sistema, avviamo una nuova istanza del sistema in parallelo, quindi trasferiamo ELB su di essa, lasciando che il vecchio sistema completi la manutenzione delle connessioni esistenti.

Che ne dici di mantenere la configurazione all'interno dell'artefatto con versione o all'esterno? Mantenere la configurazione all'interno di un artefatto significa nella maggior parte dei casi che questa configurazione ha superato lo stesso processo di controllo qualità degli altri artefatti. Quindi si potrebbe essere sicuri che la configurazione sia di buona qualità e affidabile. Al contrario la configurazione in un file separato fa sì che non ci siano tracce di chi e perché ha apportato modifiche a quel file. È importante? Crediamo che per la maggior parte dei sistemi di produzione sia meglio avere una configurazione stabile e di alta qualità.

La versione dell'artefatto consente di scoprire quando è stato creato, quali valori contiene, quali funzionalità sono abilitate/disabilitate, chi era responsabile di apportare ogni modifica alla configurazione. Potrebbe essere necessario un certo sforzo per mantenere la configurazione all'interno di un artefatto ed è una scelta di progettazione da fare.

Pro e contro

Qui vorremmo evidenziare alcuni vantaggi e discutere alcuni svantaggi dell’approccio proposto.

Vantaggi

Caratteristiche della configurazione compilabile di un sistema distribuito completo:

  1. Controllo statico della configurazione. Ciò fornisce un elevato livello di sicurezza che la configurazione sia corretta dati i vincoli di tipo.
  2. Ricco linguaggio di configurazione. Tipicamente altri approcci di configurazione si limitano al massimo alla sostituzione delle variabili.
    Utilizzando Scala è possibile utilizzare un'ampia gamma di funzionalità linguistiche per migliorare la configurazione. Ad esempio, possiamo utilizzare i tratti per fornire valori predefiniti, oggetti per impostare ambiti diversi a cui possiamo fare riferimento vals definito solo una volta nell'ambito esterno (DRY). È possibile utilizzare sequenze letterali o istanze di determinate classi (Seq, Map, Ecc.).
  3. DSL. Scala ha un supporto decente per gli scrittori DSL. È possibile utilizzare queste funzionalità per stabilire un linguaggio di configurazione che sia più conveniente e facile da usare per l'utente finale, in modo che la configurazione finale sia almeno leggibile dagli utenti del dominio.
  4. Integrità e coerenza tra i nodi. Uno dei vantaggi di avere la configurazione dell'intero sistema distribuito in un unico posto è che tutti i valori vengono definiti rigorosamente una volta e quindi riutilizzati in tutti i luoghi in cui ne abbiamo bisogno. Inoltre, digitare le dichiarazioni di porta sicura garantisce che in tutte le possibili configurazioni corrette i nodi del sistema parleranno la stessa lingua. Esistono dipendenze esplicite tra i nodi che rendono difficile dimenticare di fornire alcuni servizi.
  5. Alta qualità delle modifiche. L'approccio generale di far passare le modifiche alla configurazione attraverso il normale processo di PR stabilisce elevati standard di qualità anche nella configurazione.
  6. Modifiche simultanee della configurazione. Ogni volta che apportiamo modifiche alla configurazione, la distribuzione automatica garantisce che tutti i nodi vengano aggiornati.
  7. Semplificazione applicativa. Non è necessario che l'applicazione analizzi e convalidi la configurazione e gestisca valori di configurazione errati. Ciò semplifica l'applicazione complessiva. (Un certo aumento di complessità è nella configurazione stessa, ma è un compromesso consapevole verso la sicurezza.) È piuttosto semplice tornare alla configurazione ordinaria: basta aggiungere i pezzi mancanti. È più semplice iniziare con la configurazione compilata e posticipare l'implementazione di parti aggiuntive a tempi successivi.
  8. Configurazione con versione. Dato che le modifiche alla configurazione seguono lo stesso processo di sviluppo, di conseguenza otteniamo un artefatto con una versione unica. Ci consente di ripristinare la configurazione se necessario. Possiamo anche implementare una configurazione utilizzata un anno fa e funzionerà esattamente allo stesso modo. La configurazione stabile migliora la prevedibilità e l'affidabilità del sistema distribuito. La configurazione è fissa in fase di compilazione e non può essere facilmente manomessa su un sistema di produzione.
  9. Modularità. Il quadro proposto è modulare e i moduli possono essere combinati in vari modi
    supportare diverse configurazioni (configurazioni/layout). In particolare, è possibile avere un layout a nodo singolo su piccola scala ed un'impostazione multi nodo su larga scala. È ragionevole avere più layout di produzione.
  10. Test. A scopo di test è possibile implementare un servizio fittizio e utilizzarlo come dipendenza in modo sicuro. È possibile mantenere contemporaneamente alcuni layout di prova diversi con varie parti sostituite da simulazioni.
  11. Test d'integrazione. A volte nei sistemi distribuiti è difficile eseguire test di integrazione. Utilizzando l'approccio descritto per digitare la configurazione sicura dell'intero sistema distribuito, possiamo eseguire tutte le parti distribuite su un singolo server in modo controllabile. È facile emulare la situazione
    quando uno dei servizi non è più disponibile.

Svantaggi

L'approccio alla configurazione compilata è diverso dalla configurazione “normale” e potrebbe non soddisfare tutte le esigenze. Ecco alcuni degli svantaggi della configurazione compilata:

  1. Configurazione statica. Potrebbe non essere adatto a tutte le applicazioni. In alcuni casi è necessario fissare rapidamente la configurazione in produzione aggirando tutte le misure di sicurezza. Questo approccio rende tutto più difficile. La compilazione e la ridistribuzione sono necessarie dopo aver apportato qualsiasi modifica alla configurazione. Questa è sia la caratteristica che l'onere.
  2. Generazione della configurazione. Quando la configurazione viene generata da uno strumento di automazione, questo approccio richiede la successiva compilazione (che potrebbe a sua volta fallire). Potrebbe essere necessario uno sforzo aggiuntivo per integrare questo passaggio aggiuntivo nel sistema di compilazione.
  3. Strumenti. Esistono molti strumenti in uso oggi che si basano su configurazioni basate su testo. Alcuni di quelli
    non sarà applicabile quando la configurazione verrà compilata.
  4. È necessario un cambiamento di mentalità. Gli sviluppatori e i DevOps hanno familiarità con i file di configurazione di testo. L'idea di compilare una configurazione potrebbe sembrare loro strana.
  5. Prima di introdurre una configurazione compilabile è richiesto un processo di sviluppo software di alta qualità.

Ci sono alcune limitazioni dell'esempio implementato:

  1. Se forniamo una configurazione aggiuntiva non richiesta dall'implementazione del nodo, il compilatore non ci aiuterà a rilevare l'implementazione assente. Questo potrebbe essere risolto utilizzando HList o ADT (classi caso) per la configurazione dei nodi invece dei tratti e del Cake Pattern.
  2. Dobbiamo fornire alcuni standard nel file di configurazione: (package, import, object dichiarazioni;
    override defper i parametri che hanno valori predefiniti). Questo problema potrebbe essere parzialmente risolto utilizzando una DSL.
  3. In questo post non trattiamo la riconfigurazione dinamica di cluster di nodi simili.

Conclusione

In questo post abbiamo discusso l'idea di rappresentare la configurazione direttamente nel codice sorgente in modo typesafe. L'approccio potrebbe essere utilizzato in molte applicazioni in sostituzione di xml e di altre configurazioni basate su testo. Nonostante il nostro esempio sia stato implementato in Scala, potrebbe anche essere tradotto in altri linguaggi compilabili (come Kotlin, C#, Swift, ecc.). Si potrebbe provare questo approccio in un nuovo progetto e, nel caso in cui non si adatti bene, passare al vecchio metodo.

Naturalmente, la configurazione compilabile richiede un processo di sviluppo di alta qualità. In cambio promette di fornire una configurazione robusta di altrettanto alta qualità.

Questo approccio potrebbe essere esteso in vari modi:

  1. È possibile utilizzare le macro per eseguire la convalida della configurazione e fallire in fase di compilazione in caso di errori dei vincoli di logica aziendale.
  2. Potrebbe essere implementato un DSL per rappresentare la configurazione in modo user-friendly del dominio.
  3. Gestione dinamica delle risorse con aggiustamenti automatici della configurazione. Ad esempio, quando regoliamo il numero di nodi del cluster potremmo volere (1) che i nodi ottengano una configurazione leggermente modificata; (2) gestore cluster per ricevere informazioni sui nuovi nodi.

Grazie

Vorrei ringraziare Andrey Saksonov, Pavel Popov, Anton Nehaev per avermi fornito un feedback ispiratore sulla bozza di questo post che mi ha aiutato a renderlo più chiaro.

Fonte: habr.com