Do you want to pick up from where you left of?
Take me there

Almacenamiento de términos de Erlang (ETS)

Almacenamiento de términos de Erlang, comúnmente conocido como ETS, es un potente motor de almacenamiento incorporado en OTP y disponible para utilizar en Elixir. En esta lección vamos a ver cómo interactuar con ETS y cómo se pueden emplear en nuestras aplicaciones.

Descripción General

ETS es un robusto almacén en memoria para objetos Elixir y Erlang que viene incluido. ETS es capaz de almacenar grandes cantidades de datos y ofrece un tiempo constante para el acceso a datos.

Las tablas en ETS son creadas y son propiedad de los procesos individuales. Cuando un proceso propietario termina, sus tablas son destruidas. Por defecto ETS esta limitado a 1400 tablas por nodo.

Creando Tablas

Las tablas son creadas con new/2, aceptando un nombre de tabla y un conjunto de opciones, este devuelve un identificador de tabla que se puede utilizar en las operaciones subsiguientes.

Para nuestro ejemplo vamos a crear una tabla para almacenar y buscar usuarios por su apodo:

iex> table = :ets.new(:user_lookup, [:set, :protected])
8212

Al igual que con GenServers, hay una manera de acceder a las tablas de ETS por su nombre en lugar de su identificador. Para hacer esto necesitamos incluir :named_table y podemos acceder a nuestra tabla directamente por su nombre:

iex> :ets.new(:user_lookup, [:set, :protected, :named_table])
:user_lookup

Tipos de Tablas

Existen cuatro tipos de tablas disponibles en ETS:

Controles de Acceso

El control de acceso en ETS es similar al control de acceso con módulos:

Insertando Datos

ETS no tiene esquemas, la única limitación es que los datos deben ser almacenados como una tupla cuyo primer elemento es la clave. Para agregar nuevos datos podemos usar insert/2:

iex> :ets.insert(:user_lookup, {"doomspork", "Sean", ["Elixir", "Ruby", "Java"]})
true

Cuando usamos insert/2 con un set o ordered_set los datos existentes serán reemplazados. Para prevenir esto existe insert_new/2 que devuelve false para claves existentes:

iex> :ets.insert_new(:user_lookup, {"doomspork", "Sean", ["Elixir", "Ruby", "Java"]})
false
iex> :ets.insert_new(:user_lookup, {"3100", "", ["Elixir", "Ruby", "JavaScript"]})
true

Recuperación de Datos

ETS nos ofrece algunas maneras convenientes y flexibles para recuperar los datos almacenados. Vamos a ver cómo recuperar los datos por clave y por medio de diferentes formas de coincidencia de patrones.

El más eficiente, e ideal, método de recuperación es la búsqueda de claves. Si bien es útil, el método de concordancia itera a través de la tabla y debe utilizarse con moderación, especialmente en caso de grandes conjuntos de datos.

Búsqueda de claves

Dada una clave, podemos utilizar lookup/2 para recuperar todos los registros con esa clave:

iex> :ets.lookup(:user_lookup, "doomspork")
[{"doomspork", "Sean", ["Elixir", "Ruby", "Java"]}]

Concordancia Simple

ETS fue construido para Erlang, así que tenga cuidado, las variables de comparación pueden sentirse un poco anticuadas.

Para especificar una variable en nuestro juego usamos los átomos :"$1", :"$2", :"$3", y así sucesivamente; el número de variable refleja la posición de los resultados y no la posición del juego. Para valores que no estamos interesados usamos la :_ variable.

Los valores también se pueden utilizar en emparejamiento, pero sólo las variables se devolverán como parte de nuestro resultado. Vamos a poner todos los elementos y ver cómo funciona:

iex> :ets.match(:user_lookup, {:"$1", "Sean", :_})
[["doomspork"]]

Veamos otro ejemplo para ver cómo las variables influyen en el orden de la lista resultante:

iex> :ets.match(:user_lookup, {:"$99", :"$1", :"$3"})
[["Sean", ["Elixir", "Ruby", "Java"], "doomspork"],
 ["", ["Elixir", "Ruby", "JavaScript"], "3100"]]

¿Qué pasa si queremos que nuestro objeto original no sea una lista? Podemos usar match_object/2, que independientemente de las variables devuelve todo nuestro objeto:

iex> :ets.match_object(:user_lookup, {:"$1", :_, :"$3"})
[{"doomspork", "Sean", ["Elixir", "Ruby", "Java"]},
 {"3100", "", ["Elixir", "Ruby", "JavaScript"]}]

iex> :ets.match_object(:user_lookup, {:_, "Sean", :_})
[{"doomspork", "Sean", ["Elixir", "Ruby", "Java"]}]

Búsqueda Avanzada

Hemos aprendido acerca de los casos de los juegos sencillos pero ¿que si queremos algo más parecido a una consulta SQL? Afortunadamente hay una sintaxis más robusta disponible para nosotros. Para buscar nuestros datos con select/2 necesitamos construir una lista de tuplas con tres aridad. Estas tuplas representan nuestro patrón, cero o más guardias, y un formato de valor de retorno.

Nuestras variables de emparejamiento y dos nuevas variables, :"$$" y :"$_" pueden ser usados para construir el valor de retorno. Estas nuevas variables son accesos directos para el formato del resultado; :"$$" Obtiene resultados como listas y :"$_" los objetos de datos originales.

Vamos a tomar uno de nuestros anteriores ejemplos match/2 y convertirlo en un select/2:

iex> :ets.match_object(:user_lookup, {:"$1", :_, :"$3"})
[{"doomspork", "Sean", ["Elixir", "Ruby", "Java"]},
 {"3100", "", ["Elixir", "Ruby", "JavaScript"]}]

{% raw %}iex> :ets.select(:user_lookup, [{{:"$1", :_, :"$3"}, [], [:"$_"]}]){% endraw %}
[{"doomspork", "Sean", ["Elixir", "Ruby", "Java"]},
 {"spork", 30, ["ruby", "elixir"]}]

Aunque select/2 permite un mayor control sobre qué y cómo recuperar los registros, la sintaxis es bastante desagradable y sólo lo será aún más. Para manejar esto el módulo ETS incluye fun2ms/1, para convertir las funciones en match_specs. Con fun2ms/1 podemos crear consultas utilizando una función de sintaxis familiar.

Vamos a usar fun2ms/1 y select/2 para encontrar todos los nombres de usuario con 2 o más lenguajes:

iex> fun = :ets.fun2ms(fn {username, _, langs} when length(langs) > 2 -> username end)
{% raw %}[{{:"$1", :_, :"$2"}, [{:>, {:length, :"$2"}, 2}], [:"$1"]}]{% endraw %}

iex> :ets.select(:user_lookup, fun)
["doomspork", "3100"]

¿Quieres saber más acerca de especificación partido? Echa un vistazo a la documentación oficial de Erlang match_spec.

Eliminando Datos

Removiendo Registros

Eliminar términos es tan sencillo como insert/2 y lookup/2. Con delete/2 sólo necesitamos nuestra tabla y la clave. Esto elimina la clave y sus valores:

iex> :ets.delete(:user_lookup, "doomspork")
true

Removiendo Tablas

Las tablas ETS no son basura recolectada al menos el padre sea terminado. A veces puede ser necesario eliminar una tabla completa sin necesidad de terminar el proceso propietario. Para ello podemos utilizar delete/1:

iex> :ets.delete(:user_lookup)
true

Ejemplos de uso de ETS

Debido a lo que hemos aprendido previamente vamos a poner todo junto y construir una cache sencilla para operaciones costosas. Vamos a implementar una funcion get/4 para tomar un módulo, función, argumentos y opciones. Por ahora la única opción de la que nos preocuparemos es :ttl.

Para este ejemplo asumiremos que las tablas ETS han sido creadas como parte de otro proceso, como un supervisor:

defmodule SimpleCache do
  @moduledoc """
  A simple ETS based cache for expensive function calls.
  """

  @doc """
  Retrieve a cached value or apply the given function caching and returning
  the result.
  """
  def get(mod, fun, args, opts \\ []) do
    case lookup(mod, fun, args) do
      nil ->
        ttl = Keyword.get(opts, :ttl, 3600)
        cache_apply(mod, fun, args, ttl)

      result ->
        result
    end
  end

  @doc """
  Lookup a cached result and check the freshness
  """
  defp lookup(mod, fun, args) do
    case :ets.lookup(:simple_cache, [mod, fun, args]) do
      [result | _] -> check_freshness(result)
      [] -> nil
    end
  end

  @doc """
  Compare the result expiration against the current system time.
  """
  defp check_freshness({mfa, result, expiration}) do
    cond do
      expiration > :os.system_time(:seconds) -> result
      :else -> nil
    end
  end

  @doc """
  Apply the function, calculate expiration, and cache the result.
  """
  defp cache_apply(mod, fun, args, ttl) do
    result = apply(mod, fun, args)
    expiration = :os.system_time(:seconds) + ttl
    :ets.insert(:simple_cache, {[mod, fun, args], result, expiration})
    result
  end
end

Para demostrar la caché vamos a utilizar una función que devuelve la hora del sistema y un TTL de 10 segundos. Como se verá en el siguiente ejemplo, obtenemos el resultado almacenado en caché hasta que el valor haya expirado:

defmodule ExampleApp do
  def test do
    :os.system_time(:seconds)
  end
end

iex> :ets.new(:simple_cache, [:named_table])
:simple_cache
iex> ExampleApp.test
1451089115
iex> SimpleCache.get(ExampleApp, :test, [], ttl: 10)
1451089119
iex> ExampleApp.test
1451089123
iex> ExampleApp.test
1451089127
iex> SimpleCache.get(ExampleApp, :test, [], ttl: 10)
1451089119

Después de 10 segundos si intentamos de nuevo, deberíamos obtener resultados actualizados:

iex> ExampleApp.test
1451089131
iex> SimpleCache.get(ExampleApp, :test, [], ttl: 10)
1451089134

Como se puede ver, ahora somos capaces de implementar una caché escalable y rápida sin ningún tipo de dependencias externas, y este es sólo uno de los muchos usos para ETS.

ETS basado en disco

Ahora sabemos que ETS es para almacenamiento de términos en memoria, ¿pero que si necesitamos almacenamiento basado en disco? Para eso tenemos Almacenamiento de Términos Basado en Disco o DETS para abreviar. Las APIs de ETS y DETS son intercambiables con la excepción de cómo se crean las tablas. DETS depende de open_file/2 y no requiere la opcion :named_table:

iex> {:ok, table} = :dets.open_file(:disk_storage, [type: :set])
{:ok, :disk_storage}
iex> :dets.insert_new(table, {"doomspork", "Sean", ["Elixir", "Ruby", "Java"]})
true
iex> select_all = :ets.fun2ms(&(&1))
[{:"$1", [], [:"$1"]}]
iex> :dets.select(table, select_all)
[{"doomspork", "Sean", ["Elixir", "Ruby", "Java"]}]

Si sale de iex y buscas en su directorio local, verás un nuevo archivo disk_storage:

$ ls | grep -c disk_storage
1

Una última cosa a tener en cuenta es que DETS no soporta ordered_set como ETS, solamente set, bag, y duplicate_bag.

¿Encontraste un error o quieres contribuir a la lección? ¡Edita esta lección en GitHub!