Skip to content
Brenton Ashworth edited this page May 28, 2013 · 20 revisions

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

Starting the dev tools

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 the counter

Creating a simple counter allows us to solve four problems which are common in all applications:

  1. define a function which handles a state transition
  2. trigger a state transition
  3. define the location where a specific bit of state will be stored
  4. notice when state changes so that we can do something about it

Messages

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.

Transform functions

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.

Dataflow

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 exmaple-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.

Defining the data model

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]

Observing change

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.

Sending a message

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.

Testing

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.

Home

Clone this wiki locally