Diseño con procesos, usando Registry

¿Cómo construimos aplicaciones con OTP?

Hace tiempo hemos estado construyendo un juego de Domino, basándonos sólo en el ecosistema proveído por Erlang/Elixir, y lo que deseo explicar aquí es una segunda parte de los elementos que nos ayudaron a diseñarlo, sólo usando elementos basados en OTP, aquí explico otro de ellos.

Aquí puedes encontrar la primer parte: Agentes y supervisores en OTP

ETS

Cuando estamos creando procesos de forma dinámica, queremos almacenar una referencia de ellos en alguna parte, y para ello nos sirve :ets; solamente recordar que no se recomienda usar ETS cómo un cache de forma prematura.

ETS nos permite almacenar valores de Elixir en memoria, para su rápido acceso; en breve los datos son organizados en un conjunto de tablas dinámicas, las cuáles almacenan tuplas. Cada tabla es creada por un proceso y cuándo este termina entonces la tabla se destruye.

Existen tipos de tablas y la interface le pertenece a Erlang, sin embargo, ejemplos de este estilo ya vienen en la documentación de Elixir y Erlang, de hecho hay ejemplos de implementaciones de caché y de almacenes con ETS, pero hay una nota al final que sugiere el uso de Registry para dichas tareas.

Registry

Usaré como referencia la documentación de Registry en Elixir, diciendo que:

Es un almacén de procesos de llave valor, local, decentralizado y escalable.

Se pueden tener registros de procesos únicos(:unique) o duplicados(:duplicate). Adicionalmente, Registry usa ETS internamente, en total 3 tablas, una para registros y dos más para particiones.

  • Elixir.Registry.____
  • Elixir.Registry.____.KeyPartition0
  • Elixir.Registry.____.PIDPartition0

En dónde ____ es el nombre del átomo que le asignas al registro.

Usando :via

Lo que se necesita para registrar un proceso es una tupla de la forma:

{:via, Registry, {registry, key}}

En dónde registry es el nombre del átomo de registro personalizado y key es un identificador asignado de forma arbitraria.

Y para buscar un proceso sería suficiente utilizar la función Registry.lookup(atom, id), en dónde atom es el nombre del registro y el parámetro id es el identificador con el que se registro el proceso. Por ejemplo: Registry.lookup(Registry.ViaGame, name).

Primer abstracción

Usaré el registro para almacenar procesos con el comportamiento de Agent:

defmodule TheLiveCounter.Game do
  alias __MODULE__
  use Agent, restart: :temporary
  defstruct counter: 0, id: 0, name: ""

  ##  API Client

  def start_link(opts \\ []) do
    [name: name] = opts
    Agent.start_link(fn -> create(name) end, name: via_tuple(name))
  end

  defp create(name) do
    %Game{id: System.unique_integer([:positive]), name: name}
  end

  def get_counter(name) do
    Agent.get(via_tuple(name), fn %Game{counter: counter} -> counter end)
  end

  def get(name) do
    Agent.get(via_tuple(name), & &1)
  end

  def increase_counter(name) do
    Agent.update(via_tuple(name), fn %Game{counter: counter} = game ->
      %Game{game | counter: counter + 1}
    end)
  end

  defp via_tuple(name) do
    {:via, Registry, {Registry.ViaGame, name}}
  end
end

Lo que quiero resaltar aquí es la función privada via_tuple, la cuál me ayuda a generar la tupla necesaria para el uso de Registry, el cuál hago en start_link, dónde sólo busco un nombre para registrar, dado ese nombre entonces puedo operar las funciones del módulo Game, y sin exponer explicítamente el uso de procesos estoy operando sobre aquellos procesos registrados, esto en cada función del cliente: get, increase_counter.

Administrador del Registry

Agregué un GenServer que me ayudó a supervisar el proceso que creaba los agentes, aquí resumo el código para detallarlo:

defmodule TheLiveCounter.GameRegistry do
  alias TheLiveCounter.Game
  use GenServer

  @supervisor TheLiveCounter.DynamicSupervisor

  ## Client API

  def start_link(opts \\ []) do
    GenServer.start_link(__MODULE__, [], [name: __MODULE__] ++ opts)
  end

  def create do
    GenServer.call(__MODULE__, {:create})
  end

  def lookup(name) do
    case Registry.lookup(Registry.ViaGame, name) do
      [{pid_game, _}] -> pid_game
      [] -> nil
    end
  end

  ## More client functions...

  ##  GenServer Callbacks

  def handle_call({:create}, _from, state) do
    name = random_name()
    {:ok, game_pid} = DynamicSupervisor.start_child(@supervisor, {Game, name: name})
    {:reply, {:ok, name, game_pid}, state}
  end

  defp random_name() do
    ?a..?z
    |> Enum.take_random(6)
    |> List.to_string()
  end

  ## More callbacks...
end

El callback que recibe {:create} usará un Supervisor dinámico al cuál lo parametrizamos envíandole un nombre aleatorio, que ya recibe por sí mismo el comportamiento del Agente Game.

Supervisando Registry

Finalmente supervisamos el Registry, el GameRegistry y al Supervisor dinámico en la aplicación.

defmodule TheLiveCounter.Application do
  @moduledoc false

  use Application

  def start(_type, _args) do
    children = [
      # Another supervised processes
      {DynamicSupervisor, strategy: :one_for_one, name: TheLiveCounter.DynamicSupervisor},
      TheLiveCounter.GameRegistry,
      {Registry, keys: :unique, name: Registry.ViaGame}
    ]

    opts = [strategy: :one_for_one, name: TheLiveCounter.Supervisor]
    Supervisor.start_link(children, opts)
  end

  ## More code...

end

Con ello podríamos hacer llamados cómo los siguientes:

{:ok, name, _} = TheLiveCounter.GameRegistry.create()
# {:ok, "qzufjr", #PID<0.322.0>}
TheLiveCounter.Game.get(name)
# %TheLiveCounter.Game{counter: 0, id: 386, name: "qzufjr"}

Y aunque no se manejan explícitamente los procesos, sabemos que están en acción por debajo de dichas llamadas.

Para finalizar, una de las cosas que ofrece Registry es el hecho de que si el proceso termina por cualquier razón, entonces también es eliminado del registro, lo cuál hace más sencillo su manejo.

comments powered by Disqus