Une introduction aux tables ETS dans Elixir

Lorsque vous créez un programme Elixir, vous devez souvent partager un état. Par exemple, dans l'un de mes articles précédents, j'ai montré comment coder un serveur pour effectuer divers calculs et conserver le résultat en mémoire (et plus tard, nous avons vu comment rendre ce serveur à l'épreuve des balles avec l'aide de superviseurs). Il y a cependant un problème: si vous avez un seul processus qui prend en charge l'état et de nombreux autres processus qui y accèdent, la performance peut être sérieusement affectée. C’est tout simplement parce que le processus ne peut servir qu’une demande à la fois. 

Cependant, il existe des moyens de surmonter ce problème et nous allons parler aujourd'hui de l'un d'entre eux. Rencontrez les tables Erlang Term Storage ou tout simplement Tables ETS, un stockage rapide en mémoire pouvant héberger des nuplets de données arbitraires. Comme son nom l'indique, ces tables ont été initialement introduites à Erlang mais, comme tout autre module Erlang, nous pouvons facilement les utiliser également dans Elixir..

Dans cet article, vous allez:

  • Apprenez à créer des tables ETS disponibles lors de la création..
  • Apprenez à exécuter des opérations de lecture, d'écriture, de suppression et d'autres opérations..
  • Voir les tables ETS en action.
  • En savoir plus sur les tables ETS basées sur disque et leurs différences par rapport aux tables en mémoire.
  • Voir comment convertir ETS et DETS dans les deux sens.

Tous les exemples de code fonctionnent avec Elixir 1.4 et 1.5, récemment publiés..

Introduction aux tables ETS

Comme je l'ai mentionné précédemment, les tables ETS constituent un stockage en mémoire qui contient des n-uplets de données (appelées lignes). Plusieurs processus peuvent accéder à la table par son identifiant ou un nom représenté par un atome et effectuer des opérations de lecture, d'écriture, de suppression et autres. Les tables ETS sont créées par un processus distinct. Ainsi, si ce processus est terminé, la table est détruite. Cependant, il n'y a pas de mécanisme automatique de récupération de place, de sorte que la table peut rester en mémoire pendant un certain temps.

Les données de la table ETS sont représentées par un tuple : clé, valeur1, valeur2, valeur. Vous pouvez facilement rechercher les données par leur clé ou insérer une nouvelle ligne, mais par défaut, il ne peut pas y avoir deux lignes avec la même clé. Les opérations basées sur les clés sont très rapides, mais si, pour une raison quelconque, vous devez générer une liste à partir d'une table ETS et, par exemple, effectuer des manipulations complexes des données, c'est également possible..

De plus, il existe des tables ETS basées sur disque qui stockent leur contenu dans un fichier. Bien sûr, ils fonctionnent plus lentement, mais vous obtenez ainsi un stockage de fichiers simple et sans tracas. De plus, l'ETS en mémoire peut être facilement converti en disque et inversement.

Donc, je pense qu'il est temps de commencer notre voyage et de voir comment les tables ETS sont créées!

Création d'une table ETS

Pour créer une table ETS, utilisez le nouveau / 2 une fonction. Tant que nous utilisons un module Erlang, son nom doit être écrit sous la forme d'un atome:

cool_table =: ets.new (: cool_table, [])

Notez que jusqu'à récemment, vous ne pouviez créer que 1 400 tables par instance BEAM, mais ce n'est plus le cas: vous êtes limité à la quantité de mémoire disponible..

Le premier argument passé à la Nouveau fonction est le nom de la table (alias), tandis que le second contient une liste d'options. le cool_table La variable contient maintenant un numéro qui identifie la table dans le système:

IO.inspect cool_table # => 12306

Vous pouvez maintenant utiliser cette variable pour effectuer des opérations ultérieures sur la table (lecture et écriture de données, par exemple)..

Options disponibles

Parlons des options que vous pouvez spécifier lors de la création d'une table. La première chose (et quelque peu étrange) à noter est que, par défaut, vous ne pouvez en aucun cas utiliser l'alias de la table et qu'il n'a fondamentalement aucun effet. Mais toujours, l'alias doit être passé sur la création de la table.

Pour pouvoir accéder à la table par son alias, vous devez fournir un : named_table option comme celle-ci:

cool_table =: ets.new (: cool_table, [: named_table])

Par ailleurs, si vous souhaitez renommer la table, vous pouvez utiliser le renommer / 2 une fonction:

: ets.rename (cool_table,: cooler_table)

Ensuite, comme déjà mentionné, une table ne peut pas contenir plusieurs lignes avec la même clé, et ceci est dicté par le type. Il existe quatre types de table possibles:

  • :ensemble-c'est celui par défaut. Cela signifie que vous ne pouvez pas avoir plusieurs lignes avec exactement les mêmes clés. Les lignes ne sont pas réordonnées de manière particulière.
  • : Order_set-le même que :ensemble, mais les lignes sont ordonnées par les termes.
  • :sac-plusieurs lignes peuvent avoir la même clé, mais les lignes ne peuvent toujours pas être totalement identiques.
  • : duplicate_bag-les lignes peuvent être parfaitement identiques.

Il y a une chose qui mérite d'être mentionnée concernant le : Order_set les tables. Comme le dit la documentation d'Erlang, ces tables traitent les clés de la même manière comparer égal, non seulement quand ils rencontre. Qu'est-ce que ça veut dire?

Les termes d'Erlang ne correspondent que s'ils ont la même valeur et le même type. Donc entier 1 ne correspond qu'à un autre entier 1, mais ne flotte pas 1,0 comme ils ont différents types. Deux termes sont comparés égaux, cependant, s’ils ont la même valeur et le même type ou si les deux sont numériques et vont à la même valeur. Cela signifie que 1 et 1,0 sont égaux.

Pour fournir le type de la table, ajoutez simplement un élément à la liste des options:

cool_table =: ets.new (: cool_table, [: named_table,: orders_set])

Une autre option intéressante que vous pouvez passer est :comprimé. Cela signifie que les données à l'intérieur de la table (mais pas les clés) devineront ce qui est stocké dans une forme compacte. Bien sûr, les opérations exécutées sur la table deviendront plus lentes.

Ensuite, vous pouvez contrôler quel élément du tuple doit être utilisé comme clé. Par défaut, le premier élément (position 1) est utilisé, mais cela peut être changé facilement:

cool_table =: ets.new (: cool_table, [: keypos, 2])

Maintenant, les deuxièmes éléments dans les n-uplets seront traités comme les clés.

La dernière option, mais non la moindre, contrôle les droits d'accès de la table. Ces droits déterminent quels processus peuvent accéder à la table:

  • :Publique-n'importe quel processus peut effectuer n'importe quelle opération sur la table.
  • :protégé-la valeur par défaut. Seul le processus propriétaire peut écrire sur la table, mais tous les processus peuvent lire.
  • :privé-seul le processus propriétaire peut accéder à la table.

Donc, pour rendre une table privée, vous écririez:

cool_table =: ets.new (: cool_table, [: private])

Très bien, assez parlé d’options, voyons quelques opérations courantes que vous pouvez effectuer sur les tables!

Opérations d'écriture

Pour lire quelque chose dans la table, vous devez d’abord y écrire des données. Commençons donc par la dernière opération. Utilisez le insérer / 2 fonction pour mettre des données dans la table:

cool_table =: ets.new (: cool_table, []): ets.insert (cool_table, : number, 5)

Vous pouvez aussi passer une liste de tuples comme ceci:

: ets.insert (cool_table, [: number, 5, : string, "test"])

Notez que si la table a un type de :ensemble et si une nouvelle clé correspond à une clé existante, les anciennes données seront écrasées. De même, si une table a un type de : Order_set et une nouvelle clé est égale à l'ancienne, les données seront écrasées, alors faites attention à cela.

L'opération d'insertion (même avec plusieurs tuples à la fois) est garantie d'être atomique et isolée, ce qui signifie que tout est stocké dans la table ou rien du tout. En outre, les autres processus ne pourront pas voir le résultat intermédiaire de l'opération. Dans l'ensemble, cela ressemble assez aux transactions SQL.

Si vous craignez de dupliquer des clés ou si vous ne voulez pas écraser vos données par erreur, utilisez la commande insert_new / 2 fonction à la place. C'est similaire à insérer / 2 mais n'insérera jamais de clés en double et retournera à la place faux. C'est le cas pour le :sac et : duplicate_bag tables aussi:

cool_table =: ets.new (: cool_table, [: sac]): ets.insert (cool_table, : number, 5): ets.insert_new (cool_table, : number, 6) |> IO.inspect # = > faux

Si vous fournissez une liste de tuples, chaque clé sera vérifiée et l'opération sera annulée même si l'une des clés est dupliquée..

Opérations de lecture

Génial, nous avons maintenant quelques données dans notre tableau. Comment pouvons-nous les récupérer? Le moyen le plus simple consiste à rechercher une clé:

: ets.insert (cool_table, : number, 5) IO.inspect: ets.lookup (cool_table,: number) # => [nombre: 5]

Rappelez-vous que pour le : Order_set table, la clé doit être égale à la valeur fournie. Pour tous les autres types de table, cela devrait correspondre. Aussi, si une table est un :sac ou un : Commandé_sac, la recherche / 2 function peut renvoyer une liste avec plusieurs éléments:

cool_table =: ets.new (: cool_table, [: sac]): ets.insert (cool_table, [: numéro, 5, : numéro, 6]) IO.inspect: ets.lookup (cool_table,: numéro ) # => [nombre: 5, nombre: 6]

Au lieu de récupérer une liste, vous pouvez saisir un élément à la position souhaitée à l'aide de la touche lookup_element / 3 une fonction:

cool_table =: ets.new (: cool_table, []): ets.insert (cool_table, : number, 6) IO.inspect: ets.lookup_element (cool_table,: number, 2) # => 6

Dans ce code, nous obtenons la ligne sous la clé :nombre puis en prenant l'élément en deuxième position. Cela fonctionne aussi parfaitement avec :sac ou : duplicate_bag:

cool_table =: ets.new (: cool_table, [: bag]): ets.insert (cool_table, [: number, 5, : number, 6]) IO.inspect: ets.lookup_element (cool_table,: number , 2) # => 5,6

Si vous souhaitez simplement vérifier si une clé est présente dans le tableau, utilisez membre / 2, qui retourne soit vrai ou faux:

cool_table =: ets.new (: cool_table, [: bag]): ets.insert (cool_table, [: number, 5, : number, 6]) si: ets.member (cool_table,: number) do IO.inspect: ets.lookup_element (cool_table,: number, 2) # => 5,6 end

Vous pouvez également obtenir la première ou la dernière clé d'une table en utilisant premier / 1 et dernier / 1 respectivement:

cool_table =: ets.new (: cool_table, [: Commandé_set]): ets.insert (cool_table, [: b, 3, : a, 100]): ets.last (cool_table) |> IO.inspect # =>: b: ets.first (cool_table) |> IO.inspect # =>: a

En plus de cela, il est possible de déterminer la clé précédente ou suivante en fonction de celle fournie. Si une telle clé est introuvable, : "$ end_of_table" sera retourné:

cool_table =: ets.new (: cool_table, [: Commandé_set]): ets.insert (cool_table, [: b, 3, : a, 100]): ets.prev (cool_table,: b) |> IO.inspect # =>: a: ets.next (cool_table,: a) |> IO.inspect # =>: b: ets.prev (cool_table,: a) |> IO.inspect # =>: "$ end_of_table "

Notez cependant que la table traverse en utilisant des fonctions comme premier, suivant, dernier ou prev n'est pas isolé. Cela signifie qu'un processus peut supprimer ou ajouter plus de données à la table pendant que vous effectuez une itération dessus. Un moyen de surmonter ce problème consiste à utiliser safe_fixtable / 2, ce qui corrige la table et garantit que chaque élément ne sera extrait qu'une seule fois. La table reste fixe à moins que le processus ne la libère:

cool_table =: ets.new (: cool_table, [: sac]): ets.safe_fixtable (cool_table, true): ets.info (cool_table,: safe_fixed_monotonic_time) |> IO.inspect # => 256000, [#PID<0.69.0>, 1]: ets.safe_fixtable (cool_table, false) # => la table est publiée à ce stade: ets.info (cool_table,: safe_fixed_monotonic_time) |> IO.inspect # => false

Enfin, si vous souhaitez trouver un élément dans la table et le supprimer, utilisez le prendre 2 une fonction:

cool_table =: ets.new (: cool_table, [: Commandé_set]): ets.insert (cool_table, [: b, 3, : a, 100]): ets.take (cool_table,: b) |> IO.inspect # => [b: 3]: ets.take (cool_table,: b) |> IO.inspect # => []

Supprimer des opérations

Bon, alors maintenant, disons que vous n’avez plus besoin de la table et que vous souhaitez vous en débarrasser. Utilisation supprimer / 1 pour ça:

cool_table =: ets.new (: cool_table, [: orders_set]]: ets.delete (cool_table)

Bien sûr, vous pouvez aussi supprimer une ligne (ou plusieurs lignes) par sa clé:

cool_table =: ets.new (: cool_table, []): ets.insert (cool_table, [: b, 3, : a, 100]): ets.delete (cool_table,: a)

Pour effacer toute la table, utilisez delete_all_objects / 1:

cool_table =: ets.new (: cool_table, []): ets.insert (cool_table, [: b, 3, : a, 100]): ets.delete_all_objects (cool_table)

Et, enfin, pour rechercher et supprimer un objet spécifique, utilisez delete_object / 2:

cool_table =: ets.new (: cool_table, [: sac]): ets.insert (cool_table, [: a, 3, : a, 100]): ets.delete_object (cool_table, : a, 3 ): ets.lookup (cool_table,: a) |> IO.inspect # => [a: 100]

Conversion de la table

Une table ETS peut être convertie en une liste à tout moment en utilisant le tab2list / 1 une fonction:

cool_table =: ets.new (: cool_table, [: sac]): ets.insert (cool_table, [: a, 3, : a, 100]): ets.tab2list (cool_table) |> IO.inspect # => [a: 3, a: 100]

Cependant, rappelez-vous que l'extraction des données de la table à l'aide des clés est une opération très rapide, et vous devez vous y tenir si possible.

Vous pouvez également vider votre table dans un fichier en utilisant tab2file / 2:

cool_table =: ets.new (: cool_table, [: sac]): ets.insert (cool_table, [: a, 3, : a, 100]): ets.tab2file (cool_table, 'cool_table.txt' ) |> IO.inspect # =>: ok

Notez que le deuxième argument doit être une liste de caractères (une chaîne entre guillemets).

Il existe une poignée d'autres opérations disponibles pouvant être appliquées aux tables ETS, et bien entendu, nous n'allons pas en discuter toutes. Je recommande vraiment de parcourir la documentation Erlang sur ETS pour en savoir plus..

Persister dans l'état avec ETS

Pour résumer les faits que nous avons appris jusqu'à présent, modifions un programme simple que j'ai présenté dans mon article sur GenServer. Ceci est un module appelé CalcServer Cela vous permet d'effectuer divers calculs en envoyant des requêtes au serveur ou en récupérant le résultat:

defmodule CalcServer utilise GenServer def start (valeur_initial) do GenServer.start (__ MODULE__, valeur_initial, nom: __MODULE__) end def (valeur_initial) lorsque is_number (valeur_initial) do : ok, initial_value etend def init (_) stop, "La valeur doit être un entier!" end def sqrt do GenServer.cast (__ MODULE__,: sqrt) fin add add (nombre) do GenServer.cast (__ MODULE__, : add, nombre) end def multiply (nombre ) faire GenServer.cast (__ MODULE__, : multiplier, nombre) fin def div (nombre) faire GenServer.cast (__ MODULE__, : div, nombre) end def résultat faire GenServer.call (__ MODULE__,: result) end def handle_call (: result, _, state) do : répondre, state, state end def handle_cast (opération, état) do case operation opération do: sqrt -> : noreply,: math.sqrt (state) : multiplier, multiplicateur -> : noreply, state * multiplicateur : div, number -> : noreply, state / number : add, number -> : noreply, state + number _ -> : stop , "Non implémenté", état end fin def fin (_reason, _state) do IO.puts "La terminaison du serveur ted "end end CalcServer.start (6.1) CalcServer.sqrt CalcServer.multiply (2) CalcServer.result |> IO.puts # => 4.9396356140913875

Actuellement, notre serveur ne supporte pas toutes les opérations mathématiques, mais vous pouvez l'étendre si nécessaire. En outre, mon autre article explique comment convertir ce module en application et tirer parti des superviseurs pour s’occuper des pannes de serveur..

Ce que j'aimerais faire maintenant, c'est ajouter une autre fonctionnalité: la possibilité de consigner toutes les opérations mathématiques effectuées avec l'argument transmis. Ces opérations seront stockées dans une table ETS afin que nous puissions la récupérer plus tard.

Tout d’abord, modifiez le init fonctionner de sorte qu'une nouvelle table privée nommée avec un type de : duplicate_bag est créé. Nous utilisons : duplicate_bag car deux opérations identiques avec le même argument peuvent être effectuées:

 def init (initial_value) lorsque is_number (initial_value) do: ets.new (: calc_log, [: duplicate_bag,: private,: named_table]) : ok, initial_value end

Maintenant, modifiez le handle_cast callback pour qu'il enregistre l'opération demandée, prépare une formule, puis effectue le calcul réel:

 def handle_cast (opération, état) effectuer l'opération |> prepare_and_log |> calculer (état) end

Voici la prepare_and_log fonction privée:

 defp prepare_and_log (operation) do operation |> opération de casse de journalisation do: sqrt -> fn (valeur_current) ->: math.sqrt (valeur_current) end : multiply, number -> fn (valeur_current) -> current_value * number end  : div, number -> fn (current_value) -> current_value / number end : add, number -> fn (current_value) -> current_value + number end _ -> nil end end

Nous enregistrons l'opération immédiatement (la fonction correspondante sera présentée dans un instant). Puis retournez la fonction appropriée ou néant si on ne sait pas comment gérer l'opération.

En ce qui concerne la bûche fonction, nous devrions soit supporter un tuple (contenant à la fois le nom de l'opération et l'argument) ou un atome (contenant uniquement le nom de l'opération, par exemple, : sqrt):

 def log (opération) quand is_tuple (opération) do: ets.insert (: calc_log, opération) end def log (opération) lorsque is_atom (opération) do: ets.insert (: calc_log, opération, nil) end def log (_) do: ets.insert (: calc_log, : unsupported_operation, nil) end

Ensuite, le calculer fonction, qui renvoie un résultat correct ou un message d'arrêt:

 defp calcule (func, état) quand is_function (func) fait : noreply, func. (état) end defp calcule (_func, état) fait : stop, "Non implémenté", état end

Enfin, présentons une nouvelle fonction d'interface pour extraire toutes les opérations effectuées par leur type:

 def operations (type) do GenServer.call (__ MODULE__, : operations, type) end

Gérer l'appel:

 def handle_call (: opérations, type, _, état) do : reply, fetch_operations_by (type), état end

Et effectuez la recherche réelle:

 defp fetch_operations_by (type) do: ets.lookup (: calc_log, type) fin

Maintenant tout tester:

CalcServer.start (6.1) CalcServer.sqrt CalcServer.add (1) CalcServer.multiply (2) CalcServer.add (2) CalcServer.result |> IO.inspect # => 8.939635614091387 CalcServer.operations (: add) |> IO. inspect # => [ajouter: 1, ajouter: 2]

Le résultat est correct car nous avons effectué deux :ajouter opérations avec les arguments 1 et 2. Bien entendu, vous pouvez prolonger ce programme à votre guise. Néanmoins, n'abusez pas des tables ETS et utilisez-les quand cela améliorera réellement les performances. Dans de nombreux cas, immutables est une meilleure solution..

Disque ETS

Avant de terminer cet article, je voulais dire quelques mots sur les tables ETS basées sur disque ou simplement sur DETS.. 

Les DETS ressemblent beaucoup à ETS: ils utilisent des tables pour stocker diverses données sous forme de n-uplets. Comme vous l'avez deviné, la différence est qu'ils reposent sur le stockage de fichiers plutôt que sur la mémoire et qu'ils ont moins de fonctionnalités. Les DETS ont des fonctions similaires à celles décrites précédemment, mais certaines opérations sont effectuées un peu différemment..

Pour ouvrir une table, vous devez utiliser soit open_file / 1 ou open_file / 2-il n'y a pas nouveau / 2 fonctionner comme dans le : ets module. Comme nous n'avons pas encore de table existante, restons-en à open_file / 2, qui va créer un nouveau fichier pour nous:

: dets.open_file (: file_table, [])

Le nom de fichier est égal au nom de la table par défaut, mais cela peut être changé. Le deuxième argument passé à la fichier ouvert est la liste des options écrites sous la forme de tuples. Il y a une poignée d'options disponibles comme :accès ou : auto_save. Par exemple, pour changer un nom de fichier, utilisez l'option suivante:

: dets.open_file (: fichier_table, [: fichier, 'cool_table.txt'])

Notez qu'il y a aussi un :type option pouvant avoir l'une des valeurs suivantes:

  • :ensemble
  • :sac
  • : duplicate_bag

Ces types sont les mêmes que pour l'ETS. Notez que DETS ne peut pas avoir un type de : Order_set.

Il n'y a pas : named_table option, vous pouvez donc toujours utiliser le nom de la table pour y accéder.

Une autre chose à noter est que les tables DETS doivent être correctement fermées:

: dets.close (: table_fichier)

Si vous ne le faites pas, la table sera réparée lors de sa prochaine ouverture.

Vous effectuez des opérations de lecture et d'écriture, exactement comme vous l'avez fait avec ETS:

: dets.open_file (: file_table, [: fichier, 'cool_table.txt']): dets.insert (: file_table, : a, 3): dets.lookup (: file_table,: a) |> IO .inspect # => [a: 3]: dets.close (: fichier_table)

Gardez toutefois à l'esprit que les DETS sont plus lents que ETS, car Elixir devra accéder au disque, ce qui prend bien sûr plus de temps..

Notez que vous pouvez facilement convertir les tables ETS et DETS. Par exemple, utilisons to_ets / 2 et copier le contenu de notre table DETS en mémoire:

: dets.open_file (: file_table, [: fichier, 'cool_table.txt']): dets.insert (: file_table, : a, 3) my_ets =: ets.new (: my_ets, []): dets.to_ets (: file_table, my_ets): dets.close (: file_table): ets.lookup (my_ets,: a) |> IO.inspect # => [a: 3]

Copiez le contenu de l'ETS sur DETS à l'aide de to_dets / 2:

mes_ets =: ets.new (: mes_ets, []): ets.insert (mes_ets, : a, 3): dets.open_file (: fichier_table, [: fichier, 'cool_table.txt']):: .to_dets (my_ets,: table_fichier): dets.lookup (: table_fichier,: a) |> IO.inspect # => [a: 3]: dets.close (: table_fichier)

Pour résumer, l’ETS sur disque est un moyen simple de stocker le contenu dans le fichier, mais ce module est légèrement moins puissant que l’ETS et les opérations sont également plus lentes..

Conclusion

Dans cet article, nous avons abordé ETS et les tables ETS basées sur disque qui nous permettent de stocker des termes arbitraires en mémoire et dans des fichiers, respectivement. Nous avons vu comment créer de telles tables, quels types sont disponibles, comment effectuer des opérations de lecture et d'écriture, comment détruire des tables et comment les convertir en d'autres types. Vous pouvez trouver plus d'informations sur ETS dans le guide Elixir et sur la page officielle d'Erlang.

Encore une fois, n'abusez pas des tables ETS, et essayez de vous en tenir aux immuables si possible. Cependant, dans certains cas, ETS peut améliorer considérablement les performances. Il est donc utile de connaître cette solution.. 

J'espère que vous avez apprécié cet article. Comme toujours, merci de rester avec moi et à très bientôt!