Ubuntu TechHive
save-tokens-and-build-with-data-driven-clarity-using-clojure-script-part.md
Save Tokens and Build with Data-Driven Clarity using Clojure(Script)
article.détail

Save Tokens and Build with Data-Driven Clarity using Clojure(Script)

reading.progression 13 min de lecture

Économisez des jetons et construisez avec une clarté basée sur les données en utilisant Clojure(Script)

Petite histoire : Clojure(Script) et le Web

Du début des années 2000 et au-delà

  • Appris Vim et Emacs mais était nul aux deux
  • HTTP et le Web
  • De LAMP aux SPAs et plus de frameworks
  • Beaucoup de scripting et beaucoup de métaprogrammation (JavaScript, Ruby, Groovy, Scala ...)
  • JavaScript est passé de jouet à élément porteur

LISP

  • Beaucoup de parenthèses
  • Intriguant et étrange
  • Des sorciers ont écrit des livres (Practical Common Lisp, SICP ...)
  • Entendu beaucoup d'histoires folles à ce sujet (PG, HN, Hackers and Painters ...)
  • Diverses implémentations LISP toutes très étranges

LISP Pratique

  • Découverte de Clojure lors d'une conférence Java vers 2009
  • Enfin quelque chose de pertinent à essayer grâce à la JVM
  • Première tentative sérieuse : Interface Java + Implémentation Clojure dans une application Spring
  • Ça s'est amélioré : un nouveau Clojure au-dessus de JavaScript (Google Closure Library, Google Closure Compiler : minification agressive de qualité production)
  • Illumination progressive : (verbe données)

Clojure(Script) : Un outil pour penser clairement

  • REPL
  • Le langage s'adapte au domaine
  • Syntaxe ? Quelle syntaxe ? Ce ne sont que des structures de données
  • Isomorphe
  • Clojure est un langage hébergé (JVM, JavaScript, Dart et plus)
  • Aller à l'essentiel sans le superflu
  • Spécifications + fonctions + données + validation : (verbe-spécifié données)

Bases de Clojure(Script)

Chaque bloc de code ci-dessous est écrit de manière à ce que vous puissiez le coller tel quel dans un ClojureScript REPL lors d'une démonstration en direct.
En syntaxe Lisp, la première chose après une parenthèse ouvrante est la fonction :

(+ 1 2 3)
;; => 6

(str "Hello, " "ClojureScript")
;; => "Hello, ClojureScript"

Littéraux de données : les valeurs d'abord

Le code Clojure(Script) est principalement composé de formes de données simples :

42                         ;; nombre
"Ada"                      ;; chaîne de caractères
:admin                     ;; mot-clé, souvent utilisé comme étiquette de données
[1 2 3]                    ;; vecteur, valeurs ordonnées
{:name "Ada" :visits 1}    ;; map, clés vers valeurs
#{:cljs :repl :data}        ;; ensemble, valeurs uniques

Espaces de noms et require

Au REPL, utilisez require pour charger un espace de noms et lui donner un alias court.
Les fichiers réels utilisent une forme ns en haut, mais require est plus convivial pour les démonstrations REPL en direct.

(require ' [clojure.string :as str])

(str/upper-case "fast twitch")
;; => "FAST TWITCH"

(str/trim "  hello  ")
;; => "hello"

Les mots-clés peuvent également agir comme de petites fonctions de recherche :

(def user {:name "Ada" :visits 1})

(:name user)
;; => "Ada"

(assoc user :active? true)
;; => {:name "Ada", :visits 1, :active? true}

(update user :visits inc)
;; => {:name "Ada", :visits 2}

Clés et clés avec espace de noms

Une clé de carte est l'étiquette utilisée pour trouver une valeur. Les mots-clés sont des clés de carte courantes
car ils sont petits, lisibles et fonctionnent comme des fonctions de recherche.

(def response
  {:status 200
   :body "ok"})

(get response :status)
;; => 200

(:body response)
;; => "ok"

Les mots-clés avec espace de noms ajoutent un préfixe avant la barre oblique. Ce sont toujours de simples
mots-clés, mais le préfixe aide à éviter les collisions dans les cartes de données plus grandes.

(def person
  {:person/name "Ada"
   :account/name "ada-lovelace"
   :account/id 42})

(:person/name person)
;; => "Ada"

(:account/name person)
;; => "ada-lovelace"

(select-keys person [:person/name :account/id])
;; => {:person/name "Ada", :account/id 42}

Vous pouvez inspecter la partie espace de noms et la partie nom d'un mot-clé :

(namespace :account/id)
;; => "account"

(name :account/id)
;; => "id"

(keyword "account" "email")
;; => :account/email

Fonctions

defn donne un nom à une fonction. Le vecteur après le nom est la liste des arguments.

(defn greet [name]
  (str "Hello, " name "!"))

(greet "Ada")
;; => "Hello, Ada!"

Les fonctions anonymes sont utiles lorsque la fonction est petite et locale :

((fn [x] (* x 10)) 7)
;; => 70

(#(* % 10) 7)
;; => 70

Fonctions à arité multiple

Un nom de fonction peut prendre en charge différents nombres d'arguments.

(defn greeting
  ([] (greeting "friend"))
  ([name] (str "Hello, " name))
  ([title name] (str "Hello, " title " " name)))

(greeting)
;; => "Hello, friend"

(greeting "Ada")
;; => "Hello, Ada"

(greeting "Dr." "Hopper")
;; => "Hello, Dr. Hopper"

Cela maintient les valeurs par défaut simples proches de la version complète.

map

map exécute une fonction sur chaque élément d'une collection. Elle renvoie une séquence paresseuse,
utilisez donc vec lorsque vous voulez voir un vecteur dans le REPL.

(map inc [1 2 3])
;; => (2 3 4)

(vec (map inc [1 2 3]))
;; => [2 3 4]

(def todos
  [{:title "Read the schema" :done? true}
   {:title "Try the REPL" :done? false}])

(map :title todos)
;; => ("Read the schema" "Try the REPL")

->

La macro thread-first -> place le résultat précédent dans la forme suivante comme premier argument de données. Elle rend le nettoyage des données de gauche à droite plus facile à lire.

(-> {:name "Ada" :visits 1}
    (assoc :active? true)
    (update :visits inc))
;; => {:name "Ada", :visits 2, :active? true}

Lisez-le comme suit : commencez avec cette carte, puis associez une valeur, puis mettez à jour une valeur.

->>

La macro thread-last ->> place le résultat précédent à la fin de la forme suivante. Elle est courante pour les pipelines de collections.

(def todos
  [{:title "Read the schema" :done? true}
   {:title "Try the REPL" :done? false}])

(->> todos
     (filter :done?)
     (map :title)
     vec)
;; => ["Read the schema"]

Lisez-le comme suit : prenez les tâches, gardez les éléments terminés, prenez leurs titres, faites un vecteur.

Noms locaux avec let

let donne des noms temporaires aux valeurs. Ces noms n'existent qu'à l'intérieur du let.

(let [title "Try the REPL"
      done? false]
  {:title title
   :status (if done? "done" "open")})
;; => {:title "Try the REPL", :status "open"}

Déstructuration

La déstructuration extrait des noms de maps et de vecteurs sans appels manuels à get.

(def user {:name "Ada" :visits 1})

(let [{:keys [name visits]} user]
  (str name " visited " visits " time(s)"))
;; => "Ada visited 1 time(s)"

(let [ [first-item second-item & remaining] ["a" "b" "c" "d"]]
  {:first first-item
   :second second-item
   :remaining remaining})
;; => {:first "a", :second "b", :remaining ("c" "d")}

C'est particulièrement pratique pour les maps de requêtes :

(def request
  {:request-method :get
   :params {:id "42"}
   :headers {"accept" "application/json"}})

(let [{:keys [request-method params]} request
      {:keys [id]} params]
  {:method request-method
   :todo-id id})
;; => {:method :get, :todo-id "42"}

if, when, if-let, and when-let

if choisit entre deux valeurs. Seuls false et nil sont considérés comme faux dans
Clojure(Script) ; 0, "" et les collections vides sont considérés comme vrais.

(if (:done? {:title "Read" :done? true})
  "Done"
  "Open")
;; => "Done"

(if 0 "yes" "no")
;; => "yes"

when est utilisé pour "faire ceci seulement si le test est vrai" ; sinon, il renvoie nil.

(when (seq "Ada")
  "There is a name")
;; => "There is a name"

(when (seq "")
  "This will not appear")
;; => nil

if-let et when-let lient un nom seulement lorsque la valeur est vraie. Ce modèle
apparaît souvent dans la correspondance de routes, la gestion des cookies et le rendu du navigateur.

(def request-with-user {:session {:user "Ada"}})

(if-let [user (get-in request-with-user [:session :user])]
  (str "Connecté en tant que " user)
  "Veuillez vous connecter")
;; => "Connecté en tant que Ada"

(when-let [title (get-in {:params {:todo {:title "Ship demo"}}}
                         [:params :todo :title])]
  (str "Tâche : " title))
;; => "Tâche : Ship demo"

cond, case, and condp

Utilisez cond lorsque vous avez plusieurs tests. Il les vérifie dans l'ordre.

(defn todo-status [{:keys [done? blocked?]}]
  (cond
    blocked? "bloquée"
    done? "terminée"
    :else "ouverte"))

(todo-status {:done? false :blocked? true})
;; => "bloquée"

Utilisez case lorsqu'une valeur est comparée à des constantes connues.

(defn method-action [request-method]
  (case request-method
    :get "lire"
    :post "écrire"
    :delete "supprimer"
    "inconnu"))

(method-action :post)
;; => "écrire"

condp est moins courant dans ce dépôt, mais utile lorsque chaque branche utilise le même prédicat.

(defn score-band [score]
  (condp <= score
    90 :great
    75 :solid
    :needs-practice))

(score-band 82)
;; => :solid

cond->

cond-> commence par une valeur et applique chaque étape uniquement lorsque son test est vrai.
Le projet l'utilise pour construire des maps de requêtes, des en-têtes et la configuration du middleware.

(defn response-map [{:keys [body location]}]
  (cond-> {:status 200
           :headers {}}
    body
    (assoc :body body)

location
    (assoc-in [:headers "Location"] location)))

(response-map {:body "ok" :location "/todos"})
;; => {:status 200, :headers {"Location" "/todos"}, :body "ok"}

some->

some-> est similaire à ->, mais il s'arrête et renvoie nil dès qu'une étape renvoie nil. C'est utile lorsque des données peuvent être manquantes.

(some-> {:pathname {:groups {:id "42"}}}
        :pathname
        :groups
        :id)
;; => "42"

(some-> nil
        :pathname
        :groups
        :id)
;; => nil

Maps imbriquées : get-in, assoc-in, et update-in

Les maps de requête et les maps de réponse sont imbriquées. Ces fonctions utilitaires évitent les recherches manuelles.

(def response {:status 200 :headers {}})

(assoc-in response [:headers "Content-Type"] "text/plain")
;; => {:status 200, :headers {"Content-Type" "text/plain"}}

(get-in {:params {:todo {:title "Learn get-in"}}}
        [:params :todo :title])
;; => "Learn get-in"

(update-in {:todos {1 {:done? false}}}
           [:todos 1 :done?]
           not)
;; => {:todos {1 {:done? true}}}

État avec atom, @, swap!, et reset!

La plupart des données sont immuables. Lorsque l'application a besoin d'un état modifiable, ce dépôt utilise
des atomes, par exemple pour les tâches, les compteurs et l'état de l'interface utilisateur du navigateur.

(def store
  (atom {:clicks 0
         :chips []}))

@store
;; => {:clicks 0, :chips []}

(swap! store update :clicks inc)
;; => {:clicks 1, :chips []}

(swap! store update :chips conj "REPL")
;; => {:clicks 1, :chips ["REPL"]}

(reset! store {:clicks 0 :chips []})
;; => {:clicks 0, :chips []}

Dans les fichiers source, ce dépôt utilise souvent defonce pour l'état du navigateur ou du serveur.
defonce signifie "définir ceci uniquement si cela n'existe pas déjà", ce qui est pratique
lors des rechargements REPL. Dans une démo en direct, un simple def est moins surprenant
car il réinitialise l'exemple à chaque fois.

reduce

reduce parcourt une collection tout en transportant un accumulateur. La source l'utilise pour construire des cartes, des en-têtes et des configurations.

(reduce
 (fn [total n]
   (+ total n))
 0
 [10 20 30])
;; => 60

(reduce
 (fn [headers [k v]]
   (assoc headers k v))
 {}
 [ [:content-type "text/plain"]
  [:etag "v1"]])
;; => {:content-type "text/plain", :etag "v1"}

loop et recur

Utilisez loop avec recur lorsque vous avez besoin d'une boucle explicite sans compteurs mutables. Dans de nombreux cas, map, filter ou reduce seront plus simples.

(loop [n 3
       result []]
  (if (zero? n)
    result
    (recur (dec n) (conj result n))))
;; => [3 2 1]

Interopérabilité JavaScript

ClojureScript est hébergé sur JavaScript, nous pouvons donc appeler JavaScript directement lorsque nous avons besoin de la plateforme.

(.log js/console "Hello from ClojureScript")
;; prints in the JS console

(.toISOString (js/Date.))
;; => heure actuelle sous forme de chaîne ISO

(.round js/Math 4.6)
;; => 5

Utilisez clj->js et js->clj aux limites de l'API :

(def json-text
  (.stringify js/JSON (clj->js {:ok true :items [1 2 3]}) nil 2))

json-text
;; => "{\n  \"ok\": true,\n  \"items\": [\n    1,\n    2,\n    3\n  ]\n}"

(js->clj (.parse js/JSON json-text) :keywordize-keys true)
;; => {:ok true, :items [1 2 3]}

Pour les objets JavaScript natifs, l'accès aux propriétés est explicite :

(def js-user #js {:name "Ada" :visits 2})

(.-name js-user)
;; => "Ada"

(aget js-user "visits")
;; => 2

Promesses JavaScript

De nombreuses API de navigateur, Node, Deno et Bun renvoient des promesses. Ce dépôt utilise souvent
-> pour rendre les chaînes de promesses lisibles.

(-> (js/Promise.resolve "hello")
    (.then (fn [text]
             (.toUpperCase text)))
    (.then (fn [loud]
             (.log js/console loud)))
    (.catch (fn [error]
              (.error js/console error))))
;; affiche "HELLO" ; renvoie une Promesse

try, catch et throw

Utilisez try lorsque du code JavaScript ou d'analyse peut lever une exception. En ClojureScript,
(catch :default error ...) intercepte les erreurs JavaScript normales.

(try
  (.parse js/JSON "{bad json")
  (catch :default error
    (.-message error)))
;; => un message d'erreur d'analyse JSON

(try
  (throw (js/Error. "Boom"))
  (catch :default error
    (str "Capturé : " (.-message error))))
;; => "Capturé : Boom"

Bases des macros

Les macros transforment le code avant son exécution. Nous avons déjà utilisé des macros : ->,
->>, when, cond->, et les assistants env-var / serve du dépôt.

(macroexpand-1
 '(-> {:status 200}
      (assoc :body "ok")))
;; => (assoc {:status 200} :body "ok")

(macroexpand-1
 '(when true
    "runs when true"))
;; => (if true (do "runs when true"))

Dans ce dépôt, les macros personnalisées telles que env-var et serve se trouvent dans src/fast_twitch/macros.cljc. Pour une démonstration en direct pour débutants, commencez par macroexpand-1 sur les macros de base avant d'écrire des macros personnalisées.

Une fois l'idée comprise, évaluez le chaînage conditionnel normalement :

(cond-> {:status 200}
  true
  (assoc :body "ok")

false
  (assoc :debug true))
;; => {:status 200, :body "ok"}

Bibliothèques utiles

Pour les extraits de bibliothèque, utilisez un projet ou un exemple qui a la dépendance sur le classpath. Dans ce dépôt, examples/07-json-api-malli-openapi contient déjà Malli et Integrant.

Malli

Malli nous permet de décrire des données avec des données. Un schéma est simplement une valeur Clojure(Script)
que nous pouvons valider, expliquer, transformer ou réutiliser pour la documentation.

(require ' [malli.core :as m])
(require ' [malli.error :as me])

(def TodoCreate
  [:map
   {:closed true}
   [:title [:string {:min 1}]]
   [:done {:optional true} :boolean]])

(m/validate TodoCreate {:title "Learn Malli" :done false})
;; => true

(m/validate TodoCreate {:title "" :extra "not allowed"})
;; => false

(me/humanize
 (m/explain TodoCreate {:title "" :extra "not allowed"}))
;; => {:title ["should be at least 1 character"],
;;     :extra ["disallowed key"]}

Utilisez m/validator lorsque vous souhaitez une fonction de validation réutilisable :

(require ' [malli.core :as m])

(def TodoCreate
  [:map
   {:closed true}
   [:title [:string {:min 1}]]
   [:done {:optional true} :boolean]])

(def valid-todo? (m/validator TodoCreate))

(valid-todo? {:title "Ship the demo"})
;; => true

(valid-todo? {:done true})
;; => false

L'idée importante pour les débutants : au lieu de cacher les règles de validation dans de nombreuses conditions, nous mettons la forme des données valides dans une petite valeur de données.

Integrant

Integrant démarre une application à partir d'une carte de configuration. Chaque clé de niveau supérieur décrit une partie du système, et ig/ref indique qu'une partie dépend d'une autre.

(require ' [integrant.core :as ig])

(defmethod ig/init-key ::settings [_ options]
  options)

(defmethod ig/init-key ::greeter [_ {:keys [settings]}]
  (fn [name]
    (str (:prefix settings) ", " name "!")))

(def config
  {::settings {:prefix "Hello"}
   ::greeter {:settings (ig/ref ::settings)}})

(def system (ig/init config))

((::greeter system) "Ada")
;; => "Hello, Ada!"

Ce qui s'est passé :

  • ::settings initialisé en premier.
  • ::greeter a reçu les paramètres initialisés.
  • system est une carte des parties initialisées.

Ajoutez un nettoyage avec ig/halt-key! pour les parties qui possèdent des ressources :

(require ' [integrant.core :as ig])

(defmethod ig/init-key ::store [_ initial-state]
  (atom initial-state))

(defmethod ig/halt-key! ::store [_ store]
  (reset! store {}))

(def store-system
  (ig/init {::store {:todos []}}))

@(::store store-system)
;; => {:todos []}

(ig/halt! store-system)
;; => ignore the return value; halt! is for side effects

L'idée importante pour les débutants : gardez le câblage comme des données, et gardez le comportement de démarrage et d'arrêt dans de petites méthodes attachées à ces clés de données.

FastTwitch

Source : FastTwitch 🏃💨

  • Backend web basé sur les principaux runtimes JavaScript
  • Capturer ce qui est réellement nécessaire pour faire du backend web
  • Plusieurs tentatives
  • ExpressJs
  • Fastify
  • Maintenant juste l'API fetch et les standards web
  • API inspirée de ring