1. Introduction
In the following tutorial, we will learn how to use websockets by creating a simple chat-room application.
The tutorial will be divided in three parts:
- the first part will focus on setting up a Crails+Comet application for the tutorial.
- the second part will be targeted at server-side web sockets with Crails.
- the third part will be targeted at client-side web sockets with Comet.
Before we dwelve into websockets, let's create our development environment using the crails comment line interface:
1.1 Creating a Crails+Comet application
First things first, let's create a simple crails application:
$ crails new -n chatroom -p html
This will create a basic application with html support. Next, we need to add a comet client, which we will create using the comet plugin:
$ crails plugins comet install
1.2 Creating a controller and layout
Even though we'll be making a Single-Page-Application using Comet, we still need to provide a route to serve a basic html file which will provide a layout including the required stylesheets and javascript files. Let's start with creating the controller:
$ crails scaffold controller -n home
Now, we will create a simple default layout:
$ crails scaffold layout
The default options for the layout generator will create the layouts/application
layout.
The next step is to add an action to our home controller that will render our layout. First,
update the header to add thehomepage
action:
app/controllers/home.hpp
#pragma once
#include "app/controllers/application.hpp"
class HomeController : public ApplicationController
{
public:
HomeController(Crails::Context&);
void initialize() override;
void finalize() override;
// Add our new action here:
void homepage();
protected:
};
Then, let's implement the homepage
action in the controller's source file:
app/controllers/home.cpp
#include "home.hpp"
#include <crails/params.hpp>
...
void HomeController::homepage()
{
render("layouts/application");
}
This might seem unorthodox... and it is. Usually, you would render a view within a layout. In this context, we are directly rendering the layout as if it were a simple view. This is absolutely possible, and it fits our current needs better, as rendering the views will be handled by the client rather than the server.
To finish up our homepage route, we need to reference it in the router:
app/routes.cpp
#include <crails/router.hpp>
#include "app/controllers/home.hpp" // adds the include for our HomeController
void Crails::Router::initialize(void)
{
match_action("GET", "/", HomeController, homepage); // references the homepage action as the root action ("/")
}
1.3 Serving the Comet application from the layout
Last step, we need to serve the JavaScript for our comet application. This is done using the lib/assets.hpp
header, a generated file which provides the definitive URIs towards your application assets.
In order to take a peek at what this header will look like, let's try building your application:
$ crails build
Everything should go smoothly, and the
lib/assets.hpp
#ifndef APPLICATION_ASSETS_HPP
#define APPLICATION_ASSETS_HPP
namespace Assets
{
#ifndef __CHEERP_CLIENT__
extern const char* application_js;
#endif
extern const char* stylesheets_bootstrap_bootstrap_scss;
}
#endif
See the application_js
variable ? That's what we're interested about. This variable will contain the path
for our asset from the web. This is what we need to add to our layout to serve our comet application. Let's go and add
a <script> in our layout's header:
app/views/layouts/application.html
#include "lib/assets.hpp"
const char* @yield = nullptr;
// END LINKING
<html>
<head>
<%= tag("link", {{"rel","stylesheet"},{"href",Assets::stylesheets_bootstrap_bootstrap_scss}}) %>
<!-- Add the following script tag: -->
<%= tag("script", {{"src",Assets::application_js}}) yields %><% yields-end %>
</head>
<body>
<% if (yield != nullptr) do %>
<%= yield %>
<% end %>
</body>
</html>
And that's all there is to it ! Our setup is ready : we can now go on and dwelve into WebSockets !
2. WebSocket server with Crails
2.1 A simple echo websocket
To familiarize with the WebSocket API, let's start by writing a simple echo websocket. It's a websocket that will merely send back everything that it receives.
Let's start by scaffolding our websocket class:
app/controllers/echo_web_socket.hpp
#pragma once
#include <crails/websocket.hpp>
class EchoWebSocket : public Crails::WebSocket
{
public:
EchoWebSocket(Crails::Context& context) : Crails::WebSocket(context)
{
}
};
All we need to create a valid websocket is to inherit Crails::WebSocket
and create a constructor that
passes a context object to the superclass.
This websocket, however, does nothing yet. We will now override the received method to send back any received messages:
app/controllers/echo_web_socket.hpp
#pragma once
#include <crails/websocket.hpp>
class EchoWebSocket : public Crails::WebSocket
{
public:
EchoWebSocket(Crails::Context& context) : Crails::WebSocket(context)
{
}
void received(const std::string& message, Crails::WebSocket::MessageType type) override
{
send(message, type);
}
};
Simple stuff ! And we're almost done: we now need to register our WebSocket object in the router, so that it can wire requests towards it. Edit the routes as following:
app/routes.cpp
#include <crails/router.hpp>
#include "app/controllers/home.hpp"
#include "app/controllers/echo_web_socket.hpp" // adds the include for our EchoWebSocket
void Crails::Router::initialize(void)
{
match_action("GET", "/", HomeController, homepage);
match_websocket("/echo", EchoWebSocket, read); // references our websocket at "/echo"
}
Matching websockets routes is very similar to controller routes. Like for controllers, you also need to specify
an action as the third parameter of a route: the action specified will trigger right after the WebSocket
handshake is completed. In this case, we've used read
as our action.
The read
action is provided by Comet::WebSocket
: you don't have to implement it yourself.
It is basically what you would expect from reading on a socket: it asynchronously waits for the client to send
data, and calls received
(which we've implemented earlier) once a message has been received.
2.2 Chatroom server
Let's capitalize on our newly aquired knowledge by writing a simple chatroom server. Our goal will be to echo each message received from one websocket to all other websockets connected to the chatroom.
To that end, we will need to make a chatroom storing all the connected websockets. It will need to be a thread-safe chatroom, as distincts clients could connect, disconnect and emit messages simultaneously.
Let's have a go at it:
app/controllers/chat_room.hpp
#pragma once
#include <crails/websocket.hpp>
#include <crails/utils/singleton.hpp>
#include <mutex>
class ChatRoom
{
SINGLETON(ChatRoom) // singleton helper from the framework
typedef std::shared_ptr<Crails::WebSocket> WebSocketPtr;
typedef std::list<WebSocketPtr> Clients;
public:
void add_client(Crails::WebSocket& socket)
{
std::lock_guard<std::mutex> guard(clients_mutex);
clients.push_back(socket.shared_from_this());
}
void remove_client(const Crails::WebSocket& socket)
{
Clients::iterator it;
std::lock_guard<std::mutex> guard(clients_mutex);
it = std::find(clients.begin(), clients.end(), socket.shared_from_this());
if (it != clients.end())
clients.erase(it);
}
void send_message(const Crails::WebSocket& emitter, const std::string& message)
{
std::lock_guard<std::mutex> guard(clients_mutex);
for (WebSocketPtr client : clients)
{
if (client.get() != &emitter)
client->send(message, Crails::WebSocket::TextMessage);
}
}
private:
std::mutex clients_mutex;
Clients clients;
};
In this ChatRoom
class, we use std::mutex
to ensure thread-safety with our client list.
In the send_message
method, we loop over all our connected clients, and send a message to everyone
except the WebSocket who emitted the message in the first place.
Note that we used the SINGLETON
macro, meaning we need to instantiate the singleton somwehere,
ideally somewhere that won't be subjected to potential concurrency issues. We'll do it in our main function:
app/main.cpp
...
#include "controllers/chat_room.hpp"
int main()
{
SingletonInstantiator<ChatRoom> chat_room; // just add this line to instantiate the singleton
...
}
Let's now write a new WebSocket class that will use this chatroom:
app/controllers/chat_client.hpp
#pragma once
#include "chat_room.hpp"
class ChatClient : public Crails::WebSocket
{
std::string nickname;
public:
ChatClient(Crails::Context& context) : Crails::WebSocket(context)
{
// First, we'll use the request parameters to let participants set a nickname
nickname = context.params["nickname"].defaults_to<std::string>("anonymous");
}
// We need a method to handle the arrival of a new participant:
void connect()
{
ChatRoom::singleton::get()->send_message(*this, "<i>" + nickname + " joined the room.</i>");
ChatRoom::singleton::get()->add_client(*this);
read();
}
// Next, we need to broadcast received messages to the room
void received(const std::string& message, Crails::WebSocket::MessageType) override
{
ChatRoom::singleton::get()->send_message(*this, "<b>" + nickname + ":</b> " + message);
read();
}
// Finally, we remove the participant from the room when the socket gets disconnected
void disconnected() const override
{
ChatRoom::singleton::get()->send_message(*this, "<i>" + nickname + " left the room.</i>");
ChatRoom::singleton::get()->remove_client(*this);
}
};
Lastly, we add a route to reach our chatroom:
app/routes.cpp
#include <crails/router.hpp>
#include "app/controllers/home.hpp"
#include "app/controllers/echo_web_socket.hpp"
#include "app/controllers/chat_client.hpp" // add the ChatClient header
void Crails::Router::initialize(void)
{
match_action("GET", "/", HomeController, homepage);
match_websocket("/echo", EchoWebSocket, read);
match_websocket("/chatroom/:nickname", ChatClient, connect); // add a new route
}
3. WebSocket client with Comet
We'll now take care of the client part. We'll design it using Comet.cpp. If you haven't used Comet yet, check out the Getting started with Comet tutorial.
Before all else, move to the client's directory at app/client
: the comet
command line interface should always run from this folder, or one of its descendents.
$ cd app/client
3.1 WebSocket
Let's start with a chat room object: it will use a WebSocket to send and receive messages, and store the history of messages received. Create the following file:
app/client/chat_room_client.hpp
#pragma once
#include <comet/websocket.hpp>
class ChatRoomClient
{
std::unique_ptr<Comet::WebSocket> socket;
std::string nickname;
std::size_t maximum_history_size = 50;
std::list<std::string> history;
public:
Comet::Signal<> history_updated;
const std::list<std::string>& get_history() const { return history; }
void connect(const std::string& nickname_)
{
nickname = nickname_;
socket.reset(new Comet::WebSocket("ws://localhost:3001/chatroom/" + nickname));
socket->message_received.connect(std::bind(&ChatRoom::add_message, this, std::placeholders::_1));
}
void add_message(const std::string& message)
{
history.push_back(message);
if (history.size() > maximum_history_size)
history.erase(history.begin());
history_updated.trigger();
}
void send_message(const std::string& message)
{
socket->send(message);
add_message("<b>" + nickname + ": </b>" + message);
}
};
For receiving messages, the WebSocket object comes with a message_received
signal,
which we connect to the add_message
method right after instantiating the WebSocket
in the connect
method.
For sending messages, we use the send
method, which takes std::string
as its parameter, as seen in the send_message
method.
3.2 Overloading WebSocket
While it is possible to use Comet::WebSocket
directly for simple use cases,
if we want to handle other events from websockets, such as connection or disconnection,
we will have to create a class that inherits Comet::WebSocket
and overrides
the on_open
, on_close
, on_error
methods:
app/client/chat_web_socket.hpp
#pragma once
#include <comet/websocket.hpp>
class ChatWebSocket : public Comet::WebSocket
{
public:
Comet::Signal<bool> connection_changed;
ChatWebSocket(Comet::String url) : Comet::WebSocket(url)
{
}
void on_open(client::Event*) override
{
connection_changed.trigger(true);
}
void on_close(client::Event*) override
{
connection_changed.trigger(false);
}
void on_error(client::ErrorEvent*) override
{
}
};
In this example, we create a class that provides a signal when the state of the
connection changed. Let's integrate this signal in our ChatRoomClient
object:
app/chat_room_client.hpp
#pragma once
#include "chat_web_socket.hpp"
class ChatRoomClient
{
std::unique_ptr<ChatWebSocket> socket; // update the socket's classname
std::string nickname;
std::size_t maximum_history_size = 50;
std::list<std::string> history;
public:
Comet::Signal<> history_updated;
const std::list<std::string>& get_history() const { return history; }
void connect(const std::string& nickname_)
{
nickname = nickname_;
socket.reset(new ChatWebSocket("ws://localhost:3001/chatroom/" + nickname));
socket->message_received.connect(std::bind(&ChatRoomClient::add_message, this, std::placeholders::_1));
socket->connection_changed.connect(std::bind(&ChatRoomClient::on_connection_changed, this, std::placeholders::_1));
}
void on_connection_changed(bool connected)
{
add_message(connected ? "You are now connected." : "You are not connected anymore.");
}
void add_message(const std::string& message)
{
history.push_back(message);
if (history.size() > maximum_history_size)
history.erase(history.begin());
history_updated.trigger();
}
void send_message(const std::string& message)
{
socket->send(message);
add_message(nickname + ": " + message);
}
};
By adding a listener for the connection_changed
event, we will now display a message
when the user connects or disconnects.
3.3 Chat room views
3.3.1 Connect view
We've got the backend ready, let's move on to the frontend. We'll start by creating a connection room,
where users can pick their nicknames. Run the following command in your app/client
folder:
$ comet scaffold view -n ConnectView
And update the generated app/views/connect_view.html
file as following:
app/client/views/connect_view.html
<template>
<head>
<script>virtual void on_submit() = 0;</script>
</head>
<body>
<form submit.trigger="on_submit()">
<div class="form-group">
<input type="text" class="form-control" ref="nickname_input" />
<input type="submit" class="btn btn-primary mb-3" />
</div>
</form>
</body>
</template>
This view has a text input that will be referenced as nickname_input
, and declares
the on_submit
method, called when the form sends a submit
event. The
method needs an implementation, so let's add one in the view's header:
app/client/views/connect_view.hpp
#pragma once
#include "templates/connect_view.hpp"
class ConnectView : public HtmlTemplate::ConnectView
{
public:
Comet::Signal<std::string> accepted;
void on_submit()
{
accepted.trigger(
nickname_input.value<std::string>()
);
}
};
3.3.2 Chatroom controller
We'll now set up a controller to display the connection view, and eventually the chatroom. Run the following command:
$ comet scaffold controller -n ChatRoom
Let us now add an action to that controller that will manage our connection view.
Open the header at app/client/controllers/chat_room.hpp
, and declare the
connect
method as such :
app/controllers/chat_room.hpp
#pragma once
#include <comet/mvc/controller.hpp>
class ChatRoomController : public Comet::Controller
{
public:
ChatRoomController(const Comet::Params&);
void connect();
};
Let's now implement it in the source file:
app/client/controllers/chat_room.cpp
#include "chat_room.hpp"
#include "../views/connect_view.hpp"
using namespace std;
unique_ptr<ConnectView> connect_view;
void ChatRoomController::connect()
{
Comet::body.empty();
connect_view.reset(new ConnectView);
connect_view->append_to(Comet::body);
}
...
This will replace the content of the document's body with a freshly instantiated ConnectView
.
when the action is called. Let's add a route to the client router:
app/client/routes.cpp
#include <comet/router.hpp>
#include "controllers/chat_room.hpp"
void Comet::Router::initialize()
{
match_action("/", ChatRoomController, connect);
}
3.3.3 Connection
Now that we have a form to enter our nickname, let's use it to implement connection with the
ChatRoom
object we created in 3.1. Let's add a second route for connection:
app/client/routes.cpp
#include <comet/router.hpp>
#include "controllers/chat_room.hpp"
void Comet::Router::initialize()
{
match_action("/", ChatRoomController, connect);
match_action("/chatroom/:nickname", ChatRoomController, chatroom);
}
We'll add the chatroom action to our controller source:
app/client/controllers/chat_room.cpp
#include "chat_room.hpp"
#include "../views/connect_view.hpp"
#include "../chat_room_client.hpp"
using namespace std;
unique_ptr<ChatRoomClient> chatroom_client;
unique_ptr<ConnectView> connect_view;
void ChatRoomController::connect()
{
Comet::body.empty();
connect_view.reset(new ConnectView);
connect_view->append_to(Comet::body);
}
void ChatRoomController::chatroom()
{
chatroom_client.reset(new ChatRoomClient);
chatroom_client->connect(params["nickname"]);
}
...
Let's also update the connect
method so that submitting the form trigger the chatroom
action:
app/client/controllers/chat_room.cpp
#include "../application.hpp"
...
void ChatRoomController::connect()
{
Comet::body.empty();
connect_view.reset(new ConnectView);
connect_view->append_to(Comet::body);
connect_view->accepted.connect([](const std::string& nickname)
{
Application::get().router.navigate("/chatroom/" + nickname);
});
}
...
3.3.4 Chat view
Last, but not least: the chat view. We'll be creating a view
that displays the message history and input. Run the following command in your app/client
directory:
$ comet scaffold view -n ChatRoomView -p 'std::vector<std::string>*/history'
Open the generated file at app/client/views/chat_room_view.html
, and modify it as
following:
app/client/views/chat_room_view.html
<template>
<head>
<attribute name="history" type="const std::list<std::string>*" value="nullptr" />
<script>
Comet::Signal<std::string> send_message;
</script>
</head>
<body>
<div repeat.for="line of [const std::list<std::string>]*history">
<span text.bind="line"></span>
</div>
<form submit.trigger="send_message.trigger(message_input.value<std::string>())">
<div class="input-group">
<input type="text" class="form-control" ref="message_input" />
<input type="submit" class="btn btn-primary mb-3" />
</div>
</form>
</body>
</template>
This view consists of two parts: the first one is a repeater, which will repeat the <div> element for each line in the history. The second one is a form, much like the one we've seen in the connection view.
We're nearly done. We have to render the chat view from the chatroom
action we've
created earlier. Open app/client/controllers/chat_room.hpp
and update the chatroom
method as such:
app/client/controllers/chat_room.cpp
#include "../views/chat_room_view.hpp"
...
unique_ptr<ChatRoomView> chatroom_view;
...
void ChatRoomController::chatroom()
{
Comet::body.empty();
chatroom_view.reset(new ChatRoomView);
chatroom_client.reset(new ChatRoomClient);
chatroom_client->history_updated.connect(
std::bind(&ChatRoomView::trigger_binding_updates, chatroom_view.get())
);
chatroom_view->send_message.connect([](const std::string& message)
{
chatroom_client->send_message(message);
});
chatroom_view->set_history(&chatroom_client->get_history());
chatroom_client->connect(params["nickname"]);
chatroom_view->bind_attributes();
chatroom_view->append_to(Comet::body);
}
And this is it: the history attribute from the view is now a pointer to the history in
our ChatRoomClient
object, and the view bindings will all update
whenever the history_updated
signal is sent.
On the other side, when the send_message
signal gets called on the view,
it will call send_message
on the ChatRoomClient
object, and
it will clear the message input.
All done. Now, build, launch the server, and profit:
$ crails build $ build/server