Bij het maken van een Elixir-programma moet je vaak een staat delen. In een van mijn vorige artikelen liet ik bijvoorbeeld zien hoe een server moet worden gecodeerd om verschillende berekeningen uit te voeren en het resultaat in het geheugen te houden (en later hebben we gezien hoe deze server met behulp van supervisors kogelvrij kon worden gemaakt). Er is echter een probleem: als u een enkel proces hebt dat zorgt voor de status en veel andere processen die er toegang toe hebben, kunnen de prestaties ernstig worden aangetast. Dit komt simpelweg omdat het proces slechts één verzoek tegelijk kan verwerken.
Er zijn echter manieren om dit probleem te verhelpen, en vandaag gaan we het hebben over een van hen. Maak kennis met Erlang Term Storage-tafels of gewoon ETS-tabellen, een snelle opslag in het geheugen die tuples van willekeurige gegevens kan hosten. Zoals de naam al aangeeft, zijn deze tabellen oorspronkelijk geïntroduceerd in Erlang, maar we kunnen ze, net als met elke andere Erlang-module, eenvoudig gebruiken in Elixir.
In dit artikel zul je:
Alle codevoorbeelden werken met zowel Elixir 1.4 als 1.5, die onlangs is uitgebracht.
Zoals ik eerder al zei, zijn ETS-tabellen opslag in het geheugen die tuples data bevatten (rijen genaamd). Meerdere processen kunnen toegang krijgen tot de tabel door zijn ID of een naam die wordt voorgesteld als een atoom en lees-, schrijf-, wis- en andere bewerkingen uitvoeren. ETS-tabellen worden gemaakt door een afzonderlijk proces, dus als dit proces wordt beëindigd, wordt de tabel vernietigd. Er is echter geen automatisch mechanisme voor het verzamelen van garbage, dus de tabel kan geruime tijd in het geheugen blijven hangen.
Gegevens in de ETS-tabel worden weergegeven door een tuple : key, value1, value2, valuen
. U kunt eenvoudig de gegevens opzoeken op basis van de sleutel of een nieuwe rij invoegen, maar standaard kunnen er niet twee rijen met dezelfde sleutel zijn. Op toetsen gebaseerde bewerkingen zijn erg snel, maar als u om een of andere reden een lijst van een ETS-tabel moet produceren en bijvoorbeeld complexe manipulaties van de gegevens moet uitvoeren, is dat ook mogelijk.
Bovendien zijn er op schijven gebaseerde ETS-tabellen beschikbaar die hun inhoud in een bestand opslaan. Natuurlijk werken ze langzamer, maar op deze manier krijg je een eenvoudige bestandsopslag zonder enige moeite. Bovendien kan in-memory ETS eenvoudig worden geconverteerd naar schijfgebaseerd en omgekeerd.
Dus ik denk dat het tijd is om onze reis te beginnen en te zien hoe de ETS-tabellen worden gemaakt!
Om een ETS-tabel te maken, gebruikt u de nieuw / 2
functie. Zolang we een Erlang-module gebruiken, moet de naam ervan als een atoom worden geschreven:
cool_table =: ets.new (: cool_table, [])
Merk op dat je tot voor kort maximaal 1400 tabellen per BEAM-instantie kon maken, maar dit is niet meer het geval - je bent alleen beperkt tot de hoeveelheid beschikbaar geheugen.
Het eerste argument dat is doorgegeven aan de nieuwe
functie is de naam van de tabel (alias), terwijl de tweede een lijst met opties bevat. De cool_table
variabele bevat nu een nummer dat de tabel in het systeem identificeert:
IO.inspect cool_table # => 12306
U kunt deze variabele nu gebruiken om volgende bewerkingen naar de tabel uit te voeren (bijvoorbeeld lezen en schrijven van gegevens).
Laten we het hebben over de opties die u kunt opgeven bij het maken van een tabel. Het eerste (en enigszins vreemde) ding om op te merken is dat je standaard de alias van de tabel op geen enkele manier kunt gebruiken, en in principe heeft het geen effect. Maar toch, het alias moet worden doorgegeven aan de oprichting van de tafel.
Om toegang te krijgen tot de tabel door zijn alias, moet u een : named_table
optie zoals deze:
cool_table =: ets.new (: cool_table, [: named_table])
Trouwens, als u de tabel wilt hernoemen, kunt u dit doen met de hernoemen / 2
functie:
: ets.rename (cool_table,: cooler_table)
Vervolgens, zoals reeds vermeld, kan een tabel niet meerdere rijen met dezelfde sleutel bevatten, en dit wordt gedicteerd door de type. Er zijn vier mogelijke tafeltypen:
: set
-dat is de standaard. Dit betekent dat u niet meerdere rijen met exact dezelfde sleutels kunt hebben. De rijen worden niet op een bepaalde manier opnieuw gesorteerd.: ordered_set
-hetzelfde als : set
, maar de rijen worden gesorteerd op de voorwaarden.:zak
-meerdere rijen kunnen dezelfde sleutel hebben, maar de rijen kunnen nog steeds niet volledig identiek zijn.: duplicate_bag
-rijen kunnen volledig identiek zijn.Er is een ding dat het vermelden waard is met betrekking tot de : ordered_set
tafels. Zoals de documentatie van Erlang zegt, behandelen deze tabellen sleutels als gelijk wanneer ze vergelijk gelijk, niet alleen wanneer ze wedstrijd. Wat betekent dat?
Twee termen in Erlang-match alleen als ze dezelfde waarde en hetzelfde type hebben. Zo integer 1
komt alleen overeen met een ander geheel getal 1
, maar zweeft niet 1.0
omdat ze verschillende typen hebben. Twee termen zijn echter gelijk, als ze dezelfde waarde en hetzelfde type hebben of als beide numeriek zijn en dezelfde waarde hebben. Dit betekent dat 1
en 1.0
zijn vergelijken gelijk.
Als u het type van de tabel wilt opgeven, voegt u eenvoudig een element toe aan de lijst met opties:
cool_table =: ets.new (: cool_table, [: named_table,: ordered_set])
Een andere interessante optie die u kunt passeren is : gecomprimeerde
. Het betekent dat de gegevens in de tabel (maar niet de toetsen) zullen zijn - raad eens wat - opgeslagen in een compacte vorm. Natuurlijk zullen de bewerkingen die op de tafel worden uitgevoerd langzamer worden.
Vervolgens kunt u bepalen welk element in de tuple als de sleutel moet worden gebruikt. Standaard is het eerste element (positie 1
) wordt gebruikt, maar dit kan eenvoudig worden gewijzigd:
cool_table =: ets.new (: cool_table, [: keypos, 2])
Nu zullen de tweede elementen in de tuples worden behandeld als de toetsen.
De laatste maar niet de minste optie beheert de toegangsrechten van de tabel. Deze rechten bepalen welke processen toegang hebben tot de tabel:
:openbaar
-elk proces kan elke bewerking naar de tafel uitvoeren.: beschermde
-de standaardwaarde. Alleen het eigenaarproces kan naar de tabel schrijven, maar alle processen kunnen lezen.:privaat
-alleen het eigenaarproces heeft toegang tot de tabel.Dus, om een tafel privé te maken, zou je schrijven:
cool_table =: ets.new (: cool_table, [: private])
Oké, genoeg praten over opties - laten we enkele algemene bewerkingen zien die je op de tafels kunt uitvoeren!
Om iets uit de tabel te kunnen lezen, moet je eerst wat gegevens daar schrijven, dus laten we beginnen met de laatste bewerking. Gebruik de Plaats / 2
functie om gegevens in de tabel te plaatsen:
cool_table =: ets.new (: cool_table, []): ets.insert (cool_table, : number, 5)
Je kunt ook een lijst met tuples als deze doorgeven:
: ets.insert (cool_table, [: number, 5, : string, "test"])
Merk op dat als de tabel een type heeft : set
en een nieuwe sleutel komt overeen met een bestaande, de oude gegevens worden overschreven. Evenzo, als een tabel een type heeft : ordered_set
en een nieuwe sleutel vergelijkt gelijk aan de oude, de gegevens worden overschreven, dus let hier op.
De invoegbewerking (zelfs met meerdere tuples tegelijkertijd) is gegarandeerd atomair en geïsoleerd, wat betekent dat of alles in de tabel wordt opgeslagen of helemaal niets. Ook kunnen andere processen het tussenresultaat van de bewerking niet zien. Al met al is dit vergelijkbaar met SQL-transacties.
Als u zich zorgen maakt over het dupliceren van sleutels of als u uw gegevens niet per ongeluk wilt overschrijven, gebruikt u de insert_new / 2
functioneer in plaats daarvan. Het lijkt op Plaats / 2
maar zal nooit dubbele sleutels invoegen en zal in plaats daarvan terugkeren vals
. Dit is het geval voor de :zak
en : duplicate_bag
tabellen ook:
cool_table =: ets.new (: cool_table, [: bag]): ets.insert (cool_table, : number, 5): ets.insert_new (cool_table, : number, 6) |> IO.inspect # = > false
Als u een lijst met tuples opgeeft, wordt elke sleutel gecontroleerd en wordt de bewerking geannuleerd, zelfs als een van de sleutels wordt gedupliceerd.
Geweldig, nu hebben we wat gegevens in onze tabel - hoe halen we ze op? De gemakkelijkste manier is om opzoeking met een sleutel uit te voeren:
: ets.insert (cool_table, : number, 5) IO.inspect: ets.lookup (cool_table,: number) # => [number: 5]
Onthoud dat voor de : ordered_set
tabel, moet de sleutel gelijk zijn aan de opgegeven waarde. Voor alle andere tabeltypen moet het overeenkomen. Ook als een tabel een is :zak
of een : ordered_bag
, de lookup / 2
functie kan een lijst met meerdere elementen retourneren:
cool_table =: ets.new (: cool_table, [: bag]): ets.insert (cool_table, [: number, 5, : number, 6]) IO.inspect: ets.lookup (cool_table,: number ) # => [nummer: 5, nummer: 6]
In plaats van een lijst op te halen, kunt u een element in de gewenste positie pakken met behulp van de lookup_element / 3
functie:
cool_table =: ets.new (: cool_table, []): ets.insert (cool_table, : number, 6) IO.inspect: ets.lookup_element (cool_table,: number, 2) # => 6
In deze code krijgen we de rij onder de sleutel :aantal
en dan het element in de tweede positie te nemen. Het werkt ook perfect met :zak
of : 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
Als u eenvoudig wilt controleren of een sleutel in de tabel aanwezig is, gebruikt u lid / 2
, die ook terugkeert waar
of vals
:
cool_table =: ets.new (: cool_table, [: bag]): ets.insert (cool_table, [: number, 5, : number, 6]) if: ets.member (cool_table,: number) do IO.inspect: ets.lookup_element (cool_table,: number, 2) # => 5,6 end
Je kunt ook de eerste of de laatste sleutel in een tabel krijgen door te gebruiken Eerst / 1
en laatste / 1
respectievelijk:
cool_table =: ets.new (: cool_table, [: ordered_set]): ets.insert (cool_table, [: b, 3, : a, 100]): ets.last (cool_table) |> IO.inspect # =>: b: ets.first (cool_table) |> IO.inspect # =>: a
Bovendien is het mogelijk om de vorige of de volgende sleutel te bepalen op basis van de geleverde sleutel. Als een dergelijke sleutel niet kan worden gevonden, : "$ End_of_table"
zal worden teruggestuurd:
cool_table =: ets.new (: cool_table, [: ordered_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 "
Merk echter op dat de tabel doorloopt met behulp van functies zoals eerste
, volgende
, laatste
of prev
is niet geïsoleerd. Het betekent dat een proces mogelijk meer gegevens aan de tabel verwijdert of toevoegt terwijl u eroverheen loopt. Een manier om dit probleem te verhelpen is door het te gebruiken safe_fixtable / 2
, die de tabel repareert en ervoor zorgt dat elk element slechts één keer wordt opgehaald. De tabel blijft behouden tenzij het proces deze vrijgeeft:
cool_table =: ets.new (: cool_table, [: bag]): 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) # => tabel is vrijgegeven op dit punt: ets.info (cool_table,: safe_fixed_monotonic_time) |> IO.inspect # => false
Als u tenslotte een element in de tabel wilt vinden en het wilt verwijderen, gebruikt u het nemen / 2
functie:
cool_table =: ets.new (: cool_table, [: ordered_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 # => []
Oké, dus laten we nu zeggen dat je de tafel niet langer nodig hebt en ervan af wilt. Gebruik verwijderen / 1
daarom:
cool_table =: ets.new (: cool_table, [: ordered_set]): ets.delete (cool_table)
U kunt natuurlijk ook een rij (of meerdere rijen) verwijderen met de bijbehorende sleutel:
cool_table =: ets.new (: cool_table, []): ets.insert (cool_table, [: b, 3, : a, 100]): ets.delete (cool_table,: a)
Gebruik de volledige tabel om uit te wissen delete_all_objects / 1
:
cool_table =: ets.new (: cool_table, []): ets.insert (cool_table, [: b, 3, : a, 100]): ets.delete_all_objects (cool_table)
En, ten slotte, om een specifiek object te vinden en te verwijderen, gebruik delete_object / 2
:
cool_table =: ets.new (: cool_table, [: bag]): ets.insert (cool_table, [: a, 3, : a, 100]): ets.delete_object (cool_table, : a, 3 ): ets.lookup (cool_table,: a) |> IO.inspect # => [a: 100]
Een ETS-tabel kan op elk moment naar een lijst worden geconverteerd met behulp van de tab2list / 1
functie:
cool_table =: ets.new (: cool_table, [: bag]): ets.insert (cool_table, [: a, 3, : a, 100]): ets.tab2list (cool_table) |> IO.inspect # => [a: 3, a: 100]
Houd er echter rekening mee dat het ophalen van de gegevens uit de tabel met de toetsen een zeer snelle bewerking is en dat u zich hieraan zo mogelijk moet houden.
U kunt ook uw tabel naar een bestand dumpen met behulp van tab2file / 2
:
cool_table =: ets.new (: cool_table, [: bag]): ets.insert (cool_table, [: a, 3, : a, 100]): ets.tab2file (cool_table, 'cool_table.txt' )> IO.inspect # =>: ok
Merk op dat het tweede argument een charlist zou moeten zijn (een enkele string).
Er zijn een handvol andere operaties beschikbaar die kunnen worden toegepast op de ETS-tabellen, en natuurlijk zullen we ze niet allemaal bespreken. Ik raad echt aan om door de Erlang-documentatie over ETS te bladeren voor meer informatie.
Om de feiten die we tot nu toe hebben geleerd samen te vatten, laten we een eenvoudig programma wijzigen dat ik heb gepresenteerd in mijn artikel over GenServer. Dit is een module genaamd CalcServer
waarmee u verschillende berekeningen kunt uitvoeren door aanvragen naar de server te verzenden of het resultaat op te halen:
defmodule CalcServer gebruikt GenServer def start (initial_value) do GenServer.start (__ MODULE__, initial_value, name: __MODULE__) end def init (initial_value) wanneer is_number (initial_value) do : ok, initial_value end def init (_) do : stop, "De waarde moet een geheel getal zijn!" einde def sqrt do GenServer.cast (__ MODULE__,: sqrt) einde def add (nummer) do GenServer.cast (__ MODULE__, : add, number) end def multiply (number ) doe GenServer.cast (__ MODULE__, : vermenigvuldigen, nummer) einde def div (nummer) do GenServer.cast (__ MODULE__, : div, number) einde def resultaat do GenServer.call (__ MODULE__,: result) end def handle_call (: result, _, state) do : reply, state, state end def handle_cast (operation, state) do case operatie do: sqrt -> : noreply,: math.sqrt (state) : vermenigvuldigen, vermenigvuldiger -> : noreply, state * multiplier : div, number -> : noreply, state / number : add, number -> : noreply, state + number _ -> : stop , "Niet geïmplementeerd", state einde einde def terminate (_reason, _state) do IO.puts "The server termina ted "end end CalcServer.start (6.1) CalcServer.sqrt CalcServer.multiply (2) CalcServer.result |> IO.puts # => 4.9396356140913875
Momenteel ondersteunt onze server niet alle wiskundige bewerkingen, maar u kunt deze indien nodig uitbreiden. In mijn andere artikel wordt ook uitgelegd hoe u deze module converteert naar een toepassing en kunt profiteren van supervisors om te zorgen voor servercrashes.
Wat ik nu graag zou willen doen, is een andere functie toevoegen: de mogelijkheid om alle wiskundige bewerkingen bij te houden die zijn uitgevoerd samen met het aangenomen argument. Deze bewerkingen worden opgeslagen in een ETS-tabel zodat we deze later kunnen ophalen.
Allereerst, wijzig de in het
functie zodat een nieuwe privétabel met een type : duplicate_bag
is gecreëerd. Wij gebruiken : duplicate_bag
omdat twee identieke bewerkingen met hetzelfde argument kunnen worden uitgevoerd:
def init (initial_value) wanneer is_number (initial_value) do: ets.new (: calc_log, [: duplicate_bag,: private,: named_table]) : ok, initial_value einde
Nu tweak handle_cast
callback zodat het de gevraagde bewerking registreert, een formule voorbereidt en vervolgens de eigenlijke berekening uitvoert:
def handle_cast (operation, state) do operatie |> prepare_and_log |> berekenen (state) einde
Hier is de prepare_and_log
privé functie:
defp prepare_and_log (bewerking) bewerking doen> logboekbewerking doen: sqrt -> fn (current_value) ->: math.sqrt (current_value) end : vermenigvuldigen, nummer -> fn (current_value) -> current_value * number end : div, nummer -> fn (current_value) -> current_value / number end : add, number -> fn (current_value) -> current_value + number end _ -> nil end end
We loggen de bewerking meteen in (de corresponderende functie wordt in een moment getoond). Retourneer vervolgens de betreffende functie of nul
als we niet weten hoe we met de operatie moeten omgaan.
Wat betreft de logboek
Als we een functie gebruiken, moeten we ofwel een tuple ondersteunen (die zowel de naam van de bewerking als het argument bevat) of een atoom (met alleen de naam van de bewerking, bijvoorbeeld, : sqrt
):
def log (operatie) wanneer is_tuple (bewerking) do: ets.insert (: calc_log, operatie) end def log (operatie) wanneer is_atom (bewerking) do: ets.insert (: calc_log, operation, nil) end def log (_) do: ets.insert (: calc_log, : unsupported_operation, nil) einde
Vervolgens de berekenen
functie, die ofwel een goed resultaat of een stopbericht retourneert:
defp bereken (func, state) wanneer is_function (func) do : noreply, func. (state) einde defp bereken (_func, state) do : stop, "Not imported", state end
Laten we ten slotte een nieuwe interface-functie voorstellen om alle uitgevoerde bewerkingen op te halen volgens hun type:
def operaties (type) do GenServer.call (__ MODULE__, : operaties, type) eindigen
Behandel de oproep:
def handle_call (: operations, type, _, state) do : reply, fetch_operations_by (type), state end
En voer de daadwerkelijke opzoeking uit:
defp fetch_operations_by (type) do: ets.lookup (: calc_log, type) einde
Test nu alles:
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. inspecteer # => [voeg toe: 1, voeg toe: 2]
Het resultaat is correct omdat we er twee hebben uitgevoerd :toevoegen
bewerkingen met de argumenten 1
en 2
. Natuurlijk kunt u dit programma verder uitbreiden naar eigen inzicht. Maak echter geen misbruik van ETS-tabellen en gebruik ze niet als het de prestaties echt gaat verbeteren. In veel gevallen is het gebruik van onveranderlijke bestanden een betere oplossing..
Voordat ik dit artikel inpakte, wilde ik een paar woorden zeggen over op schijven gebaseerde ETS-tabellen of gewoon DETS.
DETS lijken op ETS: ze gebruiken tabellen om verschillende gegevens in de vorm van tuples op te slaan. Het verschil is, zoals je hebt geraden, dat ze afhankelijk zijn van bestandsopslag in plaats van geheugen en minder functies hebben. DETS hebben functies die vergelijkbaar zijn met degene die we hierboven hebben besproken, maar sommige bewerkingen worden iets anders uitgevoerd.
Om een tafel te openen, moet u een van beide gebruiken open_file / 1
of open_file / 2
-er is geen nieuw / 2
functioneer zoals in de : ets
module. Omdat we nog geen bestaande tabel hebben, houden we ons aan open_file / 2
, die een nieuw bestand voor ons gaat aanmaken:
: dets.open_file (: file_table, [])
De bestandsnaam is standaard gelijk aan de naam van de tabel, maar dit kan worden gewijzigd. Het tweede argument dat is doorgegeven aan de open bestand
is de lijst met opties geschreven in de vorm van tuples. Er zijn een handvol beschikbare opties zoals :toegang
of : auto_save
. Gebruik bijvoorbeeld de volgende optie om een bestandsnaam te wijzigen:
: dets.open_file (: file_table, [: file, 'cool_table.txt'])
Merk op dat er ook een is :type
optie die een van de volgende waarden kan hebben:
: set
:zak
: duplicate_bag
Deze typen zijn hetzelfde als voor de ETS. Merk op dat DETS geen type kan hebben : ordered_set
.
Er is geen : named_table
optie, zodat u altijd de naam van de tabel kunt gebruiken om deze te openen.
Een ander ding dat het vermelden waard is, is dat de DETS-tabellen correct gesloten moeten zijn:
: Dets.close (: file_table)
Als u dit niet doet, wordt de tafel gerepareerd de volgende keer dat deze wordt geopend.
U voert lees- en schrijfbewerkingen uit, net als bij ETS:
: dets.open_file (: file_table, [: file, 'cool_table.txt']): dets.insert (: file_table, : a, 3): dets.lookup (: file_table,: a) |> IO .inspect # => [a: 3]: dets.close (: bestand_tabel)
Houd er echter rekening mee dat DETS langzamer zijn dan ETS, omdat Elixir toegang tot de schijf nodig heeft, wat natuurlijk meer tijd in beslag neemt.
Merk op dat u gemakkelijk ETS- en DETS-tabellen kunt omzetten. Laten we het bijvoorbeeld gebruiken to_ets / 2
en kopieer de inhoud van onze DETS-tabel in het geheugen:
: dets.open_file (: file_table, [: file, '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]
Kopieer de inhoud van de ETS naar DETS met to_dets / 2
:
my_ets =: ets.new (: my_ets, []): ets.insert (my_ets, : a, 3): dets.open_file (: file_table, [: file, 'cool_table.txt']): ets .to_dets (my_ets,: file_table): dets.lookup (: file_table,: a) |> IO.inspect # => [a: 3]: dets.close (: file_table)
Kortom, op schijven gebaseerde ETS is een eenvoudige manier om inhoud in het bestand op te slaan, maar deze module is iets minder krachtig dan ETS en de bewerkingen zijn ook langzamer.
In dit artikel hebben we gesproken over ETS en op schijven gebaseerde ETS-tabellen waarmee we willekeurige termen in het geheugen en in bestanden kunnen opslaan. We hebben gezien hoe dergelijke tabellen kunnen worden gemaakt, wat de beschikbare typen zijn, hoe lees- en schrijfbewerkingen kunnen worden uitgevoerd, hoe tabellen moeten worden vernietigd en hoe deze moeten worden geconverteerd naar andere typen. U vindt meer informatie over ETS in de Elixir-gids en op de Erlang officiële pagina.
Gebruik opnieuw ETS-tabellen niet te veel en probeer indien mogelijk vast te houden aan onveranderlijke zaken. In sommige gevallen kan ETS echter een mooie prestatieverbetering zijn, dus in ieder geval is weten over deze oplossing nuttig.
Hopelijk heb je genoten van dit artikel. Zoals altijd, bedankt dat je bij me bent gebleven en ik zie je heel snel!