DEV Community

Cover image for Implementando estados mutáveis e arquitetura cliente-servidor com funções recursivas e troca de mensagens entre processos

Implementando estados mutáveis e arquitetura cliente-servidor com funções recursivas e troca de mensagens entre processos

O objetivo deste artigo é apresentar como é possível implementar estados mutáveis em uma linguagem funcional sem variáveis globais, utilizando funções recursivas. Para ilustrar, utilizaremos como exemplo os processos e troca de mensagens no sistema Elixir. A abstração dessas operações nos permite implementar uma arquitetura cliente-servidor com uma clara separação entre requisições de consulta e atualização dos dados por parte do usuário, e a computação dos dados por parte da máquina.

Estados mutáveis

Computadores, como implementações da máquina de Turing, são essencialmente máquinas de estado mutável. Um tocador de música, por exemplo, embora não tenha todas os atributos de uma máquina de Turing, também é uma máquina de estado mutável: ele registra uma música atual e, de acordo com o tempo ou o input de um usuário, aciona funções de tocar, pausar ou trocar de música. A música que está tocando é registrada na chamada variável global.

Esta é a base da computação imperativa, e pode ser exemplificada pelo código:

let song = {title: "Rainbow - Stargazer"}
function select(title){ song.title = title }
function listen() { console.log("Now playing: " + song.title) }

listen()
// Now playing: Rainbow - Stargazer
select("Rainbow - Self Portrait")
listen()
// Now playing: Rainbow - Self Portrait
Enter fullscreen mode Exit fullscreen mode

A programação funcional, implementação do cálculo lambda para sistemas computacionais, opera basicamente com funções puras para a computação de dados. Por definição, é um paradigma no qual não existem variáveis globais. Mas se o que dissemos é verdade, e computadores são implementações da máquina de Turing, uma máquina de estados mutáveis, como é possível a computação sem variáveis globais? Ou, perguntando de outra forma: como seria possível, sem variáveis globais, implementar uma máquina de estados mutáveis, capaz se registrar e, mais importante, alterar os dados registrados após a computação?

Processos e troca de mensagens

Antes de apresentar a solução para este problema, precisamos esclarecer duas ideias fundamentais para nossa implementação: processos e troca de mensagens.

Um processo em Elixir é uma thread de execução que roda na BEAM, máquina virtual Erlang. Processos são concorrenciais, leves (com baixo consumo de memória), e isolados uns dos outros. Invocamos processos por meio da funçao spawn/1, que retorna um PID, endereço do processo na máquina virtual.

Podemos, por exemplo, invocar um processo que roda uma função anônima:

spawn fn -> true end
#PID<0.109.0>
Enter fullscreen mode Exit fullscreen mode

Processos, embora independentes, são capazes de mandar e receber mensagens uns para os outros. Mensagens enviadas a um processo são registradas em sua caixa de mensagens, que ele lê e responde de acordo com as especificações determinadas.

Enviamos mensagens a qualquer processo ativo na máquina virtual por meio da função send/2, que recebe como argumentos um PID, endereço do destinatário, e a mensagem (geralmente um atom ou tuple). Os processos recebem mensagens por meio do bloco receive, que especifica as mensagens que pode receber e como responder.

O código a seguir ilustra como processos podem enviar e receber mensagens. No exemplo, self() retorna o PID do processo que está rodando atualmente, e que receberá a mensagem:

restaurante = self()
send restaurante, {:pedido, "pizza"}

receive do
    {:pedido, item} -> IO.puts "Cozinhando: #{item}"
end
# Cozinhando: pizza
Enter fullscreen mode Exit fullscreen mode

É importante ressaltar que, tal qual em uma comunicação entre pessoas, a comunicação entre processos só pode ocorrer por meio de mensagens que são capazes de entender, interpretar e responder. No exemplo acima, o processo restaurante só entende uma mensagem: {:pedido, item}. Poderíamos enviar outras mensagens para o processo, como :cancelar_pedido ou {:reclamacao, "a comida não estava boa"}, mas ele é incapaz de responder, porque não entende, isto é, não está devidamente configurado para essas mensagens.

Implementação de estados mutáveis

Com essas duas ferramentas à nossa disposição, podemos enfrentar o nosso problema anterior: implementar estados mutáveis sem recorrer a variáveis globais. Para resolvê-lo, vamos dividi-lo em duas etapas: como registrar dados e como atualizar dados.

Processos instanciados na VM executam a ação para que foram criados, e extinguem-se. Isso não ocorre, porém, com funções recursivas, que chamam a si mesmas em sua execução. Podemos, portanto, utilizá-las para registrar dados como parâmetros.

Retornemos ao nosso exemplo do tocador de música. Podemos registrar a música que está tocando em um processo:

def player(%{song: title}), do: player(%{song: title})

player = spawn fn -> player(%{song: "Rainbow - Stargazer"}) end
#PID<0.109.0>
Enter fullscreen mode Exit fullscreen mode

O processo player vai rodar infinitamente, registrando o valor passado como argumento da função. Entretanto, ainda é incapaz de trocar de música, e não é muito útil. O exemplo ilustra como registrar um dado, mas não explica como ele pode ser mudado.

Processos são isolados uns dos outros, e uma vez chamados não podem ser redefindos no meio de sua execução. O que precisamos é dum efinir processo que registre os dados como argumentos, e tenha em si próprio a capacidade chamar-se recursivamente com dados passados do exterior. A forma como passamos dados a um processo é por meio de mensagens.

Implementamos nosso tocador de música utilizando o que aprendemos até aqui:

def player(%{song: title}) do
    receive do
        {:select, new_song} -> 
            player(%{song: new_song})
        :listen -> 
            IO.puts "Now playing: #{title}"
            player(%{song: title})
    end
end

player = spawn fn -> player(%{song: "Rainbow - Stargazer"}) end

send player, :listen
# Now playing: Rainbow - Stargazer
send player, {:select, "Rainbow - Self Portrait"}
send player, :listen
# Now playing: Rainbow - Self Portrait
Enter fullscreen mode Exit fullscreen mode

Agora, nosso tocador de música não apenas registra a música que está tocando, mas também recebe comandos e responde condicionalmente, podendo trocar de música ou tocá-la. As chamadas recursivas no final de cada função (tail recursion) mantêm os processos ativos.

Vimos até aqui, então, exatamente o que procurávamos no problema inicial: uma maneira de implementar estados mutáveis em um sistema de programação funcional sem variáveis globais. Pode ser interessante, para continuar essa discussão, comparar a solução implementada em Elixir com a de outras linguagens funcionais

Agora que temos o conhecimento e as ferramentas necessárias para implementar uma máquina de estados mutáveis, somos capazes de desenvolver sistemas mais complexos e úteis. Pense nos exemplos de máquinas que você conhece: um semáforo de trânsito, um termostato, um videogame; todos esses sistemas, do mais simples ao mais complexo, gravam estados atuais e o atualizam de acordo com eventos e operações pré-programados.

Arquitetura cliente-servidor

Vamos voltar ao nosso exemplo do tocador de música. Imaginemos um sistema que permite a vários usuários consultarem a temperatura atual em diferentes lugares do mundo: o usuário descreve em uma interface o lugar desejado, e o sistema aciona uma estação meteorológica remota, que mede a temperatura e devolve ao usuário como resposta. Esse modelo, que separa a interface de usuário do núcleo de processamento, é chamado arquitetura cliente-servidor.

Entendida desta maneira, a arquitetura cliente-servidor não está restrita ao domínio das redes de Internet; é um modelo conceitual, uma abstração da máquina de estados, para implementar o sistema de forma clara e organizada. Para ilustrar, continuaremos o desenvolvimento do mesmo sistema, nosso tocador de música.

A forma ideal de implementar um sistema com modelo cliente-servidor em Elixir é usando o GenServer. É um sistema construído sobre os fundamentos que apresentamos anteriormente -- processos e troca de mensagens -- que encapsula e proporciona um padrão (behaviour) para a implementação, em que separamos as funções de interface e de processamento dos dados (callbacks).

Podemos reescrever nosso tocador de música utilizando o GenServer:

defmodule Blackmore.GenserverPlayer do
  use GenServer

  ## Client API
  def start_link(opts) do
    GenServer.start_link(__MODULE__, :ok, opts)
  end

  def select_song(pid, title) do
    GenServer.cast(pid, {:select_song, title})
  end

  def listen(pid) do
    GenServer.call(pid, {:listen})
  end

  ## Server callbacks
  @impl true
  def init(:ok) do
    {:ok, %{song: nil}}
  end

  @impl true
  def handle_cast({:select, title}, song) do
    {:noreply, %{song | song: title}}
  end

  @impl true
  def handle_call({:listen}, _from, %{song: title}) do
    {:reply, title, %{song: title}}
  end
end
Enter fullscreen mode Exit fullscreen mode

Apesar de utilizar mais linhas para cumprir um propósito semelhante, percebemos agora uma clara organização, separando as operações realizadas pelo cliente e pelo servidor. Nossos comandos -- as mensagens passadas para o processo -- não estão soltas, mas encapsuladas em funções com finalidades distintas e claras.

Entre os recursos proporcionados pelo GenServer, vale a pena observar a possibilidade de designar callbacks síncronos, isto é, que aguardam o retorno de uma resposta, e assíncronos, que devolvem apenas a promessa de execução do comando requisitado. O GenServer também proporciona um gerenciamento de erros e uma integração eficaz com outros componentes de um sistema Elixir, como Supervisors e Application. Este é um tópico que merece um artigo dedicado inteiramente a ele, então ficará para uma próxima oportunidade.

Conclusão

O controle de estados mutáveis é a base da computação, tanto em um sentido teorético como físico. Para computar, é necessário registrar valores e atualizá-los segundo determinadas operações. Tal fato nos impõe um problema quando pensamos em implementar uma sistema computacional segundo o paradigma funcional, isto é, baseado em funções puras e sem variáveis de escopo global.

Vimos, porém, que lançando mão de funcões recursivas e troca de mensagens entre processos, é possível implementar uma máquina de estados mutáveis, gravando dados nos argumentos das funções, e atualizando-os por meio de mensagens aos processos.

Tais fundamentos nos permitem o desenvolvimento de um sistema no modelo cliente-servidor, em que o usuário é capaz de realizar requisições a um processo por meio de uma interface clara e isolada das funções de processamento.

Top comments (0)