Stuart Sierra's Reloaded workflow & Component library
Karol Andrusieczko (Eyeota)
August 2017
Mostly stolen from Stuart Sierra
(def state-a (atom {}))
(def state-b (atom 0))
; hidden dependencies coming from outside
(defn op1 []
(swap! state-a ...))
(defn op2 []
(swap! state-b ...))
(let [state-a (atom {})
state-b (atom 0)]
(defn op1 []
(swap! state-a ...))
(defn op2 []
(swap! state-b ...)))
State created at the time of loading files,
not at runtime
(defn constructor []
{:a (atom {})
:b (atom 0)})
; makes function dependencies clear
(defn op1 [state]
(swap! (:a state) ...))
(defn op2 [state]
(swap! (:b state) ...))
(defn test-constructor []
{:a (atom {:initial "awesome test!"})
:b (atom 0)})
(deftest test-op1
(let [state (test-constructor)]
(is (= ... (op1 state)))))
We can have many different states for different environments
And we can call constructor many times without any side-effects!
(def ^:dynamic *resource*)
(defn- internal-op1 []
... *resource* ...)
(defn op1 [arg]
(internal-op1 ...))
For some reason, many libraries take advantage of global dynamic variables
When you try to test that, you need to redefine variables or bind them in context...
(def config (load-config-file config-url)) ; loads only once
(def db-connection (atom nil)) ; holds global state
(defn connect-to-database
[]
(reset! db-connection (db/connect! config)))
(defn init! []
(connect-to-database!)
(create-thread-pools!)
(start-background-processes!)
(start-web-server!))
What about integration tests?
Ah, we all love mocks! ;-)
;; In src/com/example/my_project/system.clj
(ns com.example.my-project.system)
(defn system
"Returns a new instance of the whole application."
[]
...)
{:db {:uri "datomic:mem://dev"}
:scheduler #<ScheduledThreadPoolExecutorService ...>
:cache #<Atom {}>
:handler #<Fn ...>
:server #<Jetty ...>}
(defrecord System
[storage config web-service])
(defn dev-system []
(map->System {:storage (mem-store)
:config (local-config {:a 1 :b 2})
:web-service (mock-web-service)}))
(defn prod-system []
(map->System {:storage (sql-store config)
:config (zookeeper-config)
:web-service (web-srv config storage)}))
;; In src/com/example/my_project/system.clj
(defn start
"Performs side effects to initialize the system, acquire resources,
and start it running. Returns an updated instance of the system."
[system]
...)
(defn stop
"Performs side effects to shut down the system and release its
resources. Returns an updated instance of the system."
[system]
...)
To be able to shut the application down, discard any transient state it might have built up, start it again, and return to a similar state (...) in less than a second.
The idea
(ns com.example.app)
(def -main [...]
(system ...))
(ns app-user
(:require [clojure.tools.namespace.repl :refer (refresh)])
(def sys nil)
(defn init [...]
(alter-var-root #'sys (constantly (system ...))))
(defn start [...]
(alter-var-root #'sys component/start))
(defn stop [...]
(alter-var-root #'sys component/stop))
(defn go []
(init)
(start))
(defn reset []
(stop)
(refresh :after 'uni-user/go))
You don't want to restart JVM every time...
it starts growing
(defn start-db [system]
(let [host (get-in system [:db :host])] ; one big complex map
(assoc-in state [:db :conn]
(db/connect host))))
(defn get-user [system name]
(let [conn (get-in system [:db :conn])] ; everything sees everything
(db/query conn "..." name)))
(defn start-all []
(-> {}
start-db
start-queues ; manual ordering
start-thread-pool
...
start-web-server)) ; hidden dependencies?
; definition, encapsulates state
(defrecord DB [host conn])
; public API (possibly with side effects)
(defn query [db & ...]
(.doQuery (:conn db) ...))
(defn insert [db & ...]
(.doStatement (:conn db) ...))
; constructor (no side effects)
(defn db [host]
(map->DB {:host host}))
(ns com.stuartsierra.component)
(defprotocol Lifecycle
(start [component])
(stop [component]))
; default implementation returns component itself
(defrecord DB [host conn]
component/Lifecycle
(start [this]
(assoc this :conn (Driver/connect host)))
(stop [this]
(.stop conn)
this))
(defrecord Customers [db email])
(defn notify-customer [customers name message]
(let [{:keys [db email]} customers
address (query db name)]
(send email address message)))
(defn customers []
(component/using
(map->Customers {})
[:db :email]))
(defn system []
(component/system-map
:customers (customers ...)
:db (db ...)
:email (email ...)
...))
(component/start (system))
(defrecord TestDB [host conn]
component/Lifecycle
(start [this]
(let [conn (Driver/connect host)]
(load-seed-data conn) ; initialise test data
(assoc this :conn conn)))
(stop [this]
(drop-database conn) ; drop db so that you start fresh
(.stop conn)
this))
Now, I can create and test the whole application with no hassle!
; test-system creates email using channels
(deftest test-notify-customer
(let [system (component/start test-system)
{:keys [customers email] system}]
(try
(notify customers "bob" "Hi, Bob!")
(is (= "Hi, Bob!" (:message (<!! (:channel email)))))
(finally
(component/stop system)))))
We're hiring!