DroneMapp Engine: Separação de responsabilidades dos componentes

Banco, modelos, API, message broker e UI

Cléber Zavadniak
clebertech
8 min readAug 27, 2017

--

Algo que ajuda a nós, desenvolvedores, a não tornar nossos sistemas uma grande bagunça é dividir adequadamente as responsabilidades de cada componente dentro deles.

O diagrama a seguir representa, muito abreviada e incompletamente, as relações entre os vários componentes do Engine, a “V2” da plataforma de software da DroneMapp:

Muito brevemente, a interface do usuário, que pode estar rodando em um navegador, numa aplicação nativa ou o que for, comunica-se com o restante do sistema por meio de requisições à nossa API HTTP REST. Esta, por sua vez, costuma consultar dados dos modelos Django que então consultam o banco de dados.

Nossa plataforma é um tipo de “macro-serviços” e, por isso, toda alteração no banco de dados gera uma mensagem (que representa um evento) que é lida pelo nosso “distribuidor/tratador” de mensagens (message broker). Este último acaba ou gerando novas mensagens ou fazendo requisições HTTP à nossa API REST.

Entender isso é importante porque torna mais clara (e até um tanto óbvia) a divisão de responsabilidades que definimos para cada componente.

1- Tabelas do banco de dados

As tabelas entendem apenas seus relacionamentos entre si. No Ishikawa, por exemplo, que é onde a maior parte dos dados vive, há diversas “apps”, como “users”, “groups”, “regions”, “missions”, “flights” e “photos”. Cada uma tem uma ou mais tabelas que se relaciona com as outras — é por isso que chamo nossa arquitetura de “macro-serviços”: escolhemos não abrir mão do conforto e agilidade que um banco de dados relacional nos dá, ao mesmo tempo que ainda fazemos a lógica funcionar por meio de eventos e não “tudo dentro do código”.

Em resumo: as tabelas só sabem e aplicam as regras de negócio mais básicas, como “cada Vôo deve referenciar uma Missão” ou “uma Região precisa ter um nome único”.

2- Modelos

Os modelos são a descrição de como queremos que fiquem nossas tabelas, mas num nível um pouco mais alto. O ORM do Django nos oferece “de graça” as receitas básicas como “dada esta Missão, eis aqui todos os Vôos relacionados a ela”, sem que tenhamos que escrever consultas SQL para tal.

Como são eles quem descrevem as tabelas, eles conhecem — embora não necessariamente apliquem — as regras de relacionamentos entre os modelos. Mas não é só. Nós usamos o poder das powerlibs para adicionar algumas regras de negócio que fazem com que a arquitetura funcione. Por exemplo: usamos a powerlibs-django-contrib-notifiers para implementar a regra de que todo “CRUD” (criação, edição e exclusão) gerará uma mensagem (evento) a ser enviada ao nosso broker.

Também informamos algumas mudanças de status ou de alguns campos específicos de alguns modelos (campos status , por exemplo).

2.1- Toda a inteligência nos modelos?

Já cheguei a trabalhar em um local que seguia uma “escola” que diz que toda “inteligência” da aplicação, ou seja, toda a implementação de lógica de negócios interna àquela aplicação, deveria ser implementada nos modelos. A ideia é que o negócio funciona mesmo que os modelos sejam acessados diretamente, sem passar pela API HTTP — no caso de “management commands” do Django, por exemplo.

Eu não vejo isso como algo muito positivo. E começa pelo fato de usar a palavra “toda”. A experiência tem me mostrado que conceitos assim tão assertivos não costumam ser muito bons.

Um exemplo: digamos que o modelo Mission tem o campo status, que segue uma ordem simples, como new -> awaiting user interaction -> processing -> processed. E a grande questão é que esses stati não podem “andar para trás”: uma Missão “processing” nunca deveria voltar para “new”, por exemplo.

De acordo com a escola que citei, essa validação deveria ser feita pelo modelo, pois pode ser que um dia implementemos algum código que mexe “diretamente” neste campo e a validação deve estar lá. De acordo comigo, deixemos essa validação para a camada que implementa a API REST (as views, no caso do Django), pois qualquer código que lide nesse campo é “irmão” da API REST e, portanto, compartilha da mesma necessidade de compreensão da lógica de negócios e deve ser livre para decidir se é interessante ou não fazer uso dessa mesma validação.

Não colocando muita validação nos modelos nós acabamos com uma situação em que “todos somos adultos”: é um pouco perigoso, mas temos muita liberdade para trabalhar com os dados.

E, veja só: quando trabalhei com esse esquema de ter toda a inteligência nos modelos, era muito (muito!) comum que os desenvolvedores estivessem lidando no shell do Django e tivessem que sobrescrever os métodos de validação para poderem fazer o que quer que precisassem fazer. E isso sempre me pareceu um indicador de que a responsabilidade estava no lugar errado: ao invés de validar dados de quem a aplicação não confia muito (quem usa a API REST, ou seja, “terceiros”), ela tentava validar dados de quem sabia o que estava fazendo: os próprios desenvolvedores.

2.2- Regras de negócio na API

A meu ver, essas regrinhas de negócio (internas à aplicação) devem ser implementadas na API REST porque, a bem da verdade, é por meio dela que as transações do negócio deve funcionar. Qualquer “bypass” disso acaba sendo “algo feito por debaixo dos panos”, o que geralmente indica que é um desenvolvedor tendo que corrigir algum problema ou um script que realmente sabe o que está fazendo.

3- A camada que implementa a API

A camada da API sabe validar regras de negócio simples, mas não deveria aplicar regra alguma. Aquela validação de que os status não podem ser “rebobinados”, por exemplo, pode muito bem implementada na API. Já aplicações da lógica de negócios, como “quando a Missão for marcada com executed=True, coloque a data e hora atuais no campo executed_atnão devem ser implementadas aqui. Você pode fazer isso direto no modelo ou pode fazer isso por meio das mensagens que trafegam pelo broker.

Eu gosto dessa última abordagem (usar o sistema de eventos) porque, apesar de criar estados “levemente inconsistentes”, como Missões executadas mas sem as datas de execução salvas (ainda), transfere praticamente toda a lógica para o sistema de mensagens (o que é bom) e permite que a API e os modelos sejam ambos bem burros e simples (o que geralmente é bom, também).

Combine isso com um sistema em que a maioria da lógica é implementada de forma meramente descritiva e você terá em mãos um sistema muito bonitinho rodando.

4- Broker de mensagens

“Message broker” traz coisas muito diversas às mentes de perfis diferentes de desenvolvedores, mas aqui na DroneMapp ele é uma implementação própria e o elemento do sistema com mais conhecimento e poder a respeito das regras de negócio. De certa forma, ele é o centro do sistema todo, integrando as várias engrenagens e fazendo tudo girar da maneira correta.

Curiosamente, enquanto é o componente que mais conhece a lógica, acaba sendo também o que menos conhece sobre os dados em si.

O broker que implementamos por aqui baseia-se em filas SQS (da AWS) e não implementa nenhum tipo de validação quanto a mensagens repetidas. E embora o SQS seja um sistema extremamente resiliente (eu nunca ouvi falar de alguém que perdeu dados que estavam em filas SQS), os componentes que fazem o envio das mensagens podem não sê-lo tanto assim, sendo o mais crítico deles, obviamente, o próprio código que nós, desenvolvedores, escrevemos.

Assim, algum bug ou falha de execução pode acarretar no envio repetido de mensagens — e em tempos muito distintos.

Imagine que a mensagem “a Missão está em processamento”, que deveria mudar o status da Missão, via API REST, para processing , é enviada duas vezes. Enquanto a primeira mensagem faz o que deveria fazer normalmente, é possível que a segunda acabe chegando, sabe-se lá por qual motivo, apenas depois de outra mensagem semelhante, que é “a Missão terminou de ser processada”.

Ou seja: a mensagem “em processamento” acabou chegando depois que o status da Missão já foi parar em processed . Como os stati não podem ser rebobinados, o broker tentará fazer uma requisição “inválida” para a API de Missões. Por quê? Porque o broker sabe muito sobre o negócio, mas pouco sobre os dados, e não consegue inferir que “ah, esta Missão já está num status mais avançado, então não faz sentido tentar mudar para um anterior”.

Viu como um “ciclo de responsabilidades” acaba se fechando? Alguém com mais conhecimento sobre os dados terá que validar as requisições feitas pelo broker ou por qualquer terceiro. E, neste caso específico, o melhor “alguém” para isso é justamente a camada da API REST.

Também pode acontecer de as mensagens demorarem para chegar (quando um componente está fora do ar, por exemplo) e alguma inconsistência acabar surgindo. Por exemplo: chega a mensagem “a Missão está sendo processadadepois de alguém ter excluído esta mesma Missão. O broker não faz ideia da situação, e simplesmente envia a requisição. A API REST avalia a requisição e, com relação à lógica, considera que está tudo okay. Cabe ao banco de dados relacional gritar que “esta Missão não existe”, o que será ouvido pela API e o código de status HTTP adequado será retornado. E a mensagem, que já não faz mais sentido, provavelmente será descartada e tudo seguirá seu fluxo normal novamente.

5- User Interface

A interface do usuário serve para implementar um fluxo que faça sentido para o usuário e, a princípio, não tem responsabilidade alguma de implementar lógica de negócios ou validações. Apesar disso, nós acabamos implementando um pouco de tudo simplesmente por “economia”: ao invés de enviar uma requisição que sabe-se que será rejeitada pela API, a interface já avisa o usuário de que “não vai rolar” antecipadamente.

Para todos os efeitos práticos, a interface de usuário não é confiável. Ou seja: nenhuma lógica de negócios pode acontecer na UI. Ela cuida apenas de fluxo.

Um exemplo simples do que é o “fluxo” é o processamento dos dados de uma Missão. Nós bem que poderíamos exibir o tempo todo o botão [Requisitar Processamento] para o usuário, independentemente do status da Missão. Este botão só funcionaria, de fato, em Missões com status new , mas nada de mais aconteceria se o usuário tentasse requisitar processamento duma Missão em status processed : a API meramente negaria e um erro do tipo “não foi possível iniciar o processamento desta Missão” seria mostrado para o usuário. E essa seria uma interface que funciona, mas seria horrenda.

O que jamais poderíamos fazer é dizer “vamos implementar a UI de forma que o usuário não consiga pedir processamento de uma Missão que não seja new , então não precisamos fazer validação disso na API”. Para todos os efeitos, um sistema seguro deve considerar que todo usuário é mal-intencionado e possuidor de habilidades avançadas — mesmo que não seja o caso. Deixar de validar as coisas na API é adicionar buracos de segurança na aplicação toda.

Resumo

  • Banco de dados relacional: relacionamentos entre dados.
  • Modelos: envio de mensagens.
  • API: validações simples.
  • Message broker: lógica de negócios.
  • User Interface: fluxo de uso do sistema.

--

--