Configuração de sistema distribuído compilado

Gostaria de contar a vocês um mecanismo interessante para trabalhar com a configuração de um sistema distribuído. A configuração é representada diretamente em uma linguagem compilada (Scala) utilizando tipos seguros. Esta postagem fornece um exemplo de tal configuração e discute vários aspectos da implementação de uma configuração compilada no processo geral de desenvolvimento.

Configuração de sistema distribuído compilado

(inglês)

Introdução

Construir um sistema distribuído confiável significa que todos os nós usam a configuração correta, sincronizada com outros nós. As tecnologias DevOps (terraform, ansible ou algo parecido) geralmente são usadas para gerar automaticamente arquivos de configuração (geralmente específicos para cada nó). Gostaríamos também de ter certeza de que todos os nós de comunicação usam protocolos idênticos (incluindo a mesma versão). Caso contrário, a incompatibilidade será incorporada ao nosso sistema distribuído. No mundo JVM, uma consequência deste requisito é que a mesma versão da biblioteca que contém as mensagens do protocolo deve ser usada em todos os lugares.

Que tal testar um sistema distribuído? Obviamente, assumimos que todos os componentes possuem testes unitários antes de passarmos para os testes de integração. (Para extrapolarmos os resultados do teste para o tempo de execução, também devemos fornecer um conjunto idêntico de bibliotecas no estágio de teste e no tempo de execução.)

Ao trabalhar com testes de integração, geralmente é mais fácil usar o mesmo classpath em todos os lugares e em todos os nós. Tudo o que precisamos fazer é garantir que o mesmo caminho de classe seja usado em tempo de execução. (Embora seja inteiramente possível executar nós diferentes com caminhos de classe diferentes, isso adiciona complexidade à configuração geral e dificuldades com testes de implantação e integração.) Para os propósitos desta postagem, estamos assumindo que todos os nós usarão o mesmo caminho de classe.

A configuração evolui com a aplicação. Usamos versões para identificar diferentes estágios de evolução do programa. Parece lógico identificar também diferentes versões de configurações. E coloque a própria configuração no sistema de controle de versão. Se houver apenas uma configuração em produção, podemos simplesmente usar o número da versão. Se usarmos muitas instâncias de produção, precisaremos de várias
ramificações de configuração e um rótulo adicional além da versão (por exemplo, o nome da ramificação). Desta forma podemos identificar claramente a configuração exata. Cada identificador de configuração corresponde exclusivamente a uma combinação específica de nós distribuídos, portas, recursos externos e versões de biblioteca. Para efeitos deste post vamos assumir que existe apenas um ramo e podemos identificar a configuração da forma habitual através de três números separados por um ponto (1.2.3).

Em ambientes modernos, os arquivos de configuração raramente são criados manualmente. Mais frequentemente, eles são gerados durante a implantação e não são mais tocados (para que não quebre nada). Surge uma pergunta natural: por que ainda usamos formato de texto para armazenar configurações? Uma alternativa viável parece ser a capacidade de usar código regular para configuração e se beneficiar das verificações em tempo de compilação.

Neste post exploraremos a ideia de representar uma configuração dentro de um artefato compilado.

Configuração compilada

Esta seção fornece um exemplo de configuração compilada estática. Dois serviços simples são implementados – o serviço de eco e o cliente de serviço de eco. Com base nesses dois serviços, são montadas duas opções de sistema. Em uma opção, ambos os serviços estão localizados no mesmo nó, em outra opção - em nós diferentes.

Normalmente, um sistema distribuído contém vários nós. Você pode identificar nós usando valores de algum tipo NodeId:

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

ou

case class NodeId(hostName: String)

ou

object Singleton
type NodeId = Singleton.type

Os nós desempenham várias funções, executam serviços e conexões TCP/HTTP podem ser estabelecidas entre eles.

Para descrever uma conexão TCP precisamos de pelo menos um número de porta. Gostaríamos também de refletir o protocolo suportado nessa porta para garantir que tanto o cliente quanto o servidor estejam usando o mesmo protocolo. Descreveremos a conexão usando a seguinte classe:

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

onde Port - apenas um número inteiro Int indicando a faixa de valores aceitáveis:

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

Tipos refinados

Ver biblioteca refinado и meu reportar. Resumindo, a biblioteca permite adicionar restrições aos tipos que são verificados em tempo de compilação. Nesse caso, os valores válidos do número da porta são números inteiros de 16 bits. Para uma configuração compilada, o uso da biblioteca refinada não é obrigatório, mas melhora a capacidade do compilador de verificar a configuração.

Para protocolos HTTP (REST), além do número da porta, também podemos precisar do caminho para o serviço:

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 em tempo de compilação, usamos um parâmetro de tipo que não é usado dentro da classe. Essa decisão se deve ao fato de não utilizarmos uma instância de protocolo em tempo de execução, mas gostaríamos que o compilador verificasse a compatibilidade do protocolo. Ao especificar o protocolo, não poderemos passar um serviço inadequado como dependência.

Um dos protocolos comuns é a API REST com serialização Json:

sealed trait JsonHttpRestProtocol[RequestMessage, ResponseMessage]

onde RequestMessage - tipo de solicitação, ResponseMessage - tipo de resposta.
É claro que podemos usar outras descrições de protocolo que forneçam a precisão de descrição necessária.

Para os fins desta postagem, usaremos uma versão simplificada do protocolo:

sealed trait SimpleHttpGetRest[RequestMessage, ResponseMessage]

Aqui, a solicitação é uma string anexada ao URL e a resposta é a string retornada no corpo da resposta HTTP.

A configuração do serviço é descrita pelo nome do serviço, portas e dependências. Esses elementos podem ser representados em Scala de diversas maneiras (por exemplo, HList-s, tipos de dados algébricos). Para os fins deste post, usaremos o Cake Pattern e representaremos módulos usando trait'Ah. (O Cake Pattern não é um elemento obrigatório desta abordagem. É simplesmente uma implementação possível.)

As dependências entre serviços podem ser representadas como métodos que retornam portas EndPointde outros nós:

  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 criar um serviço de eco, tudo o que você precisa é de um número de porta e uma indicação de que a porta suporta o protocolo de eco. Podemos não especificar uma porta específica, porque... traits permitem declarar métodos sem implementação (métodos abstratos). Nesse caso, ao criar uma configuração concreta, o compilador exigiria que fornecêssemos uma implementação do método abstrato e um número de porta. Como implementamos o método, ao criar uma configuração específica, não podemos especificar uma porta diferente. O valor padrão será usado.

Na configuração do cliente declaramos uma dependência do serviço echo:

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

A dependência é do mesmo tipo do serviço exportado echoService. Em particular, no cliente echo exigimos o mesmo protocolo. Portanto, ao conectar dois serviços, podemos ter certeza de que tudo funcionará corretamente.

Implementação de serviços

É necessária uma função para iniciar e parar o serviço. (A capacidade de interromper um serviço é fundamental para o teste.) Novamente, existem diversas opções para implementar tal recurso (por exemplo, poderíamos usar classes de tipo baseadas no tipo de configuração). Para os fins deste post usaremos o Cake Pattern. Representaremos o serviço usando uma classe cats.Resource, porque Esta classe já fornece meios para garantir com segurança a liberação de recursos em caso de problemas. Para obter um recurso, precisamos fornecer uma configuração e um contexto de tempo de execução pronto. A função de inicialização do serviço pode ser assim:

  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 configuração para este serviço
  • AddressResolver — um objeto de tempo de execução que permite descobrir os endereços de outros nós (veja abaixo)

e outros tipos da biblioteca cats:

  • F[_] — tipo de efeito (no caso mais simples F[A] poderia ser apenas uma função () => A. Neste post usaremos cats.IO.)
  • Reader[A,B] - mais ou menos sinônimo de função A => B
  • cats.Resource - um recurso que pode ser obtido e liberado
  • Timer — temporizador (permite adormecer um pouco e medir intervalos de tempo)
  • ContextShift - analógico ExecutionContext
  • Applicative — uma classe de tipo de efeito que permite combinar efeitos individuais (quase uma mônada). Em aplicações mais complexas parece melhor usar Monad/ConcurrentEffect.

Usando esta assinatura de função podemos implementar vários serviços. Por exemplo, um serviço que não faz nada:

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

(Cm. código fonte, em que outros serviços são implementados - serviço de eco, cliente de eco
и controladores vitalícios.)

Um nó é um objeto que pode lançar diversos serviços (o lançamento de uma cadeia de recursos é garantido pelo Cake Pattern):

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

Observe que estamos especificando o tipo exato de configuração necessária para este nó. Se esquecermos de especificar um dos tipos de configuração exigidos por um determinado serviço, ocorrerá um erro de compilação. Além disso, não poderemos iniciar um nó a menos que forneçamos a algum objeto do tipo apropriado todos os dados necessários.

Resolução de nome de host

Para conectar-se a um host remoto, precisamos de um endereço IP real. É possível que o endereço seja conhecido posteriormente ao resto da configuração. Portanto, precisamos de uma função que mapeie o ID do nó para um endereço:

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

Existem várias maneiras de implementar esta função:

  1. Se os endereços forem conhecidos por nós antes da implantação, poderemos gerar código Scala com
    endereços e, em seguida, execute o build. Isso irá compilar e executar testes.
    Neste caso, a função será conhecida estaticamente e poderá ser representada em código como um mapeamento Map[NodeId, NodeAddress].
  2. Em alguns casos, o endereço real só é conhecido após o início do nó.
    Neste caso, podemos implementar um “serviço de descoberta” que roda antes de outros nós e todos os nós se registrarão neste serviço e solicitarão os endereços de outros nós.
  3. Se pudermos modificar /etc/hosts, então você pode usar nomes de host predefinidos (como my-project-main-node и echo-backend) e simplesmente vincule esses nomes
    com endereços IP durante a implantação.

Neste post não consideraremos esses casos com mais detalhes. Para nós
em um exemplo de brinquedo, todos os nós terão o mesmo endereço IP - 127.0.0.1.

A seguir, consideramos duas opções para um sistema distribuído:

  1. Colocando todos os serviços em um nó.
  2. E hospedar o serviço de eco e o cliente de eco em nós diferentes.

Configuração para um nó:

Configuração de nó único

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 objeto implementa a configuração do cliente e do servidor. Uma configuração de tempo de vida também é usada para que após o intervalo lifetime encerrar o programa. (Ctrl-C também funciona e libera todos os recursos corretamente.)

O mesmo conjunto de características de configuração e implementação pode ser usado para criar um sistema que consiste em dois nós separados:

Configuração de dois nós

  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! Observe como os serviços estão vinculados. Especificamos um serviço implementado por um nó como uma implementação do método de dependência de outro nó. O tipo de dependência é verificado pelo compilador, porque contém o tipo de protocolo. Quando executada, a dependência conterá o ID do nó de destino correto. Graças a este esquema, especificamos o número da porta exatamente uma vez e sempre garantimos que nos referimos à porta correta.

Implementação de dois nós do sistema

Para esta configuração, utilizamos as mesmas implementações de serviço sem alterações. A única diferença é que agora temos dois objetos que implementam diferentes conjuntos de serviços:

  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 nó implementa o servidor e precisa apenas da configuração do servidor. O segundo nó implementa o cliente e utiliza uma parte diferente da configuração. Além disso, ambos os nós precisam de gerenciamento vitalício. O nó do servidor é executado indefinidamente até ser interrompido SIGTERM'om, e o nó cliente termina após algum tempo. Cm. aplicativo iniciador.

Processo geral de desenvolvimento

Vamos ver como essa abordagem de configuração afeta o processo geral de desenvolvimento.

A configuração será compilada junto com o restante do código e um artefato (.jar) será gerado. Parece fazer sentido colocar a configuração em um artefato separado. Isso ocorre porque podemos ter múltiplas configurações baseadas no mesmo código. Novamente, é possível gerar artefatos correspondentes a diferentes ramificações de configuração. As dependências de versões específicas de bibliotecas são salvas junto com a configuração, e essas versões são salvas para sempre sempre que decidirmos implantar essa versão da configuração.

Qualquer alteração na configuração se transforma em uma alteração no código. E portanto, cada
a alteração será coberta pelo processo normal de garantia de qualidade:

Ticket no rastreador de bugs -> PR -> revisão -> mesclar com filiais relevantes ->
integração -> implantação

As principais consequências da implementação de uma configuração compilada são:

  1. A configuração será consistente em todos os nós do sistema distribuído. Devido ao fato de todos os nós receberem a mesma configuração de uma única fonte.

  2. É problemático alterar a configuração em apenas um dos nós. Portanto, “desvios de configuração” são improváveis.

  3. Torna-se mais difícil fazer pequenas alterações na configuração.

  4. A maioria das alterações de configuração ocorrerá como parte do processo geral de desenvolvimento e estará sujeita a revisão.

Preciso de um repositório separado para armazenar a configuração de produção? Esta configuração pode conter senhas e outras informações confidenciais às quais gostaríamos de restringir o acesso. Com base nisso, parece fazer sentido armazenar a configuração final em um repositório separado. Você pode dividir a configuração em duas partes: uma contendo definições de configuração acessíveis ao público e outra contendo configurações restritas. Isso permitirá que a maioria dos desenvolvedores tenha acesso a configurações comuns. Essa separação é fácil de conseguir usando características intermediárias contendo valores padrão.

Possíveis variações

Vamos tentar comparar a configuração compilada com algumas alternativas comuns:

  1. Arquivo de texto na máquina de destino.
  2. Armazenamento centralizado de valores-chave (etcd/zookeeper).
  3. Componentes do processo que podem ser reconfigurados/reiniciados sem reiniciar o processo.
  4. Armazenar configuração fora do artefato e controle de versão.

Os arquivos de texto fornecem flexibilidade significativa em termos de pequenas alterações. O administrador do sistema pode efetuar login no nó remoto, fazer alterações nos arquivos apropriados e reiniciar o serviço. Para sistemas grandes, contudo, tal flexibilidade pode não ser desejável. As alterações feitas não deixam rastros em outros sistemas. Ninguém analisa as mudanças. É difícil determinar quem exatamente fez as alterações e por que motivo. As alterações não são testadas. Se o sistema for distribuído, o administrador poderá esquecer de fazer a alteração correspondente em outros nós.

(Deve-se notar também que o uso de uma configuração compilada não fecha a possibilidade de uso de arquivos de texto no futuro. Será suficiente adicionar um analisador e um validador que produza o mesmo tipo de saída Config, e você pode usar arquivos de texto. Segue-se imediatamente que a complexidade de um sistema com uma configuração compilada é um pouco menor do que a complexidade de um sistema que usa arquivos de texto, porque arquivos de texto requerem código adicional.)

Um armazenamento centralizado de valores-chave é um bom mecanismo para distribuir metaparâmetros de um aplicativo distribuído. Precisamos decidir o que são parâmetros de configuração e o que são apenas dados. Vamos ter uma função C => A => Be os parâmetros C raramente muda e os dados A - muitas vezes. Neste caso podemos dizer que C - parâmetros de configuração, e A - dados. Parece que os parâmetros de configuração diferem dos dados porque geralmente mudam com menos frequência do que os dados. Além disso, os dados geralmente vêm de uma fonte (do usuário) e os parâmetros de configuração de outra (do administrador do sistema).

Se raramente os parâmetros alterados precisam ser atualizados sem reiniciar o programa, isso muitas vezes pode levar à complicação do programa, porque precisaremos de alguma forma entregar parâmetros, armazenar, analisar e verificar e processar valores incorretos. Portanto, do ponto de vista de reduzir a complexidade do programa, faz sentido reduzir o número de parâmetros que podem mudar durante a operação do programa (ou não suportar tais parâmetros).

Para os fins desta postagem, diferenciaremos entre parâmetros estáticos e dinâmicos. Se a lógica do serviço exigir a alteração de parâmetros durante a operação do programa, então chamaremos esses parâmetros de dinâmicos. Caso contrário, as opções serão estáticas e poderão ser configuradas usando a configuração compilada. Para a reconfiguração dinâmica, podemos precisar de um mecanismo para reiniciar partes do programa com novos parâmetros, semelhante à forma como os processos do sistema operacional são reiniciados. (Em nossa opinião, é aconselhável evitar a reconfiguração em tempo real, pois isso aumenta a complexidade do sistema. Se possível, é melhor usar os recursos padrão do sistema operacional para reiniciar processos.)

Um aspecto importante do uso da configuração estática que faz as pessoas considerarem a reconfiguração dinâmica é o tempo que leva para o sistema reiniciar após uma atualização de configuração (tempo de inatividade). Na verdade, se precisarmos fazer alterações na configuração estática, teremos que reiniciar o sistema para que os novos valores tenham efeito. O problema de tempo de inatividade varia em gravidade para diferentes sistemas. Em alguns casos, você pode agendar uma reinicialização para um momento em que a carga seja mínima. Se precisar fornecer serviço contínuo, você pode implementar Esgotamento da conexão AWS ELB. Ao mesmo tempo, quando precisamos reinicializar o sistema, lançamos uma instância paralela deste sistema, mudamos o balanceador para ele e esperamos que as conexões antigas sejam concluídas. Depois que todas as conexões antigas forem encerradas, encerramos a instância antiga do sistema.

Consideremos agora a questão de armazenar a configuração dentro ou fora do artefato. Se armazenarmos a configuração dentro de um artefato, pelo menos teremos a oportunidade de verificar a exatidão da configuração durante a montagem do artefato. Se a configuração estiver fora do artefato controlado, será difícil rastrear quem fez alterações neste arquivo e por quê. Quão importante é isso? Na nossa opinião, para muitos sistemas de produção é importante ter uma configuração estável e de alta qualidade.

A versão de um artefato permite determinar quando ele foi criado, quais valores ele contém, quais funções estão habilitadas/desabilitadas e quem é o responsável por qualquer alteração na configuração. É claro que armazenar a configuração dentro de um artefato requer algum esforço, então você precisa tomar uma decisão informada.

Prós e Contras

Gostaria de me deter nos prós e contras da tecnologia proposta.

Vantagens

Abaixo está uma lista dos principais recursos de uma configuração de sistema distribuído compilada:

  1. Verificação de configuração estática. Permite que você tenha certeza de que
    a configuração está correta.
  2. Linguagem de configuração rica. Normalmente, outros métodos de configuração são limitados, no máximo, à substituição de variáveis ​​de string. Ao usar Scala, uma ampla variedade de recursos de linguagem estão disponíveis para melhorar sua configuração. Por exemplo podemos usar
    traits para valores padrão, usando objetos para agrupar parâmetros, podemos nos referir a vals declarados apenas uma vez (DRY) no escopo envolvente. Você pode instanciar qualquer classe diretamente dentro da configuração (Seq, Map, classes personalizadas).
  3. DSL. Scala possui vários recursos de linguagem que facilitam a criação de uma DSL. É possível aproveitar esses recursos e implementar uma linguagem de configuração mais conveniente para o grupo-alvo de usuários, de forma que a configuração seja pelo menos legível por especialistas do domínio. Os especialistas podem, por exemplo, participar do processo de revisão da configuração.
  4. Integridade e sincronia entre nós. Uma das vantagens de ter a configuração de todo um sistema distribuído armazenada em um único ponto é que todos os valores são declarados exatamente uma vez e depois reutilizados onde forem necessários. O uso de tipos fantasmas para declarar portas garante que os nós estejam usando protocolos compatíveis em todas as configurações corretas do sistema. Ter dependências obrigatórias explícitas entre nós garante que todos os serviços estejam conectados.
  5. Mudanças de alta qualidade. Fazer alterações na configuração usando um processo de desenvolvimento comum também torna possível atingir altos padrões de qualidade para a configuração.
  6. Atualização simultânea de configuração. A implantação automática do sistema após alterações na configuração garante que todos os nós sejam atualizados.
  7. Simplificando a aplicação. O aplicativo não precisa de análise, verificação de configuração ou tratamento de valores incorretos. Isso reduz a complexidade do aplicativo. (Parte da complexidade da configuração observada em nosso exemplo não é um atributo da configuração compilada, mas apenas uma decisão consciente motivada pelo desejo de fornecer maior segurança de tipo.) É muito fácil retornar à configuração normal - basta implementar o que falta peças. Portanto, você pode, por exemplo, começar com uma configuração compilada, adiando a implementação de partes desnecessárias até o momento em que for realmente necessária.
  8. Configuração verificada. Como as alterações na configuração seguem o destino normal de quaisquer outras alterações, a saída que obtemos é um artefato com uma versão exclusiva. Isto permite-nos, por exemplo, regressar a uma versão anterior da configuração se necessário. Podemos até usar a configuração de um ano atrás e o sistema funcionará exatamente da mesma forma. Uma configuração estável melhora a previsibilidade e a confiabilidade de um sistema distribuído. Como a configuração é fixada na fase de compilação, é muito difícil falsificá-la na produção.
  9. Modularidade. A estrutura proposta é modular e os módulos podem ser combinados de diferentes maneiras para criar diferentes sistemas. Em particular, é possível configurar o sistema para ser executado em um único nó em uma modalidade e em vários nós em outra. Você pode criar diversas configurações para instâncias de produção do sistema.
  10. Testando. Ao substituir serviços individuais por objetos simulados, você pode obter várias versões do sistema que são convenientes para teste.
  11. Teste de integração. Ter uma configuração única para todo o sistema distribuído torna possível executar todos os componentes em um ambiente controlado como parte dos testes de integração. É fácil emular, por exemplo, uma situação em que alguns nós se tornam acessíveis.

Desvantagens e limitações

A configuração compilada difere de outras abordagens de configuração e pode não ser adequada para alguns aplicativos. Abaixo estão algumas desvantagens:

  1. Configuração estática. Às vezes é necessário corrigir rapidamente a configuração na produção, ignorando todos os mecanismos de proteção. Com esta abordagem pode ser mais difícil. No mínimo, a compilação e a implantação automática ainda serão necessárias. Esta é uma característica útil da abordagem e uma desvantagem em alguns casos.
  2. Geração de configuração. Caso o arquivo de configuração seja gerado por uma ferramenta automática, esforços adicionais podem ser necessários para integrar o script de construção.
  3. Ferramentas. Atualmente, utilitários e técnicas projetadas para trabalhar com configuração são baseadas em arquivos de texto. Nem todos esses utilitários/técnicas estarão disponíveis em uma configuração compilada.
  4. É necessária uma mudança de atitudes. Desenvolvedores e DevOps estão acostumados com arquivos de texto. A própria ideia de compilar uma configuração pode ser um tanto inesperada e incomum e causar rejeição.
  5. É necessário um processo de desenvolvimento de alta qualidade. Para utilizar confortavelmente a configuração compilada, é necessária a automação total do processo de construção e implantação da aplicação (CI/CD). Caso contrário, será bastante inconveniente.

Detenhamo-nos também em uma série de limitações do exemplo considerado que não estão relacionadas à ideia de uma configuração compilada:

  1. Se fornecermos informações de configuração desnecessárias que não são usadas pelo nó, o compilador não nos ajudará a detectar a implementação ausente. Este problema pode ser resolvido abandonando o Cake Pattern e usando tipos mais rígidos, por exemplo, HList ou tipos de dados algébricos (classes de caso) para representar a configuração.
  2. Existem linhas no arquivo de configuração que não estão relacionadas à configuração em si: (package, import,declarações de objetos; override def's para parâmetros que possuem valores padrão). Isto pode ser parcialmente evitado se você implementar sua própria DSL. Além disso, outros tipos de configuração (por exemplo, XML) também impõem certas restrições à estrutura do arquivo.
  3. Para os fins desta postagem, não estamos considerando a reconfiguração dinâmica de um cluster de nós semelhantes.

Conclusão

Neste post, exploramos a ideia de representar a configuração no código-fonte usando os recursos avançados do sistema do tipo Scala. Essa abordagem pode ser usada em vários aplicativos como um substituto para métodos de configuração tradicionais baseados em arquivos xml ou de texto. Mesmo que nosso exemplo seja implementado em Scala, as mesmas ideias podem ser transferidas para outras linguagens compiladas (como Kotlin, C#, Swift, ...). Você pode tentar essa abordagem em um dos projetos a seguir e, se não funcionar, passar para o arquivo de texto, adicionando as partes que faltam.

Naturalmente, uma configuração compilada requer um processo de desenvolvimento de alta qualidade. Em troca, é garantida alta qualidade e confiabilidade das configurações.

A abordagem considerada pode ser expandida:

  1. Você pode usar macros para realizar verificações em tempo de compilação.
  2. Você pode implementar uma DSL para apresentar a configuração de uma forma que seja acessível aos usuários finais.
  3. Você pode implementar o gerenciamento dinâmico de recursos com ajuste automático de configuração. Por exemplo, alterar o número de nós em um cluster requer que (1) cada nó receba uma configuração ligeiramente diferente; (2) o gerenciador do cluster recebeu informações sobre novos nós.

Agradecimentos

Gostaria de agradecer a Andrei Saksonov, Pavel Popov e Anton Nekhaev pelas críticas construtivas ao rascunho do artigo.

Fonte: habr.com

Adicionar um comentário