-
Notifications
You must be signed in to change notification settings - Fork 0
Making a Counter
In this step, we will create a counter and our application will be able to receive messages which will increment the counter. This simple exercise will introduce us to the following concepts:
- messages
- Transform functions
- Dataflow Definitions
- the input queue
- the Data Renderer
- testing behavior code
A Pedestal application project is really a set of tools that will help you build a single-page application. These tools do things like compile your code, generate host pages and allow you to view different aspects of your application in isolation. The output of these tools is a set of artifacts which can be deployed.
Before we make any changes to our application, let's start up the tools so that we can see the changes as we make them.
cd tutorial-client
lein repl
Once you have a running REPL, run the following:
(use 'dev)
(run)and then open a browser and navigate to http://localhost:3000. In
the Tools Menu, click on the Data UI link where you will see
:greeting
"Hello World"
You are looking at the Data Renderer. This is a renderer which comes with Pedestal and can be used to display any rendering data which is produced by your application. When working on a new application it is very useful to be able to see and even work with the app before you have done any work on custom rendering. We will see how useful this is as we move forward.
The value being displayed here is a representation of the data model which is currently the value:
{:greeting "Hello World!"}The Data Renderer is displaying this value by making a heading for each top level key and then printing the value associated with each key. Below, we are going to make some changes which will illustrate how to define and change a data model. In a later part of the tutorial we will look a bit closer at why the data is rendered in this way.
Creating a simple counter allows us to solve four problems which are common in all applications:
- define a function which handles a state transition
- trigger a state transition
- define the location where a specific bit of state will be stored
- notice when state changes so that we can do something about it
Before we make any changes to code, we need to understand how to trigger changes in a Pedestal application. A Pedestal application is essentially one big object. It contains state and that state is changed when it receives a message. Messages are data.
Messages have a least a type and a topic. The message which we will send to cause the counter to increment will look like this:
{msg/type :inc msg/topic [:my-counter]}Here the type is :inc and the :topic is [:my-counter]. Both type
and topic are namespaced to the io.pedestal.app.messages
namespace. Message maps can contain any additional data.
The first thing that we will typically change in a new project is the
application's behavior. The code for behavior is located in the file
tutorial-client/app/src/tutorial_client/behavior.clj. It is
important to note that this project layout is not required. As an
application grows, you will not want to have all of your application
logic in this one file. You may organizing your code however you like.
To make a counter we will need a function to handle the state
transition for the counter value. This function will simply increment
the value. In the behavior.clj file, delete the existing function
named set-value-transform and replace it with the function shown
below.
(defn inc-transform [old-value _]
((fnil inc 0) old-value))In Pedestal, this is called a transform function. It takes two
arguments: the old value and the input message and it returns a new
value. This function ignores the input message and increments the
old value.
Pedestal applications are written by creating pure functions, like the
one shown above, and linking them together in a dataflow. The dataflow
is described with a map. We call this description the Dataflow
Definition. In a dataflow, transform functions handle all inputs
to the flow. There are many other parts of a dataflow which can be
configured but for now we will stick to transforms.
Update the example-app var in the tutorial-client.behavior
namespace to look like this:
(def example-app
{:version 2
:transform [[:inc [:my-counter] inc-transform]]})Our dataflow definition has two keys: :version and
:transform. :version is used to indicate the version of this
definition format.
:transform contains a vector of vectors which define which transform
function will be called when a message is received. Each vector looks
like this:
[:inc [:my-counter] inc-transform]where the first element is the type, the second element is the topic and the final element is the function to call.
When a message is received, the first matching transform function will be called. When the message
{msg/type :inc msg/topic [:my-counter]}is received it will be routed to the first transform function which matches the type and topic.
Remember that one of the problems above was to "define the location
where a specific bit of state will be stored". That location is
defined in the message as [:my-counter]. We can think of this as a
path to the location in the data model where the function will do its
work. The transform function will receive the old value at this path
as input and the value returned by the function will become the new
value at this path.
What if we wanted to have lots of counters and not just one? We could change the definition above to be:
(def example-app
{:version 2
:transform [[:inc [:*] inc-transform]]})Using a wildcard instead of a keyword. This means that any :inc
message type will be handled by this function and the actual path in
the data model which will be updated is determined by the message. Note
that this will only match a path with one element. To match any path
of any length, we could use:
[:inc [:**] inc-transform]We now have a function, a place to store the counter value and way to make the counter increment. How can we observe changes in the data model and respond to them? For example, how do we know when the counter value has changed so that we can draw it on the screen? In this simple application, the dataflow engine will be handling this for us. This combined with the Data Renderer will allow us to see changes until we have more time to spend on rendering.
In the next section we will see how to customize the notifications which are sent out for rendering.
If we make the changes above and refresh the browser window, we will see nothing. We have defined behavior but now we need to send an appropriate message to actually see it work.
In the file tutorial-client/app/src/tutorial_client/start.cljs you
will find the create-app function which is used to create and start
a new application. Notice that there is a call to p/put-message
which will add a message to the input queue which feeds into the
application.
Change this line to send an :inc message as shown below.
(p/put-message (:input app) {msg/type :inc msg/topic [:my-counter]})Now refresh the browser and you should see that the value under
:my-counter is now 1. In the next section we will add a way to
increment the counter form the user interface.
Before we move on to the next section, we should spend a moment thinking about testing. At this point we have some behavior and it is easy enough to test it by refreshing the browser. As an application grows this will be harder to do. It would be good to have some automated tests in place.
In the file tutorial-client/test/tutorial_client/test/behavior.clj
there are some tests which are now broken. We will replace these tests
with some of our own.
Transform functions are pure functions and are therefore very easy to test. We may create some tests for our transform function like the one shown below.
(deftest test-inc-transform
(is (= (inc-transform nil {msg/type :inc msg/topic [:my-counter]})
1))
(is (= (inc-transform 0 {msg/type :inc msg/topic [:my-counter]})
1))
(is (= (inc-transform 1 {msg/type :inc msg/topic [:my-counter]})
2))
(is (= (inc-transform 1 nil)
2)))Notice that this particular transform function does not depend on the input message. We may also want to test the application in general. Before we write this test, let's make a helper function which will extract the data model from an application object.
(defn- data-model [app]
(-> app :state deref :data-model))Everything that we need to know about an app is contained in the app object. It is educational to explore its contents.
The test below shows how to create an app and then send it one or more
messages. Once the messages are processed we can check the state of
the data model. The run-sync! function will ensure that all of the
messages have been processed before returning.
(deftest test-app-state
(let [app (app/build example-app)]
(is (test/run-sync! app [{msg/type :inc msg/topic [:my-counter]}]
:begin :default))
(is (= (data-model app)
{:my-counter 1})))
(let [app (app/build example-app)]
(is (test/run-sync! app [{msg/type :inc msg/topic [:my-counter]}
{msg/type :inc msg/topic [:my-counter]}
{msg/type :inc msg/topic [:my-counter]}]
:begin :default))
(is (= (data-model app)
{:my-counter 3}))))Once we have tests in place we can run them from the command line by running
lein test
or
lein difftest
The second command will show diffs when a test fails.
There are more advanced techniques which can be used to test our applications but for now this is good enough. Notice that even though this is behavior that will run in the browser, we are testing it as plain old Clojure code.
This works because the tutorial-client.behavior namespace is marked
as :shared and uses only Clojure code that can be compiled to ClojureScript.
(ns ^:shared tutorial-client.behavior
(:require [clojure.string :as string]
[io.pedestal.app.messages :as msg]))The tag for this step is step2.