Web Development |
Viele Anwendungen laufen als Web Applications.
Wir lernen jetzt, wie wir Clojure im Backend aber auch im Frontend verwenden können.
Oft liegt der Fokus auf großen Web Frameworks.
Clojure Entwickler setzen eher auf modulare Libraries.
Ein zentraler Baustein in der Webentwicklung mit Clojure ist Ring.
Ring ist eine Bibliothek, die die Erstellung von Webanwendungen in Clojure vereinfacht.
Es ist der Clojure-Standard für
HTTP-Middleware und -Server.
Ring abstrahiert HTTP-Anfragen und -Antworten in einfache Clojure-Strukturen, was die Verarbeitung und Bearbeitung erleichtert.
Ring macht keine Vorgaben, wie die Anwendung strukturiert wird oder welche sonstigen Bibliotheken verwendet werden sollen.
Für eine neue Ring Anwendung brauchen wir Ring selber und einen Webserver:
:dependencies [[org.clojure/clojure "1.11.1"]
[ring/ring-core "1.10.0"]
[ring/ring-jetty-adapter "1.10.0"]]
Ein Handler ist eine Funktion, die eine Map der HTTP-Anfrage entgegennimmt und eine Map der HTTP-Antwort zurückgibt.
(defn handler [request]
(let [user-agent (get-in request [:headers "user-agent"])]
{:status 200
:headers {"Content-Type" "text/html"}
:body (str "Dein User-Agent ist: " user-agent)}))
Wir können den Ring-Server mit unserem Handler starten.
(ns webdemo.core
(:require [ring.adapter.jetty :refer [run-jetty]])
(:gen-class))
(defn -main [& args]
(run-jetty handler {:port 8080}))
Middleware sind Funktionen, die um den Handler herum den Request und/oder die Response modifizieren.
(defn authenticated-user [handler]
(fn [request]
(if false
(handler (assoc request :user {:name "Alice"}))
{:status 401
:headers {"Content-Type" "text/html"}
:body "Du bist nicht eingeloggt"})))
Wir können die Middleware mit unserem Handler (z.B. durch Threading) verketten.
(defn -main [& args]
(run-jetty (-> handler
authenticated-user)
{:port 8080}))
Ein ähnlich modulares Middleware Konzept ist mit starker Typisierung deutlich umständlicher.
Eine der ersten Aufgaben eines Handlers ist herauszufinden, was der Nutzer mit seinem Request eigentlich will
Welche URL wurde mit welchem HTTP Verb aufgerufen?
GET /homeGET /products/123/POST /products/123/buy/Das Parsen dieser Anteile des Requests nennen wir Routing
Eine gängige Routing Library ist reitit.
Für die Integration von Reitit in unsere Anwendung benötigen wir weitere Abhängigkeiten:
:dependencies [[org.clojure/clojure "1.11.1"]
[ring/ring-core "1.10.0"]
[ring/ring-jetty-adapter "1.10.0"]
[metosin/reitit "0.6.0"]]
Die Routen sind Datenstrukturen, welche angeben, wie der Request gematched wird:
(def routes
[["/" {:get home-handler}]
["/products" {:get product-list-handler}]
["/products/:id" {:get product-handler}]])
reitit kommt mit einem eigenen Ring Handler, den wir unserer Anwendung zu Beginn übergeben:
(ns webdemo.core
(:require [ring.adapter.jetty :refer [run-jetty]]
[reitit.ring :as ring])
(:gen-class))
(def router (ring/router routes))
(def app
(ring/ring-handler
router
(ring/create-default-handler)))
(defn -main [& args]
(run-jetty app {:port 8080}))
Der Default Handler generiert eine Antwort mit Fehlercode, wenn der Router nil zurückgibt.
Jede Route definiert sich durch
Jede Route kann einen eigenen Handler bekommen:
(defn home-handler [request]
{:status 200
:headers {"Content-Type" "text/html"}
:body "Hallo Welt!"})
(defn product-list-handler [request]
{:status 200
:headers {"Content-Type" "text/html"}
:body "Produktliste"})
Für eine reguläre Website sollten die Handler HTML zurückgeben.
Mit einer Library wie hiccup können wir die Seite als Clojure Datenstruktur zusammenbauen und erst zum Schluss in HTML übersetzen.
:dependencies [[org.clojure/clojure "1.11.1"]
[ring/ring-core "1.10.0"]
[ring/ring-jetty-adapter "1.10.0"]
[metosin/reitit "0.6.0"]
[hiccup "1.0.5"]]
(defn home-handler [request]
{:status 200
:headers {"Content-Type" "text/html"}
:body (html5
[:head
[:meta {:charset "utf-8"}]
[:title "Webdemo"]]
[:body
[:h1 "Webdemo"]
[:p "This is a demo web application."]])})
Unsere Routen können URI-Parameter enthalten.
Diese bekommen einen eigenen Eintrag im request
(def routes
[["/" {:get home-handler}]
["/products" {:get product-list-handler}]
["/products/:id" {:get product-handler}]])
(defn product-handler [{{id :id} :path-params :as request}]
{:status 200
:headers {"Content-Type" "text/html"}
:body (str "Produkt " id)})
Routen können hierarchisch genested werden.
(def routes
[["/" {:get home-handler}]
["/products"
["" {:get product-list-handler}]
["/:id" {:get product-handler}]]])
Ein wichtiger Use Case für Webserver ist das Anbieten von JSON APIs
JSON steht für JavaScript Object Notation.
Es ist ein Textformat zum Speichern und Übertragen von strukturierten Daten.
Das Standard-Format für HTTP APIs.
Beispiel JSON Objekt:
{
"name": "John Doe",
"age": 30,
"city": "New York",
"hobbies": ["reading", "cooking"]
}
Mit der jsonista Library können wir Clojure Objekte von und auf JSON mappen.
:dependencies [[org.clojure/clojure "1.11.1"]
[ring/ring-core "1.10.0"]
[ring/ring-jetty-adapter "1.10.0"]
[metosin/reitit "0.6.0"]
[hiccup "1.0.5"]
[metosin/jsonista "0.3.7"]]
Wir können JSON Objekte mit jsonista/write-value-as-string serialisieren:
(def all-products
[{:name "Apfel"
:price 0.99
:quantity (atom 10)}
{:name "Birne"
:price 1.49
:quantity (atom 5)}
{:name "Banane"
:price 1.99
:quantity (atom 2)}])
(defn product-list-handler [request]
(let [product-list (map-indexed (fn [idx itm] {:id idx :name (:name itm) :quantity @(:quantity itm)}) all-products)]
{:status 200
:headers {"Content-Type" "application/json"}
:body (jsonista/write-value-as-string {:products product-list})}))
Analog können wir eine API für ein einzelnes Produkt erstellen:
(defn product-handler [{{id :id} :path-params :as request}]
(let [product (get all-products (Integer/parseInt id))]
(if product
{:status 200
:headers {"Content-Type" "application/json"}
:body (jsonista/write-value-as-string (update product :quantity deref))})))
Eigentlich sollten wir auch den Fehlerfall behandeln (z.B. einen 404 zurückgeben).
Es macht immer Sinn, häufig genutzte Teile in eigene Funktionen zu extrahieren:
(defn json-ok [data]
{:status 200
:headers {"Content-Type" "application/json"}
:body (jsonista/write-value-as-string data)})
(defn product-list-handler [request]
(let [product-list (map-indexed (fn [idx itm] {:id idx :name (:name itm)}) all-products)]
(json-ok {:products product-list})))
(defn product-handler [{{id :id} :path-params :as request}]
(let [product (get all-products (Integer/parseInt id))]
(if product
(json-ok (update product :quantity deref)))))
Bisher können Nutzer mit unserer API nur Daten lesen.
Nutzer sollen aber auch in der Lage sein Aktionen durchzuführen.
HTTP-Verben beschreiben die Aktion, die auf einer Ressource ausgeführt werden soll.
In der Praxis werden aber oft nur GET und POST verwendet:
Um einen POST-Request zu behandeln, fügen wir eine neue Route hinzu:
(def routes
[["/" {:get home-handler}]
["/products"
["" {:get product-list-handler}]
["/:id"
{:get product-handler
:post purchase-product-handler}]]])
Jedes Verb kann einen eigenen Handler bekommen.
Wir erstellen einen neuen Handler, der die Anzahl der verfügbaren Produkte verringert:
(defn purchase-handler [{{id :id} :path-params :as request}]
(let [product (get all-products (Integer/parseInt id))]
(if product
(let [[prev-available now-available] (swap-vals! (:quantity product) decrease-if-available 1)]
(if (>= prev-available 1)
(json-ok {:amount-purchased 1
:remaining now-available})
{:status 409
:headers {"Content-Type" "application/json"}
:body (jsonista/write-value-as-string {:error "Product not available"})})))))
Die GET Requests konnten wir recht einfach mit dem Browser simulieren.
Für POST Requests brauchen wir ein anderes Tool, z.B. curl:
curl -X POST localhost:8080/products/1
Um APIs zu testen gibt es auch GUI Tools, wie postman
.Wir haben jetzt zwei Stellen, an denen wir das Produkt "laden". Das können wir in eine Middleware Funktion auslagern:
(defn load-product [handler]
(fn [{{id :id} :path-params :as request}]
(let [product (get all-products (Integer/parseInt id))]
(if product
(handler (assoc request :product product))
{:status 404
:headers {"Content-Type" "application/json"}
:body (jsonista/write-value-as-string {:error "Product not found"})}))))
Middleware können wir für einzelne Routen konfigurieren:
(def routes
[["/" {:get home-handler}]
["/products"
["" {:get product-list-handler}]
["/:id" {:get product-handler
:post purchase-handler
:middleware [load-product]}]]])
Unsere Handler können sich das Produkt nun direkt aus dem Request holen:
(defn product-handler [{product :product}]
(json-ok (update product :quantity deref)))
(defn purchase-handler [{product :product}]
(let [[prev-available now-available] (swap-vals! (:quantity product) decrease-if-available 1)]
(if (>= prev-available 1)
(json-ok {:amount-purchased 1
:remaining now-available})
{:status 409
:headers {"Content-Type" "application/json"}
:body (jsonista/write-value-as-string {:error "Product not available"})})))
In der Regel brauchen wir zusätzlich zur API noch ein Frontend.
Um interaktive Webseiten zu bauen brauchen wir JavaScript.
Wir brauchen keine weitere Programmiersprache lernen, denn es gibt
ClojureScript
ClojureScript ist eine Teilmenge von Clojure, welche direkt in JavaScript übersetzt wird.
Statt Java Interop gibt es nun JavaScript Interop.
Wir können nur passende Libraries verwenden.
Eine sehr gute Frontend Library ist Reagent.
Wrapper um die populäre React Library. Passt gut mit funktionalem Stil zusammen.
Wir brauchen ein paar neue Dependencies:
:dependencies [[org.clojure/clojure "1.11.1"]
[ring/ring-core "1.10.0"]
[ring/ring-jetty-adapter "1.10.0"]
[metosin/reitit "0.6.0"]
[hiccup "1.0.5"]
[metosin/jsonista "0.3.7"]
[org.clojure/clojurescript "1.11.60"]
[reagent "1.2.0"]
[cljsjs/react "17.0.2-0"]
[cljsjs/react-dom "17.0.2-0"]]
Ausserdem müssen wir dafür sorgen, dass die ClojureScript Dateien auch compiliert werden:
:plugins [[lein-cljsbuild "1.1.8"]]
:cljsbuild {:builds
[{:id "dev"
:source-paths ["src/cljs"]
:compiler {:main webdemo.core
:asset-path "/js/out"
:output-to "target/cljsbuild/public/js/app.js"
:output-dir "target/cljsbuild/public/js/out"
:optimizations :none
:source-map true}}]}
Wir legen eine neue Datei an unter src/cljs/webdemo/core.cljs:
(ns webdemo.core)
(println "Hello from ClojureScript!")
Mitlein cljsbuild auto dev
wird unser Javascript Paket nun automatisch gebaut, wenn wir Code ändern.
Damit der Browser auf diese Datei zugreifen kann, müssen wir den Server konfigurieren, damit er Ressourcen weiterleitet.
(Wenn wir aufhttp://localhost:8080/js/app.js
gehen, bekommen wir noch gar nichts)
In project.clj:
:resource-paths ["resources" "target/cljsbuild"]
Wir brauchen noch einen Handler, der Ressourcen "as is" an den Browser liefert:
(def app
(ring/ring-handler
router
(ring/create-resource-handler {:path "/" :root "/public"})
(ring/create-default-handler)))
Jetzt müssen wir das Javascript noch in der Webseite einbinden:
(defn home-handler [request]
{:status 200
:headers {"Content-Type" "text/html"}
:body (html5
[:head
[:meta {:charset "utf-8"}]
[:title "Webdemo"]]
[:body
[:h1 "Webdemo"]
[:p "This is a demo web application."]
(include-js "/js/app.js")])})
Unser ClojureScript Code läuft jetzt im Browser
Wir sehen den Output wenn wir die Developer Tools Konsole öffnen
(z.B. mit Ctrl+Shift+J oder Cmd+Option+J)
Mit Reagent bauen wir unsere Seite aus einzelnen Komponenten.
Jede Komponente ist eine Funktion, welche Hiccup Syntax zurückgibt.
(ns webdemo.core
(:require [reagent.dom :as rdom]))
(defn shop-root []
[:div
[:h2 "Products"]])
(defn mount-root []
(rdom/render [shop-root] (.getElementById js/document "app")))
(defn init! []
(mount-root))
(set! (.-onload js/window) init!)
Unsere App wird an einem HTML Element mit ID "app" eingehängt.
Im HTML brauchen wir jetzt noch den Einhängepunkt:
(defn home-handler [request]
{:status 200
:headers {"Content-Type" "text/html"}
:body (html5
[:head
[:meta {:charset "utf-8"}]
[:title "Webdemo"]]
[:body
[:h1 "Webdemo"]
[:div#app
[:p "loading..."]]
(include-js "/js/app.js")])})
Reagent Komponenten können wir in anderen Komponenten verwenden.
(defn lister [items]
[:ul
(map #(vector :li "Item " %) items)])
(defn shop-root []
[:div
[:h2 "Products"]
[lister [1 2 3]]])
Wir haben jetzt eine funktionierende Reagent App
Sie tut aber noch nichts, was wir wir nicht mit HTML auch hinbekommen hätten.
Wenn eine Komponente ein (Reagent) Atom benutzt, wird sie neu gerendered, wenn sich der Zustand ändert:
(ns webdemo.core
(:require [reagent.dom :as rdom]
[reagent.core :as r]))
(defn lister [items]
[:ul
(map #(vector :li "Item " %) @items)])
(defn shop-root []
(let [items (r/atom [1 2 3])]
[:div
[:h2 "Products"]
[lister items]]))
Jetzt müssen wir nur noch den Zustand des Atoms ändern:
(defn add-item-button [items]
(let [add-item (fn [] (swap! items #(conj % (count %))))]
[:button {:on-click add-item} "Add Item"]))
(defn shop-root []
(let [items (r/atom [1 2 3])]
[:div
[:h2 "Products"]
[lister items]
[add-item-button items]]))
Jede Komponente muss wissen:
Von anderen Komponenten ist sie entkoppelt.
Die Liste wollen wir eigentlich gerne vom Server bekommen.
Dafür nehmen wir eine Library um AJAX calls zum Server zu machen.
:dependencies [[org.clojure/clojure "1.11.1"]
[ring/ring-core "1.10.0"]
[ring/ring-jetty-adapter "1.10.0"]
[metosin/reitit "0.6.0"]
[hiccup "1.0.5"]
[metosin/jsonista "0.3.7"]
[org.clojure/clojurescript "1.11.60"]
[reagent "1.2.0"]
[cljsjs/react "17.0.2-0"]
[cljsjs/react-dom "17.0.2-0"]
[cljs-http "0.1.46"]
[org.clojure/core.async "1.6.673"]]
Wir können jetzt die Produktliste unserer JSON API vom Server holen:
(http/get "/products")
Ein Server Call braucht Zeit - daher bekommen wir einen Channel zurück.
Den Channel können wir dann woanders in einem go Thread bearbeiten.
(ns webdemo.core
(:require [reagent.dom :as rdom]
[reagent.core :as r]
[clojure.core.async :refer [go <!]]
[cljs-http.client :as http]))
(defn load-items [items]
(let [product-response (http/get "/products")]
(go (let [response (<! product-response)
products (get-in response [:body :products])]
(reset! items products)))))
Die neue Produktliste binden wir nun ein:
(defn lister [items]
[:ul
(for [item @items]
^{:key (:id item)}
[:li (:name item) ": " (:quantity item)])])
(defn shop-root []
(let [items (r/atom [])]
(load-items items)
[:div
[:h2 "Products"]
[lister items]]))
Der Metadata key ist optional.
Wir bekommen nun immer die aktuelle Produktliste
Nächster Schritt: Wir kaufen Produkte.
Der Server Call funktioniert analog:
(defn buy-item [items id]
(let [purchase-response (http/post (str "/products/" id))]
(go (let [response (<! purchase-response)
remaining (get-in response [:body :remaining])]
(if remaining
(swap! items update-in [id :quantity] (constantly remaining))
(swap! items update-in [id :quantity] (constantly 0)))))))
Wichtig: Wir müssen davon ausgehen, dass jemand anderes das letzte Produkt weggekauft hat.
Gerade POST Request haben oft auch einen Body, den der Server anschließend lesen kann.
zum Beispiel:
(http/post "/products/123/" {:json-params {:amount 3}})
Die neue Funktion bekommt noch einen Button:
(defn lister [items]
[:ul
(for [item @items]
^{:key (:id item)}
[:li (:name item) ": " (:quantity item)
(if (> (:quantity item) 0)
[:button {:on-click #(buy-item items (:id item))}
"Buy"]
[:span "Sold out"])])])
Wir haben jetzt eine voll funktionale Webanwendung.
Styling, etc. sind kein Teil dieses Kurses.
Das gesamte Start Setup (und noch etwas mehr) bekommen wir, wenn wir das Reagent Leiningen Template verwenden:
lein new reagent my-project