Escolhendo uma alternativa de pilha de tecnologia - os altos e baixos

Publicados: 2022-03-11

Se um aplicativo da Web for grande e antigo o suficiente, pode chegar um momento em que você precise dividi-lo em partes menores e isoladas e extrair serviços dele, alguns dos quais serão mais independentes do que outros. Alguns dos motivos que podem levar a essa decisão incluem: reduzir o tempo de execução de testes, poder implantar diferentes partes do aplicativo de forma independente ou impor limites entre subsistemas. A extração de serviço exige que os engenheiros de software tomem muitas decisões vitais, e uma delas é qual pilha de tecnologia usar para o novo serviço.

Neste post, compartilhamos uma história sobre como extrair um novo serviço de uma aplicação monolítica – a Plataforma Toptal . Explicamos qual pilha técnica escolhemos e por quê, e descrevemos alguns problemas que encontramos durante a implementação do serviço.

O serviço Chronicles da Toptal é um aplicativo que trata de todas as ações do usuário realizadas na Plataforma Toptal. As ações são essencialmente entradas de log. Quando um usuário faz algo (por exemplo, publica uma postagem no blog, aprova um trabalho, etc.), uma nova entrada de log é criada.

Embora extraído de nossa plataforma, fundamentalmente não depende dela e pode ser usado com qualquer outro aplicativo. É por isso que estamos publicando um relato detalhado do processo e discutindo vários desafios que nossa equipe de engenharia teve que superar durante a transição para a nova pilha.

Há várias razões por trás de nossa decisão de extrair o serviço e melhorar a pilha:

  • Queríamos que outros serviços pudessem registrar eventos que pudessem ser exibidos e usados ​​em outros lugares.
  • O tamanho das tabelas de banco de dados que armazenam registros históricos cresceu de forma rápida e não linear, incorrendo em altos custos operacionais.
  • Consideramos que a implementação existente estava sobrecarregada por dívida técnica.

Tabela de ações - tabelas de banco de dados

À primeira vista, parecia uma iniciativa simples. No entanto, lidar com pilhas de tecnologia alternativas tende a criar desvantagens inesperadas, e é isso que o artigo de hoje pretende abordar.

Visão geral da arquitetura

O aplicativo Chronicles consiste em três partes que podem ser mais ou menos independentes e são executadas em contêineres separados do Docker.

  • O consumidor Kafka é um consumidor Kafka muito fino de mensagens de criação de entrada baseado em Karafka. Ele enfileira todas as mensagens recebidas para o Sidekiq.
  • O trabalhador Sidekiq é um trabalhador que processa mensagens Kafka e cria entradas na tabela do banco de dados.
  • Pontos de extremidade do GraphQL:
    • O endpoint público expõe a API de pesquisa de entrada, que é usada para várias funções da plataforma (por exemplo, para renderizar dicas de ferramentas de comentários em botões de triagem ou exibir o histórico de alterações de trabalho).
    • O endpoint interno fornece a capacidade de criar regras de tag e modelos de migrações de dados.

Chronicles costumava se conectar a dois bancos de dados diferentes:

  • Seu próprio banco de dados (onde armazenamos regras e modelos de tags)
  • O banco de dados da Plataforma (onde armazenamos as ações realizadas pelo usuário e suas tags e marcações)

No processo de extração do aplicativo, migramos dados do banco de dados da plataforma e encerramos a conexão da plataforma.

Plano inicial

Inicialmente, decidimos ir com o Hanami e todo o ecossistema que ele fornece por padrão (um modelo hanami suportado por ROM.rb, dry-rb, hanami-newrelic, etc). Seguir uma maneira “padrão” de fazer as coisas nos prometia baixo atrito, grande velocidade de implementação e muito boa “googleabilidade” de quaisquer problemas que possamos enfrentar. Além disso, o ecossistema hanami é maduro e popular, e a biblioteca é cuidadosamente mantida por membros respeitados da comunidade Ruby.

Além disso, uma grande parte do sistema já havia sido implementada no lado da plataforma (por exemplo, terminal GraphQL Entry Search e operação CreateEntry), então planejamos copiar muito do código da Platform para Chronicles como está, sem fazer nenhuma alteração. Essa também foi uma das principais razões pelas quais não escolhemos o Elixir, pois o Elixir não permitiria isso.

Decidimos não fazer Rails porque parecia um exagero para um projeto tão pequeno, especialmente coisas como ActiveSupport, que não forneceria muitos benefícios tangíveis para nossas necessidades.

Quando o plano vai para o sul

Embora tenhamos feito o nosso melhor para manter o plano, ele logo descarrilou por vários motivos. Uma foi a nossa falta de experiência com a pilha escolhida, seguida por problemas genuínos com a própria pilha, e depois houve nossa configuração não padrão (dois bancos de dados). No final, decidimos nos livrar do hanami-model e depois do próprio Hanami, substituindo-o por Sinatra.

Escolhemos o Sinatra porque é uma biblioteca com manutenção ativa criada há 12 anos e, como é uma das bibliotecas mais populares, todos na equipe tinham ampla experiência prática com ela.

Dependências incompatíveis

A extração de Chronicles começou em junho de 2019 e, naquela época, o Hanami não era compatível com as versões mais recentes das gemas dry-rb. Ou seja, a versão mais recente do Hanami na época (1.3.1) suportava apenas a validação seca 0.12, e queríamos a validação seca 1.0.0. Planejamos usar contratos de validação a seco que foram introduzidos apenas na versão 1.0.0.

Além disso, o Kafka 1.2 é incompatível com dry gems, então estávamos usando a versão do repositório dele. No momento, estamos usando 1.3.0.rc1, que depende das gemas secas mais recentes.

Dependências desnecessárias

Além disso, a gem Hanami incluía muitas dependências que não planejamos usar, como hanami-cli , hanami-assets , hanami-mailer , hanami-view e até hanami-controller . Além disso, olhando para o readme do modelo hanami, ficou claro que ele suporta apenas um banco de dados por padrão. Por outro lado, o ROM.rb, no qual o hanami-model é baseado, suporta configurações multi-banco de dados prontas para uso.

Em suma, o Hanami em geral e o hanami-model em particular pareciam um nível desnecessário de abstração.

Então, 10 dias depois de termos feito o primeiro PR significativo para Chronicles, substituímos completamente o hanami por Sinatra. Poderíamos ter usado o Rack puro também porque não precisamos de roteamento complexo (temos quatro endpoints “estáticos” - dois endpoints GraphQL, o endpoint /ping e a interface web sidekiq), mas decidimos não ir muito hardcore. Sinatra nos serviu muito bem. Se você quiser saber mais, confira nosso tutorial Sinatra and Sequel.

Equívocos de Esquema Seco e Validação Seca

Levamos algum tempo e muitas tentativas e erros para descobrir como “cozinhar” a validação a seco corretamente.

 params do required(:url).filled(:string) end params do required(:url).value(:string) end params do optional(:url).value(:string?) end params do optional(:url).filled(Types::String) end params do optional(:url).filled(Types::Coercible::String) end

No snippet acima, o parâmetro url é definido de várias maneiras ligeiramente diferentes. Algumas definições são equivalentes e outras não fazem sentido. No início, não podíamos realmente dizer a diferença entre todas essas definições, pois não as entendíamos completamente. Como resultado, a primeira versão de nossos contratos foi bastante confusa. Com o tempo, aprendemos a ler e escrever contratos DRY corretamente, e agora eles parecem consistentes e elegantes – na verdade, não apenas elegantes, eles são nada menos que bonitos. Até validamos a configuração do aplicativo com os contratos.

Problemas com ROM.rb e Sequel

ROM.rb e Sequel diferem do ActiveRecord, sem surpresa. Nossa ideia inicial de poder copiar e colar a maior parte do código da Plataforma falhou. O problema é que a parte da plataforma era muito pesada em AR, então quase tudo teve que ser reescrito em ROM/Sequel. Conseguimos copiar apenas pequenas porções de código que eram independentes do framework. Ao longo do caminho, enfrentamos alguns problemas frustrantes e alguns bugs.

Filtrando por Subconsulta

Por exemplo, levei várias horas para descobrir como fazer uma subconsulta em ROM.rb/Sequel. Isso é algo que eu escreveria sem nem acordar no Rails: scope.where(sequence_code: subquery ). Em Sequel, porém, não foi tão fácil.

 def apply_subquery_filter(base_query, params) subquery = as_subquery(build_subquery(params)) base_query.where { Sequel.lit('sequence_code IN ?', subquery) } end # This is a fixed version of https://github.com/rom-rb/rom-sql/blob/6fa344d7022b5cc9ad8e0d026448a32ca5b37f12/lib/rom/sql/relation/reading.rb#L998 # The original version has `unorder` on the subquery. # The fix was merged: https://github.com/rom-rb/rom-sql/pull/342. def as_subquery(relation) attr = relation.schema.to_a[0] subquery = relation.schema.project(attr).call(relation).dataset ROM::SQL::Attribute[attr.type].meta(sql_expr: subquery) end

Então, em vez de uma linha simples como base_query.where(sequence_code: bild_subquery(params)) , temos que ter uma dúzia de linhas com código não trivial, fragmentos SQL brutos e um comentário de várias linhas explicando o que causou esse infeliz caso de inchar.

Associações com campos de junção não triviais

A relação de entry (tabela performed_actions ) tem um campo de id primário. No entanto, para unir com tabelas *taggings , ele usa a coluna sequence_code . No ActiveRecord, é expresso de forma bastante simples:

 class PerformedAction < ApplicationRecord has_many :feed_taggings, class_name: 'PerformedActionFeedTagging', foreign_key: 'performed_action_sequence_code', primary_key: 'sequence_code', end class PerformedActionFeedTagging < ApplicationRecord db_belongs_to :performed_action, foreign_key: 'performed_action_sequence_code', primary_key: 'sequence_code' end

É possível escrever o mesmo em ROM também.

 module Chronicles::Persistence::Relations::Entries < ROM::Relation[:sql] struct_namespace Chronicles::Entities auto_struct true schema(:performed_actions, as: :entries) do attribute :id, ROM::Types::Integer attribute :sequence_code, ::Types::UUID primary_key :id associations do has_many :access_taggings, foreign_key: :performed_action_sequence_code, primary_key: :sequence_code end end end module Chronicles::Persistence::Relations::AccessTaggings < ROM::Relation[:sql] struct_namespace Chronicles::Entities auto_struct true schema(:performed_action_access_taggings, as: :access_taggings, infer: false) do attribute :performed_action_sequence_code, ::Types::UUID associations do belongs_to :entry, foreign_key: :performed_action_sequence_code, primary_key: :sequence_code, null: false end end end

Havia um pequeno problema com isso, no entanto. Ele compilaria muito bem, mas falharia em tempo de execução quando você realmente tentasse usá-lo.

 [4] pry(main)> Chronicles::Persistence.relations[:platform][:entries].join(:access_taggings).limit(1).to_a E, [2019-09-05T15:54:16.706292 #20153] ERROR -- : PG::UndefinedFunction: ERROR: operator does not exist: integer = uuid LINE 1: ...ion_access_taggings" ON ("performed_actions"."id" = "perform... ^ HINT: No operator matches the given name and argument types. You might need to add explicit type casts.: SELECT <..snip..> FROM "performed_actions" INNER JOIN "performed_action_access_taggings" ON ("performed_actions"."id" = "performed_action_access_taggings"."performed_action_sequence_code") ORDER BY "performed_actions"."id" LIMIT 1 Sequel::DatabaseError: PG::UndefinedFunction: ERROR: operator does not exist: integer = uuid LINE 1: ...ion_access_taggings" ON ("performed_actions"."id" = "perform...

Temos sorte que os tipos de id e sequence_code são diferentes, então o PG lança um erro de tipo. Se os tipos fossem os mesmos, quem sabe quantas horas eu gastaria depurando isso.

Então, entries.join(:access_taggings) não funciona. E se especificarmos a condição de junção explicitamente? Como em entries.join(:access_taggings, performed_action_sequence_code: :sequence_code) , como sugere a documentação oficial.

 [8] pry(main)> Chronicles::Persistence.relations[:platform][:entries].join(:access_taggings, performed_action_sequence_code: :sequence_code).limit(1).to_a E, [2019-09-05T16:02:16.952972 #20153] ERROR -- : PG::UndefinedTable: ERROR: relation "access_taggings" does not exist LINE 1: ...."updated_at" FROM "performed_actions" INNER JOIN "access_ta... ^: SELECT <snip> FROM "performed_actions" INNER JOIN "access_taggings" ON ("access_taggings"."performed_action_sequence_code" = "performed_actions"."sequence_code") ORDER BY "performed_actions"."id" LIMIT 1 Sequel::DatabaseError: PG::UndefinedTable: ERROR: relation "access_taggings" does not exist

Agora ele pensa que :access_taggings é um nome de tabela por algum motivo. Tudo bem, vamos trocá-lo pelo nome real da tabela.

 [10] pry(main)> data = Chronicles::Persistence.relations[:platform][:entries].join(:performed_action_access_taggings, performed_action_sequence_code: :sequence_code).limit(1).to_a => [#<Chronicles::Entities::Entry id=22 subject_g ... updated_at=2012-05-10 08:46:43 UTC>]

Finalmente, ele retornou algo e não falhou, embora tenha acabado com uma abstração com vazamento. O nome da tabela não deve vazar para o código do aplicativo.

Interpolação de parâmetros SQL

Há um recurso na pesquisa do Chronicles que permite aos usuários pesquisar por carga útil. A consulta fica assim: {operation: :EQ, path: ["flag", "gid"], value: "gid://plat/Flag/1"} , onde path é sempre uma matriz de strings e value é qualquer valor JSON válido.

No ActiveRecord, fica assim:

 @scope.where('payload -> :path #> :value::jsonb', path: path, value: value.to_json)

No Sequel, não consegui interpolar corretamente :path , então tive que recorrer a isso:

 base_query.where(Sequel.lit("payload #> '{#{path.join(',')}}' = ?::jsonb", value.to_json))

Felizmente, o path aqui está devidamente validado para que contenha apenas caracteres alfanuméricos, mas esse código ainda parece engraçado.

Magia silenciosa da fábrica de ROM

Usamos a gema rom-factory para simplificar a criação de nossos modelos em testes. Várias vezes, no entanto, o código não funcionou como esperado. Você consegue adivinhar o que há de errado com este teste?

 action1 = RomFactory[:action, app: 'plat', subject_type: 'Job', action: 'deleted'] action2 = RomFactory[:action, app: 'plat', subject_type: 'Job', action: 'updated'] expect(action1.id).not_to eq(action2.id)

Não, a expectativa não está falhando, a expectativa é boa.

O problema é que a segunda linha falha com um erro de validação de restrição exclusivo. A razão é que a action não é o atributo que o modelo Action possui. O nome real é action_name , então a maneira correta de criar ações deve ser assim:

 RomFactory[:action, app: 'plat', subject_type: 'Job', action_name: 'deleted']

Como o atributo digitado incorretamente foi ignorado, ele retorna ao padrão especificado na fábrica ( action_name { 'created' } ), e temos uma violação de restrição exclusiva porque estamos tentando criar duas ações idênticas. Tivemos que lidar com esse problema várias vezes, o que se provou desgastante.

Felizmente, foi corrigido em 0.9.0. O Dependabot nos enviou automaticamente uma solicitação pull com a atualização da biblioteca, que mesclamos após corrigir alguns atributos digitados incorretamente que tivemos em nossos testes.

Ergonomia Geral

Isso diz tudo:

 # ActiveRecord PerformedAction.count _# => 30232445_ # ROM EntryRepository.new.root.count _# => 30232445_

E a diferença é ainda maior em exemplos mais complicados.

As partes boas

Não era tudo dor, suor e lágrimas. Houve muitas, muitas coisas boas em nossa jornada, e elas superam em muito os aspectos negativos da nova pilha. Se não fosse esse o caso, não teríamos feito isso em primeiro lugar.

Teste de velocidade

Leva de 5 a 10 segundos para executar todo o conjunto de testes localmente e o mesmo tempo para o RuboCop. O tempo de CI é muito maior (3-4 minutos), mas isso é um problema menor porque podemos executar tudo localmente de qualquer maneira, graças ao qual, qualquer falha no CI é muito menos provável.

A gema de guarda tornou-se utilizável novamente. Imagine que você pode escrever código e executar testes em cada salvamento, fornecendo feedback muito rápido. Isso é muito difícil de imaginar quando se trabalha com a Plataforma.

Horários de implantação

O tempo para implantar o aplicativo Chronicles extraído é de apenas dois minutos. Não rápido como um relâmpago, mas ainda não é ruim. Implementamos com muita frequência, portanto, mesmo pequenas melhorias podem gerar economias substanciais.

Desempenho do aplicativo

A parte mais intensiva em desempenho de Chronicles é a pesquisa de entrada. Por enquanto, existem cerca de 20 lugares no back-end da plataforma que buscam entradas de histórico do Chronicles. Isso significa que o tempo de resposta do Chronicles contribui para o orçamento de 60 segundos da Plataforma para o tempo de resposta, então o Chronicles precisa ser rápido, o que é.

Apesar do enorme tamanho do log de ações (30 milhões de linhas e em crescimento), o tempo médio de resposta é inferior a 100ms. Dê uma olhada neste belo gráfico:

Gráfico de desempenho do aplicativo

Em média, 80-90% do tempo do aplicativo é gasto no banco de dados. É assim que um gráfico de desempenho adequado deve ser.

Ainda temos algumas consultas lentas que podem levar dezenas de segundos, mas já temos um plano de como eliminá-las, permitindo que o app extraído fique ainda mais rápido.

Estrutura

Para nossos propósitos, a validação a seco é uma ferramenta muito poderosa e flexível. Passamos todas as entradas do mundo externo por meio de contratos, e isso nos deixa confiantes de que os parâmetros de entrada são sempre bem formados e de tipos bem definidos.

Não há mais a necessidade de chamar .to_s.to_sym.to_i no código do aplicativo, pois todos os dados são limpos e tipificados nas bordas do aplicativo. De certa forma, traz fortes tipos de sanidade para o dinâmico mundo Ruby. Eu não posso recomendar o suficiente.

Palavras finais

Escolher uma pilha fora do padrão não foi tão simples quanto parecia inicialmente. Consideramos muitos aspectos ao selecionar a estrutura e as bibliotecas a serem usadas para o novo serviço: a pilha de tecnologia atual do aplicativo monolítico, a familiaridade da equipe com a nova pilha, a manutenção da pilha escolhida e assim por diante.

Embora tenhamos tentado tomar decisões muito cuidadosas e calculadas desde o início - optamos por usar a pilha padrão do Hanami - tivemos que reconsiderar nossa pilha ao longo do caminho devido a requisitos técnicos não padronizados do projeto. Acabamos com Sinatra e uma pilha baseada em DRY.

Escolheríamos o Hanami novamente se extraíssemos um novo aplicativo? Provavelmente sim. Agora sabemos mais sobre a biblioteca e seus prós e contras, para que possamos tomar decisões mais informadas desde o início de qualquer novo projeto. No entanto, também consideraríamos seriamente o uso de um aplicativo Sinatra/DRY.rb simples.

Em suma, o tempo investido no aprendizado de novas estruturas, paradigmas ou linguagens de programação nos dá uma nova perspectiva sobre nossa pilha de tecnologia atual. É sempre bom saber o que está disponível para enriquecer sua caixa de ferramentas. Cada ferramenta tem seu próprio caso de uso exclusivo, portanto, conhecê-las melhor significa ter mais delas à sua disposição e torná-las mais adequadas para sua aplicação.