Loafer é uma biblioteca open-source feita em python, para facilitar a criação de serviços, em especial aqueles que consomem ou geram eventos (mensagens).
O projeto nasceu como prova de conceito para Olist e hoje é o alicerce de dezenas de micro-serviços em produção, facilitando a manutenção e criação de novos serviços.
Para compreender um pouco melhor sobre nossa arquitetura, o cenário e onde os micro-serviços se encaixam, recomendo a leitura do artigo escrito pelo osantana sobre o funcionamento da plataforma da Olist.
O loafer também serviu para um grande aprendizado, principalmente sobre asyncio e programação assíncrona de modo geral — embora serviços também possam ser escritos com código síncrono, não recomendamos essa prática com o loafer (nesse caso, utiliza-se threads para execução do serviço).
Não é preciso ter conhecimentos profundos sobre asyncio, python ou da AWS para utilizar o loafer, mas também não atrapalha se tiver 😊
Todos os trechos de código estão no github.
Cenário
Uma visão simplificada da arquitetura que vamos precisar:
- Tópico SNS : notificador de eventos, com zero ou mais filas SQS inscritas
- Fila SQS : recebe as mensagens do tópico SNS, possui uma dead-letter-queue que retêm as mensagens que não puderam ser processadas na fila principal (maiores detalhes posteriormente)
- Um serviço que consome mensagens da fila SQS e realiza uma determinada ação (ou gera um novo evento/mensagem). O serviço é dono da fila, ela nunca é compartilhada.
Ambiente de desenvolvimento
Embora seja opcional, é sempre interessante criar um virtualenv
e considere
o uso de qualquer versão acima de Python 3.5.2 daqui em diante.
Pra refletir com mais fidelidade o funcionamento de uma aplicação, vamos utilizar um clone local da AWS, com os elementos arquiteturais descritos anteriormente usando goaws.
$ docker pull pafortin/goaws
$ docker run -d --name goaws -p 4100:4100 pafortin/goaws
# caso queira apenas parar ou iniciar o processo:
# docker start goaws
# docker stop goaws
# -> resets causam perda de dados/configurações
Para interagir e configurar o “nosso” AWS, vamos utilizar o aws-cli.
pip install awscli
Se for a primeira vez que tenha instalado o awscli, você pode precisar configurar suas credenciais, elas não precisam necessariamente ser válidas para conseguir manipular o goaws.
Vamos criar um tópico SNS “friend-created”:
$ aws --endpoint-url <http://localhost:4100> sns create-topic \
--name friend-created
{
"TopicArn": "arn:aws:sns:local:000000000000:friend-created"
}
E uma fila SQS chamada “foobar-friend-created”:
$ aws --endpoint-url <http://localhost:4100> sqs create-queue \
--queue-name foobar-friend-created
{
"QueueUrl": "<http://localhost:4100/queue/foobar-friend-created>"
}
Para simplificar um pouco, não vamos configurar uma dead-letter-queue. O mecanismo faz bastante sentido em um ambiente de produção, mas para testes locais, sempre podemos reenviar uma mensagem manualmente quando necessário.
Finalmente, vamos inscrever a fila “foobar-friend-created” no tópico “friend- created”:
$ aws --endpoint-url <http://localhost:4100> sns subscribe \
--topic-arn arn:aws:sns:local:000000000000:friend-created \
--protocol sqs --notification-endpoint <http://localhost:4100/queue/foobar-friend-created>
{
"SubscriptionArn": "arn:aws:sns:local:000000000000:friend-created:70b25381-80e4-4cce-bf44-65c505ec8d4b"
}
Montar esse ambiente não é exatamente um requisito obrigatório, mas creio que dê mais credibilidade e segurança durante o desenvolvimento de um serviço. Caso tenha disponibilidade, nada impede o uso de uma conta da AWS diretamente ou injetar manualmente mensagens no serviço sem utilizar qualquer infra- estrutura.
Para concluir nosso ambiente, vamos organizar nosso serviço dentro de um diretório “foobar_friend_service” (módulo) e instalar o loafer:
$ mkdir -p foobar_friend_service
$ touch foobar_friend_service/__init__.py
$ pip install loafer
🚀
Loafer
Os componentes principais do loafer são:
- handler : processa a mensagem
- message-translator : faz adequação da mensagem para que um handler faça o processamento
- provider : busca/recebe mensagens de uma determinada fonte (no caso, uma fila SQS)
- route : combinação de um handler , message-translator e provider
- manager : gerencia uma ou mais routes
Pode parecer que há muitos elementos para gerenciar, mas na prática é bem trivial: o “ handler ” é o código que será implementado, o restante é praticamente configuração e boilerplate.
Vamos exemplificar a implementação de um serviço simples, iremos processar uma mensagem (json) que possui as informações abaixo:
- id : identificador do recurso
- username : nome de um usuário
- github_url : url do usuário no github
{
"id": "test-1",
"username": "georgeyk",
"github_url": ""
}
Caso um usuário não tenha configurado o “github_url”, nosso serviço deve buscar essa informação e adicionar no registro. A implementação deve ficar parecida com (“foobar_friend_service/handlers.py”):
Tente ignorar todas as possíveis melhorias no código, o método importante da
classe é async def handle(self, message, *args)
. Todo handler precisa ter
uma assinatura semelhante; o *args
contêm alguns metadados e eles podem
variar bastante, inclusive vir vazio, então se houver alternativa, não confie
nas informações que vierem ali (mas podem ser úteis para auditoria).
A message
é um dicionário python comum e reflete o json
enviado ao evento.
Um handler obrigatoriamente precisa retornar um boolean ou algo que possa
ser avaliado com bool()
. O retorno é utilizado para confirmar o
processamento da mensagem:
True
: mensagem processada com sucesso, ela será confirmada e removida da
fila SQS
False
: mensagem não processada com sucesso, ela ficará na fila SQS ou
será movida para a dead-letter-queue
Qualquer desvio do fluxo será tratado do mesmo modo como se houvesse um
return False
, nunca removemos nada da fila SQS de forma implícita. Cuidado
com retornos dúbios para não remover uma mensagem acidentalmente.
Agora precisamos definir nossas rotas (“foobar_friend_service/routes.py”):
Perceba que nossa rota define uma instância de FoobarFriendCreatedHandler
como handler , alternativamente poderíamos passar uma função/ coroutine
também; usando classes convencionamos que o entrypoint é sempre o método
handle()
.
Outro detalhe sobre a configuração das rotas é que o SNSQueueRoute
automaticamente define um message-translator apropriado para mensagens que
fazem o caminho SNS ▶️ SQS. A mensagem vem “envelopada” de diferentes modos
dependendo de quem a colocou na fila.
O provider_options
só é necessário porque precisamos apontar para nosso
ambiente local, removendo essa customização, a biblioteca boto
automaticamente apontará para os endpoints corretos da amazon.
Por fim, criamos o ponto de partida para o nosso serviço (“foobar_friend_service/run.py”):
Se os deuses dos tutorais permitirem 🙏, podemos executar nosso serviço:
$ python -m foobar_friend_service.run
iniciando serviço ...
Ué ~ nada acontece feijoada ~ ?
Para o nosso serviço trabalhar de verdade, precisamos criar um evento no SNS:
$ aws --endpoint-url <http://localhost:4100> sns publish \
--topic-arn arn:aws:sns:local:000000000000:friend-created \
--message file://test_user.json
{
"MessageId": "e2d086a8-7cbc-473b-b50e-2923976db4d4"
}
Nosso serviço deve ter impresso o response de uma requisição bem sucedida:
{'args': {},
'data': '{"id": "test-1", "github_url": "<https://github.com/georgeyk>"}',
'files': {},
'form': {},
'headers': {'Accept': '*/*',
'Accept-Encoding': 'gzip, deflate',
'Connection': 'close',
'Content-Length': '61',
'Content-Type': 'application/json',
'Host': 'httpbin.org',
'User-Agent': 'Python/3.5 aiohttp/2.2.5'},
'json': {'github_url': 'https://github.com/georgeyk', 'id': 'test-1'},
'origin': '...',
'url': 'http://httpbin.org/patch'}
Para finalizar, use Control-C
ou mate o processo, o padrão é que o serviço
fique “ouvindo” a fila indefinidamente.
Uma dica final sobre a implementação, lembre sempre que é apenas código python. Parece óbvio, mas é para reforçar que tentamos não impor ou restringir o modo como um serviço é desenvolvido, o que temos aqui é apenas um exemplo, nada além disso.
Se você não fez o setup do goaws por exemplo, uma validação local seria parecida com:
Pronto! Terminamos nosso serviço 👊
Lembrando novamente que os códigos de exemplo estão no github , caso queira aprofundar um pouco mais, também temos uma documentação inicial.
Erros e monitoramento
Nosso serviço é bem simples e praticamente não possui regras de negócio sofisticadas, mas é bem próximo de um serviço real e em produção.
A ausência de tratamento de erros por exemplo, é intencional. No contexto do nosso serviço e arquitetura, é importante notar que a infra-estrutura e serviços de monitoramento são partes integrantes da aplicação.
Sempre que um erro ( exception) não tratado interromper o fluxo de execução do serviço, temos o seguinte comportamento:
- o handler não irá confirmar o processamento da mensagem (isto é, o
return True
não será alcançado fora do fluxo esperado) - toda fila SQS possui um “visibility timeout” (por padrão, 30 segundos) e após o tempo expirar, a mensagem voltará a ficar disponível ao serviço (ou seja, um mecanismo de retry )
- as retentivas irão ocorrer até a mensagem ser confirmada pelo handler ou então, após atingir um “maximum receives” a mensagem será encaminhada para uma dead-letter-queue
Vamos levantar algumas situações fora do fluxo esperado de execução e seu reflexo na confirmação da mensagem:
- indisponibilidade de serviços : caso alguma API pare de responder corretamente, a mensagem não deve ser confirmada (HTTP 5xx, timeout, etc)
- informações inválidas na mensagem : caso alguma “coisa” alimente a fila SQS com mensagens contendo valores inválidos, o serviço dispara uma requisição inválida e a mensagem não será confirmada (HTTP 4xx)
- chaves inválidas na mensagem: mensagem não confirmada (via
KeyError
por exemplo) - mais de uma instância do serviço processando a mesma mensagem : se uma das instâncias confirmar a mensagem, a mensagem sairá da fila SQS independente da confirmação das outras instâncias; do contrário, a mensagem ficará no ciclo de retentiva como descrito anteriormente
O último cenário levantado é o único que não é um erro, mas uma característica do SQS. Recomenda-se que todo serviço seja idempotente, mas existem situações em que essa característica pode ser desconsiderada (ou não implementada), pois não existe um efeito colateral grave.
A responsabilidade de implementar algum mecanismo de idempotência geralmente é delegada para o componente que possuir maior conhecimento das regras de negócio. Depende bastante do domínio e da arquitetura, usualmente o serviço não faz isso, apenas “trabalha junto” com as regras definidas externamente.
Nos demais cenários, caso um serviço de monitoramento esteja configurado (como
o sentry), ele também deve capturar esses “erros” de execução. Existem
alguns casos em que não precisamos desse report , uma vez que o
comportamento de retentiva é esperado (erros HTTP 5xx por exemplo). Para tal,
basta tratar essa situação e com um return False
no handler, indicamos que
não queremos confirmar a mensagem (deixo como exercício).
E as outras situações que o serviço de monitoramento capturar?
É bug (ou feature?)! 😄 🐛
Cenas do próximo capítulo…
A introdução ficou um pouco maior que o planejado, mas acredito que dê subsídio suficiente para quem quiser experimentar a criar os primeiros serviços utilizando o loafer e compreender os motivos que levaram a criação da biblioteca.
Como dito anteriormente, o loafer é um projeto de código aberto (contribuições são bem vindas 😃) e que ainda tem bastante para amadurecer. Acredito que seja um passo adiante para facilitar a criação de serviços em uma arquitetura orientada à eventos, embora ainda tenha restrições e vários pontos de melhoria.
Futuramente, vamos abordar casos de uso mais complexos, comparar com outras soluções e discutir vantagens e desvantagens da biblioteca. Maiores informações também podem ser obtidas na documentação do projeto.
Caso tenha se interessado ou tenha dúvidas e sugestões, não deixe de enviar um feedback.
✌️
Originalmente publicado no medium.