OTP Básico
GenServer y Supervisor: los pilares para construir aplicaciones robustas.
¿Qué es OTP?
OTP (Open Telecom Platform) es un conjunto de bibliotecas y patrones probados durante décadas en sistemas de alta disponibilidad. Los conceptos clave son:
- GenServer: Abstracción para procesos con estado
- Supervisor: Proceso que vigila y reinicia otros procesos
- Application: Unidad de despliegue y configuración
OTP encapsula las mejores prácticas de 30+ años de desarrollo en Erlang. Sistemas como WhatsApp usan OTP para manejar millones de conexiones con altísima disponibilidad.
GenServer: Servidores genéricos
GenServer es la abstracción más importante de OTP. Maneja el loop de recepción de mensajes, el estado, y proporciona una interfaz estándar:
defmodule Contador do
use GenServer
# --- API del cliente (funciones públicas) ---
def start_link(valor_inicial \\ 0) do
GenServer.start_link(__MODULE__, valor_inicial, name: __MODULE__)
end
def valor do
GenServer.call(__MODULE__, :valor)
end
def incrementar do
GenServer.cast(__MODULE__, :incrementar)
end
def decrementar do
GenServer.cast(__MODULE__, :decrementar)
end
# --- Callbacks del servidor ---
@impl true
def init(valor_inicial) do
{:ok, valor_inicial}
end
@impl true
def handle_call(:valor, _from, estado) do
{:reply, estado, estado}
end
@impl true
def handle_cast(:incrementar, estado) do
{:noreply, estado + 1}
end
@impl true
def handle_cast(:decrementar, estado) do
{:noreply, estado - 1}
end
end
Uso del contador
iex> Contador.start_link(10)
{:ok, #PID<0.123.0>}
iex> Contador.valor()
10
iex> Contador.incrementar()
:ok
iex> Contador.incrementar()
:ok
iex> Contador.valor()
12
call vs cast
| Aspecto | call | cast |
|---|---|---|
| Sincronía | Síncrono (bloquea hasta respuesta) | Asíncrono (retorna inmediato) |
| Respuesta | Sí, el servidor envía respuesta | No, solo :ok |
| Callback | handle_call/3 | handle_cast/2 |
| Retorno | {:reply, respuesta, estado} | {:noreply, estado} |
| Uso típico | Consultar estado, operaciones que necesitan confirmación | Notificaciones, operaciones "fire and forget" |
handle_info: Mensajes no estructurados
Para manejar mensajes que no vienen de call o cast:
@impl true
def handle_info(:tick, estado) do
IO.puts("Tick! Estado: #{estado}")
Process.send_after(self(), :tick, 1000) # Próximo tick en 1s
{:noreply, estado + 1}
end
# Iniciar el timer en init
@impl true
def init(estado) do
Process.send_after(self(), :tick, 1000)
{:ok, estado}
end
Supervisor: Tolerancia a fallos
Los supervisores vigilan procesos hijos y los reinician cuando fallan:
defmodule MiApp.Supervisor do
use Supervisor
def start_link(opts) do
Supervisor.start_link(__MODULE__, :ok, opts)
end
@impl true
def init(:ok) do
hijos = [
{Contador, 0},
# Más procesos hijos...
]
Supervisor.init(hijos, strategy: :one_for_one)
end
end
Estrategias de reinicio
| Estrategia | Comportamiento |
|---|---|
:one_for_one | Solo reinicia el proceso que falló |
:one_for_all | Si uno falla, reinicia todos los hijos |
:rest_for_one | Reinicia el que falló y los iniciados después de él |
La filosofía de OTP es dejar que los procesos fallen y que los supervisores los reinicien. Es más simple y robusto que intentar manejar todos los errores posibles.
Ejemplo completo: Carrito de compras
defmodule Carrito do
use GenServer
# --- API del cliente ---
def start_link(usuario_id) do
GenServer.start_link(__MODULE__, usuario_id, name: via(usuario_id))
end
def agregar(usuario_id, producto, cantidad \\ 1) do
GenServer.call(via(usuario_id), {:agregar, producto, cantidad})
end
def quitar(usuario_id, producto) do
GenServer.call(via(usuario_id), {:quitar, producto})
end
def ver(usuario_id) do
GenServer.call(via(usuario_id), :ver)
end
def vaciar(usuario_id) do
GenServer.cast(via(usuario_id), :vaciar)
end
defp via(usuario_id), do: {:via, Registry, {Carrito.Registry, usuario_id}}
# --- Callbacks del servidor ---
@impl true
def init(usuario_id) do
estado = %{
usuario_id: usuario_id,
items: %{},
creado: DateTime.utc_now()
}
{:ok, estado}
end
@impl true
def handle_call({:agregar, producto, cantidad}, _from, estado) do
items = Map.update(estado.items, producto, cantidad, &(&1 + cantidad))
nuevo_estado = %{estado | items: items}
{:reply, {:ok, items}, nuevo_estado}
end
@impl true
def handle_call({:quitar, producto}, _from, estado) do
items = Map.delete(estado.items, producto)
nuevo_estado = %{estado | items: items}
{:reply, {:ok, items}, nuevo_estado}
end
@impl true
def handle_call(:ver, _from, estado) do
{:reply, estado.items, estado}
end
@impl true
def handle_cast(:vaciar, estado) do
{:noreply, %{estado | items: %{}}}
end
end
Supervisor dinámico para carritos
defmodule Carrito.Supervisor do
use DynamicSupervisor
def start_link(opts) do
DynamicSupervisor.start_link(__MODULE__, :ok, opts)
end
def crear_carrito(usuario_id) do
DynamicSupervisor.start_child(__MODULE__, {Carrito, usuario_id})
end
@impl true
def init(:ok) do
DynamicSupervisor.init(strategy: :one_for_one)
end
end
child_spec
Cada módulo que puede ser supervisado define cómo iniciarse:
defmodule MiServidor do
use GenServer
# Personalizar child_spec
def child_spec(arg) do
%{
id: __MODULE__,
start: {__MODULE__, :start_link, [arg]},
restart: :permanent, # :permanent | :temporary | :transient
type: :worker
}
end
# ...
end
:permanent - Siempre reiniciar
:temporary - Nunca reiniciar
:transient - Reiniciar solo si termina anormalmente
Implementa una pila (stack) como GenServer:
push(valor)- Agrega al topepop()- Quita y retorna el topepeek()- Ver el tope sin quitarsize()- Cantidad de elementos
Crea un GenServer que funcione como cache:
put(clave, valor, ttl_segundos)- Guardar con tiempo de vidaget(clave)- Obtener (nil si expiró)- Usa
handle_infopara limpiar entradas expiradas periódicamente