Fork me on GitHub

Erlang Term Storage (ETS)

Some contents of this translation may be outdated.
Several major changes were applied to the original lesson since the last update.

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.

Sumário

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, aceita como parametros o nome da tabela e uma série de opções, retorna um identificador de tabela que podemos usar nas operações subsequentes.

Para o nosso exemplo, iremos criar uma tabela para armazenar e buscar por utilizadores 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 isso, precisamos incluir :named_table e assim podemos acessar nossa tabela directamente 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 modulos:

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 eferece-nos algumas formas convenientes e flexivéis 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 percore 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áveil 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ênca, 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 contruir 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 variavéis 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"]}]

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

Apesar do select/2 um controle mais fino sobre o que e como recuperamos registros, a sinaxe é 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)
[{{:"$1", :"_", :"$2"}, [{:>, {:length, :"$2"}, 2}], [:"$1"]}]

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 demostrar o uso do cache, iremos usar a função que retorna a hora do sistema e um TTL de 10 segundos. Tal como verás 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.



Compartilhe essa página