Recentemente estamos vendo uma grande expansão dos sistemas web, o surgimento de aplicações híbridas e aplicações que utilizam cada vez mais os softwares como serviço (SaaS).
Este crescimento traz um novo desafio aos times de DevOps, infra e desenvolvimento: somente o monitoramento de redes não é mais capaz de garantir a segurança dos dados. É aí que entra a observabilidade.
A observabilidade auxilia no rastreamento e identificação de problemas a partir de ferramentas de software. E hoje, vamos te ensinar a implementar a observabilidade usando o método de Observabilidade Orientada ao Domínio.
Assim, você vai tornar as diversas chamadas para serviços de log e frameworks de análises em seus sistemas menos técnicas e prolixas. Confira!
O que é observabilidade em TI?
A observabilidade em TI (também chamada de observability) é a capacidade de monitorar, rastrear, analisar e diagnosticar um sistema, a partir do uso de ferramentas de software.
Com ela, é possível criar um sistema de constante monitoramento e observação sobre um sistema com o objetivo de compreender melhor como ele se comporta, principalmente em arquiteturas cloud.
Este conceito é bastante aplicado por times DevOps, infraestrutura e desenvolvimento, pois já é difundido na Engenharia de Software que ele beneficia o software e facilita a solução de problemas.
Observabilidade orientada ao domínio na prática: Valores
Grandes aplicações voltadas para análises de métricas de alto nível, como Mixpanel, acreditam no conceito de “Momentos de valor” (value moments), que indica quais eventos de determinado produto são importantes para se instrumentar.
Estes momentos de valor variam de acordo com o produto, por exemplo, um software voltado para soluções de assinatura eletrônica, como a 1Doc, pode considerar que a assinatura de um contrato é um “momento de valor”.
Porém, o momento de valor que faz sentido para o seu negócio, não necessariamente faz sentido para os usuários.
Isso porque o valor do seu negócio é composto do equilíbrio entre duas forças: a intenção e a expectativa.
Se a intenção é facilitar o processo de assinatura de um contrato, e a expectativa dos seus usuários é exatamente essa, você alcançou o equilíbrio.
Contudo, o desencontro dessas duas forças é uma perda de oportunidade, e consequentemente, de valor.
Graças às métricas de alto nível, este desencontro não é uma causa perdida. Com elas é possível recuperar e manter o valor do seu negócio de acordo com os momentos de valor identificados pelo seu time de analistas de produto.
A partir daqui, seu papel como pessoa desenvolvedora passa a envolver a verificação da viabilidade técnica e a implementação da captura dessas métricas para que o “time de negócio” consiga lidar com os dados.
Como implementar a observabilidade orientada ao domínio?
A partir de agora vamos para a parte prática aprender a implementar a observabilidade orientada ao domínio. Para entender melhor esta implementação, vamos imaginar um pequeno sistema de gerenciamento de tarefas.
Este sistema cadastra tarefas agendadas e as executa de acordo com o agendamento. Porém, devido a uma necessidade dos usuários, em certos momentos, pode ser necessário adiantar a execução de uma dessas tarefas de forma manual.
Para atender a esta necessidade de “execução adiantada”, a estrutura abaixo foi feita:
GerenciadorTarefas: Classe responsável pela execução de uma determinada tarefa baseada em seu próprio código ─ é a classe de “caso de uso”;
RecuperadorTarefas: Classe responsável pela abstração da recuperação das tarefas do banco de dados e retorno dos objetos de domínio ─ é a classe “repositório”.
Tarefa: Classe que representa uma “tarefa” no sistema ─ é a “entidade de domínio”.
Veja o exemplo abaixo:
public class GerenciadorTarefas {
private static boolean TAREFA_PROCESSADA = true; private static boolean TAREFA_NAO_PROCESSADA = false;
private RecuperadorTarefas recuperadorTarefas;
public GerenciadorTarefas(RecuperadorTarefas recuperadorTarefas) { this.recuperadorTarefas = recuperadorTarefas; }
public boolean executaTarefaPeloCodigo(Integer codigoTarefa) { Tarefa tarefa = recuperadorTarefas.recuperaPeloCodigo(codigoTarefa);
if (tarefa == null) { return TAREFA_NAO_PROCESSADA; }
try { tarefa.iniciaProcesso(); return TAREFA_PROCESSADA; } catch (TarefaInterrompidaException e) { return TAREFA_NAO_PROCESSADA; } } }
O código acima pode não ser o melhor exemplo, mas expressa bem a sua lógica de domínio.
Agora, vamos aplicar a observabilidade no nosso método executaTarefaPeloCodigo.
Para isto, vamos imaginar duas bibliotecas em nosso projeto:
Log: É uma biblioteca genérica de logs, útil para atividades de throubleshooting por parte das pessoas desenvolvedoras.
Analytics: É uma biblioteca genérica de eventos que metrifica interações de um usuário a uma determinada funcionalidade.
public boolean executaTarefaPeloCodigo(Integer codigoTarefa) { Tarefa tarefa = recuperadorTarefas.recuperaPeloCodigo(codigoTarefa);
if (tarefa == null) { Log.warn("A tarefa %d não existe, portanto, seu processo não foi iniciado.", codigoTarefa); return TAREFA_NAO_PROCESSADA; }
try { Log.info("Processo da tarefa %d foi iniciado.", codigoTarefa); Analytics.registraEvento("tarefa_iniciada", tarefa); tarefa.iniciaProcesso(); Log.info("Processo da tarefa %d foi finalizado.", codigoTarefa); Analytics.registraEvento("tarefa_finalizada", tarefa); return TAREFA_PROCESSADA; } catch (TarefaInterrompidaException e) { Log.error(e, String.format("Processo da tarefa %d foi interrompido.", codigoTarefa)); Analytics.registraEvento("tarefa_interrompida", tarefa); return TAREFA_NAO_PROCESSADA; } }
Agora, além da execução da regra de negócio previamente expressa pelo código, também estamos lidando com diversas chamadas de logs e análises sobre o uso desta funcionalidade.
Analisando, não do ponto de vista da instrumentação da observabilidade, mas tecnicamente, com certeza, a manutenibilidade deste código caiu.
Primeiro que se essa implementação for crucial para o negócio, ela deveria ser garantida com testes unitários.
Além disso, a regra de negócio, que antes era claramente expressa, agora, está ofuscada com o uso dessas bibliotecas.
Cenários como este são comuns de se ver nos mais diversos sistemas e, geralmente, não parece soar muito bem um “código voltado à observabilidade” e um “código voltado ao domínio”, juntos.
Então, existe solução? Vamos entender melhor a seguir.
Solução para o Case de Observabilidade
Pensando na legibilidade do código escrito, instintivamente, acabamos pensando na criação de pequenos métodos que abstraiam esse conteúdo confuso de dentro do executaTarefaPeloCodigo, isolando o código voltado ao domínio do código voltado às análises.
Porém, neste caso, a observabilidade introduzida é um requisito do negócio, portanto, mesmo sendo um “código voltado às análises”, ele continua sendo um “código voltado ao domínio”. Entenda melhor na imagem abaixo:
Ou seja, nem todo código voltado ao domínio é voltado à observabilidade e nem todo código voltado à observabilidade é voltado ao domínio, mas há, em alguns casos, uma intersecção entre estes, como no nosso case apresentado.
Por fim, também recomendamos fortemente a extração das Strings “mágicas”, pois torna a leitura mais agradável e mais fácil de entender o que cada uma representa.
Talvez a introdução de alguns ENUMs também seja válida para abstrair o que seria os “trackings de eventos”, como tarefa_iniciada e tarefa_finalizada, mas não vamos nos aprofundar neste assunto, pois não é o foco.
public boolean executaTarefaPeloCodigo(Integer codigoTarefa) { Tarefa tarefa = recuperadorTarefas.recuperaPeloCodigo(codigoTarefa);
if (tarefa == null) { metrificaQueTarefaNaoExiste(codigoTarefa); return TAREFA_NAO_PROCESSADA; }
try { metrificaQueTarefaFoiIniciada(tarefa); tarefa.iniciaProcesso(); metrificaQueTarefaFoiFinalizada(tarefa); return TAREFA_PROCESSADA; } catch (TarefaInterrompidaException e) { metrificaQueTarefaFoiInterrompida(tarefa); return TAREFA_NAO_PROCESSADA; } }
private void metrificaQueTarefaNaoExiste(Integer codigoTarefa) { Log.warn(MENSAGEM_TAREFA_INEXISTENTE, codigoTarefa); }
private void metrificaQueTarefaFoiIniciada(Tarefa tarefa) { Log.info(MENSAGEM_TAREFA_INICIADA, tarefa.getCodigo()); Analytics.registraEvento(TAREFA_INICIADA, tarefa);}
private void metrificaQueTarefaFoiFinalizada(Tarefa tarefa) { Log.info(MENSAGEM_TAREFA_FINALIZADA, codigoTarefa); Analytics.registraEvento(TAREFA_FINALIZADA, tarefa);}
private void metrificaQueTarefaFoiInterrompida(Tarefa tarefa) { Log.error(e, String.format(MENSAGEM_TAREFA_INTERROMPIDA, codigoTarefa)); Analytics.registraEvento(TAREFA_INTERROMPIDA, tarefa);}
Este é um bom começo, com o código de domínio voltando a ser bem escrito ─ isso, claro, se você considerar que seu “código de domínio” é apenas o método executaTarefaPeloCodigo. Observando nossa classe, não leva muito tempo para notarmos que fizemos uma troca.
Se extrairmos de dentro do método original vários outros métodos de metrificação os quais não se encaixam com o objetivo principal da classe GerenciadorTarefas, estamos apenas “jogando o problema para debaixo do tapete”.
Quando algo assim acontece, geralmente nos indica que uma nova classe está querendo emergir.
Portanto, talvez, a mais simples solução seja a segregação dessa classe em duas: uma para lidar com as métricas e outra para o processamento das tarefas.
Ou seja, nossa proposta é a criação de uma nova classe responsável especificamente pelas análises e logs da aplicação, assim como demonstrado no desenho abaixo.
Esta também é uma boa solução pois a segregação das responsabilidades originais e o encapsulamento das funções de métricas em uma nova classe, somado à possível injeção de dependências introduzida, favorece o design para testabilidade do GerenciadorTarefas, que é detentor das nossas regras de domínio.
Podemos reforçar ainda mais essa ideia pensando no fato de que Java é uma linguagem orientada a objetos (POO) e a testabilidade de uma classe que utiliza de métodos estáticos é reduzida caso o método modifique um estado externo a si, e, geralmente, bibliotecas de logs atendem a este requisito.
Desta forma, o resultado do nosso GerenciadorTarefas seria o seguinte:
public class GerenciadorTarefas {
private static boolean TAREFA_PROCESSADA = true; private static boolean TAREFA_NAO_PROCESSADA = false;
private RecuperadorTarefas recuperador; private MetificadorTarefas metrificador;
public GerenciadorTarefas(RecuperadorTarefas recuperador, MetificadorTarefas metrificador) { this.recuperador = recuperador; this.metrificador = metrificador; }
public boolean executaTarefaPeloCodigo(Integer codigoTarefa) { Tarefa tarefa = recuperador.recuperaPeloCodigo(codigoTarefa);
if (tarefa == null) {
metrificador.metrificaQueTarefaNaoExiste(codigoTarefa);
return TAREFA_NAO_PROCESSADA;
}
try {
metrificador.metrificaQueTarefaFoiIniciada(tarefa);
tarefa.iniciaProcesso();
metrificador.metrificaQueTarefaFoiFinalizada(tarefa);
return TAREFA_PROCESSADA;
} catch (TarefaInterrompidaException e) {
metrificador.metrificaQueTarefaFoiInterrompida(tarefa);
return TAREFA_NAO_PROCESSADA;
}
}}
O processo de segregação da classe GerenciadorTarefas e o encapsulamento das métricas é chamado de Observabilidade Orientada ao Domínio, e a nova classe gerada é o nosso tão cobiçado Domain Probe.
O nome deste padrão de projetos, “Domain Probe”, remete à “Sonda de Domínio”. Este nome não poderia ser mais adequado visto que nossa classe literalmente age como uma “sonda”, em uma classe que anteriormente carecia do levantamento de métricas.
Como testar a observabilidade orientada a domínio?
Antes de testar a observabilidade de fato, vamos retomar a primeira versão da nossa classe, e tentar imaginar um cenário de teste.
public class GerenciadorTarefas {
private static boolean TAREFA_PROCESSADA = true; private static boolean TAREFA_NAO_PROCESSADA = false;
private RecuperadorTarefas recuperador;
public GerenciadorTarefas(RecuperadorTarefas recuperador) { this.recuperador = recuperador; }
public boolean executaTarefaPeloCodigo(Integer codigoTarefa) { Tarefa tarefa = recuperador.recuperaPeloCodigo(codigoTarefa);
if (tarefa == null) {
return TAREFA_NAO_PROCESSADA;
}
try {
tarefa.iniciaProcesso();
return TAREFA_PROCESSADA;
} catch (TarefaInterrompidaException e) {
return TAREFA_NAO_PROCESSADA;
}
}}
Se você está acostumado a fazer este tipo de análise, vai perceber alguns cenários:
Ou não existe tarefa com o código informado, retornando FALSE;
Ou existe tarefa e seu processamento é concluído, retornando TRUE;
Ou existe tarefa e seu processamento é interrompido, retornando FALSE;
Para simplificar, vamos utilizar apenas o terceiro cenário como exemplo. Abaixo, podemos observar como seria a implementação desta classe de testes.
public class GerenciadorTarefasTest {
private static final Integer CODIGO_TAREFA = 1;
private GerenciadorTarefas gerenciadorTarefas; private RecuperadorTarefas recuperador;
@BeforeEach public void setUp() { this.recuperador = Mockito.mock(RecuperadorTarefas.class); this.gerenciadorTarefas = new GerenciadorTarefas(recuperador); }
@Test public void deveRetornarFalso_casoOcorraErroDeProcessamento_quandoExistirTarefaComCodigoInformado() throws TarefaInterrompidaException { doReturn(criaTarefaComExcecaoEmbutida()).when(recuperador).recuperaPeloCodigo(eq(CODIGO_TAREFA));
Boolean foiExecutado = gerenciadorTarefas.executaTarefaPeloCodigo(CODIGO_TAREFA);
assertFalse(foiExecutado);
}
private Tarefa criaTarefaComExcecaoEmbutida() throws TarefaInterrompidaException { Tarefa tarefa = Mockito.spy(new Tarefa(CODIGO_TAREFA)); doThrow(new TarefaInterrompidaException()).when(tarefa).iniciaProcesso(); return tarefa; }
}
Seguindo o padrão GWT de nomenclatura (Given - When - Then), podemos expressar nossa regra de negócio no teste.
Porém, vale mencionar que aqui estamos traduzindo e “abrasileirando” a escrita da GWT (Given - When - Then), transformando em “DCQ” (Deve ─ Caso ─ Quando).
Dessa forma, usamos o DCQ significa:
“Deve retornar falso”, que é equivalente à “Then returns false”;
“Caso ocorra erro de processamento”, que equivale à expressão “When a processing error occurs”;
“Quando existir tarefa com o código informado”, que representa o mesmo que “Given an existing task with the informed code”.
A partir disso, quando reimplementamos nossa observabilidade, nossa classe GerenciadorTarefas volta a ser assim:
public class GerenciadorTarefas {
private static boolean TAREFA_PROCESSADA = true; private static boolean TAREFA_NAO_PROCESSADA = false;
private RecuperadorTarefas recuperador; private MetificadorTarefas metrificador;
public GerenciadorTarefas(RecuperadorTarefas recuperador, MetificadorTarefas metrificador) { this.recuperador = recuperador; this.metrificador = metrificador; }
public boolean executaTarefaPeloCodigo(Integer codigoTarefa) { Tarefa tarefa = recuperador.recuperaPeloCodigo(codigoTarefa);
if (tarefa == null) {
metrificador.metrificaQueTarefaNaoExiste(codigoTarefa);
return TAREFA_NAO_PROCESSADA;
}
try {
metrificador.metrificaQueTarefaFoiIniciada(tarefa);
tarefa.iniciaProcesso();
metrificador.metrificaQueTarefaFoiFinalizada(tarefa);
return TAREFA_PROCESSADA;
} catch (TarefaInterrompidaException e) {
metrificador.metrificaQueTarefaFoiInterrompida(tarefa);
return TAREFA_NAO_PROCESSADA;
}
}}
É importante lembrarmos aqui que nenhum comportamento foi alterado com o incremento da observabilidade.
Portanto, o teste feito anteriormente continua cumprindo seu papel mesmo estando desatualizado.
No máximo, o que ocorreria neste caso é um erro de compilação, que já serviria de aviso aos testes que esta classe agora possui uma nova dependência.
Sendo um incremento da nossa regra de negócio original, nada mais justo do que incrementar os testes garantindo as invocações corretas de nosso instrumentador.
Veja o exemplo a seguir:
public class GerenciadorTarefasTest {
private static final Integer CODIGO_TAREFA = 1;
private GerenciadorTarefas gerenciadorTarefas; private RecuperadorTarefas recuperador; private MetificadorTarefas metrificador;
@BeforeEach public void setUp() { this.recuperador = Mockito.mock(RecuperadorTarefas.class); this.metrificador = Mockito.mock(MetificadorTarefas.class); this.gerenciadorTarefas = new GerenciadorTarefas(recuperador, metrificador); }
@Test public void deveRetornarFalso_casoOcorraErroDeProcessamento_quandoExistirTarefaComCodigoInformado() throws TarefaInterrompidaException { doReturn(criaTarefaComExcecaoEmbutida()).when(recuperador).recuperaPeloCodigo(eq(CODIGO_TAREFA));
Boolean foiExecutado = gerenciadorTarefas.executaTarefaPeloCodigo(CODIGO_TAREFA);
Mockito.verify(metrificador, times(1)).metrificaQueTarefaFoiIniciada(any());
Mockito.verify(metrificador, times(1)).metrificaQueTarefaFoiInterrompida(any());
Mockito.verifyNoMoreInteractions(metrificador);
assertFalse(foiExecutado);
}
private Tarefa criaTarefaComExcecaoEmbutida() throws TarefaInterrompidaException { Tarefa tarefa = Mockito.spy(new Tarefa(CODIGO_TAREFA)); doThrow(new TarefaInterrompidaException()).when(tarefa).iniciaProcesso(); return tarefa; }
}
Aproveitando a dependência de um instrumentador dentro do nosso GerenciadorTarefas, podemos ainda injetar uma classe falsa para verificar apenas o número de invocações de cada método.
No teste acima, verificamos se os métodos metrificaQueTarefaFoiIniciada e metrificaQueTarefaFoiInterrompida foram invocados, e então garantimos que mais nenhuma outra interação é feita com nossa classe instrumentadora.
Assim, caso uma nova métrica surja, haja refatoração ou mudança na regra de negócio, teremos testes que garantem aquilo que o negócio espera, ou esperava.
Opinião do Autor
Este artigo é, em grande parte, uma releitura do estudo Domain-Oriented Observability, escrito por Pete Hodgson em 2019, e também inclui a visão de diversos outros autores sobre o assunto, inclusive a opinião pessoal do autor, Felipe Luan Cipriani, tech writer convidado pelo grupo Softplan..
Quando li o artigo de referência “Domain-Oriented Observability” pela primeira vez, não me surpreendi com algo revelador, pois já conhecia o método.
Porém, depois de algumas conversas com colegas próximos e mais algumas tentativas de compreender a totalidade do artigo, percebi como o subestimei.
Domain Probe não aborda encapsulamento, segregação ou injeção de dependência ─ embora estes sejam todos elementos que o compõem ─, mas sim a importância das métricas, e sua relevância para o negócio.
E embora o padrão de projeto Domain Probe tenha semelhanças com um Facade, ele se preocupa com a essência de todo sistema: o domínio.
Por isso, ele tem seu valor. Este é um padrão de projetos essencial de se conhecer e aplicar onde quer que haja instrumentos de métricas em um domínio que não tenha sido feito ou pensado para ser fácil de ler, interpretar, ou dar manutenção.
Afinal, desenvolvedores passam mais tempo lendo códigos do que escrevendo.
Além disso, este é um padrão de projetos com extrema flexibilidade no quesito de granularidade.
Ou seja, você pode criar desde um Domain Probe para cada classe de domínio, sendo esta abordagem mais “específica”, a até mesmo um Domain Probe “genérico”. Não existe uma abordagem errada, apenas diferentes abordagens.
Outro tipo de implementação de uma Observabilidade Orientada ao Domínio é através de eventos.
Neste cenário, o padrão de projetos da vez é o Observer, e sua abordagem é igualmente interessante, valendo a pena um artigo dedicado somente a ele.
Por fim, agradeço a você, caro(a) leitor(a), pelo seu tempo e interesse.
Recomendo que leia os artigos dos links presentes no decorrer deste artigo e, se você gostou do conteúdo, tem alguma dúvida ou deseja iniciar uma discussão sobre a temática, deixe seu comentário abaixo!