Artigos

Elixir: um passeio por OTP

Introdução

Nesta série de publicações, falaremos sobre o que é OTP e explicaremos suas grandes vantagens para estruturar seu código, eliminar código repetitivo, trocar código em execução, supervisionar suas aplicações e muito mais com o conhecido framework OTP. Vamos começar.

Um breve esclarecimento: para acompanhar esta série, presumimos que você tenha erlang e elixir instalados com seus ambientes de desenvolvimento. Caso contrário, pode fazer isso aqui.

O que é OTP?

OTP significa Open Telecom Platform. A plataforma surgiu de um ótimo grupo de quatro desenvolvedores da ERICSSON, uma empresa de telecomunicações em Estocolmo, Suécia. Mas o nome completo ficou, por assim dizer, fixo, pois, embora OTP tenha sido criado para resolver problemas de centrais e comutadores telefônicos, também resolve problemas gerais que enfrentamos hoje no desenvolvimento web. Agora é uma ferramenta de propósito geral para gerenciar aplicações grandes, e todos a chamam pela sigla: OTP.

Você pode pensar em OTP como um conjunto de ferramentas que inclui:

  • O interpretador e compilador de Erlang
  • Dialyzer, uma ferramenta de análise estática
  • Mnesia, um banco de dados distribuído
  • Erlang Term Storage (ETS), um banco de dados em memória
  • Um depurador
  • Um rastreador de eventos
  • Uma ferramenta de gestão de lançamentos

OTP também define os sistemas como árvores hierárquicas de applications; uma application é um conjunto de processos de elixir modelados com algum comportamento OTP.

Comportamentos OTP

Acho especialmente útil pensar nos comportamentos OTP como padrões de projeto para processos de Elixir. Há quatro comportamentos principais muito usados em aplicações elixir, mas não estamos limitados a esses quatro: podemos criar os nossos, o que é muito interessante. A tabela mostra os principais e para que são usados.

Comportamento Para que é usado
GenServer Abstração cliente-servidor
GenEvent Funcionalidade de tratamento de eventos
Supervisor Funcionalidade de supervisão de processos
Application Trabalhar com aplicações e definir seus callbacks

Primeiro comportamento: GenServer.

Se queremos criar uma abstração de uma relação cliente/servidor, podemos pensar que é como uma function: chamamos essa function com uma mensagem pedindo dados e ela retorna outra mensagem com informações importantes. Um servidor também precisa de um state e da capacidade de mudá-lo conforme o cliente quer. Portanto, a receita básica consiste em algum tipo de messaging system para gerenciar mensagens recebidas e enviadas e algo que cuide do estado e permita mudá-lo sob demanda.

Se precisamos disso na aplicação, implementar seria bastante tedioso, pois não se relaciona à lógica de negócio nem ao próprio sistema. É aqui que GenServer nos salva: GenServer faz exatamente o que precisamos, sem reinventar a roda.

Suponha que precisamos de um serviço que obtenha informações do github a partir de um nome de usuário e seja consumido por outras aplicações do sistema. Vamos ver como é fácil com o comportamento GenServer e a correspondência de padrões.

Começamos criando o projeto com mix new github no console. Depois criamos o arquivo libs/server.ex com isto:

defmodule Github.Server do
    use GenServer
end

Essas linhas incorporam toda a funcionalidade de GenServer ao escopo do módulo com alguma magia de metaprogramação. Oferecem callbacks que nos ajudarão. Por exemplo, GenServer.start_link/3 vincula o processo do servidor ao processo que chama start_link e chama o callback init/1, que precisamos implementar.

Quando GenServer.start_link/3 é chamado, invoca Github.Server.init/1 e espera que Github.Server.init/1 retorne antes de retornar. Os valores que Github.Server.init/1 pode retornar são específicos e correspondem a uma destas tuplas:

  • {:ok, state}
  • {:ok, state, timeout}
  • :ignore
  • {:stop, reason}

state será o estado do servidor e pode ser qualquer estrutura de dados válida, como uma árvore ou lista; no nosso caso, será um mapa.

defmodule Github.Server do
    use GenServer

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

    ## Server Callbacks
    def init(:ok) do
        {:ok, %{}}
    end
end

Separar seções do código com comentários, como no exemplo, é uma convenção comum em aplicações erlang/elixir e permite ver rapidamente o que acontece. GenServer.start_link/3 aceita três argumentos: o nome do módulo em que init/1 foi definido; os argumentos para init/1, de que não precisamos neste caso, então basta o átomo :ok; e uma lista de opções para GenServer.start_link/3, como um nome para registrar o processo e informações de depuração. Por enquanto, uma lista vazia atende.

Executamos iex -S mix e testamos nosso servidor assim:

iex(1)> {:ok, pid} = Github.Server.start_link
{:ok, #PID<0.148.0>}
iex(2)> pid
#PID<0.148.0>
iex(3)>

Ótimo, mas nosso servidor não faz muito. Queremos enviar um nome de usuário e obter informações do github sobre ele. Como conseguimos isso? Com GenServer.call/3, que faz uma solicitação synchronous ao servidor e espera uma resposta.

GenServer.call/3 chama o callback Github.Server.handle_call/3. Essa função que implementaremos tem tipos específicos de resposta, como init/1. Estas são as respostas válidas:

  • {:reply, reply, state}
  • {:reply, reply, state, timeout}
  • {:reply, reply, state, :hibernate}
  • {:noreply, state}
  • {:noreply, state, timeout}
  • {:noreply, state, hibernate}
  • {:stop, reason, reply, state}
  • {:stop, reason, state}

No nosso caso, queremos responder algo como {:reply, user_info, state}. Vamos ver a primeira parte do código.

defmodule Github.Server do
    use GenServer

    @github_api_url "https://api.github.com/users"

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

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


   ## Servers Callbacks

  ...
end

@github_api_url é uma variável cujo valor é a URL que queremos acessar. Criamos info_of, que espera um nome de usuário e o pid do processo que a chamou para saber quem chama e retornar a resposta. Primeiro, precisamos de duas dependências: uma para fazer solicitações http e outra para analisar arquivos JSON. Instalaremos json e httpoison.

Depois de instalar as dependências, handle_call deve se parecer com isto:

defmodule Github.Server do

    (...)

    def handle_call({:username, username}, _from, state) do
        case get_info_of(username) do
            {:ok, user_info} ->
                new_state = update_state(state, user_info, username)
                {:reply, user_info, new_state}
            _ ->
                {:reply, :error, state}
        end
    end

    ## Helper Functions
    defp get_url_for(username) do
        "#{@github_api_url}/#{username}"
    end

    defp get_info_of (username) do
        username
        |> get_url_for
        |> HTTPoison.get
        |> parse_response
    end

    defp parse_response ({:ok, %HTTPoison.Response{body: body, status_code: 200}}) do
        body
        |> JSON.decode!
        |> format_user_info
    end

    defp parse_response(_), do: :error

    defp format_user_info(info) do
        new_info = info
        |> Map.take(["name", "public_gists", "public_repos"])
        {:ok, new_info}
    end

    defp update_state(state, user_info, username) do
        Map.put(state, username, user_info)
    end
end

Para tornar a implementação de handle_call mais clara e organizada, dividimos em pequenas funções auxiliares. Vamos analisar as linhas de cima para baixo.

handle_call usa correspondência de padrões, uma instrução case e chama get_info_of, que pode retornar os dados do usuário ou um erro. Com a correspondência de padrões, observamos os dois casos: se handle_call retorna algo parecido com {:ok, user_info,}, atualizamos o estado e retornamos a solicitação correspondente. Nos outros casos, usamos o curinga _ para corresponder a qualquer outro valor de handle_call.

Pessoalmente, acho get_info_of muito descritiva: recebe o nome de usuário, obtém sua URL com get_url_for, usa HTTPoison.get para fazer a solicitação http e, por fim, chama parse_response com a resposta.

parse_response tem duas definições e usa correspondência de padrões para aceitar a resposta esperada, apenas as que têm código de status 200, ou gerar um erro.

Se parse_response recebe uma resposta com código 200, analisa o JSON e chama format_user_inof.

Por fim, format_user_info pega apenas as informações que queremos mostrar.

É isso! Obtemos o comportamento esperado.

Vamos fazer outro exemplo com handle_call por diversão: um endpoint de cliente chamado get_state que retorna o estado atual do servidor pode ser útil. Para isso, precisamos de uma solicitação síncrona a Github.Server, então usamos handle_call desta forma:

defmodule Github.Server do

    # Client API
    def get_state(pid), do: GenServer.call(pid, {:get_state})

    # Server Callbacks
    defp handle_call({:get_state), do {:reply, state, state}
end

Lembre que precisa retornar uma resposta válida. Veja esta tabela, que resume os callbacks de GenServer e suas respostas válidas:

Callbacks Respostas válidas
init {:ok, state}
{:ok, state, timeout}
:ignore
{:stop, reason}
handle_call {:reply, reply, state}
{:reply, reply, state, timeout}
{:reply, reply, state, :hibernate}
{:noreply, state}
{:noreply, state, timeout}
{:noreply, state, hibernate}
{:stop, reason, reply, state}
{:stop, reason, state}
handle_cast {:noreply, state}
{:noreply, state, timeout}
{:noreply, state, :hibernate}
{:stop, reason, state}
handle_info {:noreply, state}
{:noreply, state, timeout}
{:stop, reason, state}
terminate :ok
code_change {:ok, new_state}
{:error, reason}

Leituras relacionadas