Configuración do sistema distribuído compilado

Gustaríame contarche un mecanismo interesante para traballar coa configuración dun sistema distribuído. A configuración represéntase directamente nunha linguaxe compilada (Scala) utilizando tipos seguros. Esta publicación ofrece un exemplo desta configuración e analiza varios aspectos da implementación dunha configuración compilada no proceso de desenvolvemento global.

Configuración do sistema distribuído compilado

(Inglés)

Introdución

Construír un sistema distribuído fiable significa que todos os nós usan a configuración correcta, sincronizada con outros nodos. As tecnoloxías DevOps (terraform, ansible ou algo así) adoitan usarse para xerar automaticamente ficheiros de configuración (a miúdo específicos para cada nodo). Tamén nos gustaría estar seguros de que todos os nós que se comunican utilizan protocolos idénticos (incluíndo a mesma versión). En caso contrario, incorporarase a incompatibilidade no noso sistema distribuído. No mundo da JVM, unha consecuencia deste requisito é que a mesma versión da biblioteca que contén as mensaxes do protocolo debe usarse en todas partes.

Que tal probar un sistema distribuído? Por suposto, asumimos que todos os compoñentes teñen probas unitarias antes de pasar ás probas de integración. (Para poder extrapolar os resultados das probas ao tempo de execución, tamén debemos proporcionar un conxunto idéntico de bibliotecas na fase de proba e no tempo de execución.)

Cando se traballa con probas de integración, adoita ser máis doado usar o mesmo camiño de clase en todas partes en todos os nós. Todo o que temos que facer é asegurarnos de que se use a mesma ruta de clase no tempo de execución. (Aínda que é totalmente posible executar nodos diferentes con rutas de clases diferentes, isto engade complexidade á configuración xeral e dificultades coas probas de implantación e integración.) Para os efectos desta publicación, asumimos que todos os nodos usarán o mesmo camiño de clase.

A configuración evoluciona coa aplicación. Usamos versións para identificar diferentes etapas da evolución do programa. Parece lóxico identificar tamén diferentes versións de configuracións. E coloca a propia configuración no sistema de control de versións. Se só hai unha configuración en produción, simplemente podemos usar o número de versión. Se usamos moitas instancias de produción, necesitaremos varias
ramas de configuración e unha etiqueta adicional ademais da versión (por exemplo, o nome da rama). Deste xeito podemos identificar claramente a configuración exacta. Cada identificador de configuración corresponde unicamente a unha combinación específica de nodos distribuídos, portos, recursos externos e versións da biblioteca. Para os efectos desta publicación suporemos que só hai unha rama e podemos identificar a configuración do xeito habitual mediante tres números separados por un punto (1.2.3).

Nos ambientes modernos, os ficheiros de configuración raramente se crean manualmente. Con máis frecuencia xéranse durante a implantación e xa non se tocan (de xeito que non rompas nada). Xorde unha pregunta natural: por que seguimos usando o formato de texto para almacenar a configuración? Unha alternativa viable parece ser a posibilidade de usar código normal para a configuración e beneficiarse das comprobacións en tempo de compilación.

Neste post exploraremos a idea de representar unha configuración dentro dun artefacto compilado.

Configuración compilada

Esta sección ofrece un exemplo dunha configuración compilada estática. Impléntanse dous servizos sinxelos: o servizo de eco e o cliente de servizo de eco. En base a estes dous servizos, reúnense dúas opcións de sistema. Nunha opción, ambos os servizos están situados no mesmo nodo, noutra opción - en diferentes nodos.

Normalmente un sistema distribuído contén varios nodos. Podes identificar nodos usando valores dalgún tipo NodeId:

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

ou

case class NodeId(hostName: String)

ou mesmo

object Singleton
type NodeId = Singleton.type

Os nós realizan varias funcións, executan servizos e pódense establecer conexións TCP/HTTP entre eles.

Para describir unha conexión TCP necesitamos polo menos un número de porto. Tamén queremos reflectir o protocolo que se admite nese porto para garantir que tanto o cliente como o servidor utilizan o mesmo protocolo. Describiremos a conexión usando a seguinte clase:

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

onde Port - só un número enteiro Int indicando o rango de valores aceptables:

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

Tipos refinados

Ver biblioteca refinado и meu informe. En resumo, a biblioteca permítelle engadir restricións aos tipos que se verifican no momento da compilación. Neste caso, os valores de número de porto válidos son enteiros de 16 bits. Para unha configuración compilada, o uso da biblioteca refinada non é obrigatorio, pero mellora a capacidade do compilador para comprobar a configuración.

Para os protocolos HTTP (REST), ademais do número de porto, tamén podemos necesitar a ruta do servizo:

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

Tipos fantasmas

Para identificar o protocolo no momento da compilación, usamos un parámetro de tipo que non se usa dentro da clase. Esta decisión débese a que non usamos unha instancia de protocolo en tempo de execución, pero gustaríanos que o compilador comprobase a compatibilidade do protocolo. Especificando o protocolo, non poderemos pasar un servizo inadecuado como dependencia.

Un dos protocolos comúns é a API REST con serialización Json:

sealed trait JsonHttpRestProtocol[RequestMessage, ResponseMessage]

onde RequestMessage - tipo de solicitude, ResponseMessage - Tipo de resposta.
Por suposto, podemos usar outras descricións de protocolo que proporcionan a precisión da descrición que necesitamos.

Para os efectos desta publicación, utilizaremos unha versión simplificada do protocolo:

sealed trait SimpleHttpGetRest[RequestMessage, ResponseMessage]

Aquí a solicitude é unha cadea anexa ao URL e a resposta é a cadea devolta no corpo da resposta HTTP.

A configuración do servizo descríbese polo nome do servizo, os portos e as dependencias. Estes elementos pódense representar en Scala de varias maneiras (por exemplo, HList-s, tipos de datos alxébricos). Para os efectos desta publicación, utilizaremos o patrón de bolo e representaremos módulos usando trait'ov. (O Cake Pattern non é un elemento obrigatorio deste enfoque. É simplemente unha posible implementación).

As dependencias entre servizos pódense representar como métodos que devolven portos EndPoint's doutros nodos:

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

Para crear un servizo de eco, só necesitas un número de porto e unha indicación de que o porto admite o protocolo de eco. É posible que non especifiquemos un porto específico porque... os trazos permítenche declarar métodos sen implementación (métodos abstractos). Neste caso, ao crear unha configuración concreta, o compilador esixiría que proporcionemos unha implementación do método abstracto e proporcionemos un número de porto. Dado que implementamos o método, ao crear unha configuración específica, é posible que non especifiquemos un porto diferente. Usarase o valor predeterminado.

Na configuración do cliente declaramos unha dependencia do servizo de eco:

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

A dependencia é do mesmo tipo que o servizo exportado echoService. En particular, no cliente echo necesitamos o mesmo protocolo. Polo tanto, ao conectar dous servizos, podemos estar seguros de que todo funcionará correctamente.

Implantación de servizos

Requírese unha función para iniciar e deter o servizo. (A capacidade de deter o servizo é fundamental para a proba.) De novo, hai varias opcións para implementar tal característica (por exemplo, poderiamos usar clases de tipos baseadas no tipo de configuración). Para os efectos desta publicación usaremos o patrón de bolo. Representaremos o servizo mediante unha clase cats.Resource, porque Esta clase xa proporciona medios para garantir con seguridade a liberación de recursos en caso de problemas. Para obter un recurso, necesitamos proporcionar unha configuración e un contexto de execución listo. A función de inicio do servizo pode verse así:

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

onde

  • Config — tipo de configuración para este servizo
  • AddressResolver — un obxecto de execución que che permite descubrir os enderezos doutros nodos (ver a continuación)

e outros tipos da biblioteca cats:

  • F[_] — tipo de efecto (no caso máis sinxelo F[A] podería ser só unha función () => A. Neste post usaremos cats.IO.)
  • Reader[A,B] - máis ou menos sinónimo de función A => B
  • cats.Resource - un recurso que se pode obter e liberar
  • Timer - temporizador (permíteche adormecer un tempo e medir intervalos de tempo)
  • ContextShift - analóxico ExecutionContext
  • Applicative — unha clase de tipo de efecto que che permite combinar efectos individuais (case unha mónada). En aplicacións máis complexas parece mellor usar Monad/ConcurrentEffect.

Usando esta sinatura de función podemos implementar varios servizos. Por exemplo, un servizo que non fai nada:

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

(Ver fonte, no que se implementan outros servizos - servizo de eco, cliente de eco
и controladores de por vida.)

Un nodo é un obxecto que pode lanzar varios servizos (o lanzamento dunha cadea de recursos está garantido polo Cake Pattern):

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

Teña en conta que estamos especificando o tipo exacto de configuración que se require para este nodo. Se nos esquecemos de especificar un dos tipos de configuración requiridos por un determinado servizo, producirase un erro de compilación. Ademais, non poderemos iniciar un nodo a non ser que proporcionemos algún obxecto do tipo adecuado con todos os datos necesarios.

Resolución de nomes de host

Para conectarnos a un host remoto, necesitamos un enderezo IP real. É posible que o enderezo se coñeza máis tarde que o resto da configuración. Polo tanto, necesitamos unha función que asigne o ID de nodo a un enderezo:

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

Hai varias formas de implementar esta función:

  1. Se os enderezos son coñecidos por nós antes da implantación, entón podemos xerar código Scala con
    enderezos e despois executa a compilación. Isto compilará e executará probas.
    Neste caso, a función coñecerase de forma estática e pódese representar en código como mapeo Map[NodeId, NodeAddress].
  2. Nalgúns casos, o enderezo real só se coñece despois de que se iniciou o nodo.
    Neste caso, podemos implementar un "servizo de descubrimento" que se executa antes que outros nodos e todos os nodos rexistraranse neste servizo e solicitarán os enderezos doutros nodos.
  3. Se podemos modificar /etc/hosts, entón podes usar nomes de host predefinidos (como my-project-main-node и echo-backend) e simplemente ligue estes nomes
    con enderezos IP durante a implantación.

Neste post non imos considerar estes casos con máis detalle. Para o noso
nun exemplo de xoguete, todos os nodos terán o mesmo enderezo IP - 127.0.0.1.

A continuación, consideramos dúas opcións para un sistema distribuído:

  1. Colocando todos os servizos nun nodo.
  2. E hospeda o servizo de eco e o cliente de eco en diferentes nodos.

Configuración para un nodo:

Configuración dun só nodo

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

O obxecto implementa a configuración tanto do cliente como do servidor. Tamén se usa unha configuración de tempo de vida para que despois do intervalo lifetime finalizar o programa. (Ctrl-C tamén funciona e libera todos os recursos correctamente).

O mesmo conxunto de trazos de configuración e implementación pódese usar para crear un sistema composto por dous nodos separados:

Configuración de dous nodos

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

Importante! Observa como están ligados os servizos. Especificamos un servizo implementado por un nodo como unha implementación do método de dependencia doutro nodo. O tipo de dependencia é verificado polo compilador, porque contén o tipo de protocolo. Cando se execute, a dependencia conterá o ID do nodo de destino correcto. Grazas a este esquema, especificamos o número de porto exactamente unha vez e sempre estamos garantidos para facer referencia ao porto correcto.

Implantación de dous nodos do sistema

Para esta configuración, usamos as mesmas implementacións de servizos sen cambios. A única diferenza é que agora temos dous obxectos que implementan diferentes conxuntos de servizos:

  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
  }

O primeiro nodo implementa o servidor e só precisa a configuración do servidor. O segundo nodo implementa o cliente e usa unha parte diferente da configuración. Ademais, ambos os nodos necesitan xestión de por vida. O nodo do servidor execútase indefinidamente ata que se detén SIGTERM'om, e o nodo cliente finaliza despois dun tempo. Cm. aplicación lanzadora.

Proceso xeral de desenvolvemento

Vexamos como afecta este enfoque de configuración ao proceso de desenvolvemento global.

A configuración compilarase xunto co resto do código e xerarase un artefacto (.jar). Parece que ten sentido poñer a configuración nun artefacto separado. Isto débese a que podemos ter varias configuracións baseadas no mesmo código. De novo, é posible xerar artefactos correspondentes a diferentes ramas de configuración. As dependencias de versións específicas das bibliotecas gárdanse xunto coa configuración, e estas versións gárdanse para sempre sempre que decidimos implementar esa versión da configuración.

Calquera cambio de configuración convértese nun cambio de código. E, polo tanto, cada un
o cambio cubrirase polo proceso normal de garantía de calidade:

Ticket no rastreador de erros -> PR -> revisión -> fusionar coas ramas relevantes ->
integración -> despregamento

As principais consecuencias da implementación dunha configuración compilada son:

  1. A configuración será coherente en todos os nodos do sistema distribuído. Debido ao feito de que todos os nodos reciben a mesma configuración dunha única fonte.

  2. É problemático cambiar a configuración só nun dos nodos. Polo tanto, é improbable a "deriva de configuración".

  3. Faise máis difícil facer pequenos cambios na configuración.

  4. A maioría dos cambios de configuración produciranse como parte do proceso de desenvolvemento global e estarán suxeitos a revisión.

Necesito un repositorio separado para almacenar a configuración de produción? Esta configuración pode conter contrasinais e outra información confidencial á que nos gustaría restrinxir o acceso. En base a isto, parece que ten sentido almacenar a configuración final nun repositorio separado. Podes dividir a configuración en dúas partes: unha que contén axustes de configuración accesibles ao público e outra que contén opcións restrinxidas. Isto permitirá que a maioría dos desenvolvedores teñan acceso á configuración común. Esta separación é fácil de conseguir usando trazos intermedios que conteñen valores predeterminados.

Posibles variacións

Tentemos comparar a configuración compilada con algunhas alternativas comúns:

  1. Ficheiro de texto na máquina de destino.
  2. Tenda centralizada de valores-clave (etcd/zookeeper).
  3. Compoñentes do proceso que se poden reconfigurar/reiniciar sen reiniciar o proceso.
  4. Almacenamento da configuración fóra do control de versións e artefactos.

Os ficheiros de texto ofrecen unha flexibilidade significativa en canto a pequenos cambios. O administrador do sistema pode iniciar sesión no nodo remoto, facer cambios nos ficheiros apropiados e reiniciar o servizo. Non obstante, para sistemas grandes, tal flexibilidade pode non ser desexable. Os cambios realizados non deixan pegada noutros sistemas. Ninguén revisa os cambios. É difícil determinar quen fixo exactamente os cambios e por que razón. Non se proban os cambios. Se o sistema está distribuído, entón o administrador pode esquecer facer o cambio correspondente noutros nodos.

(Tamén hai que ter en conta que o uso dunha configuración compilada non pecha a posibilidade de utilizar ficheiros de texto no futuro. Será suficiente con engadir un analizador e validador que produza o mesmo tipo que a saída. Config, e pode usar ficheiros de texto. Inmediatamente se desprende que a complexidade dun sistema cunha configuración compilada é algo menor que a complexidade dun sistema que usa ficheiros de texto, porque os ficheiros de texto requiren código adicional.)

Unha tenda de clave-valor centralizada é un bo mecanismo para distribuír metaparámetros dunha aplicación distribuída. Necesitamos decidir cales son os parámetros de configuración e cales son só datos. Teñamos unha función C => A => B, e os parámetros C raramente cambios, e datos A - moitas veces. Neste caso podemos dicir que C - parámetros de configuración, e A -datos. Parece que os parámetros de configuración difieren dos datos en que xeralmente cambian con menos frecuencia que os datos. Ademais, os datos adoitan proceder dunha fonte (do usuario) e os parámetros de configuración doutra (do administrador do sistema).

Se raramente hai que actualizar os parámetros que cambian sen reiniciar o programa, isto pode levar a complicacións do programa, xa que, dalgún xeito, necesitaremos entregar parámetros, almacenar, analizar e comprobar e procesar valores incorrectos. Polo tanto, desde o punto de vista de reducir a complexidade do programa, ten sentido reducir o número de parámetros que poden cambiar durante o funcionamento do programa (ou non admitir tales parámetros).

Para os efectos desta publicación, diferenciaremos entre parámetros estáticos e dinámicos. Se a lóxica do servizo require cambiar parámetros durante o funcionamento do programa, chamaremos a tales parámetros dinámicos. En caso contrario, as opcións son estáticas e pódense configurar mediante a configuración compilada. Para a reconfiguración dinámica, é posible que necesitemos un mecanismo para reiniciar partes do programa con novos parámetros, de forma similar a como se reinician os procesos do sistema operativo. (Na nosa opinión, é recomendable evitar a reconfiguración en tempo real, xa que isto aumenta a complexidade do sistema. Se é posible, é mellor utilizar as capacidades estándar do SO para reiniciar procesos.)

Un aspecto importante do uso da configuración estática que fai que a xente considere a reconfiguración dinámica é o tempo que tarda o sistema en reiniciarse despois dunha actualización da configuración (tempo de inactividade). De feito, se necesitamos facer cambios na configuración estática, teremos que reiniciar o sistema para que os novos valores entren en vigor. O problema do tempo de inactividade varía en gravidade para os distintos sistemas. Nalgúns casos, pode programar un reinicio nun momento no que a carga sexa mínima. Se precisa prestar un servizo continuo, pode implementar Drenaxe de conexión AWS ELB. Ao mesmo tempo, cando necesitamos reiniciar o sistema, iniciamos unha instancia paralela deste sistema, cambiamos o equilibrador e agardamos a que se completen as conexións antigas. Despois de rematar todas as conexións antigas, pechamos a instancia antiga do sistema.

Consideremos agora a cuestión de almacenar a configuración dentro ou fóra do artefacto. Se almacenamos a configuración dentro dun artefacto, polo menos tivemos a oportunidade de verificar a corrección da configuración durante a montaxe do artefacto. Se a configuración está fóra do artefacto controlado, é difícil rastrexar quen fixo cambios neste ficheiro e por que. Que importancia ten? Na nosa opinión, para moitos sistemas de produción é importante ter unha configuración estable e de alta calidade.

A versión dun artefacto permítelle determinar cando se creou, que valores contén, que funcións están habilitadas/desactivadas e quen é responsable de calquera cambio na configuración. Por suposto, almacenar a configuración dentro dun artefacto require un esforzo, polo que cómpre tomar unha decisión informada.

Pros e contras

Gustaríame determe nos pros e contras da tecnoloxía proposta.

Vantaxes

A continuación móstrase unha lista das principais características dunha configuración de sistema distribuído compilado:

  1. Verificación de configuración estática. Permíteche estar seguro diso
    a configuración é correcta.
  2. Linguaxe de configuración rica. Normalmente, outros métodos de configuración limítanse como máximo á substitución de variables de cadea. Cando se usa Scala, hai dispoñibles unha gran variedade de funcións lingüísticas para mellorar a súa configuración. Por exemplo podemos usar
    trazos para valores predeterminados, usando obxectos para agrupar parámetros, podemos referirnos a vals declarados só unha vez (DRY) no ámbito que se encerra. Podes crear unha instancia de calquera clase directamente dentro da configuración (Seq, Map, clases personalizadas).
  3. DSL. Scala ten unha serie de funcións lingüísticas que facilitan a creación dun DSL. É posible aproveitar estas funcións e implementar unha linguaxe de configuración máis conveniente para o grupo de usuarios obxectivo, para que a configuración sexa, polo menos, lexible polos expertos do dominio. Os especialistas poden, por exemplo, participar no proceso de revisión da configuración.
  4. Integridade e sincronía entre nodos. Unha das vantaxes de ter a configuración de todo un sistema distribuído almacenada nun único punto é que todos os valores se declaran unha vez exactamente e logo reutilizan onde se precisen. Usar tipos fantasma para declarar portos garante que os nodos estean utilizando protocolos compatibles en todas as configuracións correctas do sistema. Ter dependencias obrigatorias explícitas entre nós garante que todos os servizos estean conectados.
  5. Cambios de alta calidade. Facer cambios na configuración mediante un proceso de desenvolvemento común fai posible acadar tamén altos estándares de calidade para a configuración.
  6. Actualización de configuración simultánea. A implantación automática do sistema despois dos cambios de configuración garante que todos os nós estean actualizados.
  7. Simplificando a aplicación. A aplicación non precisa analizar, verificar a configuración nin xestionar valores incorrectos. Isto reduce a complexidade da aplicación. (Parte da complexidade de configuración observada no noso exemplo non é un atributo da configuración compilada, senón só unha decisión consciente impulsada polo desexo de proporcionar unha maior seguridade de tipo.) É bastante fácil volver á configuración habitual: basta con implementar a falta. pezas. Polo tanto, pode, por exemplo, comezar cunha configuración compilada, aprazando a implementación de partes innecesarias ata o momento en que sexa realmente necesario.
  8. Configuración verificada. Dado que os cambios de configuración seguen o destino habitual de calquera outro cambio, a saída que obtemos é un artefacto cunha versión única. Isto permítenos, por exemplo, volver a unha versión anterior da configuración se é necesario. Incluso podemos usar a configuración de hai un ano e o sistema funcionará exactamente igual. Unha configuración estable mellora a previsibilidade e fiabilidade dun sistema distribuído. Dado que a configuración está fixada na fase de compilación, é bastante difícil falsificala na produción.
  9. Modularidade. O marco proposto é modular e os módulos pódense combinar de diferentes xeitos para crear diferentes sistemas. En particular, pode configurar o sistema para que se execute nun só nodo nunha realización e en varios nodos noutro. Pode crear varias configuracións para instancias de produción do sistema.
  10. Probando. Ao substituír servizos individuais por obxectos simulados, podes obter varias versións do sistema que sexan convenientes para probar.
  11. Probas de integración. Ter unha única configuración para todo o sistema distribuído fai posible executar todos os compoñentes nun ambiente controlado como parte das probas de integración. É doado emular, por exemplo, unha situación na que algúns nodos se fan accesibles.

Desvantaxes e limitacións

A configuración compilada difire doutros enfoques de configuración e pode non ser axeitada para algunhas aplicacións. Abaixo amósanse algunhas desvantaxes:

  1. Configuración estática. Ás veces cómpre corrixir rapidamente a configuración en produción, evitando todos os mecanismos de protección. Con este enfoque pode ser máis difícil. Como mínimo, aínda será necesario a compilación e o despregamento automático. Esta é unha característica útil do enfoque e unha desvantaxe nalgúns casos.
  2. Xeración de configuración. No caso de que o ficheiro de configuración sexa xerado por unha ferramenta automática, poden ser necesarios esforzos adicionais para integrar o script de compilación.
  3. Ferramentas. Actualmente, as utilidades e técnicas deseñadas para traballar coa configuración baséanse en ficheiros de texto. Non todas estas utilidades/técnicas estarán dispoñibles nunha configuración compilada.
  4. É necesario un cambio de actitudes. Os desenvolvedores e DevOps están afeitos aos ficheiros de texto. A propia idea de compilar unha configuración pode ser algo inesperada e inusual e causar rexeitamento.
  5. Requírese un proceso de desenvolvemento de alta calidade. Para poder utilizar con comodidade a configuración compilada, é necesaria a automatización total do proceso de construción e implantación da aplicación (CI/CD). Se non, será bastante inconveniente.

Detémonos tamén nunha serie de limitacións do exemplo considerado que non están relacionadas coa idea dunha configuración compilada:

  1. Se fornecemos información de configuración innecesaria que non é utilizada polo nodo, entón o compilador non nos axudará a detectar a implementación que falta. Este problema pódese resolver abandonando o patrón de bolo e empregando tipos máis ríxidos, por exemplo, HList ou tipos de datos alxébricos (clases de casos) para representar a configuración.
  2. Hai liñas no ficheiro de configuración que non están relacionadas coa propia configuración: (package, import,declaracións de obxectos; override def's para parámetros que teñen valores predeterminados). Isto pódese evitar parcialmente se implementas o teu propio DSL. Ademais, outros tipos de configuración (por exemplo, XML) tamén impoñen certas restricións á estrutura do ficheiro.
  3. Para os efectos desta publicación, non estamos considerando a reconfiguración dinámica dun grupo de nodos similares.

Conclusión

Nesta publicación, exploramos a idea de representar a configuración no código fonte usando as capacidades avanzadas do sistema de tipo Scala. Este enfoque pódese usar en varias aplicacións como substituto dos métodos de configuración tradicionais baseados en ficheiros xml ou de texto. Aínda que o noso exemplo está implementado en Scala, as mesmas ideas pódense transferir a outras linguaxes compiladas (como Kotlin, C#, Swift, ...). Podes probar este enfoque nalgún dos seguintes proxectos e, se non funciona, pasar ao ficheiro de texto, engadindo as partes que faltan.

Por suposto, unha configuración compilada require un proceso de desenvolvemento de alta calidade. A cambio, garante a alta calidade e fiabilidade das configuracións.

O enfoque considerado pódese ampliar:

  1. Podes usar macros para realizar comprobacións en tempo de compilación.
  2. Pode implementar un DSL para presentar a configuración dun xeito accesible para os usuarios finais.
  3. Pode implementar a xestión dinámica de recursos cun axuste automático da configuración. Por exemplo, cambiar o número de nodos nun clúster require que (1) cada nodo reciba unha configuración lixeiramente diferente; (2) o xestor do clúster recibiu información sobre novos nodos.

Agradecementos

Gustaríame agradecer a Andrei Saksonov, Pavel Popov e Anton Nekhaev a súa crítica construtiva ao borrador do artigo.

Fonte: www.habr.com

Engadir un comentario