Refactoring to Components¶
Before we add the next bit of functionality to our application, it’s time to take a small detour, into the use of Sandra Sierra’s Component library. [1]
As Clojure programs grow, the namespaces, and relationships between those
namespaces, grow in number and complexity.
In our previous example, we saw that the logic to
start the Jetty instance was strewn across the user
namespace.
This isn’t a problem in our toy application, but as a real application grows, we’d start to see some issues and concerns:
- A single ‘startup’ namespace (maybe with a
-main
method) imports every single other namespace. - Potential for duplication or conflict between the real startup code and the test startup code. [2]
- Is there a good way to stop things, say, between tests?
- Is there a way to mock parts of the system (for testing purposes)?
- We really want to avoid a proliferation of global variables. Ideally, none!
Component is a simple, no-nonsense way to achieve the above goals. It gives you a clear way to organize your code, and it does things in a fully functional way: no globals, no update-in-place, and easy to reason about.
The building-block of Component is, unsurprisingly, components. These components are simply ordinary Clojure maps – though for reasons we’ll discuss shortly, Clojure record types are more typically used.
The components are formed into a system, which again is just a map. Each component has a unique, well-known key in the system map.
Components may have dependencies on other components. That’s where the true value of the library comes into play.
Components may have a lifecycle; if they do, they implement the Lifecycle
protocol containing methods start
and stop
.
This is why many components are implemented as Clojure records …
records can implement a protocol, but simple maps can’t.
Rather than get into the minutiae, let’s see how it all fits together in our Clojure Game Geek application.
Add Dependencies¶
{:paths ["src" "resources"]
:deps {org.clojure/clojure {:mvn/version "1.11.1"}
com.walmartlabs/lacinia {:mvn/version "1.2-alpha-4"}
com.walmartlabs/lacinia-pedestal {:mvn/version "1.1"}
com.stuartsierra/component {:mvn/version "1.1.0"}
io.aviso/logging {:mvn/version "1.0"}}
:aliases
{:run-m {:main-opts ["-m" "my.clojure-game-geek"]}
:run-x {:ns-default my.clojure-game-geek
:exec-fn greet
:exec-args {:name "Clojure"}}
:build {:deps {io.github.seancorfield/build-clj
{:git/tag "v0.8.2" :git/sha "0ffdb4c"
;; since we're building an app uberjar, we do not
;; need deps-deploy for clojars.org deployment:
:deps/root "slim"}}
:ns-default build}
:dev {:extra-paths ["dev-resources"]}
:test {:extra-paths ["test"]
:extra-deps {org.clojure/test.check {:mvn/version "1.1.1"}
io.github.cognitect-labs/test-runner
{:git/tag "v0.5.0" :git/sha "48c3c67"}}}}}
We’ve added the component
library.
System Map¶
We’re starting quite small, with just two components in our system:
The :server
component is responsible for setting up the Pedestal service,
which requires a compiled Lacinia schema.
The :schema-provider
component exposes that schema as its :schema
key.
Later, we’ll be adding additional components for other logic, such as database connections, thread pools, authentication/authorization checks, caching, and so forth. But it’s easier to start small.
What does it mean for one service to depend on another? Dependencies are acted upon when the system is started (and again when the system is stopped).
The dependencies influence the order in which each component is started.
Here, :schema-provider
is started before :server
, as :server
depends on
:schema-provider
.
Secondly, the started version of a dependency is assoc
-ed into
the dependant component.
After :schema-provider
starts, the started version of the component
will be assoc
-ed as the :schema-provider
key of the :server
component.
Once a component has its dependencies assoc
-ed in, and is itself started
(more on that in a moment), it may be assoc
-ed into further components.
The Component library embraces Clojure’s core concept of identity vs. state; the identity of the component is its key in the system map … its state is a series of transformations of the initial map.
:schema-provider component¶
The my.clojure-game-geek.schema
namespace has been extended to provide
the :schema-provider
component.
(ns my.clojure-game-geek.schema
"Contains custom resolvers and a function to provide the full schema."
(:require [clojure.java.io :as io]
[com.stuartsierra.component :as component]
[com.walmartlabs.lacinia.util :as util]
[com.walmartlabs.lacinia.schema :as schema]
[clojure.edn :as edn]))
(defn resolve-game-by-id
[games-map context args value]
(let [{:keys [id]} args]
(get games-map id)))
(defn resolve-board-game-designers
[designers-map context args board-game]
(->> board-game
:designers
(map designers-map)))
(defn resolve-designer-games
[games-map context args designer]
(let [{:keys [id]} designer]
(->> games-map
vals
(filter #(-> % :designers (contains? id))))))
(defn entity-map
[data k]
(reduce #(assoc %1 (:id %2) %2)
{}
(get data k)))
(defn resolver-map
[]
(let [cgg-data (-> (io/resource "cgg-data.edn")
slurp
edn/read-string)
games-map (entity-map cgg-data :games)
designers-map (entity-map cgg-data :designers)]
{:Query/gameById (partial resolve-game-by-id games-map)
:BoardGame/designers (partial resolve-board-game-designers designers-map)
:Designer/games (partial resolve-designer-games games-map)}))
(defn load-schema
[]
(-> (io/resource "cgg-schema.edn")
slurp
edn/read-string
(util/inject-resolvers (resolver-map))
schema/compile))
(defrecord SchemaProvider [schema]
component/Lifecycle
(start [this]
(assoc this :schema (load-schema)))
(stop [this]
(assoc this :schema nil)))
The significant changes are at the bottom of the namespace. There’s a new record, SchemaProvider, that implements the Lifecycle protocol.
Lifecycle is optional; trivial components may not need it.
In our case, we use the start
method as an opportunity to
load and compile the Lacinia schema.
When you implement a protocol, you must implement all the methods of the
protocol.
In Component’s Lifecycle protocol, you typically will undo in stop
whatever you did in start
.
For example, a Component that manages a database connection will open it in start
and
close it in stop
.
Here we just get rid of the compiled schema, [3]
but it is also common
and acceptable for a stop
method to just return this
if the component
doesn’t have external resources,
such as a database connection, to manage.
:server component¶
Next well add the my.clojure-game-geek.server
namespace to provide the
:server
component.
(ns my.clojure-game-geek.server
(:require [com.stuartsierra.component :as component]
[com.walmartlabs.lacinia.pedestal2 :as lp]
[io.pedestal.http :as http]))
(defrecord Server [schema-provider server]
component/Lifecycle
(start [this]
(assoc this :server (-> schema-provider
:schema
(lp/default-service nil)
http/create-server
http/start)))
(stop [this]
(http/stop server)
(assoc this :server nil)))
Much of the code previously in the user
namespace has moved here.
You can see how the components work together, inside the start
method.
The Component library has assoc
-ed the :schema-provider
component
into the :server
component, so it’s possible to get the :schema
key
and build the Pedestal server from it.
start
and stop
methods often have side-effects.
This is explicit here, with the call to http/stop
before clearing
the :server
key.
stop
is especially important in this component, as it calls http/stop; without this, the system will shut down,
but the Jetty instance will continue to listen on port 8080. This kind of side-effect is exactly what
the Lifecycle protocol is used for.
system namespace¶
We’ll create a new my.clojure-game-geek.system
namespace just to put together the Component system map.
(ns my.clojure-game-geek.system
(:require [com.stuartsierra.component :as component]
[my.clojure-game-geek.schema :as schema]
[my.clojure-game-geek.server :as server]))
(defn new-system
[]
(assoc (component/system-map)
:server (component/using (server/map->Server {})
[:schema-provider])
:schema-provider (schema/map->SchemaProvider {})))
The call to component/using
establishes the dependency between the components.
You can imagine that, as the system grows larger, so will this namespace. But at the same time, the namespaces for the individual components will only need to know about the namespaces of components they directly depend upon.
user namespace¶
Next, we’ll look at changes to the user
namespace:
(ns user
(:require [com.stuartsierra.component :as component]
[my.clojure-game-geek.system :as system]
[com.walmartlabs.lacinia :as lacinia]
[clojure.java.browse :refer [browse-url]]
[clojure.walk :as walk])
(:import (clojure.lang IPersistentMap)))
(defn simplify
"Converts all ordered maps nested within the map into standard hash maps, and
sequences into vectors, which makes for easier constants in the tests, and eliminates ordering problems."
[m]
(walk/postwalk
(fn [node]
(cond
(instance? IPersistentMap node)
(into {} node)
(seq? node)
(vec node)
:else
node))
m))
(defonce system (system/new-system))
(defn q
[query-string]
(-> system
:schema-provider
:schema
(lacinia/execute query-string nil nil)
simplify))
(defn start
[]
(alter-var-root #'system component/start-system)
(browse-url "http://localhost:8888/ide")
:started)
(defn stop
[]
(alter-var-root #'system component/stop-system)
:stopped)
The user
namespace has shrunk; previously
it was responsible for loading the schema, and creating and starting
the Pedestal service; the code for all that has shifted to the individual components.
Instead, the user
namespace uses the my.clojure-game-geek.system/new-system
function
to create a system map, and can use start-system
and stop-system
on that system map: no direct knowledge of
loading schemas or starting and stopping Pedestal is present any longer.
The user namespace previously had vars for both the schema and the Pedestal service. Now it only has a single var, for the Component system map.
Interestingly, as our system grows in later chapters, the user namespace will likely
not change at all, just the system map it gets from new-system
will
expand.
The only wrinkle here is in the q
function; since there’s no longer a local
schema
var it is necessary to pull the :schema-provider
component from the system map,
and extract the schema from that component.
Summary¶
Even with just two components, using the Component library simplifies our code, and lays the groundwork for rapidly expanding the behaviour of the application.
In the next chapter, we’ll look at adding new queries and types to the schema, in preparation to adding our first mutations.
[1] | Sandra provides a really good explanation of Component in their Clojure/West 2014 talk. |
[2] | We’ve been sloppy so far, in that we haven’t even thought about testing. That will change later in the tutorial. |
[3] | You might be tempted to use a dissoc here, but if you
dissoc a declared key of a record, the result is an ordinary
map, which can break tests that rely on repeatedly starting and stopping
the system. |
[4] | This is just one approach; another would be to provide a function
that assoc -ed the component into the system map. |