Aproveitando a programação declarativa para criar aplicativos da Web sustentáveis

Publicados: 2022-03-11

Neste artigo, mostro como a adoção criteriosa de técnicas de programação no estilo declarativo pode permitir que as equipes criem aplicativos da Web mais fáceis de estender e manter.

“…programação declarativa é um paradigma de programação que expressa a lógica de uma computação sem descrever seu fluxo de controle.” —Remo H. Jansen, Programação Funcional Prática com TypeScript

Como a maioria dos problemas em software, decidir usar técnicas de programação declarativa em seus aplicativos requer uma avaliação cuidadosa das compensações. Confira um de nossos artigos anteriores para uma discussão aprofundada sobre eles.

Aqui, o foco está em como os padrões de programação declarativa podem ser adotados gradualmente para aplicativos novos e existentes escritos em JavaScript, uma linguagem que suporta vários paradigmas.

Primeiro, discutimos como usar o TypeScript no back-end e no front-end para tornar seu código mais expressivo e resiliente a mudanças. Em seguida, exploramos as máquinas de estado finito (FSMs) para simplificar o desenvolvimento de front-end e aumentar o envolvimento das partes interessadas no processo de desenvolvimento.

FSMs não são uma tecnologia nova. Eles foram descobertos há quase 50 anos e são populares em setores como processamento de sinais, aeronáutica e finanças, onde a correção do software pode ser crítica. Eles também são muito adequados para problemas de modelagem que surgem frequentemente no desenvolvimento web moderno, como coordenar atualizações e animações complexas de estado assíncrono.

Esse benefício surge devido a restrições na forma como o estado é gerenciado. Uma máquina de estado pode estar em apenas um estado simultaneamente e tem estados vizinhos limitados para os quais pode fazer a transição em resposta a eventos externos (como cliques do mouse ou busca de respostas). O resultado é geralmente uma taxa de defeitos significativamente reduzida. No entanto, as abordagens FSM podem ser difíceis de escalar para funcionar bem em grandes aplicativos. Extensões recentes para FSMs chamadas statecharts permitem que FSMs complexos sejam visualizados e dimensionados para aplicativos muito maiores, que é o sabor das máquinas de estado finito em que este artigo se concentra. Para nossa demonstração, usaremos a biblioteca XState, que é uma das melhores soluções para FSMs e statecharts em JavaScript.

Declarativo no back-end com Node.js

Programar um back-end de servidor da Web usando abordagens declarativas é um tópico grande e normalmente pode começar avaliando uma linguagem de programação funcional adequada do lado do servidor. Em vez disso, vamos supor que você esteja lendo isso em um momento em que já escolheu (ou está considerando) o Node.js para seu back-end.

Esta seção detalha uma abordagem para modelar entidades no back-end que tem os seguintes benefícios:

  • Melhor legibilidade do código
  • Refatoração mais segura
  • Potencial para melhor desempenho devido às garantias que a modelagem do tipo fornece

Garantias de comportamento por meio de modelagem de tipo

JavaScript

Considere a tarefa de procurar um determinado usuário por meio de seu endereço de e-mail em JavaScript:

 function validateEmail(email) { if (typeof email !== "string") return false; return isWellFormedEmailAddress(email); } function lookupUser(validatedEmail) { // Assume a valid email is passed in. // Safe to pass this down to the database for a user lookup.. }

Esta função aceita um endereço de e-mail como string e retorna o usuário correspondente do banco de dados quando há uma correspondência.

A suposição é que lookupUser() só será chamado depois que a validação básica for realizada. Esta é uma suposição fundamental. E se várias semanas depois, alguma refatoração for executada e essa suposição não for mais válida? Dedos cruzados para que os testes de unidade detectem o bug, ou podemos estar enviando texto não filtrado para o banco de dados!

TypeScript (primeira tentativa)

Vamos considerar um equivalente TypeScript da função de validação:

 function validateEmail(email: string) { // No longer needed the type check (typeof email === "string"). return isWellFormedEmailAddress(email); }

Esta é uma pequena melhoria, com o compilador TypeScript nos salvando de adicionar uma etapa de validação de tempo de execução adicional.

As garantias de segurança que uma digitação forte pode trazer ainda não foram aproveitadas. Vamos olhar para isso.

TypeScript (segunda tentativa)

Vamos melhorar a segurança de tipo e não permitir a passagem de strings não processadas como entrada para looukupUser :

 type ValidEmail = { value: string }; function validateEmail(input: string): Email | null { if (!isWellFormedEmailAddress(input)) return null; return { value: email }; } function lookupUser(email: ValidEmail): User { // No need to perform validation. Compiler has already ensured only valid emails have been passed in. return lookupUserInDatabase(email.value); }

Isso é melhor, mas é complicado. Todos os usos do ValidEmail acessam o endereço real por meio de email.value . O TypeScript emprega tipagem estrutural em vez da tipagem nominal empregada por linguagens como Java e C#.

Embora poderoso, isso significa que qualquer outro tipo que adere a essa assinatura é considerado equivalente. Por exemplo, o seguinte tipo de senha pode ser passado para lookupUser() sem reclamação do compilador:

 type ValidPassword = { value: string }; const password = { value: "password" }; lookupUser(password); // No error.

TypeScript (terceira tentativa)

Podemos obter tipagem nominal no TypeScript usando interseção:

 type ValidEmail = string & { _: "ValidEmail" }; function validateEmail(input: string): ValidEmail { // Perform email validation checks.. return input as ValidEmail; } type ValidPassword = string & { _: "ValidPassword" }; function validatePassword(input: string): ValidPassword { ... } lookupUser("[email protected]"); // Error: expected type ValidEmail. lookupUser(validatePassword("MyPassword"); // Error: expected type ValidEmail. lookupUser(validateEmail("[email protected]")); // Ok.

Agora atingimos o objetivo de que apenas strings de e-mail validadas podem ser passadas para lookupUser() .

Dica profissional: aplique este padrão facilmente usando o seguinte tipo de auxiliar:

 type Opaque<K, T> = T & { __TYPE__: K }; type Email = Opaque<"Email", string>; type Password = Opaque<"Password", string>; type UserId = Opaque<"UserId", number>;

Prós

Ao digitar entidades fortemente em seu domínio, podemos:

  1. Reduza o número de verificações que precisam ser executadas em tempo de execução, que consomem preciosos ciclos de CPU do servidor (embora sejam uma quantidade muito pequena, eles se somam ao atender milhares de solicitações por minuto).
  2. Mantenha menos testes básicos devido às garantias fornecidas pelo compilador TypeScript.
  3. Aproveite a refatoração assistida por editor e compilador.
  4. Melhore a legibilidade do código por meio de uma relação sinal-ruído aprimorada.

Contras

A modelagem de tipo vem com algumas compensações a serem consideradas:

  1. A introdução do TypeScript geralmente complica a cadeia de ferramentas, levando a tempos de execução mais longos de compilação e conjunto de testes.
  2. Se seu objetivo é prototipar um recurso e colocá-lo nas mãos dos usuários o mais rápido possível, o esforço extra necessário para modelar explicitamente os tipos e propagá-los pela base de código pode não valer a pena.

Mostramos como o código JavaScript existente no servidor ou a camada de validação de back-end/front-end compartilhada pode ser estendida com tipos para melhorar a legibilidade do código e permitir uma refatoração mais segura — requisitos importantes para as equipes.

Interfaces de usuário declarativas

As interfaces de usuário desenvolvidas usando técnicas de programação declarativa concentram o esforço na descrição do “o quê” sobre o “como”. Dois dos três principais ingredientes básicos da web, CSS e HTML, são linguagens de programação declarativas que resistiram ao teste do tempo e mais de 1 bilhão de sites.

As principais linguagens que alimentam a web
As principais linguagens que alimentam a web.

O React foi open-source pelo Facebook em 2013 e alterou significativamente o curso do desenvolvimento front-end. Quando o usei pela primeira vez, adorei como poderia declarar a GUI em função do estado do aplicativo. Agora eu era capaz de compor interfaces de usuário grandes e complexas a partir de blocos de construção menores sem lidar com os detalhes confusos da manipulação do DOM e rastrear quais partes do aplicativo precisam ser atualizadas em resposta às ações do usuário. Eu poderia ignorar amplamente o aspecto de tempo ao definir a interface do usuário e me concentrar em garantir que meu aplicativo faça a transição correta de um estado para o próximo.

Evolução do JavaScript front-end de como para o que
Evolução do JavaScript front-end de como para quê .

Para obter uma maneira mais simples de desenvolver UIs, o React inseriu uma camada de abstração entre o desenvolvedor e a máquina/navegador: o DOM virtual .

Outras estruturas modernas de interface do usuário da Web também preencheram essa lacuna, embora de maneiras diferentes. Por exemplo, o Vue emprega reatividade funcional por meio de getters/setters JavaScript (Vue 2) ou proxies (Vue 3). O Svelte traz reatividade por meio de uma etapa extra de compilação do código-fonte (Svelte).

Esses exemplos parecem demonstrar um grande desejo em nosso setor de fornecer ferramentas melhores e mais simples para os desenvolvedores expressarem o comportamento do aplicativo por meio de abordagens declarativas.

Estado declarativo e lógica do aplicativo

Enquanto a camada de apresentação continua a girar em torno de alguma forma de HTML (por exemplo, JSX em React, templates baseados em HTML encontrados em Vue, Angular e Svelte), eu postulo que o problema de como modelar o estado de um aplicativo de uma forma que é facilmente compreensível para outros desenvolvedores e manutenível à medida que o aplicativo cresce ainda não foi resolvido. Vemos evidências disso por meio de uma proliferação de bibliotecas de gerenciamento de estado e abordagens que continuam até hoje.

A situação é complicada pelas crescentes expectativas de aplicativos da web modernos. Alguns desafios emergentes que as abordagens modernas de gestão do estado devem suportar:

  • Primeiros aplicativos offline usando técnicas avançadas de assinatura e armazenamento em cache
  • Código conciso e reutilização de código para requisitos de tamanho de pacote cada vez menores
  • Demanda por experiências de usuário cada vez mais sofisticadas por meio de animações de alta fidelidade e atualizações em tempo real

(Re)emergência de máquinas de estado finito e gráficos de estado

Máquinas de estado finito têm sido usadas extensivamente para desenvolvimento de software em certos setores onde a robustez de aplicativos é crítica, como aviação e finanças. Também está ganhando popularidade para o desenvolvimento front-end de aplicativos da Web, por exemplo, pela excelente biblioteca XState.

A Wikipedia define uma máquina de estado finito como:

Uma máquina abstrata que pode estar em exatamente um de um número finito de estados a qualquer momento. O FSM pode mudar de um estado para outro em resposta a algumas entradas externas; a mudança de um estado para outro é chamada de transição. Um FSM é definido por uma lista de seus estados, seu estado inicial e as condições para cada transição.

E mais:

Um estado é uma descrição do status de um sistema que está esperando para executar uma transição.

FSMs em sua forma básica não se adaptam bem a grandes sistemas devido ao problema de explosão de estado. Recentemente, os statecharts UML foram criados para estender FSMs com hierarquia e simultaneidade, que são habilitadores para amplo uso de FSMs em aplicações comerciais.

Declare sua lógica de aplicativo

Primeiro, como é um FSM como código? Existem várias maneiras de implementar uma máquina de estado finito em JavaScript.

  • Máquina de estado finito como uma instrução switch

Aqui está uma máquina descrevendo os possíveis estados em que um JavaScript pode estar, implementado usando uma instrução switch:

 const initialState = { type: 'idle', error: undefined, result: undefined }; function transition(state = initialState, action) { switch (action) { case 'invoke': return { type: 'pending' }; case 'resolve': return { type: 'completed', result: action.value }; case 'error': return { type: 'completed', error: action.error ; default: return state; } }

Esse estilo de código será familiar para desenvolvedores que usaram a popular biblioteca de gerenciamento de estado Redux.

  • Máquina de estado finito como um objeto JavaScript

Aqui está a mesma máquina implementada como um objeto JavaScript usando a biblioteca JavaScript XState:

 const promiseMachine = Machine({ id: "promise", initial: "idle", context: { result: undefined, error: undefined, }, states: { idle: { on: { INVOKE: "pending", }, }, pending: { on: { RESOLVE: "success", REJECT: "failure", }, }, success: { type: "final", actions: assign({ result: (context, event) => event.data, }), }, failure: { type: "final", actions: assign({ error: (context, event) => event.data, }), }, }, });

Enquanto a versão XState é menos compacta, a representação do objeto tem várias vantagens:

  1. A máquina de estado em si é um JSON simples, que pode ser facilmente persistido.
  2. Por ser declarativo, a máquina pode ser visualizada.
  3. Se estiver usando o TypeScript, o compilador verifica se apenas as transições de estado válidas são executadas.

O XState suporta gráficos de estado e implementa a especificação SCXML, o que o torna adequado para uso em aplicativos muito grandes.

Visualização de gráficos de estado de uma promessa:

Máquina de estado finito de uma promessa
Máquina de estado finito de uma promessa.

Práticas recomendadas do XState

A seguir estão algumas práticas recomendadas a serem aplicadas ao usar o XState para ajudar a manter os projetos sustentáveis.

Efeitos colaterais separados da lógica

O XState permite que os efeitos colaterais (que incluem atividades como log ou solicitações de API) sejam especificados independentemente da lógica da máquina de estado.

Isso tem os seguintes benefícios:

  1. Ajude a detecção de erros lógicos mantendo o código da máquina de estado o mais limpo e simples possível.
  2. Visualize facilmente a máquina de estado sem precisar remover o clichê extra primeiro.
  3. Testes mais fáceis da máquina de estado injetando serviços simulados.
 const fetchUsersMachine = Machine({ id: "fetchUsers", initial: "idle", context: { users: undefined, error: undefined, nextPage: 0, }, states: { idle: { on: { FETCH: "fetching", }, }, fetching: { invoke: { src: (context) => fetch(`url/to/users?page=${context.nextPage}`).then((response) => response.json() ), onDone: { target: "success", actions: assign({ users: (context, event) => [...context.users, ...event.data], // Data holds the newly fetched users nextPage: (context) => context.nextPage + 1, }), }, onError: { target: "failure", error: (_, event) => event.data, // Data holds the error }, }, }, // success state.. // failure state.. }, });

Embora seja tentador escrever máquinas de estado dessa maneira enquanto você ainda está fazendo as coisas funcionarem, uma melhor separação de preocupações é alcançada passando os efeitos colaterais como opções:

 const services = { getUsers: (context) => fetch( `url/to/users?page=${context.nextPage}` ).then((response) => response.json()) } const fetchUsersMachine = Machine({ ... states: { ... fetching: { invoke: { // Invoke the side effect at key: 'getUsers' in the supplied services object. src: 'getUsers', } on: { RESOLVE: "success", REJECT: "failure", }, }, ... }, // Supply the side effects to be executed on state transitions. { services } });

Isso também permite testes de unidade fáceis da máquina de estado, permitindo simulação explícita de buscas do usuário:

 async function testFetchUsers() { return [{ name: "Peter", location: "New Zealand" }]; } const machine = fetchUsersMachine.withConfig({ services: { getUsers: (context) => testFetchUsers(), }, });

Dividindo grandes máquinas

Nem sempre é imediatamente óbvio a melhor forma de estruturar um domínio de problema em uma boa hierarquia de máquina de estado finito ao iniciar.

Dica: use a hierarquia de seus componentes de interface do usuário para ajudar a orientar esse processo. Consulte a próxima seção sobre como mapear máquinas de estado para componentes de interface do usuário.

Um grande benefício do uso de máquinas de estado é modelar explicitamente todos os estados e transições entre estados em seus aplicativos para que o comportamento resultante seja claramente entendido, facilitando a identificação de erros ou lacunas de lógica.

Para que isso funcione bem, as máquinas precisam ser mantidas pequenas e concisas. Felizmente, compor máquinas de estado hierarquicamente é fácil. No exemplo dos gráficos de estado canônicos de um sistema de semáforo, o próprio estado “vermelho” se torna uma máquina de estado filho. A máquina “light” pai não está ciente dos estados internos de “red”, mas decide quando entrar em “red” e qual é o comportamento pretendido ao sair:

Exemplo de semáforo usando gráficos de estado
Exemplo de semáforo usando gráficos de estado.

1-1 Mapeamento de máquinas de estado para componentes de interface do usuário com estado

Veja, por exemplo, um site de comércio eletrônico muito simplificado e fictício que possui as seguintes visualizações React:

 <App> <SigninForm /> <RegistrationForm /> <Products /> <Cart /> <Admin> <Users /> <Products /> </Admin> </App>

O processo de geração de máquinas de estado correspondentes às visualizações acima pode ser familiar para aqueles que usaram a biblioteca de gerenciamento de estado Redux:

  1. O componente tem estado que precisa ser modelado? Por exemplo, Admin/Produtos não; buscas paginadas para o servidor mais uma solução de cache (como SWR) podem ser suficientes. Por outro lado, componentes como SignInForm ou Cart geralmente contêm estados que precisam ser gerenciados, como dados inseridos em campos ou o conteúdo atual do carrinho.
  2. As técnicas de estado local (por exemplo, setState() / useState() do React) são suficientes para capturar o problema? Rastrear se o modal pop-up do carrinho está aberto no momento dificilmente requer o uso de uma máquina de estado finito.
  3. É provável que a máquina de estado resultante seja muito complexa? Nesse caso, divida a máquina em várias menores, identificando oportunidades para criar máquinas filhas que possam ser reutilizadas em outro lugar. Por exemplo, as máquinas SignInForm e RegistrationForm podem invocar instâncias de um textFieldMachine filho para modelar a validação e o estado dos campos de email, nome e senha do usuário.

Quando usar um modelo de máquina de estado finito

Embora os gráficos de estado e os FSMs possam resolver alguns problemas desafiadores de maneira elegante, decidir as melhores ferramentas e abordagens a serem usadas para um aplicativo específico geralmente depende de vários fatores.

Algumas situações em que o uso de máquinas de estado finito brilha:

  • Seu aplicativo inclui um componente de entrada de dados considerável em que a acessibilidade ou visibilidade do campo é regida por regras complexas: por exemplo, entrada de formulário em um aplicativo de sinistros de seguro. Aqui, os FSMs ajudam a garantir que as regras de negócios sejam implementadas de forma robusta. Além disso, os recursos de visualização de gráficos de estado podem ser usados ​​para ajudar a aumentar a colaboração com partes interessadas não técnicas e identificar requisitos de negócios detalhados no início do desenvolvimento.
  • Para funcionar melhor em conexões mais lentas e oferecer experiências de alta fidelidade aos usuários , os aplicativos da Web devem gerenciar fluxos de dados assíncronos cada vez mais complexos. Os FSMs modelam explicitamente todos os estados em que um aplicativo pode estar e os gráficos de estado podem ser visualizados para ajudar a diagnosticar e resolver problemas de dados assíncronos.
  • Aplicativos que exigem muita animação sofisticada baseada em estado. Para animações complexas, técnicas de modelagem de animações como fluxos de eventos ao longo do tempo com RxJS são populares. Para muitos cenários, isso funciona bem, no entanto, quando a animação rica é combinada com uma série complexa de estados conhecidos, os FSMs fornecem “pontos de descanso” bem definidos entre os quais as animações fluem. FSMs combinados com RxJS parecem a combinação perfeita para ajudar a entregar a próxima onda de experiências de usuário expressivas e de alta fidelidade.
  • Aplicativos rich client , como edição de fotos ou vídeos, ferramentas de criação de diagramas ou jogos em que grande parte da lógica de negócios reside no lado do cliente. Os FSMs são inerentemente desacoplados da estrutura ou bibliotecas de interface do usuário e são fáceis de escrever testes para permitir que aplicativos de alta qualidade sejam iterados rapidamente e enviados com confiança.

Advertências sobre máquinas de estado finito

  • A abordagem geral, as melhores práticas e a API para bibliotecas de diagramas de estado, como XState, são novidade para a maioria dos desenvolvedores front-end, que exigirão investimento de tempo e recursos para se tornarem produtivos, principalmente para equipes menos experientes.
  • Semelhante à advertência anterior, enquanto a popularidade do XState continua a crescer e está bem documentada, as bibliotecas de gerenciamento de estado existentes, como Redux, MobX ou React Context, têm muitos seguidores que fornecem uma riqueza de informações on-line que o XState ainda não corresponde.
  • Para aplicativos que seguem um modelo CRUD mais simples, as técnicas de gerenciamento de estado existentes combinadas com uma boa biblioteca de cache de recursos, como SWR ou React Query, serão suficientes. Aqui, as restrições extras fornecidas pelos FSMs, embora incrivelmente úteis em aplicativos complexos, podem retardar o desenvolvimento.
  • As ferramentas são menos maduras do que outras bibliotecas de gerenciamento de estado, com o trabalho ainda em andamento no suporte aprimorado ao TypeScript e nas extensões devtools do navegador.

Empacotando

A popularidade e a adoção da programação declarativa na comunidade de desenvolvimento web continuam a aumentar.

Enquanto o desenvolvimento web moderno continua a se tornar mais complexo, bibliotecas e estruturas que adotam abordagens de programação declarativa surgem com frequência cada vez maior. A razão parece clara – abordagens mais simples e descritivas para escrever software precisam ser criadas.

O uso de linguagens fortemente tipadas, como o TypeScript, permite que as entidades no domínio do aplicativo sejam modeladas de forma sucinta e explícita, o que reduz a chance de erros e a quantidade de código de verificação propenso a erros que precisa ser manipulado. A adoção de máquinas de estado finito e gráficos de estado no front end permite que os desenvolvedores declarem a lógica de negócios de um aplicativo por meio de transições de estado, permitindo o desenvolvimento de ferramentas de visualização ricas e aumentando a oportunidade de colaboração próxima com não desenvolvedores.

Quando fazemos isso, mudamos nosso foco dos detalhes práticos de como o aplicativo funciona para uma visão de nível superior que nos permite focar ainda mais nas necessidades do cliente e criar valor duradouro.