-
Notifications
You must be signed in to change notification settings - Fork 0
Making a Counter
In this step, we will create a counter and the application will be able to receive messages which will cause the counter to increment. This simple exercise will introduce us to the following concepts:
- transform functions
- the dataflow definition
- the input queue
- messages
- the data renderer
- testing behavior code
Before we make any changes, let's start the application 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, in the lower left corner of your browser (hover the
mouse to make the tools menu appear), click on the Data UI link. You
will see
:greeting
"Hello World"
What is this? The default application has a very simple data model which is 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 of each key. We are going to make some changes which will illustrate how to define and change a data model. In the next part of the tutorial we will look a bit closer at why the data is rendered in this way.
We need to accomplish a few things in order to make a compelling counter:
- We need to define a function which can increment a value
- We need to have some place to store the counter value
- We need to know when the value has changed so that we can do something about it
The behavior of our application is defined in the file
tutorial-client/app/src/tutorial_client/behavior.clj. This is where
we will make our changes. 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 use
normal techniques for organizing your code as the project grows.
Let's start by focusing on goal 1 above. There is a function named
set-value-transform at the top of this file. Delete it. Write the
following function.
(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 will simply increment the old-value. If you are
new to Clojure, the fnil function is used here to wrap inc in a
function which, when passed nil, will replace it with 0 before calling
inc.
We have our function, what do we do with it?
Pedestal applications are described in a map. This map allows us to describe many things about how our application works. We will start very small and configure our single transform function.
Update the exmaple-app in the behavior namespace to look like this:
(def example-app
{:version 2
:transform [[:inc [:my-counter] inc-transform]]})Our map has two keys: :version and :transform. :version is used
to indicate the version of this description.
: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. Transform functions define all of the inputs that our application can receive. A message to increment this counter will look like this:
{msg/type :inc msg/topic [:my-counter]}In Pedestal, every message has a type and a topic. Here the type is
:inc and the :topic is [:my-counter]. When this message is
received it will be routed to the first transform function which
matches the type and topic.
Remember that goal 2 above was to have some place to store the counter
value. That place is defined in the message as [:my-counter]. We can
think of this 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 description 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,
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. What about goal number 3? How will we know when the counter has changed so that we can do something about this. This is handled for us by the application at the moment. In the next section we will see how to customize this behavior but for now we will go with the default behavior.
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. This function call is adding a message to the input
queue to be fed 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, it will be helpful to discuss 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 worked with application which we started
with but are now broken. Delete these tests and we will write some new
ones.
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.