Technology

ClojureScript, pekonilla vai ilman?

April 23, 2013

Read time 5 min

Kannattaako funktionaalista reaktiivista ohjelmointia (FRP) hyödyntää ClojureScript-toteutuksissa?

Nykyisen projektini serveripuolen kielivalinta on Clojure, ja käyttöliittymäpuolen Flex. Flex on kuitenkin kuoleva kieli, eikä sitä enää kehitetä sillä intohimolla mitä ohjelmointikielestä odottaisi. Päädyimme korvaamaan Flexin ClojureScriptillä, koska syntaksi eroaa serveripään kielestä vain hitusen ja ClojureScriptiä kehitetään avoimesti ja intohimoisesti.

Hyvä tietää: threading-macrot -> ja ->>

-> toimii siten, että funktion lopputulos passataan seuraavan funktion ensimmäiseksi argumentiksi. ->> toimii samoin, mutta paluuarvo menee seuraavan funktion viimeiseksi parametriksi. Alla olevat esimerkit ovat siis identtisiä:

(->> [1 2 3 4]
     (map inc)
     (filter even?))

(filter even? (map inc [1 2 3 4]))

JavaScript-interop

ClojureScriptissä JavaScript-interop toimii kuten Java-interop Clojuressa – muutamaa poikkeusta lukuunottamatta. JavaScript-objektin funktiota kutsutaan kuten Java-metodia Clojuresta:

(.map observable f)

Ja prototyyppifunktiota:

(.textFieldValue Bacon.UI $elem)

JavaScript-objektin propertya kutsutaan ClojureScriptistä seuraavasti:

(.-property objekti)

Toisin kuin Clojuressa ClojureScriptin kääntäjä käyttää Googlen Closure compileria ja jos käännöksen taso on:advanced, kaikki muuttujat munglataan. Jotta ulkoisia JavaScript kirjastoja voidaan käyttää ClojureScriptistä:advanced-optimoinnilla, on kääntäjälle esiteltävä erityinen externs-tiedosto, missä kerrotaan mitä muuttujia ei saa munglata. Edellinen esimerkki ei siis toimi jos seuraavia rivejä ei ole esitelty externs-tiedostossa:

Bacon.UI = function() {}
Bacon.UI.prototype.textFieldValue = function () {}

Bacon.js:n kääriminen onkin siis viisasta tehdä omaksi namespacekseen, missä JavaScript-interop on kääritty näkyvistä (event-streammap ja filter -funktiotto-partial-apufunktion kanssa):

(defn- to-partial
 "Convert function and argument list to partially bound function.
 Resulting function takes single argument, value."
 [f args]
 (fn [value]
   (let [args-with-value (-> (vec args) (conj value))]
     (apply f args-with-value))))

(defn filter [observable pred & args]
 (.filter observable (to-partial pred args)))

(defn map [observable f & args]
 (.map observable (to-partial f args)))

(defn event-stream [$elem event]
 (.asEventStream $elem event))

Käyttö esimerkiksi:

(defn example-usage []
 (-> (b/event-stream $text-area
                     "keyup input cut paste change")
     (b/filter allowed-chars?)
     (b/map cut-to max-length)))

Externs-tiedostoja ei tarvitse, jos ClojureScriptin kääntää optimoinnilla :whitespace tai :simple.

Pekonilla kiitos

Aikaisemmat kokemukseni FRP:stä olivat RxJS validointikirjaston käytöstä. Nämä kokemukset olivat hyviä, joten vuorossa oli bacon.js:n kokeilu.

Case: Viestinlähetys -komponentti

Pekonia testattiin komponenttiin, minkä avulla käyttäjä pystyy lähettämään maksimissaan 42 merkkiä pitkän tekstin valitsemilleen vastaanottajille.

Komponenttiin kuului tekstikenttä, vastanottajien lukumäärä, merkkiäänen valintakomponentti ja lähetä-nappi. Lähetä nappi aktivoituu, kun muissa komponenteissa on tarvittavat valinnat.

Käytännöksi otettiin, että jQuery-objektien palauttamat funktiot nimetään $-etuliitteellä:

(defn- container-by-instance [instance]
  {:pre [(keyword? instance)]
   :post [((complement nil?) %)]}
    (instance @widget-instances))

(defn- select-for-instance [instance selector]
  (find (container-by-instance instance) selector))

(defn- $msg-area [i] (select-for-instance i ".message-area"))

($msg-area) ;;=> jQuery-objekti

Yllä @widget-instances palauttaa widget-instances -atomin sen hetkisen arvon. Atomi on alustettu komponentin containerilla, jonka kutsuja passaa komponentille:

(def widget-instances (atom {}))

(defn init [$container instance]
  (swap! widget-instances assoc instance $container)
  (render-template instance)
  (init-widget instance))

Pihvi näyttää seuraavalta:

(defn- init-widget [instance]
 (let [max-length 42
       text-area (as-text-area ($msg-area instance) max-length instance)
       selected-ids (table-selection instance)
       counter-change (as-counter-text text-area max-length)
       has-message? (b/map text-area has-text?)
       has-recipients? (b/map selected-ids (complement empty?))
       snd-btn-click (b/event-stream ($send-button instance) "click")
       on-submit (b/sampled-by (request-params text-area
                                               selected-ids
                                               instance)
                               snd-btn-click)]
   ;side effects
   (-> selected-ids
       (b/map as-recipient-label)
       (b/assign inner ($recipient-counter instance)))
   (-> counter-change
       (b/assign inner ($msg-counter instance)))
   (-> has-message?
       (b/and has-recipients?)
       (b/assign set-enabled ($send-button instance)))
   (-> on-submit
       (b/on-value send-messages max-length instance))))

text-area-propertyn muodostus:

(defn- msg-value-as-property
 [$text-area max-length instance]
 (-> (b/event-stream $text-area
                     "keyup input cut paste change")
     (b/map (partial msg-value instance))
     (b/map remove-line-breaks)
     (b/map cut-to max-length)
     (b/to-property)))

Vastaanottajat valitaan erillisestä taulukomponentista, mistä valintojen muutoksesta on tehty bacon-property:

(defn- table-selection [instance]
 (-> (b/event-stream (t/$container instance)
                     "update selectionchange")
     (b/map #(t/selected-ids instance))
     (b/to-property [])))

Hyvää tässä lähestymistavassa on koodin kompaktisuus: sivuvaikutukset on kerätty yhteen paikkaan neljään riviin, se on helposti luettavissa ja muutettavissa.

Toisaalta saman toteutuksen kirjoittaminen pelkällä ClojureScriptillä (käyttäen tässäkin käytettävää jayq-kirjastoa) olisi aikaa mennyt kertaluokkaa vähemmän.

Versio vegaaneille

Vegeversiossa ei siis käytetä bacon.js:a, mutta hyödynnetään JavaScriptin eventtejä jayq:n (jQueryn) avulla. En kirjoita ylempää esimerkkiä uudelleen, mutta esitän toisen komponentin toteutuksen. Kompleksisuus ei aivan täsmää, mutta esimerkin valossa koen sen olevan vertailukelpoinen.

Case: Usergroups -komponentti

Komponentti koostuu kahdesta listasta, kantaan jo tallennetuista käyttäjäryhmistä ja vain LDAP:ssa esiintyvistä ryhmistä. Kun ryhmää klikataan, näytetään details-näkymässä ryhmän tiedot ja passataan callback-funktio, jota kutsutaan ryhmän id:llä kun käyttäjä antaa ryhmälle oikeuksia.

(defn init [get-selection-fn]
 (render-sidebar)
 (let-ajax [with-rights {:url "/userGroups.json" :dataType :json}
            from-ldap   {:url "/FromLDAP.json"   :dataType :json}]
   (add-with-rights (decode with-rights))
   (add-without-rights (groups-without-rights (decode with-rights)
                                              (decode from-ldap)))
   (add-event-listeners)
   (set-selected get-selection-fn)))

let-ajax käyttää jQueryn Deferred toteutusta odottaakseen kummatkin GET kutsut ennen kun jatkaa suoritusta.

Kuuntelijoiden asettaminen:

(defn- on-group-created
 [id]
 (init #(get-item-with-id id)))

(defn- set-selected [selection-fn]
 (remove-class ($ ".user-group-item.selected")
               :selected)
 (let [$selection (selection-fn)]
   (add-class $selection :selected)
   (d/show-details $selection on-group-created)))

(defn- add-event-listeners []
 (doseq [item ($user-group-items)]
   (let [get-selection-fn #($ item)]
     (on ($ item)
         :click
         #(set-selected get-selection-fn)))))

user-group-item-elementtiä kliksauttaessa hoidetaan ko. näkymän (overview) rendaus ja passataan callback details-komponentille. Tässä callback on alussa kutsuttu init-funktio, missä valittu ryhmä on äsken klikattu ryhmä.

Tämän lähestymistavan huono puoli on callbackien passailu. Saman asian voisi hoitaa eventeillä ja event bubblingilla, mutta koodin paketointi kärsii, koska vastuussa oleva elementti muuttuu kaikkien keskustelevien elementtien parentiksi. Toisaalta koodin kirjoittaminen oli nopeaa ja suoraviivaista.

Pekonilla vai ilman?

Työkalut tarkoitukseen? Missä menee raja? Onko alun FRPlearning curve liian iso? Koodin paketointi? Itsellä learning curve oli melko iso, mutta akateemisesti erittäin hyödyllinen. FRP toteutuksen lopputulokseen olen myös tyytyväinen, mutta komponentin implementoimiseen meni paljon enemmän aikaa kun plain ClojureScript komponentin implementoimiseen. Balanssin löytäminen luettavuuden ja tehokkuuden välillä taitaa olla se isoin haaste.

Never miss a post