Configuración compilable de un sistema distribuido.

En esta publicación nos gustaría compartir una forma interesante de abordar la configuración de un sistema distribuido.
La configuración se representa directamente en lenguaje Scala de forma segura. Se describe en detalle una implementación de ejemplo. Se discuten varios aspectos de la propuesta, incluida la influencia en el proceso de desarrollo general.

Configuración compilable de un sistema distribuido.

(en Inglés)

Introducción

La construcción de sistemas distribuidos robustos requiere el uso de una configuración correcta y coherente en todos los nodos. Una solución típica es utilizar una descripción textual de la implementación (terraform, ansible o algo similar) y archivos de configuración generados automáticamente (a menudo, dedicados a cada nodo/rol). También nos gustaría utilizar los mismos protocolos de las mismas versiones en cada nodo de comunicación (de lo contrario, experimentaríamos problemas de incompatibilidad). En el mundo JVM, esto significa que al menos la biblioteca de mensajería debe tener la misma versión en todos los nodos que se comunican.

¿Qué hay de probar el sistema? Por supuesto, deberíamos realizar pruebas unitarias para todos los componentes antes de pasar a las pruebas de integración. Para poder extrapolar los resultados de las pruebas en tiempo de ejecución, debemos asegurarnos de que las versiones de todas las bibliotecas se mantengan idénticas tanto en el entorno de ejecución como en el de prueba.

Cuando se ejecutan pruebas de integración, suele ser mucho más fácil tener el mismo classpath en todos los nodos. Solo necesitamos asegurarnos de que se utilice el mismo classpath en la implementación. (Es posible usar diferentes classpaths en diferentes nodos, pero es más difícil representar esta configuración e implementarla correctamente). Entonces, para mantener las cosas simples, solo consideraremos classpaths idénticos en todos los nodos.

La configuración tiende a evolucionar junto con el software. Generalmente utilizamos versiones para identificar varios
Etapas de la evolución del software. Parece razonable cubrir la configuración bajo gestión de versiones e identificar diferentes configuraciones con algunas etiquetas. Si solo hay una configuración en producción, podemos usar una versión única como identificador. En ocasiones podemos tener múltiples entornos de producción. Y para cada entorno es posible que necesitemos una rama de configuración independiente. Por lo tanto, las configuraciones pueden etiquetarse con rama y versión para identificar de forma única diferentes configuraciones. Cada etiqueta de rama y versión corresponde a una única combinación de nodos distribuidos, puertos, recursos externos y versiones de la biblioteca classpath en cada nodo. Aquí solo cubriremos la rama única e identificaremos las configuraciones mediante una versión decimal de tres componentes (1.2.3), de la misma manera que otros artefactos.

En los entornos modernos, los archivos de configuración ya no se modifican manualmente. Normalmente generamos
archivos de configuración en el momento de la implementación y nunca los toques después. Entonces uno podría preguntarse ¿por qué seguimos usando el formato de texto para los archivos de configuración? Una opción viable es colocar la configuración dentro de una unidad de compilación y beneficiarse de la validación de la configuración en tiempo de compilación.

En esta publicación examinaremos la idea de mantener la configuración en el artefacto compilado.

Configuración compilable

En esta sección discutiremos un ejemplo de configuración estática. Se están configurando e implementando dos servicios simples: el servicio de eco y el cliente del servicio de eco. Luego se crean instancias de dos sistemas distribuidos diferentes con ambos servicios. Uno es para una configuración de un solo nodo y otro para una configuración de dos nodos.

Un sistema distribuido típico consta de unos pocos nodos. Los nodos podrían identificarse mediante algún tipo:

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

o solo

case class NodeId(hostName: String)

o incluso

object Singleton
type NodeId = Singleton.type

Estos nodos desempeñan varias funciones, ejecutan algunos servicios y deberían poder comunicarse con los otros nodos mediante conexiones TCP/HTTP.

Para la conexión TCP se requiere al menos un número de puerto. También queremos asegurarnos de que el cliente y el servidor utilicen el mismo protocolo. Para modelar una conexión entre nodos, declaremos la siguiente clase:

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

donde Port es solo un Int dentro del rango permitido:

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

tipos refinados

See refinado biblioteca. En resumen, permite agregar restricciones de tiempo de compilación a otros tipos. En este caso Int Solo se permite tener valores de 16 bits que puedan representar el número de puerto. No es necesario utilizar esta biblioteca para este enfoque de configuración. Simplemente parece encajar muy bien.

Para HTTP (REST) ​​también podríamos necesitar una ruta del servicio:

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

tipo fantasma

Para identificar el protocolo durante la compilación, utilizamos la función Scala de declarar argumento de tipo. Protocol que no se utiliza en la clase. es un llamado tipo fantasma. En tiempo de ejecución rara vez necesitamos una instancia de identificador de protocolo, por eso no la almacenamos. Durante la compilación, este tipo fantasma proporciona seguridad de tipo adicional. No podemos pasar el puerto con un protocolo incorrecto.

Uno de los protocolos más utilizados es la API REST con serialización Json:

sealed trait JsonHttpRestProtocol[RequestMessage, ResponseMessage]

donde RequestMessage es el tipo base de mensajes que el cliente puede enviar al servidor y ResponseMessage es el mensaje de respuesta del servidor. Por supuesto, podemos crear otras descripciones de protocolo que especifiquen el protocolo de comunicación con la precisión deseada.

Para los propósitos de esta publicación, usaremos una versión más simple del protocolo:

sealed trait SimpleHttpGetRest[RequestMessage, ResponseMessage]

En este protocolo, el mensaje de solicitud se agrega a la URL y el mensaje de respuesta se devuelve como una cadena simple.

La configuración de un servicio podría describirse mediante el nombre del servicio, una colección de puertos y algunas dependencias. Hay algunas formas posibles de representar todos estos elementos en Scala (por ejemplo, HList, tipos de datos algebraicos). Para los propósitos de esta publicación, usaremos Cake Pattern y representaremos piezas combinables (módulos) como rasgos. (Cake Pattern no es un requisito para este enfoque de configuración compilable. Es sólo una posible implementación de la idea).

Las dependencias se podrían representar usando Cake Pattern como puntos finales de otros 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)
  }

El servicio Echo solo necesita un puerto configurado. Y declaramos que este puerto admite el protocolo echo. Tenga en cuenta que no necesitamos especificar un puerto en particular en este momento, porque el rasgo permite declaraciones de métodos abstractos. Si usamos métodos abstractos, el compilador requerirá una implementación en una instancia de configuración. Aquí hemos proporcionado la implementación (8081) y se utilizará como valor predeterminado si lo omitimos en una configuración concreta.

Podemos declarar una dependencia en la configuración del cliente del servicio echo:

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

La dependencia es del mismo tipo que la echoService. En particular, exige el mismo protocolo. Por tanto, podemos estar seguros de que si conectamos estas dos dependencias funcionarán correctamente.

Implementación de servicios

Un servicio necesita una función para iniciarse y cerrarse correctamente. (La capacidad de cerrar un servicio es fundamental para las pruebas). Nuevamente, hay algunas opciones para especificar dicha función para una configuración determinada (por ejemplo, podríamos usar clases de tipos). Para esta publicación usaremos Cake Pattern nuevamente. Podemos representar un servicio usando cats.Resource que ya proporciona bracketing y liberación de recursos. Para adquirir un recurso debemos proporcionar una configuración y algún contexto de tiempo de ejecución. Entonces, la función de inicio del servicio podría 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]
  }

donde

  • Config — tipo de configuración que requiere este iniciador de servicio
  • AddressResolver — un objeto de tiempo de ejecución que tiene la capacidad de obtener direcciones reales de otros nodos (sigue leyendo para más detalles).

los otros tipos provienen de cats:

  • F[_] — tipo de efecto (en el caso más simple F[A] podría ser solo () => A. En esta publicación usaremos cats.IO.)
  • Reader[A,B] - es más o menos sinónimo de función A => B
  • cats.Resource - tiene formas de adquirir y liberar
  • Timer — permite dormir/medir el tiempo
  • ContextShift - análogo de ExecutionContext
  • Applicative — contenedor de funciones en efecto (casi una mónada) (podríamos eventualmente reemplazarlo con otra cosa)

Usando esta interfaz podemos implementar algunos servicios. Por ejemplo, un servicio que no hace nada:

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

(Véase Código fuente para implementaciones de otros servicios: servicio de eco,
cliente de eco y controladores de por vida.)

Un nodo es un objeto único que ejecuta algunos servicios (Cake Pattern permite iniciar una cadena de recursos):

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

Tenga en cuenta que en el nodo especificamos el tipo exacto de configuración que necesita este nodo. El compilador no nos permitirá construir el objeto (Cake) con un tipo insuficiente, porque cada rasgo de servicio declara una restricción en el Config tipo. Además, no podremos iniciar el nodo sin proporcionar una configuración completa.

Resolución de dirección de nodo

Para establecer una conexión necesitamos una dirección de host real para cada nodo. Es posible que se conozca más tarde que otras partes de la configuración. Por lo tanto, necesitamos una forma de proporcionar una asignación entre la identificación del nodo y su dirección real. Este mapeo es una función:

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

Hay algunas formas posibles de implementar dicha función.

  1. Si conocemos las direcciones reales antes de la implementación, durante la creación de instancias de los hosts de nodos, entonces podemos generar código Scala con las direcciones reales y ejecutar la compilación después (que realiza comprobaciones en tiempo de compilación y luego ejecuta el conjunto de pruebas de integración). En este caso, nuestra función de mapeo se conoce estáticamente y se puede simplificar a algo así como Map[NodeId, NodeAddress].
  2. A veces obtenemos direcciones reales sólo en un momento posterior, cuando el nodo se inicia realmente, o no tenemos direcciones de nodos que aún no se han iniciado. En este caso, podríamos tener un servicio de descubrimiento que se inicia antes que todos los demás nodos y cada nodo podría anunciar su dirección en ese servicio y suscribirse a dependencias.
  3. Si podemos modificar /etc/hosts, podemos usar nombres de host predefinidos (como my-project-main-node y echo-backend) y simplemente asocie este nombre con la dirección IP en el momento de la implementación.

En esta publicación no cubrimos estos casos con más detalles. De hecho, en nuestro ejemplo de juguete, todos los nodos tendrán la misma dirección IP. 127.0.0.1.

En esta publicación consideraremos dos diseños de sistemas distribuidos:

  1. Diseño de nodo único, donde todos los servicios se ubican en un nodo único.
  2. Diseño de dos nodos, donde el servicio y el cliente están en nodos diferentes.

La configuración para un nodo único el diseño es el siguiente:

Configuración de un solo 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 una configuración única que extiende la configuración tanto del servidor como del cliente. También configuramos un controlador de ciclo de vida que normalmente terminará el cliente y el servidor después lifetime pases de intervalo.

Se puede utilizar el mismo conjunto de implementaciones y configuraciones de servicios para crear el diseño de un sistema con dos nodos separados. Sólo necesitamos crear dos configuraciones de nodos separados con los servicios adecuados:

Configuración de dos 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"
  }

Vea cómo especificamos la dependencia. Mencionamos el servicio proporcionado por el otro nodo como una dependencia del nodo actual. El tipo de dependencia se verifica porque contiene un tipo fantasma que describe el protocolo. Y en tiempo de ejecución tendremos la identificación de nodo correcta. Este es uno de los aspectos importantes del enfoque de configuración propuesto. Nos brinda la posibilidad de configurar el puerto solo una vez y asegurarnos de que estamos haciendo referencia al puerto correcto.

Implementación de dos nodos

Para esta configuración utilizamos exactamente las mismas implementaciones de servicios. No hay cambios en absoluto. Sin embargo, creamos dos implementaciones de nodos diferentes que contienen diferentes conjuntos de servicios:

  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 nodo implementa el servidor y solo necesita configuración del lado del servidor. El segundo nodo implementa el cliente y necesita otra parte de la configuración. Ambos nodos requieren alguna especificación de por vida. Para los propósitos de esta publicación, el nodo de servicio tendrá una vida útil infinita que podría terminar usando SIGTERM, mientras que el cliente de eco finalizará después de la duración finita configurada. Ver el aplicación inicial para obtener más detalles.

Proceso de desarrollo general

Veamos cómo este enfoque cambia la forma en que trabajamos con la configuración.

La configuración como código se compilará y producirá un artefacto. Parece razonable separar los artefactos de configuración de otros artefactos de código. A menudo podemos tener multitud de configuraciones en la misma base de código. Y por supuesto, podemos tener múltiples versiones de varias ramas de configuración. En una configuración podemos seleccionar versiones particulares de bibliotecas y esto permanecerá constante siempre que implementemos esta configuración.

Un cambio de configuración se convierte en un cambio de código. Por lo que debería estar cubierto por el mismo proceso de garantía de calidad:

Ticket -> PR -> revisión -> fusión -> integración continua -> implementación continua

Existen las siguientes consecuencias del enfoque:

  1. La configuración es coherente para la instancia de un sistema en particular. Parece que no hay forma de tener una conexión incorrecta entre nodos.
  2. No es fácil cambiar la configuración en un solo nodo. No parece razonable iniciar sesión y cambiar algunos archivos de texto. Por lo tanto, la desviación de la configuración se vuelve menos posible.
  3. Los pequeños cambios de configuración no son fáciles de realizar.
  4. La mayoría de los cambios de configuración seguirán el mismo proceso de desarrollo y pasarán alguna revisión.

¿Necesitamos un repositorio separado para la configuración de producción? La configuración de producción puede contener información confidencial que nos gustaría mantener fuera del alcance de muchas personas. Por lo tanto, podría valer la pena mantener un repositorio separado con acceso restringido que contendrá la configuración de producción. Podemos dividir la configuración en dos partes: una que contiene los parámetros de producción más abiertos y otra que contiene la parte secreta de la configuración. Esto permitiría a la mayoría de los desarrolladores acceder a la gran mayoría de parámetros y al mismo tiempo restringiría el acceso a cosas realmente sensibles. Es fácil lograr esto utilizando rasgos intermedios con valores de parámetros predeterminados.

Variaciones

Veamos los pros y los contras del enfoque propuesto en comparación con otras técnicas de gestión de configuración.

En primer lugar, enumeraremos algunas alternativas a los diferentes aspectos de la forma propuesta de abordar la configuración:

  1. Archivo de texto en la máquina de destino.
  2. Almacenamiento centralizado de valores clave (como etcd/zookeeper).
  3. Componentes del subproceso que podrían reconfigurarse/reiniciarse sin reiniciar el proceso.
  4. Configuración fuera del control de artefactos y versiones.

El archivo de texto ofrece cierta flexibilidad en términos de correcciones ad hoc. El administrador de un sistema puede iniciar sesión en el nodo de destino, realizar un cambio y simplemente reiniciar el servicio. Puede que esto no sea tan bueno para sistemas más grandes. No quedan huellas del cambio. El cambio no es revisado por otro par de ojos. Puede resultar difícil descubrir qué ha provocado el cambio. No ha sido probado. Desde la perspectiva del sistema distribuido, un administrador puede simplemente olvidarse de actualizar la configuración en uno de los otros nodos.

(Por cierto, si eventualmente será necesario comenzar a usar archivos de configuración de texto, solo tendremos que agregar analizador + validador que podría producir el mismo Config escriba y eso sería suficiente para comenzar a usar configuraciones de texto. Esto también muestra que la complejidad de la configuración en tiempo de compilación es un poco menor que la complejidad de las configuraciones basadas en texto, porque en la versión basada en texto necesitamos código adicional).

El almacenamiento centralizado de valores clave es un buen mecanismo para distribuir los metaparámetros de la aplicación. Aquí debemos pensar en lo que consideramos valores de configuración y lo que son solo datos. Dada una función C => A => B normalmente llamamos valores que rara vez cambian C "configuración", mientras que los datos cambian con frecuencia A - solo ingrese datos. La configuración debe proporcionarse a la función antes que los datos. A. Dada esta idea, podemos decir que es la frecuencia esperada de cambios lo que podría usarse para distinguir los datos de configuración de solo los datos. Además, los datos normalmente provienen de una fuente (usuario) y la configuración proviene de una fuente diferente (administrador). Tratar con parámetros que se pueden cambiar después del proceso de inicialización conduce a un aumento de la complejidad de la aplicación. Para tales parámetros tendremos que manejar su mecanismo de entrega, análisis y validación, manejando valores incorrectos. Por lo tanto, para reducir la complejidad del programa, será mejor que reduzcamos la cantidad de parámetros que pueden cambiar en tiempo de ejecución (o incluso que los eliminemos por completo).

Desde la perspectiva de esta publicación deberíamos hacer una distinción entre parámetros estáticos y dinámicos. Si la lógica del servicio requiere cambios poco frecuentes de algunos parámetros en tiempo de ejecución, entonces podemos llamarlos parámetros dinámicos. De lo contrario, son estáticos y podrían configurarse utilizando el enfoque propuesto. Para la reconfiguración dinámica podrían ser necesarios otros enfoques. Por ejemplo, partes del sistema podrían reiniciarse con los nuevos parámetros de configuración de manera similar a reiniciar procesos separados de un sistema distribuido.
(Mi humilde opinión es evitar la reconfiguración del tiempo de ejecución porque aumenta la complejidad del sistema.
Podría ser más sencillo confiar simplemente en el soporte del sistema operativo para reiniciar procesos. Aunque puede que no siempre sea posible).

Un aspecto importante del uso de la configuración estática que a veces hace que la gente considere la configuración dinámica (sin otras razones) es el tiempo de inactividad del servicio durante la actualización de la configuración. Efectivamente, si tenemos que realizar cambios en la configuración estática, tendremos que reiniciar el sistema para que los nuevos valores se hagan efectivos. Los requisitos de tiempo de inactividad varían según los diferentes sistemas, por lo que puede que no sea tan crítico. Si es crítico, entonces debemos planificar con anticipación cualquier reinicio del sistema. Por ejemplo, podríamos implementar Drenaje de conexión AWS ELB. En este escenario, cada vez que necesitamos reiniciar el sistema, iniciamos una nueva instancia del sistema en paralelo, luego cambiamos ELB a ella, mientras dejamos que el sistema anterior complete el mantenimiento de las conexiones existentes.

¿Qué pasa con mantener la configuración dentro o fuera del artefacto versionado? Mantener la configuración dentro de un artefacto significa en la mayoría de los casos que esta configuración ha pasado el mismo proceso de control de calidad que otros artefactos. Así uno puede estar seguro de que la configuración es de buena calidad y confiable. Por el contrario, la configuración en un archivo separado significa que no hay rastros de quién y por qué realizó cambios en ese archivo. ¿Es esto importante? Creemos que para la mayoría de los sistemas de producción es mejor tener una configuración estable y de alta calidad.

La versión del artefacto permite saber cuándo fue creado, qué valores contiene, qué características están habilitadas/deshabilitadas, quién fue el responsable de realizar cada cambio en la configuración. Puede requerir algún esfuerzo mantener la configuración dentro de un artefacto y es una elección de diseño.

Pros contras

Aquí nos gustaría resaltar algunas ventajas y discutir algunas desventajas del enfoque propuesto.

Ventajas

Características de la configuración compilable de un sistema distribuido completo:

  1. Comprobación estática de la configuración. Esto proporciona un alto nivel de confianza en que la configuración es correcta dadas las restricciones de tipo.
  2. Rico lenguaje de configuración. Normalmente, otros enfoques de configuración se limitan a, como máximo, la sustitución de variables.
    Al utilizar Scala, se puede utilizar una amplia gama de funciones de lenguaje para mejorar la configuración. Por ejemplo, podemos usar rasgos para proporcionar valores predeterminados, objetos para establecer un alcance diferente, podemos referirnos a valSe define solo una vez en el alcance externo (DRY). Es posible utilizar secuencias literales o instancias de ciertas clases (Seq, Map, Etc).
  3. DSL. Scala tiene un soporte decente para escritores DSL. Se pueden utilizar estas funciones para establecer un lenguaje de configuración que sea más conveniente y fácil de usar para el usuario final, de modo que la configuración final sea al menos legible para los usuarios del dominio.
  4. Integridad y coherencia entre nodos. Uno de los beneficios de tener la configuración para todo el sistema distribuido en un solo lugar es que todos los valores se definen estrictamente una vez y luego se reutilizan en todos los lugares donde los necesitemos. Además, escriba declaraciones de puerto seguro para garantizar que en todas las configuraciones correctas posibles los nodos del sistema hablen el mismo idioma. Existen dependencias explícitas entre los nodos, lo que hace que sea difícil olvidarse de proporcionar algunos servicios.
  5. Alta calidad de cambios. El enfoque general de pasar los cambios de configuración a través del proceso de relaciones públicas normal establece altos estándares de calidad también en la configuración.
  6. Cambios de configuración simultáneos. Cada vez que realizamos algún cambio en la configuración, la implementación automática garantiza que todos los nodos se actualicen.
  7. Simplificación de aplicaciones. La aplicación no necesita analizar y validar la configuración ni manejar valores de configuración incorrectos. Esto simplifica la aplicación general. (Algo de aumento de complejidad está en la configuración misma, pero es una compensación consciente hacia la seguridad). Es bastante sencillo volver a la configuración normal: simplemente agregue las piezas que faltan. Es más fácil comenzar con la configuración compilada y posponer la implementación de piezas adicionales para momentos posteriores.
  8. Configuración versionada. Debido a que los cambios de configuración siguen el mismo proceso de desarrollo, como resultado obtenemos un artefacto con una versión única. Nos permite volver a cambiar la configuración si es necesario. Incluso podemos implementar una configuración que se usó hace un año y funcionará exactamente de la misma manera. La configuración estable mejora la previsibilidad y confiabilidad del sistema distribuido. La configuración se fija en el momento de la compilación y no se puede alterar fácilmente en un sistema de producción.
  9. Modularidad. El marco propuesto es modular y los módulos podrían combinarse de varias maneras para
    Admite diferentes configuraciones (configuraciones/diseños). En particular, es posible tener un diseño de un solo nodo a pequeña escala y una configuración de múltiples nodos a gran escala. Es razonable tener múltiples diseños de producción.
  10. Pruebas. Para fines de prueba, se podría implementar un servicio simulado y usarlo como una dependencia de forma segura. Se podrían mantener simultáneamente algunos diseños de prueba diferentes con varias partes reemplazadas por simulacros.
  11. Pruebas de integración. A veces, en sistemas distribuidos es difícil ejecutar pruebas de integración. Utilizando el enfoque descrito para escribir la configuración segura del sistema distribuido completo, podemos ejecutar todas las partes distribuidas en un único servidor de forma controlable. Es fácil emular la situación.
    cuando uno de los servicios deja de estar disponible.

Desventajas

El enfoque de configuración compilada es diferente de la configuración "normal" y es posible que no se adapte a todas las necesidades. Estas son algunas de las desventajas de la configuración compilada:

  1. Configuración estática. Puede que no sea adecuado para todas las aplicaciones. En algunos casos existe la necesidad de corregir rápidamente la configuración en producción, evitando todas las medidas de seguridad. Este enfoque lo hace más difícil. La compilación y la reimplementación son necesarias después de realizar cualquier cambio en la configuración. Ésta es a la vez la característica y la carga.
  2. Generación de configuración. Cuando alguna herramienta de automatización genera la configuración, este enfoque requiere una compilación posterior (que a su vez podría fallar). Es posible que sea necesario un esfuerzo adicional para integrar este paso adicional en el sistema de compilación.
  3. Instrumentos. Actualmente se utilizan muchas herramientas que se basan en configuraciones basadas en texto. Algunos
    no será aplicable cuando se compile la configuración.
  4. Es necesario un cambio de mentalidad. Los desarrolladores y DevOps están familiarizados con los archivos de configuración de texto. La idea de compilar la configuración puede parecerles extraña.
  5. Antes de introducir una configuración compilable, se requiere un proceso de desarrollo de software de alta calidad.

Existen algunas limitaciones del ejemplo implementado:

  1. Si proporcionamos una configuración adicional que no exige la implementación del nodo, el compilador no nos ayudará a detectar la implementación ausente. Esto podría abordarse mediante el uso HList o ADT (clases de casos) para la configuración de nodos en lugar de rasgos y Cake Pattern.
  2. Tenemos que proporcionar algún texto estándar en el archivo de configuración: (package, import, object declaraciones;
    override def's para parámetros que tienen valores predeterminados). Esto podría solucionarse parcialmente mediante un DSL.
  3. En esta publicación no cubrimos la reconfiguración dinámica de clústeres de nodos similares.

Conclusión

En esta publicación hemos discutido la idea de representar la configuración directamente en el código fuente de una manera segura. El enfoque podría usarse en muchas aplicaciones como reemplazo de xml y otras configuraciones basadas en texto. A pesar de que nuestro ejemplo ha sido implementado en Scala, también podría traducirse a otros lenguajes compilables (como Kotlin, C#, Swift, etc.). Se podría probar este enfoque en un nuevo proyecto y, en caso de que no encaje bien, cambiar al método antiguo.

Por supuesto, la configuración compilable requiere un proceso de desarrollo de alta calidad. A cambio, promete proporcionar una configuración robusta de igual alta calidad.

Este enfoque podría ampliarse de varias maneras:

  1. Se podrían usar macros para realizar la validación de la configuración y fallar en el momento de la compilación en caso de fallas en las restricciones de la lógica empresarial.
  2. Se podría implementar un DSL para representar la configuración de una manera fácil de usar para el usuario del dominio.
  3. Gestión dinámica de recursos con ajustes automáticos de configuración. Por ejemplo, cuando ajustamos la cantidad de nodos del clúster, es posible que deseemos (1) que los nodos obtengan una configuración ligeramente modificada; (2) administrador de clúster para recibir información de nuevos nodos.

Muchas Gracias

Me gustaría agradecer a Andrey Saksonov, Pavel Popov y Anton Nehaev por sus comentarios inspiradores sobre el borrador de esta publicación que me ayudaron a aclararla.

Fuente: habr.com