Um guia para programação orientada a processos em Elixir e OTP
Publicados: 2022-03-11As pessoas gostam de categorizar linguagens de programação em paradigmas. Existem linguagens orientadas a objetos (OO), linguagens imperativas, linguagens funcionais, etc. Isso pode ser útil para descobrir quais linguagens resolvem problemas semelhantes e que tipos de problemas uma linguagem pretende resolver.
Em cada caso, um paradigma geralmente tem um foco e técnica “principal” que é a força motriz dessa família de linguagens:
Em linguagens OO, é a classe ou objeto como forma de encapsular o estado (dados) com manipulação desse estado (métodos).
Em linguagens funcionais, pode ser a manipulação das próprias funções ou os dados imutáveis passados de função para função.
Enquanto Elixir (e Erlang antes dele) são frequentemente categorizados como linguagens funcionais porque exibem os dados imutáveis comuns às linguagens funcionais, eu diria que eles representam um paradigma separado de muitas linguagens funcionais . Eles existem e são adotados por causa da existência de OTP, então eu os classificaria como linguagens orientadas a processos .
Neste post, vamos capturar o significado do que é programação orientada a processos ao usar essas linguagens, explorar as diferenças e semelhanças com outros paradigmas, ver as implicações para treinamento e adoção e terminar com um pequeno exemplo de programação orientada a processos.
O que é programação orientada a processos?
Vamos começar com uma definição: A programação orientada a processos é um paradigma baseado em Comunicar Processos Seqüenciais, originalmente de um artigo de Tony Hoare em 1977. Isso também é popularmente chamado de modelo ator de simultaneidade. Outras línguas com alguma relação com este trabalho original incluem Occam, Limbo e Go. O documento formal trata apenas da comunicação síncrona; a maioria dos modelos de atores (incluindo OTP) também usa comunicação assíncrona. É sempre possível construir comunicação síncrona em cima da comunicação assíncrona, e OTP suporta ambas as formas.
Nesta história, a OTP criou um sistema para computação tolerante a falhas através da comunicação de processos sequenciais. As facilidades tolerantes a falhas vêm de uma abordagem “deixe-o falhar” com sólida recuperação de erros na forma de supervisores e o uso de processamento distribuído habilitado pelo modelo de ator. O “deixe falhar” pode ser contrastado com “impedir que falhe”, pois o primeiro é muito mais fácil de acomodar e foi comprovado no OTP ser muito mais confiável do que o último. A razão é que o esforço de programação necessário para evitar falhas (como mostrado no modelo de exceção verificado em Java) é muito mais complexo e exigente.
Assim, a programação orientada a processos pode ser definida como um paradigma no qual a estrutura do processo e a comunicação entre os processos de um sistema são as principais preocupações .
Programação orientada a objetos vs. orientada a processos
Na programação orientada a objetos, a estrutura estática de dados e funções é a principal preocupação. Quais métodos são necessários para manipular os dados incluídos e quais devem ser as conexões entre objetos ou classes. Assim, o diagrama de classes da UML é um excelente exemplo desse foco, como visto na Figura 1.
Pode-se notar que uma crítica comum à programação orientada a objetos é que não há fluxo de controle visível. Como os sistemas são compostos por um grande número de classes/objetos definidos separadamente, pode ser difícil para uma pessoa menos experiente visualizar o fluxo de controle de um sistema. Isso é especialmente verdadeiro para sistemas com muita herança, que usam interfaces abstratas ou não têm tipagem forte. Na maioria dos casos, torna-se importante para o desenvolvedor memorizar uma grande parte da estrutura do sistema para ser eficaz (quais classes têm quais métodos e quais são usadas de que maneiras).
A força da abordagem de desenvolvimento orientado a objetos é que o sistema pode ser estendido para suportar novos tipos de objetos com impacto limitado no código existente, desde que os novos tipos de objetos estejam em conformidade com as expectativas do código existente.
Programação orientada a processos versus funcional
Muitas linguagens de programação funcionais abordam a simultaneidade de várias maneiras, mas seu foco principal é a passagem de dados imutáveis entre funções ou a criação de funções de outras funções (funções de ordem superior que geram funções). Na maioria das vezes, o foco da linguagem ainda é um espaço de endereço único ou executável, e as comunicações entre esses executáveis são tratadas de uma maneira específica do sistema operacional.
Por exemplo, Scala é uma linguagem funcional construída na Java Virtual Machine. Embora possa acessar recursos Java para comunicação, não é uma parte inerente da linguagem. Embora seja uma linguagem comum usada na programação do Spark, é novamente uma biblioteca usada em conjunto com a linguagem.
Um ponto forte do paradigma funcional é a capacidade de visualizar o fluxo de controle de um sistema dada a função de nível superior. O fluxo de controle é explícito em que cada função chama outras funções e passa todos os dados de uma para a próxima. No paradigma funcional não há efeitos colaterais, o que facilita a determinação do problema. O desafio com sistemas funcionais puros é que os “efeitos colaterais” precisam ter um estado persistente. Em sistemas bem arquitetados, a persistência do estado é tratada no nível superior do fluxo de controle, permitindo que a maior parte do sistema seja livre de efeitos colaterais.
Elixir/OTP e Programação Orientada a Processos
Em Elixir/Erlang e OTP, as primitivas de comunicação fazem parte da máquina virtual que executa a linguagem. A capacidade de comunicação entre processos e entre máquinas é incorporada e central ao sistema de linguagem. Isso enfatiza a importância da comunicação nesse paradigma e nesses sistemas de linguagem.
Enquanto a linguagem Elixir é predominantemente funcional em termos da lógica expressa na linguagem, seu uso é orientado ao processo .
O que significa ser orientado a processos?
Ser orientado a processos, conforme definido neste post, é projetar um sistema primeiro na forma de quais processos existem e como eles se comunicam. Uma das principais questões é quais processos são estáticos e quais são dinâmicos, quais são gerados sob demanda para solicitações, quais servem a um propósito de longa duração, quais mantêm o estado compartilhado ou parte do estado compartilhado do sistema e quais recursos de o sistema são inerentemente concorrentes. Assim como OO tem tipos de objetos e funcional tem tipos de funções, a programação orientada a processos tem tipos de processos.
Como tal, um projeto orientado a processos é a identificação do conjunto de tipos de processos necessários para resolver um problema ou atender a uma necessidade .
O aspecto do tempo entra rapidamente nos esforços de projeto e requisitos. Qual é o ciclo de vida do sistema? Que necessidades personalizadas são ocasionais e quais são constantes? Onde está a carga no sistema e qual é a velocidade e o volume esperados? É somente depois que esses tipos de considerações são entendidos que um design orientado a processos começa a definir a função de cada processo ou a lógica a ser executada.
Implicações do treinamento
A implicação dessa categorização para o treinamento é que o treinamento deve começar não com a sintaxe da linguagem ou exemplos “Hello World”, mas com o pensamento de engenharia de sistemas e um foco de design na alocação de processos .
As preocupações de codificação são secundárias ao design e à alocação do processo, que são melhor abordadas em um nível superior e envolvem o pensamento multifuncional sobre ciclo de vida, controle de qualidade, DevOps e requisitos de negócios do cliente. Qualquer curso de treinamento em Elixir ou Erlang deve (e geralmente inclui) incluir OTP e deve ter uma orientação de processo desde o início, não como a abordagem do tipo “Agora você pode codificar em Elixir, então vamos fazer concorrência”.
Implicações da Adoção
A implicação para adoção é que a linguagem e o sistema são mais bem aplicados a problemas que requerem comunicação e/ou distribuição de computação. Problemas que são uma carga de trabalho única em um único computador são menos interessantes nesse espaço e podem ser mais bem resolvidos com outra linguagem. Os sistemas de processamento contínuo de longa duração são o alvo principal dessa linguagem porque ela possui tolerância a falhas incorporada desde o início.
Para documentação e trabalho de design, pode ser muito útil usar uma notação gráfica (como a figura 1 para linguagens OO). A sugestão para o Elixir e a programação orientada a processos da UML seria o diagrama de sequência (exemplo na figura 2) para mostrar as relações temporais entre os processos e identificar quais processos estão envolvidos no atendimento de uma solicitação. Não há um tipo de diagrama UML para capturar o ciclo de vida e a estrutura do processo, mas ele pode ser representado com um diagrama simples de caixa e seta para tipos de processo e seus relacionamentos. Por exemplo, Figura 3:
Um Exemplo de Orientação de Processo
Por fim, veremos um pequeno exemplo de aplicação da orientação de processo a um problema. Suponha que tenhamos a tarefa de fornecer um sistema que suporte eleições globais. Esse problema é escolhido porque muitas atividades individuais são executadas em rajadas, mas a agregação ou sumarização dos resultados é desejável em tempo real e pode ter uma carga significativa.

Projeto de Processo Inicial e Alocação
Podemos ver inicialmente que a emissão de votos por cada indivíduo é uma rajada de tráfego para o sistema a partir de muitas entradas discretas, não é ordenada no tempo e pode ter alta carga. Para apoiar essa atividade, gostaríamos de um grande número de processos, todos coletando essas entradas e encaminhando-as para um processo mais central para tabulação. Esses processos poderiam estar localizados próximos às populações de cada país que estariam gerando votos e, portanto, forneceriam baixa latência. Eles reteriam os resultados locais, registrariam suas entradas imediatamente e as encaminhariam para tabulação em lotes para reduzir a largura de banda e a sobrecarga.
Podemos ver inicialmente que será necessário haver processos que rastreiem os votos em cada jurisdição em que os resultados devem ser apresentados. Vamos supor para este exemplo que precisamos acompanhar os resultados para cada país e dentro de cada país por província/estado. Para apoiar esta atividade, gostaríamos de pelo menos um processo por país realizando o cálculo e mantendo os totais atuais e outro conjunto para cada estado/província de cada país. Isso pressupõe que precisamos ser capazes de responder totais por país e estado/província em tempo real ou com baixa latência. Se os resultados puderem ser obtidos de um sistema de banco de dados, podemos escolher uma alocação de processo diferente onde os totais são atualizados por processos transitórios. A vantagem de usar processos dedicados para esses cálculos é que os resultados ocorrem na velocidade da memória e podem ser obtidos com baixa latência.
Finalmente, podemos ver que muitas e muitas pessoas estarão visualizando os resultados. Esses processos podem ser particionados de várias maneiras. Podemos querer distribuir a carga colocando processos em cada país responsável pelos resultados desse país. Os processos podem armazenar em cache os resultados dos processos de computação para reduzir a carga de consulta nos processos de computação e/ou os processos de computação podem enviar seus resultados para os processos de resultados apropriados periodicamente, quando os resultados mudam em uma quantidade significativa ou mediante a processo de computação tornando-se ocioso, indicando uma taxa de mudança mais lenta.
Em todos os três tipos de processos, podemos dimensionar os processos independentemente uns dos outros, distribuí-los geograficamente e garantir que os resultados nunca sejam perdidos por meio do reconhecimento ativo de transferências de dados entre processos.
Conforme discutido, começamos o exemplo com um design de processo independente da lógica de negócios em cada processo. Nos casos em que a lógica de negócios possui requisitos específicos para agregação de dados ou geografia que podem afetar a alocação do processo de forma iterativa. Nosso projeto de processo até agora é mostrado na figura 4.
O uso de processos separados para receber votos permite que cada voto seja recebido independentemente de qualquer outro voto, registrado no recebimento e agrupado no próximo conjunto de processos, reduzindo significativamente a carga nesses sistemas. Para um sistema que consome uma grande quantidade de dados, a redução do volume de dados pelo uso de camadas de processos é um padrão comum e útil.
Ao realizar a computação em um conjunto isolado de processos, podemos gerenciar a carga desses processos e garantir sua estabilidade e requisitos de recursos.
Ao colocar a apresentação do resultado em um conjunto isolado de processos, controlamos a carga para o resto do sistema e permitimos que o conjunto de processos seja dimensionado dinamicamente para carga.
Requisitos adicionais
Agora, vamos adicionar alguns requisitos complicadores. Vamos supor que em cada jurisdição (país ou estado), o apuramento dos votos pode resultar em um resultado proporcional, um resultado do tipo "o vencedor leva tudo" ou nenhum resultado se houver votos insuficientes em relação à população dessa jurisdição. Cada jurisdição tem controle sobre esses aspectos. Com essa mudança, os resultados dos países não são uma simples agregação dos resultados brutos da votação, mas são uma agregação dos resultados do estado/província. Isso altera a alocação do processo do original para exigir que os resultados dos processos do estado/província sejam inseridos nos processos do país. Se o protocolo usado entre a coleta de votos e os processos estado/província e província para país for o mesmo, então a lógica de agregação pode ser reutilizada, mas são necessários processos distintos contendo os resultados e seus caminhos de comunicação são diferentes, conforme mostrado na Figura 5.
O código
Para completar o exemplo, revisaremos uma implementação do exemplo no Elixir OTP. Para simplificar as coisas, este exemplo pressupõe que um servidor da Web como o Phoenix seja usado para processar solicitações reais da Web e esses serviços da Web fazem solicitações para o processo identificado acima. Isso tem a vantagem de simplificar o exemplo e manter o foco no Elixir/OTP. Em um sistema de produção, ter esses processos separados tem algumas vantagens, além de separar preocupações, permitir implantação flexível, distribuir carga e reduzir a latência. O código-fonte completo com testes pode ser encontrado em https://github.com/technomage/voting. A fonte é abreviada neste post para facilitar a leitura. Cada processo abaixo se encaixa em uma árvore de supervisão OTP para garantir que os processos sejam reiniciados em caso de falha. Consulte a fonte para obter mais informações sobre esse aspecto do exemplo.
Gravador de votos
Esse processo recebe votos, registra-os em um armazenamento persistente e agrupa os resultados em lotes para os agregadores. O módulo VoteRecoder usa Task.Supervisor para gerenciar tarefas de curta duração para registrar cada voto.
defmodule Voting.VoteRecorder do @moduledoc """ This module receives votes and sends them to the proper aggregator. This module uses supervised tasks to ensure that any failure is recovered from and the vote is not lost. """ @doc """ Start a task to track the submittal of a vote to an aggregator. This is a supervised task to ensure completion. """ def cast_vote where, who do Task.Supervisor.async_nolink(Voting.VoteTaskSupervisor, fn -> Voting.Aggregator.submit_vote where, who end) |> Task.await end end
Agregador de votos
Esse processo agrega votos dentro de uma jurisdição, calcula o resultado para essa jurisdição e encaminha os resumos de votos para o próximo processo superior (uma jurisdição de nível superior ou um apresentador de resultados).
defmodule Voting.Aggregator do use GenStage ... @doc """ Submit a single vote to an aggregator """ def submit_vote id, candidate do pid = __MODULE__.via_tuple(id) :ok = GenStage.call pid, {:submit_vote, candidate} end @doc """ Respond to requests """ def handle_call {:submit_vote, candidate}, _from, state do n = state.votes[candidate] || 0 state = %{state | votes: Map.put(state.votes, candidate, n+1)} {:reply, :ok, [%{state.id => state.votes}], state} end @doc """ Handle events from subordinate aggregators """ def handle_events events, _from, state do votes = Enum.reduce events, state.votes, fn e, votes -> Enum.reduce e, votes, fn {k,v}, votes -> Map.put(votes, k, v) # replace any entries for subordinates end end # Any jurisdiction specific policy would go here # Sum the votes by candidate for the published event merged = Enum.reduce votes, %{}, fn {j, jv}, votes -> # Each jourisdiction is summed for each candidate Enum.reduce jv, votes, fn {candidate, tot}, votes -> Logger.debug "@@@@ Votes in #{inspect j} for #{inspect candidate}: #{inspect tot}" n = votes[candidate] || 0 Map.put(votes, candidate, n + tot) end end # Return the published event and the state which retains # Votes by jourisdiction {:noreply, [%{state.id => merged}], %{state | votes: votes}} end end
Apresentador de resultados
Esse processo recebe votos de um agregador e armazena em cache esses resultados para solicitações de serviço para apresentação de resultados.
defmodule Voting.ResultPresenter do use GenStage … @doc """ Handle requests for results """ def handle_call :get_votes, _from, state do {:reply, {:ok, state.votes}, [], state} end @doc """ Obtain the results from this presenter """ def get_votes id do pid = Voting.ResultPresenter.via_tuple(id) {:ok, votes} = GenStage.call pid, :get_votes votes end @doc """ Receive votes from aggregator """ def handle_events events, _from, state do Logger.debug "@@@@ Presenter received: #{inspect events}" votes = Enum.reduce events, state.votes, fn v, votes -> Enum.reduce v, votes, fn {k,v}, votes -> Map.put(votes, k, v) end end {:noreply, [], %{state | votes: votes}} end end
Leve embora
Este post explorou Elixir/OTP a partir de seu potencial como uma linguagem orientada a processos, comparou isso com paradigmas orientados a objetos e funcionais e revisou as implicações disso para treinamento e adoção.
A postagem também inclui um pequeno exemplo de aplicação dessa orientação a um exemplo de problema. Caso você queira revisar todo o código, aqui está um link para nosso exemplo no GitHub novamente, apenas para que você não precise rolar para trás procurando por ele.
A principal lição é ver os sistemas como uma coleção de processos de comunicação. Planeje o sistema do ponto de vista do design do processo primeiro e depois do ponto de vista da codificação lógica.