1. Introduction
1.1 Guide Assumptions
In order to follow this guide, you should:
- Be familiar with channels.
- Be familiar with the ODB plugin for crails
(the getting started tutorial should be enough)
1.2 Setup
In this tutorial, we will build a minimalist message board that will update itself. Users will be able to add messages to the board, and other users will see those appear are they are created.
To achieve this behaviour, we will use the sync
plugin, which you can add to your application with the following
command:
$ crails plugins sync install
2. ODB Transactions
The sync plugin provides features to broadcast database transactions to your clients. This chapter will focus on SQL transactions, using both the odb and sync plugins.
2.1 Server
Let's start by generating a resource:
$ crails scaffold resource -n message -p std::string/message std::string/author
2.1.1 Controller
Controllers that interact with an SQL database inherit from the Crails::Odb::Controller
class
template. To synchronize transactions with the client, we will use the second parameter of that template
to overload the default wrapper making for SQL queries with the Crails::Odb::Sync::Connection
class:
app/controllers/message.hpp
#pragma once
#include "application.hpp"
#include "app/models/message.hpp"
// Define our controller with synchronization enabled:
typedef Crails::Odb::Controller<
ApplicationController,
Crails::Odb::Sync::Connection
> MessageSuperController;
class MessageController : public MessageSuperController
{
public:
MessageController(Crails::Context&);
void initialize() override;
void finalize() override;
void index();
void show();
void create();
void update();
void destroy();
private:
std::shared_ptr<Message> find_model(Crails::Odb::id_type id);
void require_model(Crails::Odb::id_type id);
void find_list();
std::shared_ptr<Message> model;
std::vector<Message> model_list;
};
Synchronization are broadcasted through channels. You may perform all synchronizations using a single channel, or even use a channel for each of your model. In this example, we'll set up a channel for our controller:
app/controllers/message.cpp
#include "message.hpp"
#include <crails/odb/to_vector.hpp>
#include "lib/odb/application-odb.hxx"
using namespace std;
MessageController::MessageController(Crails::Context& context) : MessageSuperController(context)
{
database.sync_transaction.set_channel(
Crails::Sync::Channels::singleton::get()->require_unlocked_channel("/message/sync")
);
}
...
2.1.2 Router
Now we just need to make the channel available via the router, so that clients can listen to it using WebSockets:
app/routes.cpp
#include "app/controllers/message.hpp"
#include <crails/router.hpp>
#include <crails/sync/channel_actions.hpp>
void Crails::Router::initialize()
{
// Append routes here (do not remove this line)
resource_actions(message, MessageController);
match_sync_channel("/message/sync", Crails::Sync::ChannelListener);
}
ChannelListener
is similar to ChannelClient
, but it does not have the ability
to broadcast messages: it is read-only.
2.2 Client
We'll now have the message index automatically update when a message is added or updated.
Let's start by updating the index view template. We need to make some changes so that rows can be added and updated from a script:
app/views/message/index.html
#include "app/models/message.hpp"
#include <boost/lexical_cast.hpp>
using namespace std;
vector<Message> @models;
string route = "message";
// END LINKING
<%= tag("a", {{"href", '/' + route + "/new"}}) yields %>
New
<% end %>
<table class="table">
<thead>
<th>Author</th>
<th>Content</th>
</thead>
<tbody>
<% for (const Message& model : models) do %>
<!-- the [data-id] attribute will help
our script find which rows to update -->
<tr data-id="<%= model.get_id() %>">
<td><%= model.get_author() %></td>
<td><%= model.get_content() %></td>
</tr>
<% end %>
</tbody>
</table>
Now that the view is ready, let's write a script that will listen to our message channel and update the table when it receives notification of new transactions:
app/assets/messages.js
const socket = new WebSocket("ws://0.0.0.0:3001/message/sync");
function updateRow(row, properties) {
row.children[0].textContent = properties.author;
row.children[1].textContent = properties.content;
}
function createRow(list, properties) {
const row = document.createElement("tr");
row.insertCell(document.createElement("td"));
row.insertCell(document.createElement("td"));
updateRow(row, properties;)
list.insertRow(row);
}
function forEachModel(list, callback) {
for (let id in list.message)
callback(id, list.message[id]);
}
socket.onmessage = function (event) {
const transaction = JSON.parse(event.data);
const list = document.querySelector("tbody");
forEachModel(transaction.updates, function(id, properties) {
const row = list.querySelector(`[data-id="${id}"]`);
row ? updateRow(row, properties) : createRow(list, properties);
});
forEachModel(transaction.removals, function(id, properties) {
const row = list.querySelector(`[data-id="${id}"]`);
if (row)
list.removeChild(row);
});
}
Now this might need some explaining. As you can see in the onmessage
callback, the
transaction data is transmitted as JSON. Once the message received by the WebSocket is parsed,
the object received will contain two properties: updates
and removals
.
Added and edited models will appear in the former, while deleted models will appear in the former.
Within both updates
and removals
, models are then sorted by scope. In
this example, the Message::scope
constant is used, which defaults to message.
This is why the forEachModel
function loops over list.message
.
Each scope itself is a map of all updated or deleted models, where the model id is used as key.
The updates
object also provides all the model's attribute, which is how we update
or create the cells in the createRow
and updateRow
functions.
to_json
method of your model
(if you did not use a scaffold to create your model, don't forget to add that method). Alternatively,
if there is a json template matching your model's view constant (Message::view
in this
exmaple), it will use this view instead.
Our script is not yet included into our index view. Let's fix that:
app/views/message/index.html
#include "lib/assets.hpp" // add the assets include
#include "app/models/message.hpp"
#include <boost/lexical_cast.hpp>
using namespace std;
vector<Message> @models;
string route = "message";
// END LINKING
...
<table class="table">
...
</table>
<%= tag("script", {{"src", Assets::application_js}}) yields %>
<% yields-end %>
And voilĂ , we're done. Build your server and go to http://localhost:3001/message to see the result of your work.
3. Task progression
The sync plugin also provides tools to follow the progress of a task remotely.
Create an application with the following command:
$ crails new -n synctask -p html $ cd synctask
Add the sync plugin:
$ crails plugins sync install
3.1 Controller
We'll now add a simple controller which will perform an asynchronous task when called upon:
app/controller/task.hpp
#pragma once
#include "application.hpp"
#include <iostream>
class TaskController : public ApplicationController
{
public:
TaskController(Crails::Context& context) :
ApplicationController(context)
{
}
void index()
{
std::thread(std::bind(run_async_task)).detach();
}
private:
static void run_async_task()
{
for (int i = 60 ; i > 0 ; --i)
{
std::cout << "Incrementing task: " << i << std::endl;
sleep(1);
}
}
};
In the index
action, we create an asynchronous task using std::thread
.
We will now use the Crails::Sync::Task
class, which will help us broadcast the progress
of the task over WebSockets.
Let's update the index
action:
app/controller/task.hpp
#pragma once
#include "application.hpp"
#include <crails/sync/task.hpp> // add the header for Crails::Sync::Task
#include <iostream>
class TaskController : public ApplicationController
{
public:
TaskController(Crails::Context& context) :
ApplicationController(context)
{
}
void index()
{
int step_count = 60;
auto task = std::make_shared<Crails::Sync::Task>("task", step_count);
std::thread(std::bind(run_async_task, task, step_count)).detach();
}
...
};
We instantiate the Task
object with two parameters:
- The scope "task", which is used to define the base path on which the progress will be broadcasted.
- The step count, which should match the amount of time you expect to increment the task progress.
Now, let's include the task object in the run_async_task
method:
static void run_async_task(std::shared_ptr<Crails::Sync::Task> task, int step_count)
{
for (int i = step_count ; i > 0 ; --i)
{
std::cout << "Incrementing task: " << i << std::endl;
sleep(1);
task->increment();
}
}
A simple call to task->increment()
is all it takes on the server part.
3.2 Router
We now have to add routes for both our controller action, and the channel our task progress will be broadcasted to:
app/routes.cpp
#include <crails/router.hpp>
#include <crails/sync/channel_actions.hpp> // don't forget this include
#include "controllers/task.hpp"
void Crails::Router::initialize()
{
match_action("GET", "/task", TaskController, index);
match_sync_task("/task/:task_id");
}
The match_sync_task
is a macro that creates the required route for task progress
broadcasting. It's composed of the task's scope and its id as a parameter.
3.3 View
Now we'll create a view to display our task progress. Let's start by adding a Bootstrap layout:
$ crails scaffold layout --toolkit bootstrap
Then create the following view:
app/views/task/show.html
std::string @task_uri;
std::string @task_id;
// END LINKING
<h1>Task Watcher</h1>
<h3>Watching task <%= task_id %></h3>
<div class="progress">
<div id="progress-bar" class="progress-bar" role="progressbar">
0/0
</div>
</div>
<script>
const socket = new WebSocket("ws://0.0.0.0:3001/<%= task_uri %>");
socket.onmessage = event => {
const metadata = JSON.parse(event.data);
const percents = metadata.progress * 100;
const progressBar = document.querySelector("#progress-bar");
progressBar.textContent = `${metadata.item_progress}/${metadata.item_count}`;
progressBar.style.width = `${Math.ceil(percents)}%`;
};
</script>
We use a bootstrap progressbar to display the task progress, and a WebSocket to receive the udpates. The update messages are in JSON, and feature a progress value that goes from 0 to 1, as well as counters for the amount of tasks done, and the total amount of task to complete.
We will now update the index
action in our controller to provide
the task_uri
and task_uid
shared variable that our
view is expecting:
app/controllers/task.hpp
void index()
{
int step_count = 60;
auto task = std::make_shared<Crails::Sync::Task>("task", step_count);
std::thread(std::bind(run_async_task, task, step_count)).detach();
vars["layout"] = std::string("layouts/application");
vars["task_uri"] = task->uri();
vars["task_id"] = task->get_id();
render("task/show");
}
Don't forget to also set the view layout to layouts/application
.
Build and launch your server, and then go to http://localhost:3001/task: you should see the task slowly update every 3 seconds or so.
3.4 Customization
3.4.1 Update broadcasting rate
The updates happen slowly due to the notification policy of the Task
object.
To avoid firing too many notifications, the Task
object only notify clients
of progresses by blocks of 5 percents by default.
Let's change our index
action to use a different update rate:
void index()
{
int step_count = 60;
auto task = std::make_shared<Crails::Sync::Task>("task", step_count);
task->set_notification_step(1);
...
}
By calling set_notification_step
, we tell our notifier to broadcast update
everytime the progress moves by 1 percent.
3.4.2 Custom notification messages
You may also manually broadcast an update whenever you want by calling the notify
method:
static void run_async_task(std::shared_ptr<Crails::Sync::Task> task, int task_count)
{
...
task->notify();
...
}
You may also add your own message to the broadcasted update:
task->notify("Hello world !");
The client WebSocket will be able to access this message in the JSON data broadcasted, such as:
socket.onmessage = function (event) {
const data = JSON.parse(event.data);
console.log("Received task update with message:", data.message);
};