En el mundo de Elixir, los GenServers son una herramienta fundamental para construir aplicaciones concurrentes y distribuidas. Cuando trabajamos con ellos, es crucial entender cómo enviar y manejar mensajes de manera eficiente, y para ello contamos con distintas funciones que nos permiten controlar la comunicación entre procesos.
Una función que destaca por su utilidad es handle_call. Esta función, con aridad 3, es la encargada de gestionar mensajes que requieren una respuesta. A diferencia de handle_info, que solo recibe el mensaje sin información sobre quién lo envió, handle_call nos proporciona el mensaje, el PID del proceso que lo envió y el estado actual del GenServer. Esto nos permite implementar una arquitectura cliente-servidor real, donde el proceso cliente puede enviar una solicitud y esperar una respuesta concreta, como si estuviera llamando a una función en otro proceso.
Por ejemplo, imaginemos que queremos crear una calculadora distribuida. Podemos definir mensajes que representen operaciones como suma, resta o multiplicación, y manejar cada uno en handle_call mediante pattern matching. La función debe devolver una tupla que comienza con :reply, seguida del resultado que queremos enviar de vuelta al cliente, y el nuevo estado del GenServer. Así, cuando el cliente use GenServer.call, recibirá la respuesta directamente, sin necesidad de manejar manualmente los mensajes.
def handle_call({:add, a, b}, _from, state) do
result = a + b
{:reply, result, state}
end
Al enviar mensajes con GenServer.call, podemos también especificar un tiempo máximo de espera para la respuesta. Por defecto, si el GenServer no responde en cinco segundos, la llamada falla, evitando que el proceso cliente quede bloqueado indefinidamente. Si necesitamos más tiempo, podemos pasar un tercer parámetro con el timeout deseado, o incluso usar :infinity para esperar indefinidamente.
Para ilustrar esto, podemos hacer que handle_call espere un tiempo antes de responder usando Process.sleep:
def handle_call({:add, a, b}, _from, state) do
Process.sleep(2000) # Espera 2 segundos
result = a + b
{:reply, result, state}
end
Si el tiempo de espera supera el timeout configurado en la llamada, el cliente recibirá un error, pero el GenServer seguirá funcionando normalmente.
Por otro lado, cuando queremos enviar mensajes sin esperar respuesta, utilizamos handle_cast. Esta función, con aridad 2, recibe el mensaje y el estado, y no devuelve una respuesta al proceso que envió el mensaje. Es útil para operaciones que no requieren confirmación inmediata, como reiniciar un contador o actualizar un estado interno.
Un ejemplo sencillo de handle_cast para reiniciar un contador sería:
def handle_cast(:reset, _state) do
IO.puts("Reset del contador")
{:noreply, 0}
end
Al enviar un mensaje con GenServer.cast, el proceso cliente continúa su ejecución sin esperar nada, y el GenServer procesa el mensaje en segundo plano.
Es importante distinguir entre handle_info y handle_cast. Aunque ambos manejan mensajes que no esperan respuesta, handle_info se utiliza para mensajes enviados con send y suele reservarse para eventos del sistema o mensajes de monitoreo, mientras que handle_cast es la forma estándar de enviar mensajes asíncronos a un GenServer.
Para facilitar el uso de GenServers, es común envolver las llamadas a GenServer.call y GenServer.cast en funciones con nombres descriptivos, evitando tener que manipular directamente las tuplas de mensajes. Por ejemplo, podemos definir una función add que envíe el mensaje adecuado al GenServer y devuelva el resultado:
def add(pid, a, b) do
GenServer.call(pid, {:add, a, b})
end
De esta manera, la interfaz para interactuar con el GenServer es mucho más limpia y fácil de usar.
En resumen, al trabajar con GenServers, handle_call es nuestra herramienta para solicitudes que requieren respuesta, con la ventaja de conocer quién envió el mensaje y poder gestionar timeouts. handle_cast nos permite enviar mensajes sin esperar respuesta, ideal para operaciones asíncronas. Y handle_info queda reservado para mensajes externos o de sistema que no encajan en las otras dos categorías. Con estas funciones, podemos construir arquitecturas cliente-servidor robustas y eficientes en Elixir.