Notes on Component (Part 1)
Stuart Sierra’s Component library for Clojure took awhile for me to really understand. Hence these notes.
Component provides four things:
- Start/Stop functionality
- Code reloading
- Declarative dependencies
- No global state
Other approaches like Mount provide #1 and #2, and infer #3 at run-time from the relationships between namespaces. However, they use global state.
I think my main difficulty was the documentation focusses on “Components,” as opposed to focusing on “Systems.”
Systems
A System is defined in terms of its components (ignoring the dependency bit for now):
1 2 3 4 5 6 7 8 9 10 11 12 13 |
(ns foo.system (:require [com.stuartsierra.component :as component])) (declare init-logging init-config init-feat-flags init-app-routes init-web-server) (defn make-system [] (component/system-map :logging (init-logging) ; :config (init-config) ; :feat-flags (init-feat-flags) ; :app-routes (init-app-routes) ; :web-server (init-web-server) )) |
The workflow is
- Create the “system” in a non-running state with (make-system)
- Start the “system” by calling (component/start) on it
- Your application here
- Eventually, Stop the “system”
Of course, the following code won’t work yet, since we haven’t defined any of the components, but the third line shows how you access a particular component in the system; (:config system-started) returns the “Config” component.
1 2 3 4 5 6 7 8 |
(let [system-unstarted (make-system) system-started (component/start system-unstarted)] (println "The logging component is " (pr-str (:logging system-started))) (component/stop system-started)) ; IllegalStateException Attempting to call unbound fn: ; #'foo.system/init-logging ; clojure.lang.Var$Unbound.throwArity (Var.java:43) |
First Component
Let’s now turn to defining the first component, :logging. Part of the beauty of Component is that you can start with extremely simple, naive implementations to get your whole system built, and then replace them later
This logger will just count the number of log lines written, and print it on “stop:”
1 2 3 4 5 6 7 8 9 10 11 |
(defrecord Logging [how count] component/Lifecycle (start [c] (println "Logging component (start) called with:" c) (assoc c :count (atom 0))) (stop [c] (println (format "stop: Log lines written: %d" (-> c :count deref))) (assoc c :count nil))) (defn init-logging [] (map->Logging {:how :stdout, :count nil})) |
So we’ve defined a very simple component. Let’s take a look at what’s happening here…
1 2 3 4 |
(init-logging) ; => #foo.system.Logging{:how :stdout, :count nil} (make-system) ; => #<SystemMap> (:logging (make-system)) ; => #foo.system.Logging{:how :stdout, :count nil} (get-in (make-system) [:logging :how]) ; => :stdout |
(make-system) creates some abstract thing which we can treat as a map, extract our :logging component, and treat it like a map.
We still need to do one thing, and that’s actually make our Logging do something (like, you know, log things…)
1 2 3 4 5 6 7 8 |
(defprotocol Logging-Protocol (log-text [sys msg])) (extend-type Logging Logging-Protocol (log-text [this-component msg] (swap! (:count this-component) inc) (println "Log: " msg))) |
And this is how we use it… We extract the Logging component from the system, then call the Logging-Protocol method “log-text” with the Component as the first parameter…
1 2 3 4 5 6 7 |
(let [system (component/start (make-system))] (log-text (:logging system) "Hello World") (component/stop system)) ; Logging component (start) called with: #foo.system.Logging{:how :stdout, :count nil} ; Log: Hello World ; stop: Log lines written: 1 |
Now, that’s a LOT of code to print “Hello World” to the screen. But we needed to get this far to show the next important piece of the puzzle, which is dependencies.
Second Component & Dependencies
Let’s define a Component for Configuration; specifically whether we want logging to go to a file or to the screen. Again, this is a very naive implementation for purposes of example…
1 2 3 4 5 6 7 8 9 10 11 12 |
(defrecord Config [logging] component/Lifecycle (start [c] (assoc c :logging logging)) (stop [c] (assoc c :logging nil))) (defn init-config-for-stdout-logging [] (->Config {:how :stdout})) (defn init-config-for-file-logging [filename] (->Config {:how :file, :filename filename})) |
Now that we have two Components, we have to re-define our System. While we’re doing that, we’ll encode that Logging depends on Configuration…
We do that by replacing (init-logging) with (component/using (init-logging) [:config])
1 2 3 4 5 |
(defn make-system [] (component/system-map :logging (component/using (init-logging) [:config]) :config (init-config-for-stdout-logging) )) |
So what’s the difference between this and before?
Without Dependencies | With Dependencies | ||||
---|---|---|---|---|---|
|
|
The key is the highlighted lines on the right; (component/using …) told the system that the Logging component’s (start) and (stop) functions need to be passed the Config Component, which ends up under the key :config.
Now let’s re-write this all so that the Component start function for Logging actually makes use of the Config component…
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 |
(ns foo.system (:require [com.stuartsierra.component :as component] [clojure.java.io :as io])) ;; ---- Logging Component --- (defrecord Logging [how count out] component/Lifecycle (start [c] (let [how (get-in c [:config :logging :how] :stdout) out (if (= :file how) (io/writer (-> c :config :logging :filename), :append true) *out*) bare (dissoc c :config)] (assoc bare :count (atom 0) :how how :out out))) (stop [c] (println (format "stop: Log lines written: %d" (-> c :count deref))) (if-not (= :stdout (:how c)) (.close (:out c))) (assoc c :how nil :out nil :count nil))) (defn init-logging [] (map->Logging {:how nil, :count nil, :out nil})) ;; --- Logging Protocol --- (defprotocol Logging-Protocol (log-text [c msg])) (extend-type Logging Logging-Protocol (log-text [this-component msg] (swap! (:count this-component) inc) (.write (:out this-component) (str "Log: " msg "\n")))) ;; --- Config Component --- (defrecord Config [logging] component/Lifecycle (start [c] (assoc c :logging logging)) (stop [c] (assoc c :logging nil))) ;; === How we set up the system for STDOUT logging === (defn init-config-for-stdout-logging [] (->Config {:how :stdout})) (defn make-system-stdout [] (component/system-map :logging (component/using (init-logging) [:config]) :config (init-config-for-stdout-logging) )) ;; === How we set up the system for FILE logging === (defn init-config-for-file-logging [filename] (->Config {:how :file, :filename filename})) (defn make-system-fileout [] (component/system-map :logging (component/using (init-logging) [:config]) :config (init-config-for-file-logging "/tmp/foo.system.log") )) |
How System has isolated the dependency
We can use the exact same code now regardless of whether the System has been created with file- or stdout-logging
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
(for [init-fn ["make-system-stdout" "make-system-fileout"]] (do (println (format "\n\n=== Creating system with \"%s\" ===" init-fn)) (let [sys (component/start ((resolve (symbol init-fn))))] (prn "Logging Component is: " (:logging sys)) (log-text (:logging sys) (str "Hello World with " (name init-fn))) (println "=== Stopping Component ===") (component/stop sys)))) === Creating system with "make-system-stdout" === "Logging Component is: " #foo.system.Logging{:how :stdout, :count #object[clojure.lang.Atom 0x155ca256 {:status :ready, :val 0}], :out #object[java.io.PrintWriter 0x7bf0788d "java.io.PrintWriter@7bf0788d"]} Log: Hello World with make-system-stdout === Stopping Component === stop: Log lines written: 1 === Creating system with "make-system-fileout" === "Logging Component is: " #foo.system.Logging{:how :file, :count #object[clojure.lang.Atom 0x4fd518cf {:status :ready, :val 0}], :out #object[java.io.BufferedWriter 0x3332812a "java.io.BufferedWriter@3332812a"]} === Stopping Component === stop: Log lines written: 1 |
Leave a Reply