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

Erlang Term Storage (ETS)

Erlang Term Storage, comumente referenciado como ETS, é um poderoso mecanismo de armazenamento, incorporado no OTP e disponível para uso no Elixir. Nesta lição iremos ver como interagir com ETS e como podemos usá-lo nas nossas aplicações.

Visão Geral

ETS é um mecanismo de armazenamento em memória robusto para objetos do Elixir e do Erlang que já vem incluído. ETS é capaz de armazenar grandes quantidades de dados e oferecer um tempo constante para acesso aos dados.

Tabelas em ETS são criadas por processos individuais. Quando um processo proprietário termina suas tabelas são destruídas. Por padrão ETS está limitado a 1400 tabelas por cada nó.

Criando Tabelas

Tabelas são criadas usando new/2, que aceita como parâmetros o nome da tabela, uma série de opções, e retorna um identificador de tabela que podemos usar nas operações subsequentes.

Para o nosso exemplo, iremos criar uma tabela para armazenar e buscar usuários pelos seus apelidos:

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

Tal como GenServers, existe um mecanismo para acessar tabelas ETS usando nome em vez de identificador. Para fazer isso, precisamos incluir :named_table e assim podemos acessar nossa tabela diretamente pelo nome:

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

Tipos de Tabelas

Existem quatro tipos de tabelas disponíveis no ETS:

Controle de Acesso

Controle de acesso no ETS é semelhante ao controle de acesso dentro de módulos:

Race Conditions

Se mais de um processo pode escrever em uma tabela - através de acesso :public ou por mensagens para o processo dono - race conditions são possíveis. Por exemplo, dois processos leem um contador de valor 0, incrementam ele, e escrevem 1; o resultado final reflete apenas um único incremento.

Para contadores especificamente, :ets.update_counter/3 fornece leitura e escrita atômicas. Para outros casos, pode ser necessário que o processo dono do execute operações atômicas customizadas em resposta à mensagens recebidas, como “adicione esse valor à lista na chave :results“.

Inserindo dados

ETS não possui esquema (schema). A única limitação é que dados devem ser armazenados como uma tupla onde o seu primeiro elemento é a chave. Para adicionar novos dados podemos usar insert/2:

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

Quando usamos insert/2 com um set ou ordered_set dados existentes serão substituídos. Para evitar isso, existe o insert_new/2 que retorna false para chaves existentes:

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

Recuperando Dados

ETS oferece-nos algumas formas convenientes e flexíveis para recuperar nossos dados armazenados. Iremos ver como recuperar dados usando a chave através de diferentes formas de correspondência de padrão (pattern matching).

O mais eficiente, e ideal, método de recuperar dados é a busca por chave. Enquanto útil, matching percorre a tabela e deve ser usado com moderação especialmente para grandes conjuntos de dados.

Pesquisa de chave

Dado uma chave, podemos usar lookup/2 para recuperar todos os registos com esta chave:

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

Correspondências Simples

ETS foi construído para o Erlang, logo, tenha em atenção que correspondência de variáveis pode parecer um pouco desajeitado.

Para especificar uma variável no nosso match, usamos os atoms :"$1", :"$2", :"$3", e assim por diante; o número da variável reflete a posição do resultado e não a posição do match. Para valores que não nos interessam usamos a variável :_.

Valores podem ser usados na correspondência, mas apenas variáveis farão parte do nosso resultado. Vamos juntar tudo isso e ver como funciona:

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

Vamos olhar outro exemplo para ver como variáveis influenciam a ordem da lista resultante:

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

O que se queremos nosso objeto original e não uma lista? Podemos usar match_object/2, que independentemente das variáveis retorna nosso objeto inteiro:

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"]}]

Pesquisa Avançada

Aprendemos sobre casos simples de fazer match, mas o que se quisermos algo mais parecido a uma consulta SQL? Felizmente existe uma sintaxe mais robusta disponível para nós. Para pesquisar nossos dados com select/2 precisamos construir uma lista de tuplas com três aridades. Estas tuplas representam o nosso padrão, zero ou mais guardas, e um formato de valor de retorno.

Nossas variáveis de correspondência e mais duas novas variáveis, :"$$" e :"$_" podem ser usadas para construir o valor de retorno. Estas novas variáveis são atalhos para o formato do resultado; :"$$" recebe resultados como listas e :"$_" o objeto do dado original.

Vamos pegar um dos nossos exemplos match/2 anterior e transforma-lo num 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"]},
 {"3100", "", ["Elixir", "Ruby", "JavaScript"]}]

Apesar do select/2 um controle mais fino sobre o que e como recuperamos registros, a sintaxe é um bastante hostil e tende a ser pior. Para lidar com isso, o módulo ETS inclui fun2ms/1, para transformar as funções em match_specs. Com fun2ms/1 podemos criar consultas usando uma sintaxe de função mais familiar.

Vamos usar fun2ms/1 e select/2 para encontrar todos os usuários com 2 ou mais línguas:

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"]

Quer aprender mais sobre a especificação match? Confira a documentação oficial do Erlang para match_spec.

Eliminando Dados

Removendo Registros

Eliminar termos é tão simples como insert/2 e lookup/2. Com delete/2 precisamos apenas da nossa tabela e a chave. Isso elimina tanto a chave como o seu respectivo valor:

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

Removendo Tabelas

Tables ETS não são lixo coletáveis, a menos que o processo pai seja terminado. As vezes poderá ser necessário eliminar a tabela inteira sem terminar o processo pai. Para isso podemos usar delete/1:

iex> :ets.delete(:user_lookup)
true

Exemplos de uso do ETS

Tendo em conta o que aprendemos acima, vamos juntar tudo e construir um simples cache para operações pesadas. Iremos implementar uma função get/4 que recebe um módulo, uma função, argumentos, e opções. Por enquanto a única opção com que iremos nos preocupar é :ttl.

Para este exemplo estamos assumindo que a tabela ETS foi criada como parte de um outro processo, tal como um 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 demonstrar o uso do cache, iremos usar a função que retorna a hora do sistema e um TTL de 10 segundos. Tal como veremos no exemplo abaixo, obtemos o resultado em cache até que o valor expire:

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

Depois de 10 segundos se não tentarmos novamente deveremos receber um novo resultado:

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

Como podes ver, podemos implementar um sistema de cache rápido e escalável sem nenhuma dependência externa e isso é apenas um de muitos casos de uso para ETS.

ETS baseado em disco

Agora sabemos que ETS é para armazenamento em memória, mas o que fazer se precisarmos de armazenamento em disco? Para isso temos o Armazenamento Baseado em Disco ou apenas DETS (Disk Based Term Storage). Os APIs ETS e DETS são intercambiáveis a exceção de quantas tabelas são criadas. DETS depende de open_file/2 e não requer a opção :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"]}]

Se saires do iex e olhares no seu diretório local, verás um arquivo novo disk_storage:

$ ls | grep -c disk_storage
1

Uma última coisa a notar é que DETS não suporta ordered_set como ETS, apenas set, bag, e duplicate_bag.

Caught a mistake or want to contribute to the lesson? Edit this lesson on GitHub!