Padrões e Boas Práticas em Angular (Que te ajudarão a escalar)
Atenção: Este artigo foi originalmente escrito por Bartosz Pietrucha do site Angular Academy. Veja o link original aqui.
Construir softwares escaláveis é uma tarefa desafiadora. Quando pensamos em escalabilidade em aplicações front-end, podemos pensar em aumentar a complexidade, cada vez mais regras de negócio, uma quantidade crescente de dados carregados e grandes equipes geralmente distribuídas em todo o mundo. Para lidar com os fatores mencionados para manter uma alta qualidade de entrega e evitar dívidas técnicas, é necessária uma arquitetura robusta e bem fundamentada. O Angular em si possui uma estrutura bastante opinativa, forçando os desenvolvedores a fazer as coisas da maneira correta, mas há muitos lugares onde as coisas podem dar errado. Neste artigo, serão apresentadas recomendações de alto nível de arquitetura de aplicações Angular bem projetadas, com base nas melhores práticas e padrões comprovados. Nosso objetivo final neste artigo é aprender a projetar aplicações Angular para manter a velocidade do desenvolvimento sustentável e facilitar a adição de novos recursos a longo prazo. Para atingir esses objetivos, aplicaremos:
- abstrações adequadas entre as camadas da aplicação,
- fluxo de dados unidirecional,
- gerenciamento de estado reativo,
- design modular,
- padrão de componentes “inteligentes” e “burros”.
Problemas de escalabilidade no front-end
Vamos pensar nos problemas em termos de escalabilidade que podemos enfrentar no desenvolvimento de aplicações front-end modernas. Hoje, as aplicações front-end não estão “apenas exibindo” dados e validando as entradas do usuário. As Single Page Applications (SPAs) estão fornecendo aos usuários interações avançadas e usam o back-end principalmente como uma camada de persistência de dados. Isso significa que muito mais responsabilidade foi transferida para a parte front-end dos sistemas de software. Isso leva a uma complexidade crescente da lógica de front-end, com a qual precisamos lidar. Não apenas o número de requisições aumenta com o tempo, mas a quantidade de dados que carregamos na aplicação está aumentando. Além disso, precisamos manter o desempenho da aplicação, que pode ser facilmente prejudicada. Por fim, nossas equipes de desenvolvimento estão crescendo (ou pelo menos girando — as pessoas vêm e vão) e é importante que os novatos cheguem à velocidade o mais rápido possível.
Uma das soluções para os problemas descritos acima é uma sólida arquitetura de sistema. Mas, isso vem com o custo, o custo de investir nessa arquitetura desde o primeiro dia. Pode ser muito tentador para nós, desenvolvedores, fornecer novos recursos muito rapidamente, quando o sistema ainda é muito pequeno. Nesta fase, tudo é fácil e compreensível, portanto o desenvolvimento é muito rápido. Mas, a menos que nos preocupemos com a arquitetura, após algumas rotações dos desenvolvedores, recursos complicados, refatorações, alguns novos módulos, a velocidade do desenvolvimento diminui radicalmente. O diagrama abaixo apresenta como ele geralmente parecia na minha carreira de desenvolvimento. Este não é um estudo científico, é exatamente como eu o vejo.
Arquitetura de software
Para discutir as melhores práticas e padrões de arquitetura, precisamos responder a uma pergunta: o que é a arquitetura de software, em primeiro lugar. Martin Fowler define arquitetura como uma “quebra do nível mais alto de um sistema em suas partes”. Além disso, eu diria que a arquitetura de software descreve como o software é composto de suas partes e quais são as regras e restrições da comunicação entre essas partes. Geralmente, as decisões arquiteturais que tomamos no desenvolvimento de nosso sistema são difíceis de mudar à medida que o sistema cresce com o tempo. É por isso que é muito importante prestar atenção a essas decisões desde o início de nosso projeto, especialmente se o software que construímos estiver em produção por muitos anos. Robert C. Martin disse uma vez: o verdadeiro custo do software é sua manutenção. Ter uma arquitetura bem fundamentada ajuda a reduzir os custos de manutenção do sistema.
Arquitetura de software é a maneira como o software é composto de suas partes e as regras e restrições da comunicação entre essas partes.
Camadas de abstração de alto nível
O primeiro passo é decompormos nosso sistema através das camadas de abstração. O diagrama abaixo mostra o conceito geral dessa decomposição. A ideia é colocar a responsabilidade apropriada na camada apropriada do sistema: camada principal (Core), de abstração (Abstraction)ou apresentação (Presentation). Analisaremos cada camada de forma independente e analisaremos sua responsabilidade. Essa divisão do sistema também determina as regras de comunicação. Por exemplo, a camada de apresentação pode falar com a camada principal apenas através da camada de abstração. Mais tarde, aprenderemos quais são os benefícios desse tipo de restrição.
Camada de apresentação
Vamos começar a analisar a divisão do sistema a partir da camada de apresentação. Este é o lugar onde todos os nossos componentes Angular vivem. As únicas responsabilidades dessa camada são apresentar e delegar. Em outras palavras, ela apresenta a UI (Interface do Usuário) e delega as ações do usuário para a camada principal, por meio da camada de abstração. Ela sabe o que exibir e o que fazer, mas não sabe como as interações do usuário devem ser tratadas.
O trecho de código abaixo contém CategoriesComponent usando a instância SettingsFacade da camada de abstração para delegar a interação do usuário (via addCategory() e updateCategory()) e apresentar algum estado em seu modelo (via isUpdating$).
Camada de abstração
A camada de abstração separa a camada de apresentação da camada principal e também possui suas próprias responsabilidades definidas. Essa camada expõe os fluxos de estado e a interface dos componentes na camada de apresentação, desempenhando o papel do Padrão de Projeto Facade. Esse tipo de sandbox Facade mostra o que os componentes podem ver e fazer no sistema. Podemos implementar Facades usando simplesmente serviços no Angular. As classes aqui podem ser nomeadas com o sufixo Facade, por exemplo SettingsFacade. Abaixo, você pode encontrar um exemplo dessa implementação.
Interface de abstração
Já conhecemos as principais responsabilidades dessa camada; expor fluxos de estado e interface para os componentes. Vamos começar com a interface. Os métodos públicos loadCashflowCategories(), addCashflowCategory() e updateCashflowCategory() abstraem os detalhes do gerenciamento de estado e as chamadas de API externas dos componentes. Não estamos usando serviços de API (como CashflowCategoryApi) nos componentes diretamente, pois eles residem na camada principal. Além disso, como o estado muda não é uma preocupação dos componentes. A camada de apresentação não deve se importar com a maneira como as coisas são feitas e os componentes devem somente chamar os métodos da camada de abstração quando necessário (delegar). Observando os métodos públicos em nossa camada de abstração, ele deve nos dar uma visão rápida sobre os casos de uso de alto nível nesta parte do sistema.
Mas devemos lembrar que a camada de abstração não é um lugar para implementar a regra de negócio. Aqui, apenas queremos conectar a camada de apresentação à nossa lógica de negócios, abstraindo a maneira como ela está conectada.
Estado
Quando se trata do estado, a camada de abstração torna nossos componentes independentes da solução de gerenciamento de estado. Os componentes recebem Observables com dados para serem exibidos nos modelos (geralmente com async pipe) e não se importam como e de onde esses dados vêm. Para gerenciar nosso estado, podemos escolher qualquer biblioteca de gerenciamento de estado que suporte RxJS (como NgRx) ou simplesmente usar BehaviorSubjects para modelar nosso estado. No exemplo acima, estamos usando o objeto de estado que usa BehaviorSubjects internamente (o objeto de estado faz parte da nossa camada principal). No caso do NgRx, enviaríamos (dispatch) actions para a store.
Ter esse tipo de abstração nos dá muita flexibilidade e permite alterar a maneira como gerenciamos o estado sem nem tocar na camada de apresentação. É ainda possível migrar perfeitamente para um back-end em tempo real como o Firebase, tornando nossa aplicação em tempo real. Pessoalmente, gosto de começar com BehaviorSubjects para gerenciar o estado. Se mais tarde, em algum momento do desenvolvimento do sistema, for necessário usar outra coisa, com esse tipo de arquitetura, é muito fácil refatorar.
Estratégia de sincronização
Agora, vamos dar uma olhada no outro aspecto importante da camada de abstração. Independentemente da solução de gerenciamento de estado que escolhemos, podemos implementar atualizações da interface do usuário de maneira otimista ou pessimista. Imagine que queremos criar um novo registro na coleção de algumas entidades. Esta coleção foi buscada no back-end e exibida no DOM. Em uma abordagem pessimista, primeiro tentamos atualizar o estado no lado de back-end (por exemplo, com solicitação HTTP) e, em caso de sucesso, atualizamos o estado no aplicação front-end. Por outro lado, em uma abordagem otimista, fazemos isso em uma ordem diferente. Primeiro, supomos que a atualização de back-end terá êxito e atualize o estado do front-end imediatamente. Em seguida, enviamos uma solicitação para atualizar o estado do servidor. Em caso de sucesso, não precisamos fazer nada, mas em caso de falha, precisamos reverter a alteração em nossa aplicação front-end e informar o usuário sobre essa situação.
A atualização otimista altera primeiro o estado da interface do usuário e tenta atualizar o estado de back-end. Isso fornece ao usuário uma experiência melhor, pois ele não vê atrasos devido à latência da rede. Se a atualização do back-end falhar, a alteração da interface do usuário deverá ser revertida.
A atualização pessimista altera primeiro o estado de back-end e somente em caso de êxito atualiza o estado da interface do usuário. Geralmente, é necessário mostrar algum tipo de spinner ou barra de loading durante a execução da solicitação do back-end, devido à latência da rede.
Armazenamento em cache
Às vezes, podemos decidir que os dados que buscamos no back-end não farão parte do nosso estado da aplicação. Isso pode ser útil para dados somente leitura que não queremos manipular e apenas passar (via camada de abstração) para os componentes. Nesse caso, podemos aplicar o cache de dados em nosso Facade. A maneira mais fácil de realizar esse procedimento é usar o operador shareReplay() do RxJS que emitirá o último valor do Observable para cada novo assinante (subscriber). Dê uma olhada no trecho de código abaixo com o RecordsFacade usando o RecordsApi para buscar, armazenar em cache e filtrar os dados para os componentes.
Em resumo, o que podemos fazer na camada de abstração é:
- expor métodos para os componentes nos quais:
— delegam a execução lógica à camada principal,
— decidem sobre a estratégia de sincronização de dados (otimista vs. pessimista), - expor Observables de estado para os componentes:
— escolha um ou mais Observables do estado da interface do usuário (e combine-os, se necessário com a função combineLatest e outros operadores do RxJS),
— cachear dados de uma API externa.
Como vemos, a camada de abstração desempenha um papel importante em nossa arquitetura em camadas. Ele tem responsabilidades claramente definidas, o que ajuda a entender e raciocinar melhor sobre o sistema. Dependendo do seu caso particular, você pode criar um Facade por módulo Angular ou um pra cada entidade. Por exemplo, o SettingsModule pode ter um único SettingsFacade, se não estiver muito sobrecarregado. Mas, às vezes, pode ser melhor criar Facades de abstração mais granulares para cada entidade individualmente, como o UserFacade para a entidade User.
Camada Principal
A última camada é a camada principal. Aqui é onde a lógica principal da aplicação é implementada. Toda manipulação de dados e comunicação fora do mundo externo acontece aqui. Se para gerenciamento de estado, estivéssemos usando uma solução como NgRx, aqui é um lugar para colocar nossa definição de estado, actions e reducers. Como em nossos exemplos estamos modelando o estado com BehaviorSubjects, podemos encapsulá-lo em uma classe de estado conveniente. Abaixo, você pode encontrar o exemplo SettingsState na camada principal.
Na camada principal, também implementamos consultas HTTP na forma de serviços de classe. Esse tipo de classe pode ter o sufixo API no nome do serviço. Os serviços de API têm apenas uma responsabilidade: se comunicar com os pontos de extremidade da API e nada mais. Devemos evitar qualquer cache, lógica ou manipulação de dados aqui. Um exemplo simples de serviço de API pode ser encontrado abaixo.
Nesta camada, também podemos colocar validadores, mapeadores ou casos de uso mais avançados que exijam a manipulação de muitas fatias do nosso estado da interface do usuário.
Abordamos o tópico das camadas de abstração em nossa aplicação front-end. Cada camada possui limites e responsabilidades bem definidos. Também definimos as regras estritas de comunicação entre as camadas. Isso tudo ajuda a entender e raciocinar sobre o sistema ao longo do tempo, à medida que ele se torna cada vez mais complexo.
Fluxo de dados unidirecional e gerenciamento de estado reativo
O próximo princípio que queremos introduzir em nosso sistema é sobre o fluxo de dados e a propagação da mudança. O próprio Angular usa fluxo de dados unidirecional no nível da apresentação (via input bindings), mas imporemos uma restrição semelhante no nível da aplicação. Juntamente com o gerenciamento de estado reativo (baseado em Observables), ele nos dará uma propriedade muito importante do sistema — consistência dos dados. Esses são alguns dos princípios do Redux, que é bastante utilizado no React. O diagrama abaixo apresenta a ideia geral de fluxo de dados unidirecional.
Sempre que qualquer valor de modelo for alterado em nossa aplicação, o sistema de detecção de alterações do Angular cuida da propagação dessa alteração. Ele faz isso por meio de input property bindings de cima para baixo de toda a árvore de componentes. Isso significa que um componente filho pode depender apenas de seu pai e nunca vice-versa. É por isso que chamamos de fluxo de dados unidirecional. Isso permite que o Angular percorra a árvore de componentes apenas uma vez (já que não há ciclos na estrutura da árvore) para alcançar um estado estável, o que significa que todo estado nos input bindings é propagado.
Como sabemos nos capítulos anteriores, existe a camada principal acima da camada de apresentação, onde nossa lógica da aplicação é implementada. Existem os serviços e provedores que operam em nossos dados. E se aplicarmos o mesmo princípio de manipulação de dados nesse nível? Podemos colocar os dados da aplicação (o estado) em um local “acima” dos componentes e propagar os valores até os componentes por meio de Observables (Redux e NgRx chamam esse local de store). O estado pode ser propagado para vários componentes e exibido em vários locais, mas nunca modificado localmente. A alteração pode vir apenas “de cima” e os componentes abaixo apenas “refletem” o estado atual do sistema. Isso nos fornece a importante propriedade do sistema mencionada anteriormente — consistência dos dados — e o objeto de estado se torna a única fonte da verdade (single source of truth). Na prática, podemos exibir os mesmos dados em vários locais e não ter medo de que os valores sejam diferentes.
Nosso objeto de estado expõe os métodos para os serviços na nossa camada principal manipularem o estado. Sempre que houver necessidade de alterar o estado, isso pode acontecer apenas chamando um método no objeto de estado (ou despachando uma ação no caso de usar o NgRx). Em seguida, a alteração é propagada “abaixo”, por meio de Observables, para a camada de apresentação (ou qualquer outro serviço). Dessa forma, nosso gerenciamento de estado é reativo. Além disso, com essa abordagem, também aumentamos o nível de previsibilidade em nosso sistema, devido a regras estritas de manipulação e compartilhamento do estado do aplicação. Abaixo, você encontra um trecho de código de como o template pode ser desenvolvido.
Uma vez que a arquitetura do sistema é desenvolvida desta forma, temos dados imutáveis. Podemos então alterar a estratégia de detecção de mudanças para o modo OnPush em nossos componentes na camada de apresentação. Assim o componente passa a ser responsável por todos os eventos ocorridos nele mesmo e temos um ganho de performance, já que agora o Angular não tentará atualizar a View desnecessariamente a cada evento que ocorre.
Vamos recapitular as etapas de manipulação da interação do usuário, tendo em mente todos os princípios que já introduzimos. Primeiro, vamos imaginar que haja algum evento na camada de apresentação (por exemplo, clique no botão). O componente delega a execução para a camada de abstração, chamando o método settingsFacade.addCategory(). Em seguida, o Facade chama os métodos nos serviços da camada principal — categoryApi.create() e settingsState.addCategory(). A ordem de chamada desses dois métodos depende da estratégia de sincronização que escolhemos (pessimista ou otimista). Finalmente, o estado da aplicação é propagado até a camada de apresentação através dos Observables. Este processo é bem definido.
Design modular
Vimos a divisão horizontal em nosso sistema e os padrões de comunicação nele. Agora vamos introduzir uma separação vertical nos módulos de recursos (Feature Modules). A idéia é dividir o aplicativo em módulos de recursos que representam diferentes funcionalidades de negócios. Este é mais um passo para desconstruir o sistema em partes menores para melhor manutenção. Cada um dos módulos de recursos compartilha a mesma separação horizontal da camada principal, da camada de abstração e da camada de apresentação. É importante observar que esses módulos podem ser carregados lentamente e pré-carregados (Lazy Loading) no navegador, aumentando o tempo de carregamento inicial da aplicação. Abaixo, você pode encontrar um diagrama que ilustra a separação dos módulos de recursos.
Nossa aplicação também possui dois módulos adicionais por razões mais técnicas. Temos o CoreModule que define nossos serviços singleton, componentes de instância única, configuração e exportação de quaisquer módulos de terceiros necessários no AppModule. Este módulo é importado apenas uma vez no AppModule. O segundo módulo é o SharedModule, que contém componentes/pipes/diretivas comuns e também exporta módulos Angular usados com freqüência (como o CommonModule). O SharedModule pode ser importado por qualquer módulo de recurso. O diagrama abaixo apresenta a estrutura de importações.
Estrutura de diretórios do módulo
O diagrama abaixo mostra como podemos colocar todas as partes do nosso SettingsModule dentro dos diretórios. Podemos colocar os arquivos dentro das pastas com um nome representando sua função.
Componentes "inteligentes" e "burros"
O padrão arquitetural final que iremos apresentar neste artigo é sobre os próprios componentes. Queremos dividí-los em duas categorias, dependendo de suas responsabilidades. Primeiro, são os componentes inteligentes (também conhecidos como containers). Esses componentes geralmente:
- possuem Facades e outros serviços injetados,
- comunicam-se com a camada principal,
- passam dados para os componentes burros,
- reagem aos eventos de componentes burros,
- são componentes roteáveis de nível mais alto (mas nem sempre!).
O CategoriesComponent apresentado anteriormente é um componente inteligente. Ele tem o SettingsFacade injetado e o utiliza para se comunicar com a camada principal da aplicação.
Na segunda categoria, existem componentes burros (também conhecidos como de apresentação). Suas únicas responsabilidades são apresentar o elemento da interface do usuário e delegar a interação do usuário “até” os componentes inteligentes por meio de eventos (Outputs). Pense em um elemento HTML nativo como <button>Clique em mim</button>. Esse elemento não possui nenhuma lógica específica implementada. Podemos pensar no texto ‘Clique em mim’ como um Input para este componente. Ele também possui alguns Outputs que podem ser emitidos, como o evento de clique. Abaixo, você pode encontrar um trecho de código de um componente de apresentação com um Input e dois Outputs. Repare também que a estratégia OnPush do sistema de detecção de mudanças do Angular foi adotada para termos um ganho de performance.
Sumário
Abordamos algumas idéias sobre como projetar a arquitetura de uma aplicação Angular. Esses princípios, se aplicados com sabedoria, podem ajudar a manter a velocidade do desenvolvimento sustentável ao longo do tempo e permitir que novos recursos sejam entregues facilmente. Por favor, não as trate como regras estritas, mas como recomendações que possam ser empregadas quando fizerem sentido.
Examinamos de perto as camadas de abstração, o fluxo de dados unidirecional e o gerenciamento de estado reativo, o design modular e o padrão de componentes inteligentes/burros. Espero que esses conceitos sejam úteis em seus projetos e, como sempre, se você tiver alguma dúvida, fico feliz em conversar com você.
Segue o link do repositório do Github onde são aplicados os princípios descritos neste artigo.