Construindo uma API RESTful com Java e Spring Framework — Parte 3

Nas primeiras duas partes dessa série, que podem ser lidas aqui e aqui, construímos uma API RESTful com Java e Spring Framework e apresentamos algumas boas práticas a serem aplicadas no projeto inicial. O código desse artigo está nesse repositório no Github:

Nessa terceira parte, continuaremos a discussão com foco em dois assuntos importantes: técnicas, regras e políticas para ganho de performance; e profiling da sua aplicação.

Performance é, certamente, um dos assuntos amplamente discutidos em arquitetura de software, pois influencia diretamente na utilização de recursos de servidores, e consequentemente, no seu nível de escalabilidade. Profiling é crucial para identificar possíveis falhas estruturais na sua aplicação e monitorar para garantir que tudo está funcionando perfeitamente.

Nos próximos tópicos, falaremos sobre: caching, compressão de dados, as políticas de throttling e rate-limiting, e profiling com Spring Boot Admin. O repositório que usaremos como admin é o abaixo.

Caching

A regra é: otimizar e poupar recursos!

Para entendermos qual é o papel do controle de cache em uma API, vamos imaginar que temos uma aplicação web que integra com a nossa API de exemplo, a Travels Java Api, onde um usuário poderá acessar sua conta na agência de turismo, para cadastrar, listar suas viagens e calcular estatísticas.

Imagine que a página principal dessa aplicação tenha um dashboard que carrega todas as viagens cadastradas nas últimas horas. Para carregar essa informação, é preciso fazer uma consulta no banco de dados. Se um usuário não cadastrar nenhuma viagem nas últimas horas, significa que os dados buscados no banco de dados não foram alterados, certo? Dessa forma, todas as vezes que acessamos página principal nesse período, estamos sempre fazendo a mesma consulta no banco de dados, que vai sempre retornar, se não houver nenhuma alteração, o mesmo resultado!

E se existisse uma forma de garantir a reutilização dos resultados da consulta sempre que tivermos esse comportamento?

O cache é uma estrutura que permite fazer essa reutilização ao armazenar dados que são acessados com mais frequência, mantendo cópias dessas consultas por um tempo determinado. O propósito é otimizar e poupar a utilização de recursos de um servidor, ou seja, aprimorar a performance de uma API. Com isso, reduziremos a latência da rede, a carga de processamento dos servidores e iremos otimizar o tempo de resposta da API ao cliente.

Existem duas categorias de cache: shared cache, que armazena dados para serem reutilizados por vários usuários; e o private cache, que armazena dados para um único usuário. Nesse artigo, a nossa implementação será mais simplificada e criaremos uma ideia de private cache do lado do servidor, pelo header da requisição.

Para criar estrutura de cache na nossa API, vamos utilizar o próprio starter do SpringBoot, o spring-boot-starter-cache, juntamente com o JCache, a API de cache padrão para Java, como provider, para complementar funções que o starter não possui. As dependências no pom.xml do projeto ficarão como o exemplo abaixo:

O próximo passo é criar a classe de configuração, incluindo a annotation @EnableCaching. Nessa classe, também criaremos uma bean para criar um cache manager customizado para a nossa API, baseado no Concurrent Map Cache. O ConcurrentMapCacheManager é uma implementação do CacheManager que cria, no modo lazy, instâncias ConcurrentMapCache para cada solicitação via getCache.

Em seguida, criaremos o arquivo ehcache.xml, com a configuração de cache das consultas da API. Esse arquivo ficará armazenado no diretório src/main/resources/cache e conterá a configuração: da consulta findByOrderNumber api-travels/v1/travels/byOrderNumber/{orderNumber} e da consulta findById api-travels/v1/travels/{id}. Em resumo, definiremos para cada uma das configurações: o tempo de expiração (de 1 minuto), qual deve ser a classe que gerenciará os logs relacionados e quais são os parâmetros do cache (key — tipo do parâmetro da consulta; value — objeto retornado da consulta).

Após a configuração do cache, precisamos definir no arquivo de propriedades a localização do arquivo ehcache.xml do projeto.

Atualizaremos também, os serviços findByOrderNumber e findById, com a annotation @Cachable, no TravelServiceImpl. Essa annotation é que vai ativar o comportamento de cache em ambos os métodos. Além disso, será necessário parametrizar o Cachable com o nome do cache em que os resultados serão armazenados: travelOrderNumberCache e travelIdCache, respectivamente.

Criaremos também uma classe de logs para que a cada evento de criação e expiração dos caches. Esses eventos serão disparados de forma assíncrona e não ordenada. Chamaremos essa classe de CacheEventLogger, que ficará no pacote util da API.

Com a API em execução, podemos ver esse controle funcionando: ao invocar a rota api-travels/v1/travels/1, o resultado é demonstrado no console e uma cópia desse resultado é criada como cache. Passado um minuto da primeira consulta, essa cópia fica com prazo expirado e uma nova cópia é criada. Utilizamos 1 minuto como exemplo de configuração, mas cabe ressaltar que é de escolha do desenvolvedor ou de sua equipe um prazo de expiração adequado para cada consulta.

Exemplo do controle de cache na rota api-travels/v1/travels/byOrderNumber

Compressão de dados

Empacotando os dados da API

Uma API RESTful permite responses em diversos formatos de dados, por exemplo, XML, JSON, HTML, JavaScript, CSS, etc. Imagine que o número de usuários da API aumente significamente. Proporcionalmente, a tendência é que a quantidade de dados transitados na API aumente e o tamanho das mensagens trafegadas também.

Existe uma configuração no Spring que torna possível comprimir a mensagem criada, fazendo com que o servidor trafegue menos dados, e, dessa forma, melhorando de performance da API. Para isso, acrescentar nos arquivos de propriedades do projeto as configurações abaixo:

  • server.compression.enabled: habilita ou desabilita a compressão.
  • server.compression.min-response-size: configura o número mínimo de bytes na resposta da requisição para que a compressão seja executada. O tamanho padrão é 2048 bytes.
  • server.compression.mime-types: habilita a compressão apenas se o tipo de conteúdo for um dos mime-types especificados.
Exemplo de configuração de compressão de dados application.properties

Assim, além de compactar os dados para retornar ao cliente de forma otimizada, no header Content-Encoding da resposta temos o valor gzip preenchido.

Exemplo do header Content-Encoding do response

Cabe ressaltar que, os mime-types a serem acrescentados na configuração vão depender as informações que possivelmente sua API pode trafegar. Coloquei alguns exemplos dos tipos mais comuns, mas fiquem à vontade para configurar com os tipos que façam mais sentido à implementação de vocês.

Throttling e Rate-limiting

Limitando para economizar!

O Throttling e Rate-limiting são políticas (ou estratégias) projetadas para limitar o acesso em APIs, com intuito de economia de recursos, proteção contra abusos e invasões. As duas políticas possuem ideias complementares, mas funções completamente diferentes.

Quando falamos de Rate-limiting, estamos falando da configuração do limite de requisições que podem ser aceitas pela API em uma determinada janela de tempo. Esse limite pode ser definido em segundos, minutos, horas, dias, etc. Por exemplo, a nossa API terá como limite 20 requisições a cada 20 minutos.

Já o Throttling é a configuração da fila de requisições excedidas para processamento em uma janela de tempo posterior, ou seja, é o tempo de espera do processamento da requisição após o cliente exceder os parâmetros de rate-limit. Por exemplo, a nossa API permitirá 2 retentativas de requisição com tempo de espera de 1 minuto para cada retentativa.

A política de rate-limiting que usaremos será baseada no usuário (validando uma api-key) e utilizaremos como referênia o projeto Bucket4j. O Bucket4j é uma biblioteca Java baseada no algoritmo token/leaky-bucket para aplicar a estratégia de limitar acessos à APIs. Para utilizá-la, as dependências abaixo devem ser adicionadas nas tags properties e dependencies no pom.xml do projeto:

Antes de entrarmos nos detalhes da implementação, vamos falar como o algoritmo deve funcionar no contexto de rate limiting da API do Bucket4j.

A premissa do algoritmo é a relação entre tokens e buckets. Imaginem que toda instância da API tem um bucket, cuja capacidade dele é definida como o número de tokens que ele pode conter. Então, sempre que alguém deseja consumir alguma rota da API, esse consumer precisa obter um token do bucket.

Se houver um token disponível no momento da solicitação, essa solicitação é aceita. Por outro lado, se não houverem tokens disponíveis, rejeitamos a solicitação e retornamos para esse consumer que a API teve mais requisições que sua capacidade permite (Too Many Requests429). Uma vez que as solicitações estão consumindo tokens, é preciso reabastecê-los à um rate fixo, de forma que a capacidade do bucket nunca se exceda.

Com esse detalhamento do algoritmo, para facilitar o controle da capacidade do bucket, vamos criar um Enumque representa planos de acesso para API: gratuito (com 20 tokens disponíveis), básico (40 tokens disponíveis) e profissional (100 tokens disponíveis). O nosso tempo de espera para a retentativa será de 20 minutos.

Em seguida, criaremos o RateLimitInterceptor, que será um interceptador de requisições com a implementação dessa lógica do controle dos tokens e o refill deles nos buckets.

Quando uma requisição para a API é interceptada, verificamos se existe uma apiKey nos headers. É o valor desse header que vai nos dizer qual é o plano de uso do cliente na API. Definido o plano de uso, o algoritmo atualiza o valor do header rate-limit-remaining, ou seja, quantos tokens faltam para o bucket esvaziar. Caso o bucket chegue no limite, a API vai retornar o status 429 e informar no header rate-limit-retry-after-seconds quanto tempo ainda falta para o bucket ser reabastecido.

Mas como eu atribuo um bucket e manipulo sua capacidade na API? Por meio de um Service que implementamos para isso: APIUsagePlansServiceImpl.

No arquivo de propriedades, adicionaremos também duas configurações do SpringMVC, que o interceptador que implementamos funcione corretamente: throw-exception-if-no-handler-found, para tratar exceções nas respostas e resources.add-mappings, para fazer com que as respostas do interceptador retornem em JSON.

Por fim, vamos implementar a classe que habilitará todo esse fluxo na API: o RateLimitingConfiguration. A ideia dessa classe é adicionar o RateLimit como uma Configuration, adicionando a funcionalidade de um interceptador das rotas api-travels/v1/travels e api-travels/v1/statistics.

Para validarmos o rate-limit na nossa API, acrescentaremos um teste na classe TravelsJavaApiIntegrationTest, para simular o comportamento do Too Many Requests: faremos 21 requests na rota findById, ou seja, uma request acima do limite permitido pelo plano de uso gratuito (plano que tem menor número de tokens disponíveis).

Ao executar essa classe de teste no JUnit, podemos ver que todos os testes passaram, ou seja, que o status do response na última request foi 429 Too many requests.

Exemplo da execução dos testes na TravelsJavaApiIntegrationTest

Profiling com Spring Boot

Você sabe o quanto de memória, threads e outras coisas mais que sua aplicação Spring consome?

Coletar de métricas de desempenho, verificar o estado da sua aplicação (se está online ou não), verificar consumo de memória, depurar threads são atividades essenciais para os desenvolvedores, não só em ambiente produtivo. Esses “hábitos” são cruciais para localizar gargalhos de desempenho que precisam ser corrigidos, inclusive aplicando práticas como as mencionadas acima. E esse processo analítico e de monitoramento da aplicação é conhecido como profiling.

Mas como usar profiling no nosso dia-a-dia? No Spring, temos uma aplicação que tem por objetivo nos apoiar nessa tarefa: o Actuator. O Actuator possui rotas de monitoramento da saúde da aplicação, mas para visualizar essas informações monitoradas por ele, precisamos de uma outra aplicação Spring: o Spring Boot Admin. Na figura abaixo, temos um exemplo de um Admin funcional.

Exemplo do Wallboard do Spring Boot Admin

Para que a sua aplicação seja monitorada pelo Spring Boot Admin, é necessário registrá-la como cliente do Admin (importando o projeto client no pom.xml do seu serviço ou API).

Além disso, precisamos mapear no application.properties os dados de acesso da aplicação que será servidor, por exemplo: a porta em que a aplicação Spring Admin irá ser executada, a porta da aplicação cliente, a url base da instância, quais endpoints do Actuator estarão expostos e o path para arquivo de log da aplicação.

Dessa forma, quando ambas as aplicações estiverem online, você poderá visualizar todas as informações relevantes em um dashboard completo, acessível clicando no hexágono na página do Wallboard. Esse dashboard deve parecer como o demonstrado abaixo:

Exemplo do dashboard no Spring Admin

Com isso, conseguirmos fechar o conjunto de melhorias que precisávamos implementar para deixar nossa API ainda mais performática e passível de monitoramento.

Caso queiram analisar o código final e também sugerir melhorias, é só dar um fork no projeto travels-java-api no Github, codar ou adicionar sugestões via issues. Ainda temos outros tópicos a serem discutidos que farão de nossa API um software ainda mais robusto, mas isso fica para outro(s) artigo(s).

Espero que tenham gostado do post. Abraços!

Senior Software Developer/Tech Lead, master in Computer Science/Software Engineering, Java, open source, and software quality enthusiast.

Senior Software Developer/Tech Lead, master in Computer Science/Software Engineering, Java, open source, and software quality enthusiast.