Clojure Webapps schrijven met Ring

1. Inleiding

Ring is een bibliotheek voor het schrijven van webapplicaties in Clojure. Het ondersteunt alles wat nodig is om volledig functionele web-apps te schrijven en heeft een bloeiend ecosysteem om het nog krachtiger te maken.

In deze tutorial geven we een inleiding op Ring en laten we enkele dingen zien die we ermee kunnen bereiken.

Ring is geen raamwerk dat is ontworpen voor het maken van REST API's, zoals zoveel moderne toolkits. Het is een framework op een lager niveau om HTTP-verzoeken in het algemeen af ​​te handelen, met een focus op traditionele webontwikkeling. Sommige bibliotheken bouwen er echter bovenop om vele andere gewenste applicatiestructuren te ondersteunen.

2. Afhankelijkheden

Voordat we met Ring kunnen gaan werken, moeten we het aan ons project toevoegen. De minimale afhankelijkheden die we nodig hebben, zijn:

  • ring / ringkern
  • ring / ring-steiger-adapter

We kunnen deze toevoegen aan ons Leiningen-project:

 : dependencies [[org.clojure / clojure "1.10.0"] [ring / ring-core "1.7.1"] [ring / ring-steiger-adapter "1.7.1"]]

We kunnen dit dan toevoegen aan een minimaal project:

(ns ring.core (: use ring.adapter.jetty)) (defn handler [request] {: status 200: headers {"Content-Type" "text / plain"}: body "Hello World"}) (defn - main [& args] (run-jetty handler {: port 3000}))

Hier hebben we een handlerfunctie gedefinieerd - die we binnenkort zullen behandelen - die altijd de string "Hallo wereld" retourneert. We hebben ook onze hoofdfunctie toegevoegd om deze handler te gebruiken - het luistert naar verzoeken op poort 3000.

3. Kernconcepten

Leiningen heeft een aantal kernconcepten waar alles omheen is gebouwd: verzoeken, antwoorden, handlers en middleware.

3.1. Verzoeken

Verzoeken zijn een weergave van inkomende HTTP-verzoeken. Ring vertegenwoordigt een verzoek als een kaart, waardoor onze Clojure-applicatie gemakkelijk kan communiceren met de afzonderlijke velden. Er is een standaardset sleutels op deze kaart, inclusief maar niet beperkt tot:

  • : uri - Het volledige URI-pad.
  • : query-tekenreeks - De volledige queryreeks.
  • : request-method - De verzoekmethode, een van : get,: head,: post,: put,: delete of : opties.
  • : headers - Een overzicht van alle HTTP-headers die aan het verzoek zijn verstrekt.
  • :lichaam - Een InputStream het vertegenwoordigen van de verzoekende instantie, indien aanwezig.

Middleware kan ook meer sleutels aan deze map toevoegen indien nodig.

3.2. Reacties

Op dezelfde manier zijn reacties een weergave van de uitgaande HTTP-reacties. Ring stelt deze ook voor als kaarten met drie standaardsleutels:

  • :toestand - De statuscode om terug te sturen
  • :headers - Een kaart van alle HTTP-headers om terug te sturen
  • :lichaam - De optionele instantie om terug te sturen

Zoals eerder, Middleware kan dit veranderen tussen onze handler die het produceert en het uiteindelijke resultaat dat naar de client wordt gestuurd.

Ring biedt ook enkele hulpmiddelen om het bouwen van de antwoorden gemakkelijker te maken.

De meest elementaire hiervan is de ring.util.response / antwoord functie, die een eenvoudig antwoord creëert met een statuscode van 200 OK:

ring.core => (ring.util.response / response "Hello") {: status 200,: headers {},: body "Hello"}

Er zijn een paar andere methoden die hiermee gepaard gaan voor algemene statuscodes - bijvoorbeeld, foute aanvraag, niet gevonden en omleiding:

ring.core => (ring.util.response / bad-request "Hello") {: status 400,: headers {},: body "Hello"} ring.core => (ring.util.response / created "/ post / 123 ") {: status 201,: headers {" Location "" / post / 123 "},: body nil} ring.core => (ring.util.response / redirect" //ring-clojure.github. io / ring / ") {: status 302,: headers {" Location "" //ring-clojure.github.io/ring/ "},: body" "}

We hebben ook de toestand methode die een bestaand antwoord naar een willekeurige statuscode converteert:

ring.core => (ring.util.response / status (ring.util.response / response "Hello") 409) {: status 409,: headers {},: body "Hello"}

We hebben dan enkele methoden om andere kenmerken van de respons op dezelfde manier aan te passen - bijvoorbeeld, content-type, header of set-cookie:

ring.core => (ring.util.response / content-type (ring.util.response / response "Hallo") "text / plain") {: status 200,: headers {"Content-Type" "text / plain "},: body" Hello "} ring.core => (ring.util.response / header (ring.util.response / response" Hello ")" X-Tutorial-For "" Baeldung ") {: status 200, : headers {"X-Tutorial-For" "Baeldung"},: body "Hello"} ring.core => (ring.util.response / set-cookie (ring.util.response / response "Hello") "Gebruiker "" 123 ") {: status 200,: headers {},: body" Hallo ",: cookies {" Gebruiker "{: waarde" 123 "}}}

Let daar op de set-cookie methode voegt een geheel nieuw item toe aan de responsmap. Dit heeft de wrap-koekjes middleware om het correct te verwerken om het te laten werken.

3.3. Handlers

Nu we verzoeken en antwoorden begrijpen, kunnen we beginnen met het schrijven van onze handlerfunctie om deze aan elkaar te koppelen.

Een handler is een eenvoudige functie die het inkomende verzoek als parameter neemt en het uitgaande antwoord retourneert. Wat we in deze functie doen, is geheel aan onze applicatie, zolang het maar binnen dit contract past.

We zouden op zijn eenvoudigst een functie kunnen schrijven die altijd hetzelfde antwoord retourneert:

(defn handler [request] (ring.util.response / response "Hallo"))

We kunnen ook op het verzoek reageren als dat nodig is.

We kunnen bijvoorbeeld een handler schrijven om het inkomende IP-adres te retourneren:

(defn check-ip-handler [request] (ring.util.response / content-type (ring.util.response / response (: remote-addr request)) "text / plain"))

3.4. Middleware

Middleware is een naam die in sommige talen gebruikelijk is, maar minder in de Java-wereld. Conceptueel zijn ze vergelijkbaar met Servlet Filters en Spring Interceptors.

In Ring verwijst middleware naar eenvoudige functies die de hoofdhandler omhullen en sommige aspecten ervan op de een of andere manier aanpassen. Dit kan betekenen dat het inkomende verzoek wordt gemuteerd voordat het wordt verwerkt, het uitgaande antwoord moet worden gemuteerd nadat het is gegenereerd of mogelijk niets anders moet doen dan vastleggen hoe lang het duurde om te verwerken.

Over het algemeen, middlewarefuncties nemen een eerste parameter van de handler om te verpakken en retourneren een nieuwe handlerfunctie met de nieuwe functionaliteit.

De middleware kan zoveel andere parameters gebruiken als nodig is. We kunnen bijvoorbeeld het volgende gebruiken om de Inhoudstype koptekst op elk antwoord van de ingepakte handler:

(defn wrap-content-type [handler content-type] (fn [request] (let [response (handler request)] (assoc-in response [: headers "Content-Type"] content-type))))

Als we het doorlezen, kunnen we zien dat we een functie retourneren die een verzoek accepteert - dit is de nieuwe handler. Dit roept vervolgens de opgegeven handler op en retourneert vervolgens een gemuteerde versie van het antwoord.

We kunnen dit gebruiken om een ​​nieuwe handler te produceren door ze eenvoudig aan elkaar te koppelen:

(def app-handler (wrap-content-type handler "text / html"))

Clojure biedt ook een manier om veel op een natuurlijkere manier aan elkaar te koppelen - door het gebruik van Threading Macros. Dit is een manier om een ​​lijst met aan te roepen functies te bieden, elk met de uitvoer van de vorige.

In het bijzonder willen we de macro Thread First, ->. Hierdoor kunnen we elke middleware aanroepen met de opgegeven waarde als de eerste parameter:

(def app-handler (-> handler (wrap-content-type "text / html") wrap-trefwoord-params wrap-params))

Dit heeft vervolgens een handler opgeleverd die de oorspronkelijke handler is, verpakt in drie verschillende middlewarefuncties.

4. Schrijfhandlers

Nu we de componenten van een Ring-applicatie begrijpen, moeten we weten wat we kunnen doen met de daadwerkelijke handlers. Dit zijn de kern van de hele applicatie en daar gaat het merendeel van de bedrijfslogica naartoe.

We kunnen elke gewenste code in deze handlers stoppen, inclusief databasetoegang of het bellen naar andere services. Ring geeft ons enkele extra mogelijkheden om rechtstreeks met de inkomende verzoeken of uitgaande antwoorden te werken, die ook erg handig zijn.

4.1. Statische bronnen bedienen

Een van de eenvoudigste functies die elke webtoepassing kan uitvoeren, is het aanbieden van statische bronnen. Ring biedt twee middleware-functies om dit gemakkelijk te maken - wrap-bestand en wrap-resource.

De wrap-bestand middleware neemt een map op het bestandssysteem. Als het inkomende verzoek overeenkomt met een bestand in deze map, wordt dat bestand geretourneerd in plaats van de handlerfunctie aan te roepen:

(gebruik 'ring.middleware.file) 
(def app-handler (wrap-file your-handler "/ var / www / public"))

Op een vergelijkbare manier de wrap-resource middleware krijgt een classpath prefix waarin het naar de bestanden zoekt:

(gebruik 'ring.middleware.resource) 
(def app-handler (wrap-resource your-handler "public"))

In beide gevallen, de ingepakte handler-functie wordt alleen aangeroepen als er geen bestand wordt gevonden om naar de client terug te keren.

Ring biedt ook extra middleware om deze schoner te maken voor gebruik via de HTTP-API:

(gebruik 'ring.middleware.resource' ring.middleware.content-type 'ring.middleware.not-modified) (def app-handler (-> your-handler (wrap-resource "public") wrap-content-type wrap -niet-gemodificeerd)

De wrap-content-type middleware bepaalt automatisch het Inhoudstype header om in te stellen op basis van de aangevraagde bestandsnaamextensie. De wrap-niet-gewijzigd middleware vergelijkt het If-Not-Modified koptekst naar het Laatst gewijzigd waarde om HTTP-caching te ondersteunen, waarbij het bestand alleen wordt geretourneerd als dat nodig is.

4.2. Toegang tot verzoekparameters

Bij het verwerken van een verzoek zijn er enkele belangrijke manieren waarop de client informatie aan de server kan verstrekken. Deze omvatten parameters van de querytekenreeks - opgenomen in de URL- en formulierparameters - die zijn ingediend als de payload van het verzoek voor POST- en PUT-verzoeken.

Voordat we parameters kunnen gebruiken, moeten we de wrap-params middleware om de handler in te pakken. Dit parseert de parameters correct, ondersteunt URL-codering, en maakt ze beschikbaar voor het verzoek. Dit kan optioneel de te gebruiken tekencodering specificeren, standaard UTF-8 indien niet gespecificeerd:

(def app-handler (-> your-handler (wrap-params {: encoding "UTF-8"})))

Eenmaal gedaan, het verzoek wordt bijgewerkt om de parameters beschikbaar te maken. Deze gaan naar de juiste sleutels in het inkomende verzoek:

  • : query-params - De parameters die zijn geparseerd uit de querytekenreeks
  • : form-params - De parameters die uit de hoofdtekst van het formulier zijn geparseerd
  • : params - De combinatie van beide : query-params en : form-params

Hiervan kunnen wij in onze request handler gebruik maken, precies zoals verwacht.

(defn echo-handler [{params: params}] (ring.util.response / content-type (ring.util.response / response (haal params "input")) "text / plain"))

Deze handler retourneert een antwoord met de waarde van de parameter invoer.

Parameters worden toegewezen aan een enkele tekenreeks als er slechts één waarde aanwezig is, of aan een lijst als er meerdere waarden aanwezig zijn.

We krijgen bijvoorbeeld de volgende parameterkaarten:

// / echo? input = hallo {"input" hallo "} // / echo? input = hallo & naam = Fred {" input "hallo" "naam" "Fred"} // / echo? input = hallo & input = wereld {" input ["hallo" "wereld"]}

4.3. Bestandsuploads ontvangen

Vaak willen we webapplicaties kunnen schrijven waarnaar gebruikers bestanden kunnen uploaden. In het HTTP-protocol wordt dit meestal afgehandeld met behulp van multipart-verzoeken. Deze maken het mogelijk dat een enkel verzoek zowel formulierparameters als een set bestanden bevat.

Ring wordt geleverd met een middleware genaamd wrap-multipart-params om dit soort verzoeken te behandelen. Dit is vergelijkbaar met de manier waarop wrap-params parseert eenvoudige verzoeken.

wrap-multipart-params decodeert automatisch alle geüploade bestanden en slaat ze op in het bestandssysteem en vertelt de handler waar ze zijn om ermee te werken:

(def app-handler (-> uw-handler wrap-params wrap-multipart-params))

Standaard, de geüploade bestanden worden opgeslagen in de tijdelijke systeemdirectory en na een uur automatisch verwijderd. Merk op dat dit vereist dat de JVM het komende uur nog steeds actief is om de opschoning uit te voeren.

Indien gewenst, er is ook een geheugenopslag, hoewel dit uiteraard het risico loopt onvoldoende geheugen te hebben als grote bestanden worden geüpload.

We kunnen indien nodig ook onze opslagengines schrijven, zolang deze maar aan de API-vereisten voldoet.

(def app-handler (-> your-handler wrap-params (wrap-multipart-params {: store ring.middleware.multipart-params.byte-array / byte-array-store})))

Zodra deze middleware is ingesteld, de geüploade bestanden zijn beschikbaar op het inkomende verzoekobject onder de params sleutel. Dit is hetzelfde als het gebruik van de wrap-params middleware. Dit item is een kaart met de details die nodig zijn om met het bestand te werken, afhankelijk van de gebruikte winkel.

De standaardopslag voor tijdelijke bestanden retourneert bijvoorbeeld waarden:

 {"file" {: filename "words.txt": inhoudstype "text / plain": tempfile #object [java.io.File ...]: grootte 51}}

Waar de : tempfile invoer is een java.io.File object dat rechtstreeks het bestand op het bestandssysteem vertegenwoordigt.

4.4. Werken met cookies

Cookies zijn een mechanisme waarbij de server een kleine hoeveelheid gegevens kan leveren die de klant bij volgende verzoeken blijft terugsturen. Dit wordt doorgaans gebruikt voor sessie-ID's, toegangstokens of permanente gebruikersgegevens, zoals de geconfigureerde lokalisatie-instellingen.

Ring heeft middleware waarmee we gemakkelijk met cookies kunnen werken. Hiermee worden cookies automatisch geparseerd op inkomende verzoeken, en kunnen we ook nieuwe cookies maken op uitgaande reacties.

Het configureren van deze middleware volgt dezelfde patronen als hiervoor:

(def app-handler (-> your-handler wrap-cookies))

Op dit punt, bij alle inkomende verzoeken worden de cookies geparseerd en in het : koekjes toets het verzoek in. Dit bevat een kaart met de cookienaam en -waarde:

{"session_id" {: waarde "session-id-hash"}}

We kunnen dan cookies toevoegen aan uitgaande reacties door de : koekjes sleutel tot het uitgaande antwoord. We kunnen dit doen door het antwoord rechtstreeks te creëren:

{: status 200: headers {}: cookies {"session_id" {: waarde "session-id-hash"}}: body "Een cookie instellen."}

Er is ook een hulpfunctie die we kunnen gebruiken om cookies aan reacties toe te voegen, op dezelfde manier als hoe we eerder statuscodes of headers konden instellen:

(ring.util.response / set-cookie (ring.util.response / response "Instellen van een cookie.") "session_id" "session-id-hash")

Cookies kunnen ook extra opties hebben, zoals nodig voor de HTTP-specificatie. Als we gebruiken set-cookie dan geven we deze als een kaartparameter na de sleutel en waarde. De sleutels tot deze kaart zijn:

  • :domein - Het domein waartoe de cookie moet worden beperkt
  • :pad - Het pad waartoe de cookie moet worden beperkt
  • : veiligwaar om de cookie alleen via HTTPS-verbindingen te verzenden
  • : alleen httpwaar om de cookie ontoegankelijk te maken voor JavaScript
  • : max-leeftijd - Het aantal seconden waarna de browser de cookie verwijdert
  • : vervalt - Een specifiek tijdstempel waarna de browser de cookie verwijdert
  • : same-site - Indien ingesteld op : streng, dan zal de browser deze cookie niet terugsturen met cross-site verzoeken.
(ring.util.response / set-cookie (ring.util.response / response "Een cookie instellen.") "session_id" "session-id-hash" {: secure true: http-only true: max-age 3600} )

4.5. Sessies

Cookies stellen ons in staat om stukjes informatie op te slaan die de klant bij elk verzoek terugstuurt naar de server. Een krachtigere manier om dit te bereiken, is door sessies te gebruiken. Deze worden volledig op de server opgeslagen, maar de client behoudt de identificatie die bepaalt welke sessie moet worden gebruikt.

Zoals met al het andere hier, sessies worden geïmplementeerd met behulp van een middleware-functie:

(def app-handler (-> your-handler wrap-sessie))

Standaard, dit slaat sessiegegevens op in het geheugen. We kunnen dit indien nodig wijzigen, en Ring komt met een alternatieve winkel die cookies gebruikt om alle sessiegegevens op te slaan.

Net als bij het uploaden van bestanden, indien nodig kunnen wij onze opslagfunctie verzorgen.

(def app-handler (-> uw-handler wrap-cookies (wrap-session {: store (cookie-store {: key "a 16-byte secret"})})))

We kunnen ook de details aanpassen van de cookie die wordt gebruikt om de sessiesleutel op te slaan.

Om er bijvoorbeeld voor te zorgen dat de sessiecookie een uur blijft staan, kunnen we het volgende doen:

(def app-handler (-> your-handler wrap-cookies (wrap-session {: cookie-attrs {: max-age 3600}})))

De cookiekenmerken hier zijn dezelfde als die worden ondersteund door het wrap-koekjes middleware.

Sessies kunnen vaak fungeren als gegevensopslag om mee te werken. Dit werkt niet altijd even goed in een functioneel programmeermodel, dus Ring implementeert ze iets anders.

In plaats daarvan, we hebben toegang tot de sessiegegevens van het verzoek en we retourneren een kaart met gegevens om erin op te slaan als onderdeel van het antwoord. Dit is de volledige sessiestatus die moet worden opgeslagen, niet alleen de gewijzigde waarden.

Het volgende houdt bijvoorbeeld bij hoe vaak de handler is aangevraagd:

(defn handler [{session: session}] (let [count (: count session 0) session (assoc session: count (inc count))]] (-> (response (str "Je hebt deze pagina" count "keer bezocht." )) (assoc: sessie sessie))))

Op deze manier kunnen we verwijder gegevens uit de sessie door simpelweg de sleutel niet op te nemen. We kunnen ook de hele sessie verwijderen door terug te keren nihil voor de nieuwe kaart.

(defn handler [request] (-> (antwoord "Sessie verwijderd.") (assoc: sessie nihil)))

5. Leiningen-plug-in

Ring biedt een plug-in voor de Leiningen-buildtool om zowel ontwikkeling als productie te ondersteunen.

We hebben de plug-in ingesteld door de juiste plug-in-details toe te voegen aan het project.clj het dossier:

 : plugins [[lein-ring "0.12.5"]]: ring {: handler ring.core / handler}

Het is belangrijk dat de versie van lein-ring is correct voor de versie van Ring. Hier hebben we Ring 1.7.1 gebruikt, wat betekent dat we lein-ring 0.12.5. Over het algemeen is het het veiligst om alleen de nieuwste versie van beide te gebruiken, zoals te zien op Maven Central of met de lein zoeken opdracht:

$ lein zoeken ring-core Clojars zoeken ... [ring / ring-core "1.7.1"] Ring-core bibliotheken. $ lein search lein-ring Clojars zoeken ... [lein-ring "0.12.5"] Leiningen Ring plugin

De : handler parameter naar de :ring call is de volledig gekwalificeerde naam van de handler die we willen gebruiken. Dit kan elke middleware omvatten die we hebben gedefinieerd.

Het gebruik van deze plug-in betekent dat we niet langer een hoofdfunctie nodig hebben. We kunnen Leiningen gebruiken om in ontwikkelingsmodus te draaien, of we kunnen een productieartefact bouwen voor implementatiedoeleinden. Onze code komt nu precies neer op onze logica en niets meer.

5.1. Een productieartefact bouwen

Zodra dit is ingesteld, we kunnen nu een WAR-bestand bouwen dat we in elke standaard servlet-container kunnen implementeren:

$ lein ring uberwar 2019-04-12 07: 10: 08.033: INFO :: main: Logging geïnitialiseerd @ 1054ms naar org.eclipse.jetty.util.log.StdErrLog Gemaakt ./clojure/ring/target/uberjar/ring-0.1 .0-SNAPSHOT-standalone.war

We kunnen ook een op zichzelf staand JAR-bestand maken dat onze handler precies zal uitvoeren zoals verwacht:

$ lein ring uberjar Ring.core compileren 2019-04-12 07: 11: 27.669: INFO :: main: Logging geïnitialiseerd @ 3016ms naar org.eclipse.jetty.util.log.StdErrLog Gemaakt ./clojure/ring/target/uberjar /ring-0.1.0-SNAPSHOT.jar Gemaakt ./clojure/ring/target/uberjar/ring-0.1.0-SNAPSHOT-standalone.jar

Dit JAR-bestand bevat een hoofdklasse die de handler start in de ingesloten container die we hebben opgenomen. Hiermee wordt ook rekening gehouden met een omgevingsvariabele van HAVEN waardoor we het gemakkelijk kunnen draaien in een productieomgeving:

PORT = 2000 java -jar ./clojure/ring/target/uberjar/ring-0.1.0-SNAPSHOT-standalone.jar 2019-04-12 07: 14: 08.954: INFO :: main: Logging geïnitialiseerd @ 1009ms naar org. eclipse.jetty.util.log.StdErrLog WAARSCHUWING: seqable? verwijst al naar: # 'clojure.core / seqable? in de naamruimte: clojure.core.incubator, wordt vervangen door: # 'clojure.core.incubator / seqable? 2019-04-12 07: 14: 10.795: INFO: oejs.Server: main: jetty-9.4.z-SNAPSHOT; gebouwd: 2018-08-30T13: 59: 14.071Z; git: 27208684755d94a92186989f695db2d7b21ebc51; jvm 1.8.0_77-b03 2019-04-12 07: 14: 10.863: INFO: oejs.AbstractConnector: main: Started [email protected] {HTTP / 1.1, [http / 1.1]} {0.0.0.0:2000} 2019- 04-12 07: 14: 10.863: INFO: oejs.Server: main: Started @ 2918ms Gestart server op poort 2000

5.2. Draait in ontwikkelingsmodus

Voor ontwikkelingsdoeleinden, we kunnen de handler rechtstreeks vanuit Leiningen uitvoeren zonder deze handmatig te hoeven bouwen en uitvoeren. Dit maakt het gemakkelijker om onze applicatie in een echte browser te testen:

$ lein ring server 2019-04-12 07: 16: 28.908: INFO :: main: Logging geïnitialiseerd @ 1403ms naar org.eclipse.jetty.util.log.StdErrLog 2019-04-12 07: 16: 29.026: INFO: oejs .Server: main: jetty-9.4.12.v20180830; gebouwd: 2018-08-30T13: 59: 14.071Z; git: 27208684755d94a92186989f695db2d7b21ebc51; jvm 1.8.0_77-b03 2019-04-12 07: 16: 29.092: INFO: oejs.AbstractConnector: main: Started [email protected] {HTTP / 1.1, [http / 1.1]} {0.0.0.0:3000} 2019- 04-12 07: 16: 29.092: INFO: oejs.Server: main: Started @ 1587ms

Dit eert ook de HAVEN omgevingsvariabele als we dat hebben ingesteld.

Bovendien, er is een Ring Development-bibliotheek die we aan ons project kunnen toevoegen. Als dit beschikbaar is, dan de ontwikkelingsserver zal proberen om alle gedetecteerde bronwijzigingen automatisch opnieuw te laden. Dit kan ons een efficiënte workflow geven om de code te wijzigen en deze live in onze browser te bekijken. Dit vereist de ring-ontwikkeling afhankelijkheid toevoegen:

[ring / ring-devel "1.7.1"]

6. Conclusie

In dit artikel hebben we een korte inleiding gegeven tot de Ring-bibliotheek als middel om webtoepassingen in Clojure te schrijven. Waarom probeer je het niet bij het volgende project?

Voorbeelden van enkele van de concepten die we hier hebben behandeld, zijn te zien in GitHub.


$config[zx-auto] not found$config[zx-overlay] not found