Elixir: un recorrido por OTP

Introducción
En esta serie de publicaciones hablaremos de qué es OTP y explicaremos sus grandes ventajas para estructurar tu código, eliminar código repetitivo, intercambiar código en caliente, supervisar tus aplicaciones y mucho más mediante el conocido framework OTP. Empecemos.
Una breve aclaración: para seguir esta serie suponemos que tienes erlang y elixir instalados con sus entornos de desarrollo. Si no es así, puedes hacerlo aquí.
¿Qué es OTP?
OTP significa Open Telecom Platform. La plataforma surgió de un gran grupo de cuatro desarrolladores de ERICSSON, una empresa de telecomunicaciones de Estocolmo, Suecia. Pero el nombre completo quedó, por así decirlo, fijado, porque, aunque OTP se creó para resolver problemas de centrales y conmutadores telefónicos, también resuelve problemas generales que enfrentamos hoy en el desarrollo web. Ahora es una herramienta de propósito general para gestionar aplicaciones grandes y todos la llaman por su sigla: OTP.
Puedes pensar en OTP como un conjunto de herramientas que incluye:
- El intérprete y compilador de Erlang
- Dialyzer, una herramienta de análisis estático
- Mnesia, una base de datos distribuida
- Erlang Term Storage (ETS), una base de datos en memoria
- Un depurador
- Un rastreador de eventos
- Una herramienta de gestión de lanzamientos
OTP también define los sistemas como árboles jerárquicos de applications; una application es un conjunto de procesos de elixir modelados con algún comportamiento OTP.
Comportamientos OTP
Me resulta especialmente útil pensar en los comportamientos OTP como patrones de diseño para procesos de Elixir. Hay cuatro comportamientos principales muy utilizados en las aplicaciones de elixir, pero no estamos limitados a esos cuatro: podemos crear los propios, lo que es muy interesante. La tabla muestra los principales y para qué se usan.
| Comportamiento | Para qué se usa |
|---|---|
| GenServer | Abstracción cliente-servidor |
| GenEvent | Funcionalidad de manejo de eventos |
| Supervisor | Funcionalidad de supervisión de procesos |
| Application | Trabajar con aplicaciones y definir sus callbacks |
Primer comportamiento: GenServer.
Si queremos crear una abstracción de una relación cliente/servidor, podemos pensar que es como una function: llamamos a esta function con un mensaje que solicita datos y devuelve otro mensaje con información importante. Un servidor también necesita un state y la capacidad de cambiarlo según lo que quiera el cliente. Por lo tanto, la receta básica consiste en algún tipo de messaging system para gestionar mensajes entrantes y salientes y algo que se ocupe del estado y permita cambiarlo a demanda.
Si necesitamos esto en nuestra aplicación, implementarlo sería bastante aburrido porque no tiene relación con la lógica de negocio ni con el propio sistema. Aquí nos rescata GenServer: GenServer hace exactamente lo que necesitamos y no tenemos que reinventar la rueda.
Supongamos que necesitamos un servicio que obtenga información de github a partir de un nombre de usuario y que lo consuman otras aplicaciones de nuestro sistema. Veamos lo fácil que resulta con el comportamiento GenServer y la coincidencia de patrones.
Empecemos creando el proyecto con mix new github en la consola. Luego creemos el archivo libs/server.ex con esto:
defmodule Github.Server do
use GenServer
end
Estas líneas incorporan toda la funcionalidad de GenServer al ámbito de nuestro módulo mediante algo de magia de metaprogramación. Nos dan callbacks que nos ayudarán. Por ejemplo, GenServer.start_link/3 vincula el proceso del servidor con el proceso que invoca start_link y llama al callback init/1, que debemos implementar.
Cuando se llama a GenServer.start_link/3, invoca Github.Server.init/1 y espera a que Github.Server.init/1 haya retornado antes de retornar. Los valores que puede devolver Github.Server.init/1 son específicos, y corresponden a una de estas tuplas:
{:ok, state}{:ok, state, timeout}:ignore{:stop, reason}
state será el estado del servidor y puede ser cualquier estructura de datos válida, como un árbol o una lista; en nuestro caso, será un 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 secciones del código mediante comentarios, como en el ejemplo, es una convención común en aplicaciones erlang/elixir y permite ver rápidamente qué ocurre. GenServer.start_link/3 acepta tres argumentos: el nombre del módulo donde se definió init/1; los argumentos para init/1, que en este caso no necesitamos, por lo que basta el átomo :ok; y una lista de opciones para GenServer.start_link/3, como un nombre para registrar el proceso e información de depuración. Por ahora nos sirve una lista vacía.
Ejecutamos iex -S mix y probamos nuestro servidor así:
iex(1)> {:ok, pid} = Github.Server.start_link
{:ok, #PID<0.148.0>}
iex(2)> pid
#PID<0.148.0>
iex(3)>
Bien, pero nuestro servidor no hace mucho. Queremos enviar un nombre de usuario y obtener información de github sobre él. ¿Cómo lo logramos? Con GenServer.call/3, que hace una solicitud synchronous al servidor y espera una respuesta.
GenServer.call/3 llama al callback Github.Server.handle_call/3. Esta función que implementaremos tiene tipos de respuesta específicos, como init/1. Estas son las respuestas 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}
En nuestro caso queremos responder algo como {:reply, user_info, state}. Veamos la primera parte del 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 es una variable cuyo valor es la URL a la que queremos acceder. Creamos info_of, que espera un nombre de usuario y el pid del proceso que la llamó para saber quién llama y devolver la respuesta. Primero necesitaremos dos dependencias: una para hacer solicitudes http y otra para analizar archivos JSON. Instalaremos json y httpoison.
Después de instalar las dependencias, handle_call debería verse así:
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 hacer más clara y ordenada la implementación de handle_call, la dividimos en pequeñas funciones auxiliares. Analicemos las líneas de arriba abajo.
handle_call usa coincidencia de patrones, una sentencia case y llama a get_info_of, que puede devolver los datos del usuario o un error. Con coincidencia de patrones observamos ambos casos: si handle_call devuelve algo parecido a {:ok, user_info,}, actualizamos el estado y devolvemos la solicitud correspondiente. Para los otros casos usamos el comodín _ para coincidir con cualquier otro valor de handle_call.
Personalmente, encuentro get_info_of muy descriptiva: toma el nombre de usuario, obtiene su URL mediante get_url_for, usa HTTPoison.get para hacer la solicitud http y finalmente llama a parse_response con la respuesta.
parse_response tiene dos definiciones y usa coincidencia de patrones para tomar la respuesta esperada, solo las que tienen código de estado 200, o generar un error.
Si parse_response recibe una respuesta con código 200, analiza el JSON y llama a format_user_inof.
Por último, format_user_info toma solo la información que queremos mostrar.
¡Eso es todo! Obtenemos el comportamiento esperado.
Hagamos otro ejemplo con handle_call por diversión: un endpoint de cliente llamado get_state que devuelve el estado actual del servidor podría ser útil. Para lograrlo, necesitamos una solicitud sincrónica a Github.Server, así que usamos handle_call de esta manera:
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
Recuerda que debes devolver una respuesta válida. Consulta esta tabla, que resume los callbacks de GenServer y sus respuestas válidas:
| Callbacks | Respuestas 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} |