Configuración compilable dun sistema distribuído

Nesta publicación gustaríanos compartir unha forma interesante de tratar a configuración dun sistema distribuído.
A configuración represéntase directamente na linguaxe Scala de forma segura. Descríbese detalladamente un exemplo de implementación. Discútanse varios aspectos da proposta, incluíndo a influencia no proceso global de desenvolvemento.

Configuración compilable dun sistema distribuído

(en ruso)

introdución

A construción de sistemas distribuídos robustos require o uso dunha configuración correcta e coherente en todos os nodos. Unha solución típica é usar unha descrición de despregamento textual (terraform, ansible ou algo semellante) e ficheiros de configuración xerados automaticamente (a miúdo, dedicados a cada nodo/rol). Tamén queremos usar os mesmos protocolos das mesmas versións en cada nodo de comunicación (se non, experimentaríamos problemas de incompatibilidade). No mundo da JVM isto significa que polo menos a biblioteca de mensaxería debe ser da mesma versión en todos os nodos de comunicación.

Que tal probar o sistema? Por suposto, deberíamos ter probas unitarias para todos os compoñentes antes de chegar ás probas de integración. Para poder extrapolar os resultados das probas en tempo de execución, debemos asegurarnos de que as versións de todas as bibliotecas se manteñan idénticas tanto en ambientes de execución como de proba.

Cando se executan probas de integración, adoita ser moito máis fácil ter o mesmo camiño de clase en todos os nodos. Só necesitamos asegurarnos de que se usa o mesmo camiño de clase na implantación. (É posible usar diferentes rutas de clases en distintos nodos, pero é máis difícil representar esta configuración e implementala correctamente.) Polo tanto, para que as cousas sexan sinxelas, só consideraremos rutas de clases idénticas en todos os nodos.

A configuración tende a evolucionar xunto co software. Normalmente usamos versións para identificar varias
etapas da evolución do software. Parece razoable cubrir a configuración baixo a xestión de versións e identificar diferentes configuracións con algunhas etiquetas. Se só hai unha configuración en produción, podemos usar unha única versión como identificador. Ás veces podemos ter varios ambientes de produción. E para cada ambiente podemos necesitar unha rama de configuración separada. Polo tanto, as configuracións poden etiquetarse con rama e versión para identificar de forma única as diferentes configuracións. Cada etiqueta e versión de rama corresponde a unha única combinación de nodos distribuídos, portos, recursos externos e versións da biblioteca de rutas de clases en cada nodo. Aquí só cubriremos a única rama e identificaremos configuracións mediante unha versión decimal de tres compoñentes (1.2.3), do mesmo xeito que outros artefactos.

Nos contornos modernos os ficheiros de configuración xa non se modifican manualmente. Normalmente xeramos
ficheiros de configuración no momento da implantación e nunca os toques despois. Entón, pódese preguntar por que seguimos usando o formato de texto para os ficheiros de configuración? Unha opción viable é colocar a configuración dentro dunha unidade de compilación e beneficiarse da validación da configuración en tempo de compilación.

Nesta publicación examinaremos a idea de manter a configuración no artefacto compilado.

Configuración compilable

Nesta sección comentaremos un exemplo de configuración estática. Dous servizos sinxelos: o servizo de eco e o cliente do servizo de eco estanse configurando e implementando. Despois instancianse dous sistemas distribuídos diferentes con ambos servizos. Un é para a configuración dun só nodo e outro para a configuración de dous nodos.

Un sistema distribuído típico consta duns poucos nodos. Os nodos pódense identificar mediante algún tipo:

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

ou simplemente

case class NodeId(hostName: String)

ou mesmo

object Singleton
type NodeId = Singleton.type

Estes nodos realizan varias funcións, executan algúns servizos e deberían poder comunicarse cos outros nodos mediante conexións TCP/HTTP.

Para a conexión TCP é necesario polo menos un número de porto. Tamén queremos asegurarnos de que o cliente e o servidor están falando o mesmo protocolo. Para modelar unha conexión entre nodos imos declarar a seguinte clase:

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

onde Port é só un Int dentro do intervalo permitido:

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

Tipos refinados

Ver refinado biblioteca. En resumo, permite engadir restricións de tempo de compilación a outros tipos. Neste caso Int só se permite ter valores de 16 bits que poden representar o número de porto. Non hai ningún requisito para usar esta biblioteca para este enfoque de configuración. Parece que encaixa moi ben.

Para HTTP (REST) ​​tamén podemos necesitar unha ruta do servizo:

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

Tipo fantasma

Para identificar o protocolo durante a compilación estamos a usar a función Scala de declarar o argumento de tipo Protocol que non se usa na clase. É un así chamado tipo fantasma. En tempo de execución raramente necesitamos unha instancia de identificador de protocolo, por iso non o almacenamos. Durante a compilación, este tipo fantasma proporciona unha seguridade adicional de tipo. Non podemos pasar o porto cun protocolo incorrecto.

Un dos protocolos máis utilizados é a API REST con serialización Json:

sealed trait JsonHttpRestProtocol[RequestMessage, ResponseMessage]

onde RequestMessage é o tipo base de mensaxes que o cliente pode enviar ao servidor e ResponseMessage é a mensaxe de resposta do servidor. Por suposto, podemos crear outras descricións de protocolos que especifiquen o protocolo de comunicación coa precisión desexada.

Para os efectos desta publicación, utilizaremos unha versión máis sinxela do protocolo:

sealed trait SimpleHttpGetRest[RequestMessage, ResponseMessage]

Neste protocolo a mensaxe de solicitude engádese ao URL e a mensaxe de resposta devólvese como unha cadea simple.

Unha configuración de servizo podería describirse polo nome do servizo, unha colección de portos e algunhas dependencias. Existen algunhas formas posibles de representar todos estes elementos en Scala (por exemplo, HList, tipos de datos alxébricos). Para os efectos desta publicación usaremos Cake Pattern e representaremos pezas combinables (módulos) como trazos. (O patrón de torta non é un requisito para este enfoque de configuración compilable. É só unha posible implementación da idea.)

As dependencias poderían representarse usando o Cake Pattern como puntos finais 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)
  }

O servizo Echo só precisa un porto configurado. E declaramos que este porto admite o protocolo de eco. Teña en conta que non precisamos especificar un porto en particular neste momento, porque os trazos permiten declaracións de métodos abstractos. Se usamos métodos abstractos, o compilador requirirá unha implementación nunha instancia de configuración. Aquí proporcionamos a implementación (8081) e empregarase como valor predeterminado se o omitimos nunha configuración concreta.

Podemos declarar unha dependencia na configuración do cliente do servizo de eco:

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

A dependencia ten o mesmo tipo que a echoService. En particular, esixe o mesmo protocolo. Polo tanto, podemos estar seguros de que se conectamos estas dúas dependencias funcionarán correctamente.

Implementación de servizos

Un servizo necesita unha función para iniciarse e apagarse con gracia. (A capacidade de apagar un servizo é fundamental para a proba.) De novo hai algunhas opcións para especificar tal función para unha configuración determinada (por exemplo, poderiamos usar clases de tipo). Para esta publicación usaremos Cake Pattern de novo. Podemos representar un servizo usando cats.Resource que xa proporciona corchetes e liberación de recursos. Para adquirir un recurso debemos proporcionar unha configuración e algún contexto de execución. Polo tanto, a función de inicio do servizo pode parecer:

  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 que require este iniciador de servizo
  • AddressResolver — un obxecto de execución que ten a capacidade de obter enderezos reais doutros nodos (continúa lendo para obter máis detalles).

os outros tipos proceden cats:

  • F[_] — tipo de efecto (no caso máis sinxelo F[A] podería ser xusto () => A. Neste post usaremos cats.IO.)
  • Reader[A,B] — é máis ou menos sinónimo dunha función A => B
  • cats.Resource - ten formas de adquirir e liberar
  • Timer — permite durmir/medir o tempo
  • ContextShift - análogo de ExecutionContext
  • Applicative — envoltorio de funcións en vigor (case unha mónada) (podemos substituílo por outra cousa)

Usando esta interface podemos implementar algúns 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 Código fonte para implementacións doutros servizos - servizo de eco,
cliente de eco controladores de por vida.)

Un nodo é un único obxecto que executa algúns servizos (cake Pattern habilita o inicio dunha cadea de recursos):

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 no nodo especificamos o tipo exacto de configuración que necesita este nodo. O compilador non nos permitirá construír o obxecto (Cake) cun tipo insuficiente, porque cada trazo de servizo declara unha restrición no Config tipo. Ademais, non poderemos iniciar o nodo sen proporcionar unha configuración completa.

Resolución de enderezos de nodos

Para establecer unha conexión necesitamos un enderezo de host real para cada nodo. Pode que se coñeza máis tarde que outras partes da configuración. Polo tanto, necesitamos un xeito de proporcionar unha asignación entre o id de nodo e o seu enderezo real. Este mapeo é unha función:

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

Hai algunhas formas posibles de implementar tal función.

  1. Se coñecemos os enderezos reais antes da implantación, durante a instanciación dos hosts de nodos, podemos xerar código Scala cos enderezos reais e executar a compilación despois (que realiza comprobacións de tempo de compilación e despois executa a suite de probas de integración). Neste caso, a nosa función de mapeo coñécese de forma estática e pódese simplificar a algo así como a Map[NodeId, NodeAddress].
  2. Ás veces obtemos enderezos reais só nun momento posterior cando o nodo se inicia realmente, ou non temos enderezos de nodos que aínda non se iniciaron. Neste caso, podemos ter un servizo de descubrimento que se inicia antes que todos os outros nodos e cada nodo pode anunciar o seu enderezo nese servizo e subscribirse ás dependencias.
  3. Se podemos modificar /etc/hosts, podemos usar nomes de host predefinidos (como my-project-main-node echo-backend) e só asocia este nome co enderezo IP no momento da implantación.

Neste post non tratamos estes casos con máis detalle. De feito, no noso exemplo de xoguetes todos os nodos terán o mesmo enderezo IP: 127.0.0.1.

Nesta publicación consideraremos dous esquemas de sistemas distribuídos:

  1. Disposición dun único nodo, onde todos os servizos se sitúan nun único nodo.
  2. Disposición de dous nodos, onde o servizo e o cliente están en nodos diferentes.

A configuración para a único nodo a disposición é a seguinte:

Configuración dun único 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.
}

Aquí creamos unha única configuración que estende tanto a configuración do servidor como do cliente. Tamén configuramos un controlador de ciclo de vida que normalmente finalizará cliente e servidor despois lifetime pases de intervalo.

Pódese usar o mesmo conxunto de implementacións e configuracións de servizos para crear a disposición dun sistema con dous nodos separados. Só necesitamos crear dúas configuracións de nodos separadas cos servizos adecuados:

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

Mira como especificamos a dependencia. Mencionamos o servizo proporcionado polo outro nodo como unha dependencia do nodo actual. O tipo de dependencia compróbase porque contén un tipo pantasma que describe o protocolo. E no tempo de execución teremos o ID de nodo correcto. Este é un dos aspectos importantes do enfoque de configuración proposto. Ofrécenos a posibilidade de establecer o porto só unha vez e asegurarnos de que estamos facendo referencia ao porto correcto.

Implementación de dous nodos

Para esta configuración usamos exactamente as mesmas implementacións de servizos. Sen cambios en absoluto. Non obstante, creamos dúas implementacións de nodos diferentes que conteñen 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 necesita outra parte da configuración. Ambos os nós requiren algunha especificación de vida útil. Para os efectos deste nodo de servizo posterior, terá unha vida útil infinita que podería rematar usando SIGTERM, mentres que o cliente de eco finalizará despois da duración finita configurada. Vexa o aplicación de inicio para máis detalles.

Proceso de desenvolvemento global

Vexamos como este enfoque cambia a forma de traballar coa configuración.

A configuración como código compilarase e produce un artefacto. Parece razoable separar os artefactos de configuración doutros artefactos de código. Moitas veces podemos ter multitude de configuracións na mesma base de código. E, por suposto, podemos ter varias versións de varias ramas de configuración. Nunha configuración podemos seleccionar determinadas versións das bibliotecas e esta permanecerá constante sempre que despreguemos esta configuración.

Un cambio de configuración convértese en cambio de código. Polo tanto, debería estar cuberto polo mesmo proceso de garantía de calidade:

Ticket -> PR -> revisión -> fusión -> integración continua -> despregamento continuo

Hai as seguintes consecuencias do enfoque:

  1. A configuración é coherente para a instancia dun sistema particular. Parece que non hai forma de ter unha conexión incorrecta entre nós.
  2. Non é fácil cambiar a configuración só nun nodo. Parece pouco razoable iniciar sesión e cambiar algúns ficheiros de texto. Polo tanto, a deriva da configuración faise menos posible.
  3. Non é fácil facer pequenos cambios de configuración.
  4. A maioría dos cambios de configuración seguirán o mesmo proceso de desenvolvemento e pasarán algunha revisión.

Necesitamos un repositorio separado para a configuración de produción? A configuración de produción pode conter información confidencial que queremos manter fóra do alcance de moitas persoas. Polo tanto, pode valer a pena manter un repositorio separado con acceso restrinxido que conterá a configuración de produción. Podemos dividir a configuración en dúas partes: unha que contén os parámetros de produción máis abertos e outra que contén a parte secreta da configuración. Isto permitiríalle o acceso á maioría dos desenvolvedores á gran maioría dos parámetros mentres restrinxiría o acceso a cousas realmente sensibles. É doado logralo usando trazos intermedios con valores de parámetros predeterminados.

Variacións

Vexamos os pros e os contras do enfoque proposto en comparación coas outras técnicas de xestión de configuración.

En primeiro lugar, enumeraremos algunhas alternativas aos diferentes aspectos da forma proposta de xestionar a configuración:

  1. Ficheiro de texto na máquina de destino.
  2. Almacenamento centralizado de valores-clave (como etcd/zookeeper).
  3. Componentes do subproceso que se poderían reconfigurar/reiniciar sen reiniciar o proceso.
  4. Configuración fóra de artefacto e control de versións.

O ficheiro de texto ofrece certa flexibilidade en canto a correccións ad-hoc. O administrador dun sistema pode iniciar sesión no nodo de destino, facer un cambio e simplemente reiniciar o servizo. Isto pode non ser tan bo para sistemas máis grandes. Non quedan rastros detrás do cambio. O cambio non é revisado por outro par de ollos. Pode ser difícil descubrir o que causou o cambio. Non foi probado. Desde a perspectiva do sistema distribuído, un administrador simplemente pode esquecerse de actualizar a configuración nun dos outros nodos.

(Por certo, se finalmente hai que comezar a usar ficheiros de configuración de texto, só teremos que engadir analizador + validador que poida producir o mesmo Config tipo e iso sería suficiente para comezar a usar as configuracións de texto. Isto tamén mostra que a complexidade da configuración en tempo de compilación é un pouco menor que a complexidade das configuracións baseadas en texto, porque na versión baseada en texto necesitamos algún código adicional.

O almacenamento centralizado de clave-valor é un bo mecanismo para distribuír os metaparámetros da aplicación. Aquí temos que pensar no que consideramos valores de configuración e o que son só datos. Dada unha función C => A => B adoitamos chamar valores raramente cambiantes C "configuración", mentres que os datos cambian con frecuencia A - só introducir datos. A configuración debe proporcionarse á función antes dos datos A. Tendo en conta esta idea, podemos dicir que se espera que a frecuencia de cambios se poida usar para distinguir os datos de configuración dos só datos. Tamén os datos adoitan proceder dunha fonte (usuario) e a configuración procede dunha fonte diferente (administrador). Tratar os parámetros que se poden cambiar despois do proceso de inicialización leva a un aumento da complexidade da aplicación. Para tales parámetros teremos que manexar o seu mecanismo de entrega, análise e validación, manexando valores incorrectos. Polo tanto, para reducir a complexidade do programa, é mellor reducir o número de parámetros que poden cambiar no tempo de execución (ou incluso eliminalos por completo).

Desde a perspectiva deste post debemos facer unha distinción entre parámetros estáticos e dinámicos. Se a lóxica do servizo require un cambio raro dalgúns parámetros no tempo de execución, entón podemos chamalos parámetros dinámicos. En caso contrario, son estáticos e poderían configurarse mediante o enfoque proposto. Para a reconfiguración dinámica poden ser necesarios outros enfoques. Por exemplo, partes do sistema poden reiniciarse cos novos parámetros de configuración dun xeito similar ao reinicio de procesos separados dun sistema distribuído.
(A miña humilde opinión é evitar a reconfiguración do tempo de execución porque aumenta a complexidade do sistema.
Pode ser máis sinxelo confiar só no soporte do SO para reiniciar procesos. Aínda que non sempre sexa posible).

Un aspecto importante do uso da configuración estática que ás veces fai que a xente considere a configuración dinámica (sen outros motivos) é o tempo de inactividade do servizo durante a actualización da configuración. De feito, se temos que facer cambios na configuración estática, temos que reiniciar o sistema para que os novos valores sexan efectivos. Os requisitos para o tempo de inactividade varían segundo os distintos sistemas, polo que quizais non sexa tan crítico. Se é crítico, entón temos que planificar con antelación calquera reinicio do sistema. Por exemplo, poderiamos implementar Drenaxe de conexión AWS ELB. Neste escenario, sempre que necesitamos reiniciar o sistema, iniciamos unha nova instancia do sistema en paralelo, despois cambiamos ELB a ela, mentres deixamos que o sistema antigo complete o servizo de conexións existentes.

Que tal manter a configuración dentro do artefacto versionado ou fóra? Manter a configuración dentro dun artefacto significa na maioría dos casos que esta configuración pasou o mesmo proceso de garantía de calidade que outros artefactos. Polo tanto, pódese estar seguro de que a configuración é de boa calidade e fiable. Pola contra, a configuración nun ficheiro separado significa que non hai rastros de quen e por que fixo cambios nese ficheiro. Isto é importante? Cremos que para a maioría dos sistemas de produción é mellor ter unha configuración estable e de alta calidade.

A versión do artefacto permite saber cando foi creado, que valores contén, que funcións están habilitadas/desactivadas, quen foi o responsable de facer cada cambio na configuración. Pode ser necesario un esforzo para manter a configuración dentro dun artefacto e é unha opción de deseño.

Pros e contras

Aquí queremos destacar algunhas vantaxes e discutir algunhas desvantaxes do enfoque proposto.

vantaxes

Características da configuración compilable dun sistema distribuído completo:

  1. Comprobación estática da configuración. Isto dá un alto nivel de confianza en que a configuración é correcta dadas as restricións de tipo.
  2. Linguaxe rica de configuración. Normalmente, outros enfoques de configuración limítanse como máximo á substitución variable.
    Usando Scala pódese usar unha ampla gama de funcións lingüísticas para mellorar a configuración. Por exemplo, podemos usar trazos para proporcionar valores predeterminados, obxectos para establecer diferentes ámbitos, aos que podemos referirnos vals definido só unha vez no ámbito exterior (DRY). É posible usar secuencias literais ou instancias de certas clases (Seq, Map, Etc)
  3. DSL. Scala ten un soporte decente para os escritores DSL. Pódese utilizar estas funcións para establecer unha linguaxe de configuración máis cómoda e amigable para o usuario final, de xeito que a configuración final sexa polo menos lexible polos usuarios do dominio.
  4. Integridade e coherencia entre nós. Unha das vantaxes de ter a configuración de todo o sistema distribuído nun só lugar é que todos os valores se definen estrictamente unha vez e despois reutilizan en todos os lugares onde os necesitemos. Tamén escriba declaracións de porto seguro para asegurarse de que en todas as configuracións correctas posibles os nodos do sistema falarán o mesmo idioma. Existen dependencias explícitas entre os nós que dificultan esquecer a prestación dalgúns servizos.
  5. Alta calidade dos cambios. O enfoque xeral de pasar os cambios de configuración a través do proceso normal de RRPP establece altos estándares de calidade tamén na configuración.
  6. Cambios de configuración simultáneos. Sempre que fagamos algún cambio na configuración, o despregamento automático garante que todos os nós se están actualizando.
  7. Simplificación de aplicacións. A aplicación non precisa analizar e validar a configuración nin xestionar valores de configuración incorrectos. Isto simplifica a aplicación xeral. (Algún aumento de complexidade está na propia configuración, pero é unha compensación consciente cara á seguridade.) É bastante sinxelo volver á configuración normal: só tes que engadir as pezas que faltan. É máis doado comezar coa configuración compilada e aprazar a implementación de pezas adicionais nalgúns momentos posteriores.
  8. Configuración con versión. Debido ao feito de que os cambios de configuración seguen o mesmo proceso de desenvolvemento, como resultado obtemos un artefacto cunha versión única. Permítenos cambiar a configuración de novo se é necesario. Incluso podemos implementar unha configuración que se utilizou hai un ano e que funcionará exactamente do mesmo xeito. A configuración estable mellora a previsibilidade e a fiabilidade do sistema distribuído. A configuración está fixada no momento da compilación e non se pode manipular facilmente nun sistema de produción.
  9. Modularidade. O marco proposto é modular e os módulos pódense combinar de varias maneiras
    admite diferentes configuracións (configuracións/diseños). En particular, é posible ter un deseño de nodo único a pequena escala e unha configuración de varios nodos a gran escala. É razoable ter varios esquemas de produción.
  10. Probando. Para fins de proba pódese implementar un servizo simulado e usalo como unha dependencia dun xeito seguro. Poderíanse manter simultáneamente algúns esquemas de proba diferentes con varias pezas substituídas por simulacros.
  11. Probas de integración. Ás veces, nos sistemas distribuídos é difícil realizar probas de integración. Usando o enfoque descrito para escribir a configuración segura do sistema distribuído completo, podemos executar todas as partes distribuídas nun único servidor dun xeito controlable. É doado emular a situación
    cando un dos servizos non está dispoñible.

Desvantaxes

O enfoque de configuración compilada é diferente da configuración "normal" e pode non satisfacer todas as necesidades. Aquí están algunhas das desvantaxes da configuración compilada:

  1. Configuración estática. Pode que non sexa adecuado para todas as aplicacións. Nalgúns casos, é necesario arranxar rapidamente a configuración en produción evitando todas as medidas de seguridade. Este enfoque fai que sexa máis difícil. A compilación e a redistribución son necesarias despois de facer calquera cambio na configuración. Esta é tanto a característica como a carga.
  2. Xeración de configuración. Cando a configuración é xerada por algunha ferramenta de automatización, este enfoque require unha compilación posterior (que á súa vez pode fallar). Pode ser necesario un esforzo adicional para integrar este paso adicional no sistema de compilación.
  3. Instrumentos. Hai moitas ferramentas en uso hoxe en día que dependen de configuracións baseadas en texto. Algúns deles
    non será aplicable cando se compile a configuración.
  4. É necesario un cambio de mentalidade. Os desenvolvedores e DevOps están familiarizados cos ficheiros de configuración de texto. A idea de compilar a configuración pode parecerlles estraña.
  5. Antes de introducir a configuración compilable é necesario un proceso de desenvolvemento de software de alta calidade.

Hai algunhas limitacións do exemplo implementado:

  1. Se fornecemos unha configuración adicional que non esixe a implementación do nodo, o compilador non nos axudará a detectar a implementación ausente. Isto podería solucionarse usando HList ou ADT (clases de casos) para a configuración de nodos en lugar de trazos e Cake Pattern.
  2. Temos que proporcionar un boilerplate no ficheiro de configuración: (package, import, object declaracións;
    override def's para parámetros que teñen valores predeterminados). É posible que isto se solucione parcialmente mediante un DSL.
  3. Neste post non tratamos a reconfiguración dinámica de clusters de nodos similares.

Conclusión

Neste post comentamos a idea de representar a configuración directamente no código fonte dun xeito seguro. O enfoque podería usarse en moitas aplicacións como substitución de xml e outras configuracións baseadas en texto. A pesar de que o noso exemplo foi implementado en Scala, tamén se podería traducir a outras linguaxes compilables (como Kotlin, C#, Swift, etc.). Pódese probar este enfoque nun novo proxecto e, por se non encaixa ben, pasar á antiga.

Por suposto, a configuración compilable require un proceso de desenvolvemento de alta calidade. A cambio, promete proporcionar unha configuración robusta de igual calidade.

Este enfoque pódese ampliar de varias maneiras:

  1. Pódese usar macros para realizar a validación da configuración e fallar no momento da compilación en caso de fallos de restricións da lóxica empresarial.
  2. Pódese implementar un DSL para representar a configuración dun xeito amigable para o usuario do dominio.
  3. Xestión dinámica de recursos con axustes automáticos de configuración. Por exemplo, cando axustamos o número de nodos do clúster podemos querer (1) que os nodos obteñan unha configuración lixeiramente modificada; (2) xestor de clúster para recibir información sobre novos nós.

Grazas

Gustaríame agradecer a Andrey Saksonov, Pavel Popov e Anton Nehaev por dar unha opinión inspiradora sobre o borrador desta publicación que me axudou a aclaralo.

Fonte: www.habr.com