Configuração compilável de um sistema distribuído

Neste post gostaríamos de compartilhar uma maneira interessante de lidar com a configuração de um sistema distribuído.
A configuração é representada diretamente na linguagem Scala de maneira segura. Um exemplo de implementação é descrito em detalhes. Vários aspectos da proposta são discutidos, incluindo a influência no processo geral de desenvolvimento.

Configuração compilável de um sistema distribuído

(на русском)

Introdução

A construção de sistemas distribuídos robustos requer o uso de configuração correta e coerente em todos os nós. Uma solução típica é usar uma descrição textual de implantação (terraform, ansible ou algo parecido) e arquivos de configuração gerados automaticamente (geralmente - dedicados para cada nó/função). Também gostaríamos de usar os mesmos protocolos das mesmas versões em cada nó de comunicação (caso contrário, teríamos problemas de incompatibilidade). No mundo JVM, isso significa que pelo menos a biblioteca de mensagens deve ter a mesma versão em todos os nós de comunicação.

Que tal testar o sistema? É claro que deveríamos ter testes unitários para todos os componentes antes de passarmos aos testes de integração. Para poder extrapolar os resultados dos testes em tempo de execução, devemos nos certificar de que as versões de todas as bibliotecas sejam mantidas idênticas nos ambientes de tempo de execução e de teste.

Ao executar testes de integração, geralmente é muito mais fácil ter o mesmo classpath em todos os nós. Precisamos apenas ter certeza de que o mesmo caminho de classe seja usado na implantação. (É possível usar caminhos de classe diferentes em nós diferentes, mas é mais difícil representar essa configuração e implantá-la corretamente.) Portanto, para manter as coisas simples, consideraremos apenas caminhos de classe idênticos em todos os nós.

A configuração tende a evoluir junto com o software. Geralmente usamos versões para identificar vários
estágios da evolução do software. Parece razoável cobrir a configuração no gerenciamento de versões e identificar configurações diferentes com alguns rótulos. Se houver apenas uma configuração em produção, poderemos usar uma versão única como identificador. Às vezes podemos ter vários ambientes de produção. E para cada ambiente poderemos precisar de um ramo de configuração separado. Portanto, as configurações podem ser rotuladas com ramificação e versão para identificar exclusivamente diferentes configurações. Cada rótulo e versão da ramificação corresponde a uma única combinação de nós distribuídos, portas, recursos externos e versões da biblioteca de caminho de classe em cada nó. Aqui cobriremos apenas a ramificação única e identificaremos as configurações por uma versão decimal de três componentes (1.2.3), da mesma forma que outros artefatos.

Em ambientes modernos, os arquivos de configuração não são mais modificados manualmente. Normalmente geramos
arquivos de configuração no momento da implantação e nunca toque neles após. Então alguém poderia perguntar por que ainda usamos formato de texto para arquivos de configuração? Uma opção viável é colocar a configuração dentro de uma unidade de compilação e se beneficiar da validação da configuração em tempo de compilação.

Neste post examinaremos a ideia de manter a configuração no artefato compilado.

Configuração compilável

Nesta seção discutiremos um exemplo de configuração estática. Dois serviços simples - serviço de eco e cliente do serviço de eco estão sendo configurados e implementados. Em seguida, dois sistemas distribuídos diferentes com ambos os serviços são instanciados. Um é para configuração de nó único e outro para configuração de dois nós.

Um sistema distribuído típico consiste em alguns nós. Os nós podem ser identificados usando algum tipo:

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

ou apenas

case class NodeId(hostName: String)

ou mesmo

object Singleton
type NodeId = Singleton.type

Esses nós desempenham diversas funções, executam alguns serviços e devem ser capazes de se comunicar com outros nós por meio de conexões TCP/HTTP.

Para conexão TCP é necessário pelo menos um número de porta. Também queremos ter certeza de que o cliente e o servidor estão falando no mesmo protocolo. Para modelar uma conexão entre nós vamos declarar a seguinte classe:

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

onde Port é apenas um Int dentro da faixa permitida:

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

Tipos refinados

See refinado biblioteca. Resumindo, permite adicionar restrições de tempo de compilação a outros tipos. Nesse caso Int só é permitido ter valores de 16 bits que possam representar o número da porta. Não há nenhum requisito para usar esta biblioteca para esta abordagem de configuração. Parece se encaixar muito bem.

Para HTTP (REST) ​​também podemos precisar de um caminho do serviço:

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 compilação, estamos usando o recurso Scala de declaração de argumento de tipo Protocol que não é usado na aula. É um chamado tipo fantasma. Em tempo de execução raramente precisamos de uma instância de identificador de protocolo, por isso não o armazenamos. Durante a compilação, esse tipo fantasma oferece segurança de tipo adicional. Não podemos passar porta com protocolo incorreto.

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

sealed trait JsonHttpRestProtocol[RequestMessage, ResponseMessage]

onde RequestMessage é o tipo base de mensagens que o cliente pode enviar ao servidor e ResponseMessage é a mensagem de resposta do servidor. É claro que podemos criar outras descrições de protocolo que especifiquem o protocolo de comunicação com a precisão desejada.

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

sealed trait SimpleHttpGetRest[RequestMessage, ResponseMessage]

Neste protocolo, a mensagem de solicitação é anexada ao url e a mensagem de resposta é retornada como uma string simples.

Uma configuração de serviço pode ser descrita pelo nome do serviço, uma coleção de portas e algumas dependências. Existem algumas maneiras possíveis de representar todos esses elementos em Scala (por exemplo, HList, tipos de dados algébricos). Para os fins deste post usaremos Cake Pattern e representaremos peças combináveis ​​(módulos) como traços. (Cake Pattern não é um requisito para esta abordagem de configuração compilável. É apenas uma implementação possível da ideia.)

As dependências podem ser representadas usando o Cake Pattern como pontos finais de 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)
  }

O serviço Echo só precisa de uma porta configurada. E declaramos que esta porta suporta protocolo echo. Observe que não precisamos especificar uma porta específica neste momento, porque as traits permitem declarações de métodos abstratos. Se usarmos métodos abstratos, o compilador exigirá uma implementação em uma instância de configuração. Aqui fornecemos a implementação (8081) e será usado como valor padrão se o ignorarmos em uma configuração concreta.

Podemos declarar uma dependência na configuração do cliente do serviço de eco:

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

A dependência tem o mesmo tipo que a echoService. Em particular, exige o mesmo protocolo. Portanto, podemos ter certeza de que se conectarmos essas duas dependências elas funcionarão corretamente.

Implementação de serviços

Um serviço precisa de uma função para iniciar e encerrar normalmente. (A capacidade de encerrar um serviço é fundamental para o teste.) Novamente, existem algumas opções para especificar tal função para uma determinada configuração (por exemplo, poderíamos usar classes de tipo). Para este post usaremos Cake Pattern novamente. Podemos representar um serviço usando cats.Resource que já fornece bracketing e liberação de recursos. Para adquirir um recurso devemos fornecer uma configuração e algum contexto de tempo de execução. Portanto, a função de início do serviço pode ser semelhante a:

  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 exigida por este iniciador de serviço
  • AddressResolver — um objeto de tempo de execução que tem a capacidade de obter endereços reais de outros nós (continue lendo para obter detalhes).

os outros tipos vêm de cats:

  • F[_] — tipo de efeito (no caso mais simples F[A] poderia ser apenas () => A. Neste post usaremos cats.IO.)
  • Reader[A,B] - é mais ou menos sinônimo de função A => B
  • cats.Resource - tem maneiras de adquirir e liberar
  • Timer — permite dormir/medir o tempo
  • ContextShift - análogo de ExecutionContext
  • Applicative — wrapper de funções em vigor (quase uma mônada) (podemos eventualmente substituí-lo por outra coisa)

Usando esta interface podemos implementar alguns 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](()))
  }

(Veja Código fonte para outras implementações de serviços - serviço de eco,
cliente de eco e controladores vitalícios.)

Um nó é um objeto único que executa alguns serviços (o início de uma cadeia de recursos é habilitado 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 no nó especificamos o tipo exato de configuração necessária para este nó. O compilador não nos permite construir o objeto (Cake) com tipo insuficiente, porque cada característica de serviço declara uma restrição no Config tipo. Além disso, não poderemos iniciar o nó sem fornecer a configuração completa.

Resolução de endereço de nó

Para estabelecer uma conexão precisamos de um endereço de host real para cada nó. Pode ser conhecido posteriormente em outras partes da configuração. Portanto, precisamos de uma maneira de fornecer um mapeamento entre o ID do nó e seu endereço real. Este mapeamento é uma função:

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

Existem algumas maneiras possíveis de implementar tal função.

  1. Se soubermos os endereços reais antes da implantação, durante a instanciação dos hosts do nó, poderemos gerar o código Scala com os endereços reais e executar a construção posteriormente (que executa verificações em tempo de compilação e, em seguida, executa o conjunto de testes de integração). Neste caso, nossa função de mapeamento é conhecida estaticamente e pode ser simplificada para algo como Map[NodeId, NodeAddress].
  2. Às vezes, obtemos endereços reais apenas posteriormente, quando o nó é realmente iniciado, ou não temos endereços de nós que ainda não foram iniciados. Nesse caso, podemos ter um serviço de descoberta iniciado antes de todos os outros nós e cada nó pode anunciar seu endereço nesse serviço e assinar dependências.
  3. Se pudermos modificar /etc/hosts, podemos usar nomes de host predefinidos (como my-project-main-node e echo-backend) e apenas associe esse nome ao endereço IP no momento da implantação.

Neste post não abordamos esses casos com mais detalhes. Na verdade, em nosso exemplo de brinquedo, todos os nós terão o mesmo endereço IP – 127.0.0.1.

Nesta postagem, consideraremos dois layouts de sistema distribuído:

  1. Layout de nó único, onde todos os serviços são colocados em um único nó.
  2. Layout de dois nós, onde o serviço e o cliente estão em nós diferentes.

A configuração para um nó único layout é o seguinte:

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

Aqui criamos uma configuração única que estende a configuração do servidor e do cliente. Também configuramos um controlador de ciclo de vida que normalmente encerrará o cliente e o servidor após lifetime intervalos passam.

O mesmo conjunto de implementações e configurações de serviços pode ser usado para criar um layout de sistema com dois nós separados. Só precisamos criar duas configurações de nó separadas com os serviços apropriados:

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

Veja como especificamos a dependência. Mencionamos o serviço fornecido pelo outro nó como uma dependência do nó atual. O tipo de dependência é verificado porque contém o tipo fantasma que descreve o protocolo. E em tempo de execução teremos o ID do nó correto. Este é um dos aspectos importantes da abordagem de configuração proposta. Ele nos fornece a capacidade de definir a porta apenas uma vez e garantir que estamos referenciando a porta correta.

Implementação de dois nós

Para esta configuração usamos exatamente as mesmas implementações de serviços. Nenhuma mudança. No entanto, criamos duas implementações de nós diferentes que contêm conjuntos diferentes 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 só precisa de configuração do lado do servidor. O segundo nó implementa o cliente e precisa de outra parte da configuração. Ambos os nós requerem alguma especificação de vida útil. Para os propósitos deste nó de pós-serviço, o nó terá vida útil infinita que pode ser encerrada usando SIGTERM, enquanto o cliente echo será encerrado após a duração finita configurada. Veja o aplicativo inicial para obter detalhes.

Processo geral de desenvolvimento

Vamos ver como essa abordagem muda a forma como trabalhamos com configuração.

A configuração como código será compilada e produzirá um artefato. Parece razoável separar o artefato de configuração de outros artefatos de código. Muitas vezes podemos ter uma infinidade de configurações na mesma base de código. E, claro, podemos ter múltiplas versões de vários ramos de configuração. Em uma configuração podemos selecionar versões específicas de bibliotecas e isso permanecerá constante sempre que implantarmos esta configuração.

Uma alteração de configuração torna-se uma alteração de código. Portanto, deve ser coberto pelo mesmo processo de garantia de qualidade:

Ticket -> PR -> revisão -> mesclagem -> integração contínua -> implantação contínua

Existem as seguintes consequências da abordagem:

  1. A configuração é coerente para uma instância específica do sistema. Parece que não há como haver conexão incorreta entre nós.
  2. Não é fácil alterar a configuração apenas em um nó. Não parece razoável fazer login e alterar alguns arquivos de texto. Portanto, o desvio de configuração torna-se menos possível.
  3. Pequenas alterações de configuração não são fáceis de fazer.
  4. A maioria das alterações de configuração seguirá o mesmo processo de desenvolvimento e passará por algumas revisões.

Precisamos de um repositório separado para configuração de produção? A configuração de produção pode conter informações confidenciais que gostaríamos de manter fora do alcance de muitas pessoas. Portanto, pode valer a pena manter um repositório separado com acesso restrito que conterá a configuração de produção. Podemos dividir a configuração em duas partes - uma que contém os parâmetros de produção mais abertos e outra que contém a parte secreta da configuração. Isso permitiria o acesso da maioria dos desenvolvedores à grande maioria dos parâmetros, ao mesmo tempo que restringiria o acesso a coisas realmente confidenciais. É fácil fazer isso usando características intermediárias com valores de parâmetro padrão.

Variações

Vejamos os prós e os contras da abordagem proposta em comparação com outras técnicas de gerenciamento de configuração.

Em primeiro lugar, listaremos algumas alternativas para os diferentes aspectos da forma proposta de lidar com a configuração:

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

O arquivo de texto oferece alguma flexibilidade em termos de correções ad hoc. O administrador do sistema pode fazer login no nó de destino, fazer uma alteração e simplesmente reiniciar o serviço. Isso pode não ser tão bom para sistemas maiores. Nenhum vestígio é deixado para trás da mudança. A mudança não é revisada por outro par de olhos. Pode ser difícil descobrir o que causou a mudança. Não foi testado. Da perspectiva do sistema distribuído, um administrador pode simplesmente esquecer de atualizar a configuração em um dos outros nós.

(Aliás, se eventualmente houver necessidade de começar a usar arquivos de configuração de texto, só teremos que adicionar analisador + validador que possa produzir o mesmo Config digite e isso seria suficiente para começar a usar configurações de texto. Isso também mostra que a complexidade da configuração em tempo de compilação é um pouco menor que a complexidade das configurações baseadas em texto, porque na versão baseada em texto precisamos de algum código adicional.)

O armazenamento centralizado de valores-chave é um bom mecanismo para distribuir metaparâmetros de aplicativos. Aqui precisamos pensar no que consideramos valores de configuração e no que são apenas dados. Dada uma função C => A => B geralmente chamamos de valores que raramente mudam C "configuração", enquanto dados frequentemente alterados A - basta inserir dados. A configuração deve ser fornecida à função antes dos dados A. Dada esta ideia podemos dizer que é esperada uma frequência de mudanças que poderia ser usada para distinguir dados de configuração de apenas dados. Além disso, os dados normalmente vêm de uma fonte (usuário) e a configuração vem de uma fonte diferente (admin). Lidar com parâmetros que podem ser alterados após o processo de inicialização leva a um aumento na complexidade da aplicação. Para tais parâmetros teremos que lidar com seu mecanismo de entrega, análise e validação, manipulando valores incorretos. Portanto, para reduzir a complexidade do programa, seria melhor reduzir o número de parâmetros que podem ser alterados em tempo de execução (ou até mesmo eliminá-los completamente).

Da perspectiva deste post, devemos fazer uma distinção entre parâmetros estáticos e dinâmicos. Se a lógica de serviço requer alterações raras de alguns parâmetros em tempo de execução, então podemos chamá-los de parâmetros dinâmicos. Caso contrário, eles serão estáticos e poderão ser configurados usando a abordagem proposta. Para a reconfiguração dinâmica, outras abordagens podem ser necessárias. Por exemplo, partes do sistema podem ser reiniciadas com os novos parâmetros de configuração de maneira semelhante à reinicialização de processos separados de um sistema distribuído.
(Minha humilde opinião é evitar a reconfiguração do tempo de execução porque aumenta a complexidade do sistema.
Pode ser mais simples confiar apenas no suporte do sistema operacional para reiniciar processos. Porém, nem sempre é possível.)

Um aspecto importante do uso da configuração estática que às vezes faz as pessoas considerarem a configuração dinâmica (sem outros motivos) é o tempo de inatividade do serviço durante a atualização da configuração. Na verdade, se tivermos que fazer alterações na configuração estática, teremos que reiniciar o sistema para que os novos valores entrem em vigor. Os requisitos de tempo de inatividade variam de acordo com os diferentes sistemas, por isso pode não ser tão crítico. Se for crítico, teremos que planejar com antecedência qualquer reinicialização do sistema. Por exemplo, poderíamos implementar Esgotamento da conexão AWS ELB. Neste cenário, sempre que precisarmos reiniciar o sistema, iniciamos uma nova instância do sistema em paralelo e, em seguida, alternamos o ELB para ele, enquanto deixamos o sistema antigo concluir o serviço das conexões existentes.

Que tal manter a configuração dentro ou fora do artefato versionado? Manter a configuração dentro de um artefato significa, na maioria dos casos, que essa configuração passou pelo mesmo processo de garantia de qualidade que outros artefatos. Portanto, pode-se ter certeza de que a configuração é de boa qualidade e confiável. Pelo contrário, a configuração em um arquivo separado significa que não há vestígios de quem e por que fez alterações nesse arquivo. Isso é importante? Acreditamos que para a maioria dos sistemas de produção é melhor ter uma configuração estável e de alta qualidade.

A versão do artefato permite saber quando ele foi criado, quais valores ele contém, quais funcionalidades estão habilitadas/desabilitadas, quem foi o responsável por fazer cada alteração na configuração. Pode exigir algum esforço manter a configuração dentro de um artefato e é uma escolha de design a ser feita.

Prós e contras

Gostaríamos aqui de destacar algumas vantagens e discutir algumas desvantagens da abordagem proposta.

Vantagens

Recursos da configuração compilável de um sistema distribuído completo:

  1. Verificação estática da configuração. Isso proporciona um alto nível de confiança de que a configuração está correta, dadas as restrições de tipo.
  2. Linguagem rica de configuração. Normalmente, outras abordagens de configuração são limitadas, no máximo, à substituição de variáveis.
    Usando Scala é possível usar uma ampla gama de recursos de linguagem para melhorar a configuração. Por exemplo, podemos usar características para fornecer valores padrão, objetos para definir escopos diferentes, podemos nos referir a vals definido apenas uma vez no escopo externo (DRY). É possível usar sequências literais ou instâncias de certas classes (Seq, Map, Etc.)
  3. DSL. Scala tem suporte decente para gravadores DSL. Pode-se usar esses recursos para estabelecer uma linguagem de configuração que seja mais conveniente e amigável ao usuário final, de modo que a configuração final seja pelo menos legível pelos usuários do domínio.
  4. Integridade e coerência entre nós. Um dos benefícios de ter a configuração de todo o sistema distribuído em um só lugar é que todos os valores são definidos estritamente uma vez e depois reutilizados em todos os locais onde precisarmos deles. Digite também declarações de porta segura para garantir que em todas as configurações corretas possíveis os nós do sistema falarão o mesmo idioma. Existem dependências explícitas entre os nós, o que torna difícil esquecer de fornecer alguns serviços.
  5. Alta qualidade de mudanças. A abordagem geral de passar as alterações de configuração através do processo normal de PR estabelece altos padrões de qualidade também na configuração.
  6. Mudanças simultâneas de configuração. Sempre que fazemos alguma alteração na configuração, a implantação automática garante que todos os nós estejam sendo atualizados.
  7. Simplificação da aplicação. O aplicativo não precisa analisar e validar a configuração e manipular valores de configuração incorretos. Isso simplifica a aplicação geral. (Algum aumento de complexidade está na configuração em si, mas é uma troca consciente em relação à segurança.) É bastante simples retornar à configuração normal – basta adicionar as peças que faltam. É mais fácil começar com a configuração compilada e adiar a implementação de peças adicionais para momentos posteriores.
  8. Configuração versionada. Devido ao fato das alterações de configuração seguirem o mesmo processo de desenvolvimento, como resultado obtemos um artefato com versão única. Isso nos permite reverter a configuração, se necessário. Podemos até implantar uma configuração que foi usada há um ano e funcionará exatamente da mesma maneira. A configuração estável melhora a previsibilidade e a confiabilidade do sistema distribuído. A configuração é fixada em tempo de compilação e não pode ser facilmente alterada em um sistema de produção.
  9. Modularidade. A estrutura proposta é modular e os módulos podem ser combinados de várias maneiras para
    suporta diferentes configurações (setups/layouts). Em particular, é possível ter um layout de nó único em pequena escala e uma configuração de vários nós em grande escala. É razoável ter vários layouts de produção.
  10. Testando. Para fins de teste, pode-se implementar um serviço simulado e usá-lo como uma dependência de maneira segura. Alguns layouts de teste diferentes com várias peças substituídas por simulações poderiam ser mantidos simultaneamente.
  11. Teste de integração. Às vezes, em sistemas distribuídos, é difícil executar testes de integração. Usando a abordagem descrita para configurar com segurança o sistema distribuído completo, podemos executar todas as partes distribuídas em um único servidor de maneira controlável. É fácil imitar a situação
    quando um dos serviços fica indisponível.

Desvantagens

A abordagem de configuração compilada é diferente da configuração “normal” e pode não atender a todas as necessidades. Aqui estão algumas das desvantagens da configuração compilada:

  1. Configuração estática. Pode não ser adequado para todas as aplicações. Em alguns casos, existe a necessidade de corrigir rapidamente a configuração na produção, contornando todas as medidas de segurança. Essa abordagem torna tudo mais difícil. A compilação e a reimplantação são necessárias após qualquer alteração na configuração. Esta é a característica e o fardo.
  2. Geração de configuração. Quando a configuração é gerada por alguma ferramenta de automação, essa abordagem requer compilação subsequente (que, por sua vez, pode falhar). Pode ser necessário um esforço adicional para integrar esta etapa adicional ao sistema de compilação.
  3. Instrumentos. Existem muitas ferramentas em uso hoje que dependem de configurações baseadas em texto. Alguns deles
    não será aplicável quando a configuração for compilada.
  4. É necessária uma mudança de mentalidade. Desenvolvedores e DevOps estão familiarizados com arquivos de configuração de texto. A ideia de compilar a configuração pode parecer estranha para eles.
  5. Antes de introduzir a configuração compilável, é necessário um processo de desenvolvimento de software de alta qualidade.

Existem algumas limitações do exemplo implementado:

  1. Se fornecermos configuração extra que não é exigida pela implementação do nó, o compilador não nos ajudará a detectar a implementação ausente. Isso poderia ser resolvido usando HList ou ADTs (classes de caso) para configuração de nós em vez de características e Cake Pattern.
  2. Temos que fornecer alguns padrões no arquivo de configuração: (package, import, object declarações;
    override def's para parâmetros que possuem valores padrão). Isto pode ser parcialmente resolvido usando uma DSL.
  3. Neste post não abordamos a reconfiguração dinâmica de clusters de nós semelhantes.

Conclusão

Neste post discutimos a ideia de representar a configuração diretamente no código-fonte de uma forma segura de tipo. A abordagem pode ser usada em muitos aplicativos como um substituto para configurações baseadas em xml e outras configurações baseadas em texto. Apesar de nosso exemplo ter sido implementado em Scala, ele também pode ser traduzido para outras linguagens compiláveis ​​(como Kotlin, C#, Swift, etc.). Pode-se tentar essa abordagem em um novo projeto e, caso não se encaixe bem, mudar para o método antigo.

É claro que a configuração compilável requer um processo de desenvolvimento de alta qualidade. Em troca, promete fornecer configuração robusta de igualmente alta qualidade.

Esta abordagem pode ser estendida de várias maneiras:

  1. Pode-se usar macros para realizar a validação da configuração e falhar em tempo de compilação em caso de falha de qualquer restrição de lógica de negócios.
  2. Uma DSL poderia ser implementada para representar a configuração de uma forma amigável ao usuário do domínio.
  3. Gerenciamento dinâmico de recursos com ajustes automáticos de configuração. Por exemplo, quando ajustamos o número de nós do cluster, podemos querer que (1) os nós obtenham uma configuração ligeiramente modificada; (2) gerenciador de cluster para receber informações de novos nós.

obrigado

Gostaria de agradecer a Andrey Saksonov, Pavel Popov e Anton Nehaev por fornecerem comentários inspiradores sobre o rascunho deste post que me ajudaram a torná-lo mais claro.

Fonte: habr.com