Configuració del sistema distribuït compilat

M'agradaria explicar-vos un mecanisme interessant per treballar amb la configuració d'un sistema distribuït. La configuració es representa directament en un llenguatge compilat (Scala) utilitzant tipus segurs. Aquesta publicació proporciona un exemple d'aquesta configuració i tracta diversos aspectes de la implementació d'una configuració compilada en el procés de desenvolupament global.

Configuració del sistema distribuït compilat

(Anglès)

Introducció

Construir un sistema distribuït fiable significa que tots els nodes utilitzen la configuració correcta, sincronitzada amb altres nodes. Les tecnologies DevOps (terraform, ansible o alguna cosa semblant) solen utilitzar-se per generar automàticament fitxers de configuració (sovint específics per a cada node). També ens agradaria assegurar-nos que tots els nodes que es comuniquen utilitzen protocols idèntics (inclosa la mateixa versió). En cas contrari, la incompatibilitat s'integrarà al nostre sistema distribuït. Al món de la JVM, una conseqüència d'aquest requisit és que la mateixa versió de la biblioteca que conté els missatges del protocol s'ha d'utilitzar a tot arreu.

Què passa amb provar un sistema distribuït? Per descomptat, assumim que tots els components tenen proves unitàries abans de passar a les proves d'integració. (Per tal que puguem extrapolar els resultats de la prova al temps d'execució, també hem de proporcionar un conjunt idèntic de biblioteques en l'etapa de prova i en temps d'execució.)

Quan es treballa amb proves d'integració, sovint és més fàcil utilitzar el mateix camí de classe a tot arreu a tots els nodes. Tot el que hem de fer és assegurar-nos que s'utilitza el mateix classpath en temps d'execució. (Tot i que és totalment possible executar diferents nodes amb diferents classpaths, això afegeix complexitat a la configuració general i dificultats amb les proves de desplegament i integració.) Per als propòsits d'aquesta publicació, suposem que tots els nodes utilitzaran el mateix classpath.

La configuració evoluciona amb l'aplicació. Utilitzem versions per identificar diferents etapes d'evolució del programa. Sembla lògic identificar també diferents versions de configuracions. I col·loqueu la pròpia configuració al sistema de control de versions. Si només hi ha una configuració en producció, simplement podem utilitzar el número de versió. Si fem servir moltes instàncies de producció, en necessitarem diverses
branques de configuració i una etiqueta addicional a més de la versió (per exemple, el nom de la branca). D'aquesta manera podem identificar clarament la configuració exacta. Cada identificador de configuració correspon de manera única a una combinació específica de nodes distribuïts, ports, recursos externs i versions de biblioteca. Als efectes d'aquest post assumirem que només hi ha una branca i podem identificar la configuració de la manera habitual mitjançant tres números separats per un punt (1.2.3).

En entorns moderns, els fitxers de configuració rarament es creen manualment. Més sovint es generen durant el desplegament i ja no es toquen (de manera que no trenquis res). Sorgeix una pregunta natural: per què continuem utilitzant el format de text per emmagatzemar la configuració? Una alternativa viable sembla ser la possibilitat d'utilitzar codi normal per a la configuració i beneficiar-se de les comprovacions en temps de compilació.

En aquest post explorarem la idea de representar una configuració dins d'un artefacte compilat.

Configuració compilada

Aquesta secció proporciona un exemple d'una configuració compilada estàtica. S'implementen dos serveis senzills: el servei d'eco i el client de servei d'eco. A partir d'aquests dos serveis, es combinen dues opcions de sistema. En una opció, tots dos serveis es troben al mateix node, en una altra opció, en nodes diferents.

Normalment, un sistema distribuït conté diversos nodes. Podeu identificar nodes mitjançant valors d'algun tipus NodeId:

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

o

case class NodeId(hostName: String)

o

object Singleton
type NodeId = Singleton.type

Els nodes realitzen diferents funcions, executen serveis i es poden establir connexions TCP/HTTP entre ells.

Per descriure una connexió TCP necessitem almenys un número de port. També ens agradaria reflectir el protocol que s'admet en aquest port per garantir que tant el client com el servidor utilitzen el mateix protocol. Descriurem la connexió utilitzant la classe següent:

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

on Port - només un nombre enter Int indicant el rang de valors acceptables:

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

Tipus refinats

Veure biblioteca refinat и la meva informe. En resum, la biblioteca us permet afegir restriccions als tipus que es comproven en temps de compilació. En aquest cas, els valors de número de port vàlids són enters de 16 bits. Per a una configuració compilada, l'ús de la biblioteca refinada no és obligatori, però millora la capacitat del compilador per comprovar la configuració.

Per als protocols HTTP (REST), a més del número de port, també podem necessitar la ruta al servei:

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

Tipus de fantasmes

Per identificar el protocol en temps de compilació, utilitzem un paràmetre de tipus que no s'utilitza dins de la classe. Aquesta decisió es deu al fet que no utilitzem una instància de protocol en temps d'execució, però ens agradaria que el compilador comprove la compatibilitat del protocol. En especificar el protocol, no podrem passar un servei inadequat com a dependència.

Un dels protocols comuns és l'API REST amb serialització Json:

sealed trait JsonHttpRestProtocol[RequestMessage, ResponseMessage]

on RequestMessage - tipus de sol·licitud, ResponseMessage - tipus de resposta.
Per descomptat, podem utilitzar altres descripcions de protocol que proporcionin la precisió de la descripció que necessitem.

Als efectes d'aquesta publicació, utilitzarem una versió simplificada del protocol:

sealed trait SimpleHttpGetRest[RequestMessage, ResponseMessage]

Aquí la sol·licitud és una cadena afegida a l'URL i la resposta és la cadena retornada al cos de la resposta HTTP.

La configuració del servei es descriu pel nom del servei, els ports i les dependències. Aquests elements es poden representar a Scala de diverses maneres (per exemple, HList-s, tipus de dades algebraiques). Als efectes d'aquesta publicació, utilitzarem el Patró de pastís i representarem mòduls utilitzant trait'ov. (El patró de pastís no és un element obligatori d'aquest enfocament. És simplement una implementació possible.)

Les dependències entre serveis es poden representar com a mètodes que retornen ports EndPoint's d'altres nodes:

  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 crear un servei d'eco, tot el que necessiteu és un número de port i una indicació que el port admet el protocol d'eco. És possible que no especifiquem un port específic, perquè... els trets permeten declarar mètodes sense implementació (mètodes abstractes). En aquest cas, quan es crea una configuració concreta, el compilador requeriria que proporcionem una implementació del mètode abstracte i proporcionem un número de port. Com que hem implementat el mètode, en crear una configuració específica, és possible que no especifiquem un port diferent. S'utilitzarà el valor predeterminat.

A la configuració del client declarem una dependència del servei d'eco:

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

La dependència és del mateix tipus que el servei exportat echoService. En particular, al client echo necessitem el mateix protocol. Per tant, en connectar dos serveis, podem estar segurs que tot funcionarà correctament.

Implantació de serveis

Es requereix una funció per iniciar i aturar el servei. (La capacitat d'aturar un servei és fonamental per a la prova.) De nou, hi ha diverses opcions per implementar aquesta característica (per exemple, podríem utilitzar classes de tipus basades en el tipus de configuració). Per als propòsits d'aquesta publicació utilitzarem el patró de pastís. Representarem el servei mitjançant una classe cats.Resource, perquè Aquesta classe ja proporciona mitjans per garantir de manera segura l'alliberament de recursos en cas de problemes. Per obtenir un recurs, hem de proporcionar una configuració i un context d'execució preparat. La funció d'inici del servei pot tenir aquest aspecte:

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

on

  • Config — tipus de configuració per a aquest servei
  • AddressResolver — un objecte d'execució que us permet esbrinar les adreces d'altres nodes (vegeu més avall)

i altres tipus de la biblioteca cats:

  • F[_] — tipus d'efecte (en el cas més simple F[A] podria ser només una funció () => A. En aquest post farem servir cats.IO.)
  • Reader[A,B] - més o menys sinònim de funció A => B
  • cats.Resource - un recurs que es pot obtenir i alliberar
  • Timer - temporitzador (permet adormir-se una estona i mesurar intervals de temps)
  • ContextShift - analògic ExecutionContext
  • Applicative — una classe de tipus d'efecte que us permet combinar efectes individuals (gairebé una mónada). En aplicacions més complexes sembla millor utilitzar-lo Monad/ConcurrentEffect.

Amb aquesta signatura de funció podem implementar diversos serveis. Per exemple, un servei que no fa res:

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

(Cm. font, en què s'implanten altres serveis - servei d'eco, client d'eco
и controladors de per vida.)

Un node és un objecte que pot llançar diversos serveis (el llançament d'una cadena de recursos està assegurat pel Cake Pattern):

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

Tingueu en compte que estem especificant el tipus exacte de configuració que es requereix per a aquest node. Si ens oblidem d'especificar un dels tipus de configuració requerits per un servei concret, hi haurà un error de compilació. A més, no podrem iniciar un node tret que proporcionem algun objecte del tipus adequat amb totes les dades necessàries.

Resolució del nom d'amfitrió

Per connectar-nos a un host remot, necessitem una adreça IP real. És possible que l'adreça es conegui més tard que la resta de la configuració. Per tant, necessitem una funció que assigni l'ID del node a una adreça:

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

Hi ha diverses maneres d'implementar aquesta funció:

  1. Si les adreces ens es coneixen abans del desplegament, podem generar codi Scala amb
    adreces i, a continuació, executeu la compilació. Això compilarà i executarà proves.
    En aquest cas, la funció es coneixerà estàticament i es pot representar en codi com a mapeig Map[NodeId, NodeAddress].
  2. En alguns casos, l'adreça real només es coneix després que el node s'hagi iniciat.
    En aquest cas, podem implementar un "servei de descoberta" que s'executa abans que altres nodes i tots els nodes es registraran amb aquest servei i demanaran les adreces d'altres nodes.
  3. Si podem modificar /etc/hosts, llavors podeu utilitzar noms d'amfitrió predefinits (com ara my-project-main-node и echo-backend) i simplement enllaça aquests noms
    amb adreces IP durant el desplegament.

En aquest post no tractarem aquests casos amb més detall. Per la nostra
en un exemple de joguina, tots els nodes tindran la mateixa adreça IP: 127.0.0.1.

A continuació, considerem dues opcions per a un sistema distribuït:

  1. Col·locar tots els serveis en un sol node.
  2. I allotjar el servei d'eco i el client d'eco en diferents nodes.

Configuració per un node:

Configuració d'un sol node

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

L'objecte implementa la configuració tant del client com del servidor. També s'utilitza una configuració de temps de vida de manera que després d'un interval lifetime finalitzar el programa. (Ctrl-C també funciona i allibera tots els recursos correctament.)

El mateix conjunt de trets de configuració i implementació es pot utilitzar per crear un sistema format per dos nodes separats:

Configuració de dos nodes

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

Important! Observeu com estan enllaçats els serveis. Especifiquem un servei implementat per un node com a implementació del mètode de dependència d'un altre node. El compilador verifica el tipus de dependència, perquè conté el tipus de protocol. Quan s'executa, la dependència contindrà l'ID de node de destinació correcte. Gràcies a aquest esquema, especifiquem el número de port exactament una vegada i sempre tenim la garantia de fer referència al port correcte.

Implementació de dos nodes del sistema

Per a aquesta configuració, utilitzem les mateixes implementacions de servei sense canvis. L'única diferència és que ara tenim dos objectes que implementen diferents conjunts de serveis:

  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
  }

El primer node implementa el servidor i només necessita la configuració del servidor. El segon node implementa el client i utilitza una part diferent de la configuració. També els dos nodes necessiten una gestió de tota la vida. El node del servidor s'executa indefinidament fins que s'atura SIGTERM'om, i el node client finalitza després d'un temps. Cm. aplicació llançadora.

Procés general de desenvolupament

Vegem com aquest enfocament de configuració afecta el procés de desenvolupament global.

La configuració es compilarà juntament amb la resta del codi i es generarà un artefacte (.jar). Sembla que té sentit posar la configuració en un artefacte separat. Això es deu al fet que podem tenir diverses configuracions basades en el mateix codi. De nou, és possible generar artefactes corresponents a diferents branques de configuració. Les dependències de versions específiques de biblioteques es guarden juntament amb la configuració, i aquestes versions es guarden per sempre quan decidim desplegar aquesta versió de la configuració.

Qualsevol canvi de configuració es converteix en un canvi de codi. I per tant, cadascun
el canvi estarà cobert pel procés normal d'assegurament de la qualitat:

Entrada al rastrejador d'errors -> PR -> revisió -> fusiona amb les branques rellevants ->
integració -> desplegament

Les principals conseqüències d'implementar una configuració compilada són:

  1. La configuració serà coherent en tots els nodes del sistema distribuït. A causa del fet que tots els nodes reben la mateixa configuració d'una única font.

  2. És problemàtic canviar la configuració només en un dels nodes. Per tant, és poc probable que la "deriva de la configuració".

  3. Es fa més difícil fer petits canvis a la configuració.

  4. La majoria dels canvis de configuració es produiran com a part del procés de desenvolupament global i estaran subjectes a revisió.

Necessito un repositori separat per emmagatzemar la configuració de producció? Aquesta configuració pot contenir contrasenyes i altra informació sensible a la qual voldríem restringir l'accés. En base a això, sembla que té sentit emmagatzemar la configuració final en un repositori separat. Podeu dividir la configuració en dues parts: una que conté paràmetres de configuració accessibles al públic i una altra que conté paràmetres restringits. Això permetrà que la majoria dels desenvolupadors tinguin accés a la configuració comuna. Aquesta separació és fàcil d'aconseguir mitjançant trets intermedis que contenen valors per defecte.

Possibles variacions

Intentem comparar la configuració compilada amb algunes alternatives habituals:

  1. Fitxer de text a la màquina de destinació.
  2. Botiga de valor-clau centralitzada (etcd/zookeeper).
  3. Components del procés que es poden reconfigurar/reiniciar sense reiniciar el procés.
  4. Emmagatzemar la configuració fora del control d'artefactes i versions.

Els fitxers de text proporcionen una flexibilitat important en termes de petits canvis. L'administrador del sistema pot iniciar sessió al node remot, fer canvis als fitxers adequats i reiniciar el servei. Per a sistemes grans, però, aquesta flexibilitat pot no ser desitjable. Els canvis realitzats no deixen rastre en altres sistemes. Ningú revisa els canvis. És difícil determinar qui va fer els canvis exactament i per quin motiu. Els canvis no es posen a prova. Si el sistema està distribuït, l'administrador pot oblidar-se de fer el canvi corresponent en altres nodes.

(També cal tenir en compte que l'ús d'una configuració compilada no tanca la possibilitat d'utilitzar fitxers de text en el futur. N'hi haurà prou amb afegir un analitzador i validador que produeixi el mateix tipus que la sortida. Config, i podeu utilitzar fitxers de text. Immediatament es dedueix que la complexitat d'un sistema amb una configuració compilada és una mica menor que la complexitat d'un sistema que utilitza fitxers de text, perquè els fitxers de text requereixen codi addicional.)

Un magatzem de valor-clau centralitzat és un bon mecanisme per distribuir meta-paràmetres d'una aplicació distribuïda. Hem de decidir quins són els paràmetres de configuració i què són només dades. Fem una funció C => A => B, i els paràmetres C poques vegades canvia, i dades A - sovint. En aquest cas podem dir que C - paràmetres de configuració, i A - dades. Sembla que els paràmetres de configuració difereixen de les dades perquè generalment canvien amb menys freqüència que les dades. A més, les dades solen venir d'una font (de l'usuari) i els paràmetres de configuració d'una altra (de l'administrador del sistema).

Si els paràmetres que canvien poques vegades s'han d'actualitzar sense reiniciar el programa, sovint això pot comportar una complicació del programa, perquè d'alguna manera haurem de lliurar paràmetres, emmagatzemar, analitzar i comprovar i processar valors incorrectes. Per tant, des del punt de vista de reduir la complexitat del programa, té sentit reduir el nombre de paràmetres que poden canviar durant el funcionament del programa (o no suportar aquests paràmetres).

Als efectes d'aquest post, diferenciarem entre paràmetres estàtics i dinàmics. Si la lògica del servei requereix canviar paràmetres durant el funcionament del programa, anomenarem aquests paràmetres dinàmics. En cas contrari, les opcions són estàtiques i es poden configurar mitjançant la configuració compilada. Per a la reconfiguració dinàmica, és possible que necessitem un mecanisme per reiniciar parts del programa amb paràmetres nous, similar a com es reinicien els processos del sistema operatiu. (Segons la nostra opinió, és recomanable evitar la reconfiguració en temps real, ja que això augmenta la complexitat del sistema. Si és possible, és millor utilitzar les capacitats estàndard del sistema operatiu per reiniciar processos.)

Un aspecte important de l'ús de la configuració estàtica que fa que la gent consideri la reconfiguració dinàmica és el temps que triga el sistema a reiniciar-se després d'una actualització de configuració (temps d'inactivitat). De fet, si hem de fer canvis a la configuració estàtica, haurem de reiniciar el sistema perquè els nous valors tinguin efecte. El problema del temps d'inactivitat varia en gravetat per a diferents sistemes. En alguns casos, podeu programar un reinici en un moment en què la càrrega sigui mínima. Si necessiteu oferir un servei continu, podeu implementar-lo Drenatge de connexió AWS ELB. Al mateix temps, quan necessitem reiniciar el sistema, iniciem una instància paral·lela d'aquest sistema, hi canviem l'equilibrador i esperem que es completin les connexions antigues. Després que totes les connexions antigues hagin finalitzat, tanquem la instància antiga del sistema.

Considerem ara el problema d'emmagatzemar la configuració dins o fora de l'artefacte. Si emmagatzemem la configuració dins d'un artefacte, almenys hem tingut l'oportunitat de verificar la correcció de la configuració durant el muntatge de l'artefacte. Si la configuració està fora de l'artefacte controlat, és difícil fer un seguiment de qui ha fet canvis en aquest fitxer i per què. Què tan important és? Segons la nostra opinió, per a molts sistemes de producció és important tenir una configuració estable i de gran qualitat.

La versió d'un artefacte permet determinar quan es va crear, quins valors conté, quines funcions estan habilitades/desactivades i qui és responsable de qualsevol canvi en la configuració. Per descomptat, emmagatzemar la configuració dins d'un artefacte requereix un cert esforç, de manera que cal prendre una decisió informada.

Pros i contres

M'agradaria detenir-me en els pros i els contres de la tecnologia proposada.

Avantatges

A continuació es mostra una llista de les característiques principals d'una configuració de sistema distribuït compilada:

  1. Comprovació de la configuració estàtica. Et permet estar segur d'això
    la configuració és correcta.
  2. Llenguatge de configuració ric. Normalment, altres mètodes de configuració es limiten a la substitució de variables de cadena com a màxim. Quan feu servir Scala, hi ha disponibles una àmplia gamma de funcions d'idioma per millorar la vostra configuració. Per exemple podem utilitzar
    trets per als valors predeterminats, utilitzant objectes per agrupar paràmetres, podem referir-nos a vals declarats només una vegada (DRY) a l'àmbit adjunt. Podeu crear una instancia de qualsevol classe directament dins de la configuració (Seq, Map, classes personalitzades).
  3. DSL. Scala té una sèrie de funcions d'idioma que faciliten la creació d'un DSL. És possible aprofitar aquestes característiques i implementar un llenguatge de configuració que sigui més convenient per al grup objectiu d'usuaris, de manera que la configuració sigui almenys llegible pels experts del domini. Els especialistes poden, per exemple, participar en el procés de revisió de la configuració.
  4. Integritat i sincronia entre nodes. Un dels avantatges de tenir la configuració de tot un sistema distribuït emmagatzemat en un únic punt és que tots els valors es declaren exactament una vegada i després es reutilitzen allà on siguin necessaris. L'ús de tipus fantasma per declarar ports garanteix que els nodes utilitzen protocols compatibles en totes les configuracions correctes del sistema. Tenir dependències obligatòries explícites entre nodes garanteix que tots els serveis estiguin connectats.
  5. Canvis d'alta qualitat. Fer canvis a la configuració mitjançant un procés de desenvolupament comú també permet assolir estàndards de qualitat elevats per a la configuració.
  6. Actualització de la configuració simultània. El desplegament automàtic del sistema després dels canvis de configuració garanteix que tots els nodes estiguin actualitzats.
  7. Simplificant l'aplicació. L'aplicació no necessita anàlisi, verificació de configuració ni maneig de valors incorrectes. Això redueix la complexitat de l'aplicació. (Alguna de la complexitat de la configuració observada al nostre exemple no és un atribut de la configuració compilada, sinó només una decisió conscient impulsada pel desig de proporcionar una major seguretat de tipus.) És bastant fàcil tornar a la configuració habitual: només cal implementar la que falta. parts. Per tant, podeu, per exemple, començar amb una configuració compilada, ajornant la implementació de parts innecessàries fins al moment en què realment es necessita.
  8. Configuració verificada. Com que els canvis de configuració segueixen el destí habitual de qualsevol altre canvi, la sortida que obtenim és un artefacte amb una versió única. Això ens permet, per exemple, tornar a una versió anterior de la configuració si cal. Fins i tot podem utilitzar la configuració de fa un any i el sistema funcionarà exactament igual. Una configuració estable millora la predictibilitat i la fiabilitat d'un sistema distribuït. Com que la configuració es fixa en l'etapa de compilació, és bastant difícil falsificar-la en producció.
  9. Modularitat. El marc proposat és modular i els mòduls es poden combinar de diferents maneres per crear diferents sistemes. En particular, podeu configurar el sistema perquè s'executi en un sol node en una realització i en diversos nodes en una altra. Podeu crear diverses configuracions per a instàncies de producció del sistema.
  10. Prova. Si substituïu serveis individuals per objectes simulats, podeu obtenir diverses versions del sistema convenients per provar.
  11. Proves d'integració. Tenir una única configuració per a tot el sistema distribuït permet executar tots els components en un entorn controlat com a part de les proves d'integració. És fàcil emular, per exemple, una situació en què alguns nodes esdevenen accessibles.

Inconvenients i limitacions

La configuració compilada difereix d'altres enfocaments de configuració i pot ser que no sigui adequada per a algunes aplicacions. A continuació es mostren alguns desavantatges:

  1. Configuració estàtica. De vegades cal corregir ràpidament la configuració en producció, obviant tots els mecanismes de protecció. Amb aquest enfocament pot ser més difícil. Com a mínim, encara serà necessari la compilació i el desplegament automàtic. Aquesta és alhora una característica útil de l'enfocament i un desavantatge en alguns casos.
  2. Generació de configuracions. En cas que el fitxer de configuració sigui generat per una eina automàtica, és possible que calguin esforços addicionals per integrar l'script de compilació.
  3. Eines. Actualment, les utilitats i tècniques dissenyades per treballar amb la configuració es basen en fitxers de text. No totes aquestes utilitats/tècniques estaran disponibles en una configuració compilada.
  4. Cal un canvi d'actituds. Els desenvolupadors i DevOps estan acostumats als fitxers de text. La mateixa idea de compilar una configuració pot ser una mica inesperada i inusual i provocar rebuig.
  5. Es requereix un procés de desenvolupament d'alta qualitat. Per utilitzar còmodament la configuració compilada, és necessària una automatització completa del procés de construcció i desplegament de l'aplicació (CI/CD). En cas contrari, serà força incòmode.

Detenim també una sèrie de limitacions de l'exemple considerat que no estan relacionades amb la idea d'una configuració compilada:

  1. Si proporcionem informació de configuració innecessària que no fa servir el node, el compilador no ens ajudarà a detectar la implementació que falta. Aquest problema es pot resoldre abandonant el patró de pastís i utilitzant tipus més rígids, per exemple, HList o tipus de dades algebraiques (classes de casos) per representar la configuració.
  2. Hi ha línies al fitxer de configuració que no estan relacionades amb la pròpia configuració: (package, import,declaracions d'objectes; override def's per als paràmetres que tenen valors per defecte). Això es pot evitar parcialment si implementeu el vostre propi DSL. A més, altres tipus de configuració (per exemple, XML) també imposen certes restriccions a l'estructura de fitxers.
  3. Als efectes d'aquesta publicació, no estem considerant la reconfiguració dinàmica d'un clúster de nodes similars.

Conclusió

En aquesta publicació, vam explorar la idea de representar la configuració en el codi font utilitzant les capacitats avançades del sistema de tipus Scala. Aquest enfocament es pot utilitzar en diverses aplicacions com a reemplaçament dels mètodes de configuració tradicionals basats en fitxers xml o de text. Tot i que el nostre exemple està implementat a Scala, les mateixes idees es poden transferir a altres llenguatges compilats (com ara Kotlin, C#, Swift, ...). Podeu provar aquest enfocament en un dels projectes següents i, si no funciona, passar al fitxer de text, afegint les parts que falten.

Naturalment, una configuració compilada requereix un procés de desenvolupament d'alta qualitat. A canvi, es garanteix una alta qualitat i fiabilitat de les configuracions.

L'enfocament considerat es pot ampliar:

  1. Podeu utilitzar macros per realitzar comprovacions en temps de compilació.
  2. Podeu implementar un DSL per presentar la configuració d'una manera accessible als usuaris finals.
  3. Podeu implementar la gestió dinàmica de recursos amb l'ajust automàtic de la configuració. Per exemple, canviar el nombre de nodes d'un clúster requereix que (1) cada node rebi una configuració lleugerament diferent; (2) el gestor del clúster va rebre informació sobre nous nodes.

Agraïments

M'agradaria agrair a Andrei Saksonov, Pavel Popov i Anton Nekhaev la seva crítica constructiva a l'esborrany d'article.

Font: www.habr.com

Afegeix comentari