Maniaques des olfactions extrêmes, adeptes de coinstots bizarres, veuillez vous réorienter vers des pages plus spécialisées.

Dans le précédent article, nous avons discuté de l'interopérabilité entre une application cljfx et des WebViews ClojureScript. Nous avons eu recours à la mécanique de base qu'offre JavaFX via ses WebViews et plus particulièrement son WebEngine pour communiquer entre l'application et la WebView.

Une autre solution est toutefois envisageable : il s'agit d'utiliser les WebSockets comme moyen de communication. Cela présente l'avantage de nous laisser développer nos vues d'une manière plus traditionnelle, le serveur pouvant s'occuper de servir les assets de notre vue. (Avec les WebView brutes, nous avions été contraints de compiler un unique HTML contenant tout).

Le code sous-tendant cet article a été écrit avant l'article précédent. La perspective de plonger dans la documentation Java du WebEngine de JavaFX ne m'enchantant guère en premier lieu. Cela dit, après avoir mis sur pied le système que je suis sur le point de vous présenter et en discutant avec mon directeur technique, ce dernier m'a fait la réflexion suivante : "C'est un petit peu comme passer par le jardin pour se rendre dans une autre pièce de sa maison".

Après avoir fait un pas en arrière et implémenté la matière du précédent article, j'en reviens à me demander si ce système légèrement alambiqué ne présente pas certains intérêts.

Stack

Serveur

Client(s)

ClojureScript :

Code

deps.edn

{:paths   ["src" "resources"],

 :dep     {org.clojure/clojure           {:mvn/version "1.10.0"},
           org.clojure/clojurescript     {:mvn/version "1.10.764"},
           thheller/shadow-cljs          {:mvn/version "2.11.18"},

           cljfx/cljfx                   {:mvn/version "1.7.13"},

           aleph/aleph                   {:mvn/version "0.4.6"},
           compojure/compojure           {:mvn/version "1.6.2"},

           reagent/reagent               {:mvn/version "1.0.0-alpha2"},
           haslett/haslett               {:mvn/version "0.1.6"}}}

Veuillez noter l'ajout de "resources" dans :paths . Cela est nécessaire pour que notre serveur puisse servir les assets de nos WebViews.

shadow-cljs.edn

{:deps     true
 :nrepl    {:port 8777}
 :dev-http {8080 "resources/public/"}

 :builds   {:view1 {:asset-path "."
                    :modules    {:main {:init-fn chaussette.view1/init}}
                    :output-dir "resources/public/view1"
                    :target     :browser}}}

Le résultat de la compilation ira directement dans "resources/public" afin de pouvoir être servi par notre serveur.

src/chaussette/server.clj

Ici nous utiliserons aleph, manifold (RIP ztellman) et compojure pour implémenter notre serveur.

(ns chaussette.server
  (:require
   [aleph.http :as http]
   [manifold.stream :as s]
   [manifold.deferred :as d]
   [compojure.core :as compojure :refer [GET]]
   [compojure.route :as route]))

Chaque connexion serveur/WebView sera persistée dans l'atom connections.

(def connections (atom {}))

Nous allons avoir besoin d'un handler pour nos connexions WebSocket. Manifold fourni des abstractions très pratiques pour ça :

(defn make-handler
  "build a web-socket handler from the given function ``f``
   that will be called on every received messages.
   The resulting handler will treat the first message from the client as its ID,
   this ID will be useful to retrieve this particular connection in the ``connections`` atom"
  [f]
  (fn [req]
    (d/let-flow [conn (http/websocket-connection req)
                 ;; the first message received by this websocket is the id of the client
                 id-str (s/take! conn)]
                (let [id (read-string id-str)]
                  ;; we put the new connection into our connections atom
                  (swap! connections assoc id conn)
                  ;; then apply f to subsequent messages
                  (s/consume (partial f id) conn))))) 

Grâce à ce constructeur nous pouvons définir un handler tout bête qui log simplement ce qu'il reçoit sans rien faire de plus :

(def logging-handler
  "An handler that logs what it receives"
  (make-handler #(println %1 "sent: " %2)))

Maintenant, nous pouvons définir nos routes avec compojure :

(def handler
  (compojure/routes
   (GET "/ws" [] logging-handler)
   (route/resources "/")
   (route/not-found "No such page.")))

Et enfin définir l'API de ce namespace :

  • Démarrer et arrêter le serveur :
(defn start! [port]
  (http/start-server handler {:port port}))

(defn close! [server]
  (.close server))
  • Envoyer des messages vers une webView :
(defn send! [id message]
  (when-let [conn (get @connections id)]
    (s/put! conn (pr-str message))))

src/chaussette/view1.cljs

(ns chaussette.view1
  (:require [reagent.core :as r]
            [reagent.dom :as rd]
            [haslett.client :as ws]
            [cljs.reader :as reader]
            [cljs.core.async
             :refer [<! >!]
             :refer-macros [go go-loop]]))

Constantes et état :

(def WS_ENDPOINT "ws://localhost:3000/ws")

(def VIEW_ID :chaussette.view1)

(def *conn (atom nil))

WebSockets :

(defn init-websocket!
  [handle]
  (go
   (let [;; connecting
         {:as conn :keys [sink source]} (<! (ws/connect WS_ENDPOINT))]
     ;; persist connection
     (reset! *conn conn)
     ;; give the connection id to server
     (>! sink VIEW_ID)
     ;; listening to incoming messages
     (loop []
       (handle (reader/read-string (<! source)))
       (recur)))))

(defn send! [message]
  (go (>! (get @*conn :sink)
          (pr-str message))))

Composant réactif :

(def reactive-state
  (r/atom {:title    "View1"
           :messages []}))

(defn root []
  [:div#app
   [:h1 (:title @reactive-state)]
   [:button {:on-click #(send! "hello from client")} "say hello to server !"]
   [:h3 "Messages : "]
   (into [:div#messages]
         (map (fn [m] [:div.message (str m)])
              (:messages @reactive-state)))])

(defn handler [message]
  (println "received: " message)
  (swap! reactive-state update :messages
         conj message))

API :

(defn ^:dev/after-load reload! []
  (init-websocket! handler)
  (rd/render [root] (.getElementById js/document "app")))

(defn ^:export init []
  (reload!))

src/chaussette/core.clj

(ns chaussette.core
  (:require [cljfx.api :as fx]
            [cljfx.ext.web-view :as web-view]
            [chaussette.server :as server]))

(def VIEW_URL "http://localhost:3000/view1/index.html")

(defn root [_]
      {:fx/type :stage
       :showing true
       :scene   {:fx/type :scene
                 :root {:fx/type web-view/with-engine-props
                        :props   {:url VIEW_URL}
                        :desc    {:fx/type :web-view}}}})

(def renderer
  (fx/create-renderer
   :middleware (fx/wrap-map-desc #'root)))

(defonce server
  (server/start! 3000))

Utilisation

  • Lancer le watcher shadow-cljs depuis la racine du projet via le terminal : shadow-cljs watch view1
  • Lancer une REPL et charger chaussette.core
  • Essayer d'évaluer ces expressions :
;; affiche notre webView
(renderer {:fx/type root})

;; envoie soit :ping soit :pong au client
(server/send! :chaussette.view1
              (rand-nth [:ping :pong]))

;; arrête le server
(server/stop! server)

;; compile la version definitive du javascript
(require '[shadow.cljs.devtools.api :as shad])
(shad/release :view1))

Pour la compilation définitive vous pouvez aussi appeler directement : shadow-cljs release view1

Conclusion

Nous avons vu comment faire communiquer une application cljfx avec une ou plusieurs WebViews ClojureScript via WebSocket. Les points forts de cette approche sont :

  • La possibilité de développer nos WebViews de manière plus classique (HTML + assets vs juste HTML).
  • La simplicité du setup (pas besoin de passer la tête sous le capot (Java)