WEB3DEV

Cover image for Rearquitetando apps para escala
Lorenzo Battistela
Lorenzo Battistela

Posted on

Rearquitetando apps para escala

Como a Coinbase está usando Relay e GraphQL para permitir o super crescimento.

Este artigo foi traduzido por Lorenzo Battistela, e pode ser encontrado originalmente aqui, escrito por Chris Erickson e Terence Bezman.

Mais ou menos há 1 ano, a Coinbase completou a migração da nossa aplicação primária mobile para React Native. Durante a migração, nós percebemos que nossa abordagem para dados (Endpoint REST e uma library REST de fetching feita em casa) não iria acompanhar o supercrescimento que estávamos experienciando como empresa.

“Hipercrescimento” é um jargão utilizado demais, então vamos esclarecer o que isso significa nesse contexto. Nos 12 meses depois que migramos para o app React Native, o trânsito da nossa API cresceu 10 vezes e nós aumentamos o número de ativos aceitos em 5 vezes. No mesmo período de tempo, o número de contribuidores mensais nos nossos apps triplicou para quase 300. Com essas adições veio um grande aumento nas ferramentas e experimentos, e nós não vemos esse crescimento diminuindo nos tempos próximos (estamos buscando contratar mais 2000 pessoas para cargos de Produto, Engenharia e Design só neste ano).

Para gerenciar esse crescimento, nós decidimos migrar nossas aplicações para GraphQL e Relay. Essa troca nos permitiu resolver de forma holística alguns dos maiores desafios que estávamos enfrentando em relação à evolução da API, paginação aninhada e arquitetura da aplicação.

Evolução da API

O GraphQL foi inicialmente proposto como uma abordagem para ajudar com a evolução da API e agregação de requisições.

Anteriormente, para limitar requisições simultâneas, nós íamos criar vários endpoints para agregar os dados de uma visão específica (ex: o Dashboard). Entretanto, com a mudança das ferramentas, esses endpoints continuaram crescendo e campos que não estavam sendo utilizados não puderam ser removidos de maneira segura - já que era impossível saber se um cliente antigo ainda estava usando eles.

Em seu estado final, nós estávamos limitados por um sistema ineficiente, como ilustrado por algumas anedotas:

  1. Um endpoint do dashboard existente foi reaproveitado para uma nova tela inicial. Esse endpoint era responsável por 14% do nosso carregamento total do backend. Infelizmente, o novo dashboard estava utilizando esse endpoint para um único campo booleano.
  2. Nosso endpoint de usuário se tornou tão inchado que tinha uma resposta de quase 8MB - mas nenhum cliente precisava de todos esses dados.
  3. O app mobile tinha que fazer 25 chamadas paralelas à API, mas naquele tempo o React Native estava limitando a 4 chamadas paralelas, causando uma cachoeira incontrolável.

Cada um desses podia ser resolvido em isolamento utilizando variadas técnicas (melhor processamento, versionamento de API, etc), o que era muito difícil de implementar enquanto a empresa estava crescendo de maneira muito rápida.

Com sorte, isso era exatamente o motivo do GraphQL ter sido criado. Com GraphQL, o cliente pode fazer uma única requisição, buscando apenas os dados necessários para a visão que está mostrando. (Na verdade, com o Relay nós podemos exigir que eles façam a requisição apenas dos dados necessários - mais nisso depois). Isso nos leva a requisições mais rápidas, tráfego reduzido da rede, menor carregamento nos serviços backend e, de maneira geral, uma aplicação mais rápida.

Paginação aninhada

Quando a Coinbase suportava 5 ativos, a aplicação podia fazer algumas requisições: uma para pegar os ativos (5), e outra para pegar os endereços das carteiras (mais ou menos 10) para esses ativos, e costurar eles juntos no cliente. Entretanto, esse modelo não funciona muito bem quando os dados ficam grandes o suficiente para precisar de paginação. Você terá ou uma página inaceitavelmente grande (o que reduz a performance da API), ou você fica com APIs complicadas e pedidos de cachoeira.

Se você não está familiarizado, uma cachoeira, nesse contexto, acontece quando o cliente precisa primeiro pedir por uma página de ativos (me dê os primeiros 10 ativos suportados), e depois tem que pedir pelas carteiras daqueles ativos (me dê as carteiras para BTC, ETH, LTC, DOGE, SOL,...). Visto que a segunda requisição é dependente da primeira, ele cria uma cachoeira de requisições. Quando essas requisições dependentes são feitas pelo cliente, a latência dos dois combinada pode levar a uma performance terrível.

Esse é outro problema que o GraphQL resolve: ele permite que dados relacionados sejam aninhados na requisição, movendo a cachoeira para o servidor backend, que pode combinar essas requisições com uma latência muito menor.

Arquitetura de aplicação

Nós escolhemos o Relay como nossa library de cliente para o GraphQL, que entregou uma variedade de benefícios inesperados. A migração foi desafiadora na evolução do nosso código para seguir as práticas idiomáticas do Relay, que demorou mais do que esperávamos. Entretanto, os benefícios do Relay (colocação, desacoplação, eliminação de cachoeiras, performance e maleabilidade) tiveram um impacto muito mais positivo do que tínhamos previsto.

De maneira simples, o Relay é a única entre as librarys do GraphQL na maneira que permite que uma aplicação escale para mais contribuidores mas mantém a maleabilidade e performance.

Esses benefícios vindos do padrão do Relay utilizam fragmentos para colocar as dependências dos dados dentro dos componentes para renderizar os dados. Se um componente precisa dos dados, eles precisam ser passados por meio de um tipo especial de propriedades. Essas propriedades são opacas (o componente pai só sabe que precisa passar um fragmento {NomeComponenteCriança} sem saber o que ele contém), o que limita a acoplação entre componentes. Os fragmentos também garantem que o componente só leia os campos que ele pediu explicitamente, diminuindo a acoplação dos dados subjacentes. Isso aumenta a maleabilidade, segurança e performance. O compilador do Relay é apto a agregar fragmentos em uma única query, o que evita ambas cachoeiras de requisição e requisições dos mesmos dados múltiplas vezes.

Isso é bem abstrato, então considere um componente React simples que busca os dados de uma API REST e renderiza uma lista (similar ao que você construiria usando React Query, SWR ou até Apollo):

type Asset = {
 uuid: string;
 slug: string;
 symbol: string;
 name: string;
 color: string;
 imageUrl: string;

 price: string;
 priceData1hr: PriceData;
 priceData24hr: PriceData;
 priceDataMonth: PriceData;
 newsArticles: NewsArticle[];
};

export const AssetList: Component = () => {
 const data = useFetchAssets();

 return (
   <ul>
     {data.map((asset) => (
       <AssetListItem asset={asset} />
     ))}
   </ul>
 );
};

const AssetListItem: Component<{asset: Asset}> = ({ asset }) => {
 return (
   <li>
     <AssetHeader asset={asset} />
     <AssetPriceAndBalance asset={asset} />
   </li>
 );
};

const AssetHeader: Component<{asset: Asset}> = ({ asset }) => {
 return (
   <>
     <img src={asset.imageUrl} />
     {asset.name}
   </>
 );
};

const AssetPriceAndBalance: Component<{asset: Asset}> = ({ asset }) => {
 const wallet = useFetchWallet(asset.name);
 return (
   <>
     Price: {asset.price}
     <br />
     Balance: {wallet.balance}
   </>
 );
};
Enter fullscreen mode Exit fullscreen mode

Algumas observações:

  1. O componente AssetList vai causar uma requisição de rede, mas isso é opaco para o componente que o renderiza. Isso deixa quase impossível o pré carregamento desses dados usando análise estatística.
  2. Da mesma maneira, AssetPriceAndBalance causa outra chamada de rede, mas também vai causar uma cachoeira, porque a requisição não vai começar até que os componentes pais terminem de buscar seus dados e renderize a lista de itens, (o Time React discute isso quando discutem “fetch-on-render”)
  3. AssetList e AssetListItem estão muito acoplados - o AssetList precisa fornecer um objeto de ativo que contém todos os campos exigidos pela subárvore. Além disso, o AssetHeader exige um ativo inteiro ser passado, mas ele usaria apenas um campo.
  4. Qualquer hora os dados para um único ativo podem mudar, e a lista inteira seria renderizada novamente.

Enquanto esse é um exemplo trivial, podemos imaginar como algumas dúvidas de componentes como esse na tela podem interagir para criar um grande número de cachoeiras de busca de dados. Algumas abordagens tentam resolver isso movendo todas as chamadas de busca de dados para o topo da árvore de componentes (associando com uma rota). Entretanto, esse processo é manual e passível de erro, com as dependências de dados sendo duplicadas e com probabilidade de saírem de sincronia. Também não resolve problemas de acoplação e de performance.

O Relay resolve esse tipo de problema, por design.

Vamos ver o mesmo código escrito em Relay:

export const AssetList: Component<{ assetListRef: AssetListFragment$key }> = ({ assetListRef }) => {
 const assetList = useFragment(
   graphql`
     fragment AssetListFragment on AssetList {
       item {
         ...AssetListItemFragment
       }
     }
   `,
   assetListRef,
 );

 return (
   <ul>
     {assetList.map((node) => (
       <AssetListItem assetRef={node.item} />
     ))}
   </ul>
 );
};

const AssetListItem: Component<{ assetRef: AssetListItemFragment$key }> = ({ assetRef }) => {
 const asset = useFragment(
   graphql`
     fragment AssetListItemFragment on Asset {
       ...AssetHeaderFragment
       ...AssetPriceAndBalanceFragment
     }
   `,
   assetRef,
 );

 return (
   <li>
     <AssetHeader assetRef={asset} />
     <AssetPriceAndBalance assetRef={asset} />
   </li>
 );
};

const AssetHeader: Component<{ assetRef: AssetHeaderFragment$key }> = ({ assetRef }) => {
 const asset = useFragment(
   graphql`
     fragment AssetHeaderFragment on Asset {
       imageUrl
       name
     }
   `,
   assetRef,
 );

 return (
   <>
     <img src={asset.imageUrl} />
     {asset.name}
   </>
 );
};

const AssetPriceAndBalance: Component<{ assetRef: AssetPriceAndBalanceFragment$key }> = ({
 assetRef,
}) => {
 const asset = useFragment(
   graphql`
     fragment AssetPriceAndBalanceFragment on Asset {
       price
       wallet {
         balance
       }
     }
   `,
   assetRef,
 );

 return (
   <>
     Price: {asset.price}
     <br />
     Balance: {asset.wallet.balance}
   </>
 );
};
Enter fullscreen mode Exit fullscreen mode

Como nossas observações anteriores se saem?

  1. AssetList não tem mais dependências de dados escondidas: ele claramente expõe o fato de que exige os dados via propriedades.
  2. Porque o componente é transparente sobre a necessidade de dados, todas as exigências de dados para uma página podem ser agrupadas e requisitadas antes que a renderização comece. Isso elimina as cachoeiras sem que os engenheiros tenham que pensar nelas.
  3. Enquanto os dados exigidos são passados pela árvore por meio das props, o Relay permite que isso seja feito de maneira que não cria acoplamentos adicionais (porque os campos são acessíveis apenas para o componente filho). O AssetList sabe que ele precisa passar para o AssetListItem um AssetListItemFragmentRef, sem saber o que ele contém. (Compare esse carregamento de dados baseado em rotas, onde as requisições de dados são duplicadas nos componentes e na rota, e precisam ser mantidas sincronizadas).
  4. Isso faz nosso código mais maleável e fácil de evoluir - um item de lista pode ser mudado em isolamento sem tocar nenhuma outra parte da aplicação. Se precisar de um novo campo, ele adiciona-o para seu fragmento. Quando ele parar de precisar de um campo, ele é removido sem se preocupar com a possibilidade de quebra de outra parte da aplicação. Tudo isso é reforçado com checagem de tipo e regras de lint. Isso também resolve o problema da evolução da API mencionado no início do post: os clientes param de fazer requisições de dados que não são mais usados, e eventualmente os campos podem ser removidos do esquema.
  5. Como as dependências de dados estão declaradas localmente, React e Relay estão aptos a otimizar a renderização: se o preço de um ativo mudar, APENAS os componentes que mostram o preço vão precisar ser renderizados novamente.

Enquanto em uma aplicação trivial esses benefícios podem não ser uma grande coisa, é difícil exagerar seu impacto em uma base de código grande com milhares de contribuidores semanais. Talvez seu melhor seja capturado por essa frase de uma recente ReactConf em uma fala sobre Relay: o Relay permite você “pensar localmente, e otimizar globalmente”.

Para onde vamos daqui?

Migrar nossas aplicações para GraphQL e Relay é só o começo. Temos muito trabalho a fazer para continuar a implementação do GraphQL na Coinbase. Aqui estão algumas coisas no roadmap:

Entrega Incremental

A API GraphQL da Coinbase depende de muitos serviços upstream - alguns dos quais são mais lentos que outros. Por padrão, o GraphQL não vai mandar sua resposta até que todos os dados estejam prontos, o que significa que uma query será tão lenta quanto o serviço de upstream mais lento. Isso pode ser prejudicial para a performance da aplicação: um elemento da UI de baixa prioridade que tiver um backend lento pode degradar a performance da página inteira.

Para resolver isso, a comunidade GraphQL vem padronizando uma nova diretriz chamada @defer. Isso permite que seções de uma query sejam marcadas como baixa prioridade. O servidor GraphQL vai mandar o primeiro chunk assim que todos os dados necessários estejam prontos, e vai fazer o stream das partes marcadas quando estiverem disponíveis.

Queries em tempo real

As aplicações da coinbase costumam ter velocidade em dados que mudam (como preços de cripto e saldos). Tradicionalmente, nós utilizamos coisas como Pusher ou outras soluções proprietárias para manter os dados atualizados. Com o GraphQL, podemos usar Subscriptions para entregar atualizações em tempo real. De qualquer maneira, sentimos que o Subscriptions não é a ferramenta ideal para nossas necessidades, e planejamos explorar o uso de Queries em tempo real (mais sobre isso em outro post).

Cache de borda

A Coinbase está dedicada em aumentar a liberdade econômica global. Para esse fim, estamos trabalhando para fazer com que nossos produtos tenham uma boa performance não importando onde você mora, incluindo áreas com conexão de dados mais lenta. Para ajudar a tornar isso uma realidade, gostaríamos de construir e implantar uma camada de cache de borda segura, confiável e consistente para diminuir o tempo de viagem para todas as queries.

Colaboração com Relay

O time do Relay fez um trabalho maravilhoso e estamos incrivelmente gratos pelo trabalho extra que eles fizeram para deixar o mundo tirar vantagens de seus aprendizados na Meta. Posteriormente, gostaríamos de tornar essa relação unilateral em uma reação mútua. Começando no Q2, a Coinbase emprestará recursos para ajudar o trabalho no Relay OSS. Estamos muito empolgados com o progresso do Relay!

Top comments (0)