Histórico de transações: das escolhas arquiteturais ao processo de construção

Rosicléia Frasson
iFood Tech
Published in
9 min readMar 16, 2023

Aqui no iFood, trabalhamos para proporcionar uma experiência incrível para todo nosso ecossistema. Sempre estamos pensando e construindo funcionalidades para que o uso dos nossos serviços seja fluido, intuitivo, confiável e que forneça autonomia e transparência das operações efetuadas.

Eu faço parte da equipe de tecnologia que cuida das transações financeiras dos pedidos e nesse artigo vou mostrar as decisões de arquitetura e design em uma nova funcionalidade que colocamos no ar recentemente: o histórico de transações.

Fluxo Histórico de Transações

O histórico de transações faz parte da área global de pagamentos e nasceu com o intuito de dar uma visibilidade maior de todas as transações financeiras efetuadas pelo cliente. Ele funciona como uma espécie de extrato e contempla os pagamentos de pedidos, gorjetas, doações, compras de iFood card, reembolsos, entre outros. A estrutura para processar e suportar essas transações já existe há algum tempo e nesse artigo vou chamar de fluxo transacional. Você pode entender melhor como funciona o fluxo transacional de um pagamento no iFood aqui. A ideia desse artigo é explorar a outra face que foi construída recentemente: o fluxo de consulta/visualização.

Desde o início das discussões sobre a funcionalidade, tínhamos muito claro que o fluxo transacional e o fluxo de visualização deveriam ser dissociados e independentes um do outro. Isso nos permitiria escalabilidade ajustável a demanda de cada cenário e aumento de resiliência, pois problemas no fluxo transacional não afetariam o fluxo de visualização e vice-versa. Outro ponto importante, é que essa estrutura usada para prover o histórico de transação deveria ser pensada como um motor de busca flexível para atender outras funcionalidades além da já mencionada.

Para embasar um pouco a decisão, separar as responsabilidades de escrita e leitura em infraestruturas apartadas faz parte de um padrão arquitetural chamado CQRS (Segregação de Responsabilidade de Comando e Consulta) e foi nosso ponto de partida para o design da solução.

Segregar as responsabilidades arquiteturalmente aumenta a flexibilidade na escolha das tecnologias, sendo possível escolher soluções mais indicadas para cada caso de uso. Existe a possibilidade de ajustar durabilidade e disponibilidade das informações de acordo com a necessidade. Além disso, conseguimos escalar a quantidade de pessoas trabalhando nos projetos.

E tudo começa com a escolha da estrutura de armazenamento

Transacionamos milhares de pedidos por dia e precisávamos de uma estrutura de armazenamento que oferecesse capacidade de indexação e pesquisa extremamente performática para grandes quantidades de dados. Além disso, tínhamos em mente que essa estrutura de armazenamento precisaria estar preparada para servir como motor de busca de pagamentos para outras funcionalidades além do histórico de transações, sendo necessário prover um mecanismo de busca adaptável para serviços distintos.

O escolhido para essa missão foi o OpenSearch. O OpenSearch possui uma estrutura chamada índice invertido, que torna seu mecanismo de busca imbatível em termos de velocidade. Para explicar melhor, no momento da indexação, cada palavra é listada separadamente e junto a ela, existe a identificação de todos os lugares que a mesma pode ser encontrada. No momento da busca, o OpenSearch já sabe onde um termo pode ser encontrado, ele não precisa fazer uma busca geral em todos os documentos para verificar se o termo existe, isso torna o processo de consulta em grandes quantidades de dados muito mais veloz. E o mais importante, embora o mecanismo de busca seja complexo por baixo dos panos, o processo de consulta é extremamente facilitado com uma linguagem de consulta proprietária baseada em json, onde é possível montar uma infinidade de filtros customizáveis de forma simples.

Além disso, por ser amplamente utilizado em serviços de busca, possui uma comunidade grande e engajada, o que facilita a jornada de implementação. Vale ressaltar, que sua grande popularidade fez com que SDKs para manipulação de dados fossem construídos em diversas linguagens de programação. Ademais, é um serviço conhecido por ser performático, com alta disponibilidade e consegue escalar horizontalmente. Em outras palavras, para aumentar a resiliência e poder de processamento, mais nós (servidores) podem ser adicionados ao cluster.

A título de curiosidade, o OpenSearch é um fork do ElasticSearch de código aberto, mantido pela comunidade e que está disponível como um serviço de mesmo nome na AWS. Deixar o gerenciamento a cargo da AWS nos permite focar essencialmente na construção da estrutura de armazenamento e pesquisa. Dessa forma, a AWS fica responsável pela administração do cluster, que inclui atualizações, controle de integridade e replicação dos dados.

E como os dados chegam…

Escolhida a forma de armazenamento precisaríamos construir uma ponte que transportasse os dados do fluxo transacional até o OpenSearch. Lembrando que temos estruturas apartadas e que todas as transações financeiras precisam ser replicadas para a estrutura de consulta.

Em casos como esse, usamos preferencialmente um mecanismo de transmissão assíncrona, para que o impacto no fluxo transacional seja o menor possível. Não gostaríamos que o tempo de resposta de uma operação de pagamento pudesse aumentar em virtude de uma operação de indexação no OpenSearch ou até mesmo, deixar de efetuar um pagamento por um período de downtime na estrutura responsável pela consulta.

Usamos o princípio de uma arquitetura orientada a eventos que é composta por produtores e consumidores de eventos. Neste caso, o produtor do evento é a estrutura transacional do processamento de pagamento e o consumidor a estrutura de pesquisa. Isso significa que o produtor possui a responsabilidade de postar um evento a cada tentativa de transação de pagamento ou uma mudança de estado de uma transação, como nos casos de estorno e o consumidor lê esses eventos e armazena no OpenSearch para consultas futuras. Nesse caso, temos um baixo acoplamento entre o produtor e o consumidor, onde o produtor do evento não tem conhecimento se o consumidor conseguiu detectar e consumir os eventos produzidos. Precisamos apenas ter um contrato de modelo de dados bem definido entre as duas partes, o que garante que o consumidor conseguirá processar esse evento, mesmo que isso não ocorra ao mesmo tempo que a transação é efetuada.

Levando em consideração as características apresentadas, optamos pelo uso do Apache Kafka para esse fim. O Kafka possui uma alta taxa de transferência, escalabilidade e um sistema de armazenamento configurável. Além disso, os produtores e consumidores são totalmente dissociados um do outro, isso significa que os produtores não precisam esperar pelos consumidores. Um consumidor pode processar as mensagens em real-time sempre que possível, mas nos casos de indisponibilidade nos serviços consumidores, as mensagens estarão disponíveis para serem processadas quando estes estiverem estáveis novamente.

Um outro ponto importante é que existe a possibilidade de trabalhar com o Kafka em conjunto com o Schema Registry. Com o Schema Registry são definidos schemas e serializadores conectados a clientes Kafka, efetuam o processo de serialização usando esses schemas. Isso garante que as mensagens trafegadas obedecem a um contrato previamente estabelecido entre ambas as partes: produtor e consumidor.

Já temos os dados, como exibi-los

Construir interfaces de usuário é sempre um desafio à parte. Existe a complexidade de trabalhar com duas plataformas: Android e IOS e ter a necessidade de desenvolver para dois sistemas operacionais diferentes. Todas as alterações de interface precisam ser carregadas em ambas as lojas: App Store e Play Store e carregar uma nova versão em uma das lojas é um processo que leva alguns dias, pois existe um processo de validação de ambas as empresas antes do app ser disponibilizado para download. Tudo isso contribui para que novas funcionalidades ou correções de bugs demorem um certo tempo para serem visualizadas pelo usuário final. Além disso, após a disponibilização de uma nova versão, precisamos que o usuário atualize a versão do app para ter acesso às correções ou novas funcionalidades. A atualização de 100% dos clientes pode levar meses.

Precisávamos de algo que nos permitisse ciclos de desenvolvimento mais curtos sem infringir as políticas das lojas de aplicativos e que ao mesmo tempo nos desse a possibilidade de experimentação mais rápida. A abordagem padrão não nos atenderia.

Existe uma técnica chamada server driven UI, que já é usada em outras funcionalidades dentro do iFood, onde o servidor é responsável pela montagem das estruturas visuais que compõem a interface do usuário. Essa técnica separa a renderização em um contêiner genérico no aplicativo, como uma página em branco, enquanto a estrutura e os dados de exibição são montados pelo servidor. O aplicativo sabe que irá renderizar uma tela, mas não sabe a aparência dessa tela. Nessa abordagem, o servidor é responsável por carregar os dados e também por montar a interface do usuário.

Para montar a resposta do servidor usamos uma espécie de linguagem de marcação proprietária que o app entende.Em outras palavras, ao invés do aplicativo buscar apenas os dados referentes a uma lista de transações financeiras, o aplicativo busca a visualização da lista contendo os dados, as imagens, espaçamento, alinhamento, cor e tipografia. Enfim, tudo o que é necessário para montar a página.

Isso significa que as alterações que antes precisavam ser desenvolvidas em dois sistemas operacionais, carregadas em duas lojas de aplicativos, passar pelo processo de validação das duas lojas e aguardar a atualização do usuário foi reduzido a alterações e deploy do servidor.

Construindo a visualização

Muitas coisas já tinham sido discutidas até aqui, mas ainda precisávamos definir como seriam os serviços responsáveis por fazer essa estrutura funcionar. Optamos por uma arquitetura distribuída e a construção de quatro serviços com responsabilidades bem definidas em cada um deles:

  • Worker: Responsável por consumir as mensagens do kafka, efetuar alguns tratamentos nessas mensagens — conversão de dados, enriquecimento de informações — e salvar no OpenSearch.
  • Search: Responsável por efetuar as buscas no OpenSearch, montando filtros diferentes para os diversos padrões de pesquisa que possuímos.
  • Maitre: Conhece a estrutura da página. Dividimos a estrutura da página em cards e esse serviço é responsável por coordenar a montagem dos cards chamando o serviço responsável por essa montagem.
  • Cook: Responsável pela montagem dos cards. Este serviço efetua o carregamento dos dados e os formata de acordo com a visualização que deve ser exibida no app.

Um fato curioso é que os nomes dos serviços são analogias ao mundo real. Maitre e cook, por exemplo, remetem ao ambiente de um restaurante, onde o maitre coordena os pedidos e o cook é responsável por preparar a comida. O nome search é auto-explicativo já que se trata de um serviço de busca. Já o worker, remete a um trabalhador por ser um serviço que fica executando em segundo plano, lendo e tratando as mensagens recebidas e salvando na base de dados.

A seguir, um desenho ilustra de forma bem simplista como ficou a distribuição e a conexão entre os serviços mencionados anteriormente.

Fluxo de integração serviços

Para esclarecer melhor, search, maitre e cook estão conectados entre si via comunicação síncrona, e no momento que o usuário tenta acessar o histórico existe uma chamada ao maitre que é seguida de uma chamada ao cook, que por sua vez efetua uma chamada ao search.

Esses três serviços foram construídos em Golang. Antes de iniciarmos a construção, efetuamos alguns testes comportamentais e de carga. Para o cenário que estávamos construindo, o Go se mostrou mais aderente pelo tempo de warmup ser menor, se comparado a outras linguagens de programação. Isso garante que em picos de uso, conseguimos escalar em pouquíssimo tempo. Outro fator relevante é que o uso de recursos computacionais, como memória e CPU são extremamente otimizados para serviços construídos usando Golang.

Para o worker, optamos pelo uso do Kotlin. Já temos uma lib de mensageria desenvolvida internamente nessa linguagem e o esforço para construir o serviço seria infinitamente menor com o uso dessa lib. O uso da lib também garante padronização com os outros workers que possuímos, facilitando a manutenção do serviço.

Para finalizar

De forma bem sucinta tentei mostrar a linha de raciocínio que seguimos ao construir uma nova funcionalidade. Nem todos os detalhes foram abordados aqui, pois seriam necessárias inúmeras páginas para descrever tudo o que foi discutido e implementado até a liberação da funcionalidade para o usuário.

Por fim, gostaria de reforçar que estamos sempre antenados aos novos padrões e tecnologias que surgem no mercado e sempre buscamos utilizar o que tem mais aderência ao tipo de problema que estamos tentando resolver. Procuramos sempre prover a melhor experiência para o usuário e também para os times de desenvolvimento, construindo soluções flexíveis, escaláveis, resilientes e fáceis de manter.

--

--