[Catalyst-commits] r14482 - trunk/examples/CatalystAdvent/root/2013
jnapiorkowski at dev.catalyst.perl.org
jnapiorkowski at dev.catalyst.perl.org
Mon Dec 9 15:11:25 GMT 2013
Author: jnapiorkowski
Date: 2013-12-09 15:11:25 +0000 (Mon, 09 Dec 2013)
New Revision: 14482
Added:
trunk/examples/CatalystAdvent/root/2013/22.pod
Log:
new article
Added: trunk/examples/CatalystAdvent/root/2013/22.pod
===================================================================
--- trunk/examples/CatalystAdvent/root/2013/22.pod (rev 0)
+++ trunk/examples/CatalystAdvent/root/2013/22.pod 2013-12-09 15:11:25 UTC (rev 14482)
@@ -0,0 +1,419 @@
+=head1 Websocket Multiuser Chat
+
+=head1 Overview
+
+Following up on our Websockets Echo example let's build a basic and functional
+multiuser chat application.
+
+=head1 Introduction
+
+The main claim to fame (AFAIK) for Websockets is in how it makes it much easier
+to keep a persistent, bi-directional connection open between a client (like a
+web browser) and a server. The low overhead, low latency websocket is ideal for
+applications where you want to let lots of people interact in near real time.
+One such application could be a multiuser chat application, similar to IRC.
+
+Although in theory one could build such a system on top of a blocking server
+such as L<Starman>, you'd be limited in the number of allowed persistent
+connections. As you might recall from earlier articles, a blocking server
+can only be simultaneously connected to a number of clients which is equal to
+its number of workers (be they forked or threaded). As a result it is common
+to build persistent, websocket based applications on top of a non blocking
+server, managed by an event loop (such as L<AnyEvent> and L<Twiggy>). In this
+example we will use that approach.
+
+=head1 Scope
+
+A true multiuser chat would need significantly more security hardening than this
+example will show. There's no reason you could not build such security on top
+of existing tools in the L<Catalyst> ecosystem.
+
+=head1 The Code
+
+Although I usually try to break my model down more carefully, sometimes for
+prototyping, or for build a proof of concept, I make uber controllers. Since
+all L<Catalyst::Controller>s play nice with L<Moose> this makes it easy to
+build some simple models and attributes on your controller. Once you nail
+down the API, you can start to refactor code more correctly. But it makes
+a nice first draft!
+
+Let's look at the first part of the controller:
+
+ package MyApp::Controller::Root;
+
+ use Moose;
+ use MooseX::MethodAttributes;
+ use AnyEvent::Handle;
+ use Protocol::WebSocket::Handshake::Server;
+ use JSON;
+
+ extends 'Catalyst::Controller';
+
+ has 'history' => (
+ is => 'bare',
+ traits => ['Array'],
+ isa => 'ArrayRef[HashRef]',
+ default => sub { +[] },
+ handles => {
+ history => 'elements',
+ add_to_history => 'push'});
+
+ has 'clients' => (
+ is => 'bare',
+ traits => ['Array'],
+ default => sub { +[] },
+ handles => {
+ clients => 'elements',
+ add_client => 'push'});
+
+So the start is all the bits we are pulling in from CPAN to get the job done
+and we set this class up to be a L<Catalyst::Controller>. We then create two
+attributes to hold the two basic models for this chat application. The first
+one is the history of the chat, which will be an arrayref of user => message:
+
+ [
+ { john => 'Hello bob' },
+ { bob => 'Hey john' },
+ ...
+ ]
+
+The C<clients> is a arrayref of the connected clients. Ultimately as you will see
+this is an arrayref of handles to the websocket.
+
+Since we are going to run this under a single process server, we get away with
+creating a model like this. Controllers are typically singletons associated
+with the application. So when runing under a single process server these
+attributes are always going to be available. If you ran this under a forking
+server, each child in the fork would have its own copy. In that case you'd
+need to save everything to a share storage, such as a database.
+
+Lets look at an action:
+
+ sub index :Path(/) {
+ my ($self, $c) = @_;
+ (my $url = $c->uri_for_action($self->action_for('ws')))
+ ->scheme('ws');
+
+ $c->stash(websocket_url => $url);
+ $c->forward($c->view('HTML'));
+ }
+
+So this is going to be the root page. We just create a single link to what is
+going to be the websocket URL and pass on to the view. Lets see the view
+template:
+
+ <!DOCTYPE html>
+ <html lang="en" ng-app>
+ <head>
+ <title>Example Chat Server</title>
+ <script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.1.4/angular.min.js"></script>
+ <script>
+ function ChatCtrl($scope) {
+
+ $scope.status = "Disconnected";
+ $scope.username = "";
+ $scope.socket = "";
+ $scope.checked = 1;
+ $scope.history = new Array;
+
+ $scope.join = function() {
+ socket = new WebSocket('[% websocket_url %]');
+
+ socket.addEventListener("open", function(event) {
+ $scope.$apply(function() {
+ $scope.status = "Connected";
+ $scope.checked = 0;
+ $scope.username = $scope.new_username;
+ socket.send(
+ angular.toJson({new: $scope.new_username})
+ );
+ });
+ });
+
+ socket.addEventListener("close", function(event) {
+ $scope.$apply(function() {
+ $scope.status = "Disconnected";
+ });
+ });
+
+ socket.addEventListener("message", function(event) {
+ $scope.$apply(function() {
+ $scope.history.push(JSON.parse(event.data));
+ });
+ });
+
+ $scope.socket = socket;
+ };
+
+ $scope.send = function () {
+ $scope.socket.send(
+ angular.toJson({
+ username: $scope.username,
+ message: $scope.message}));
+
+ $scope.message = '';
+ }
+ }
+ </script>
+ </head>
+ <body ng-controller="ChatCtrl">
+ <h1>The Chatroom Example</h1>
+ <ul id="history">
+ <li ng-repeat="item in history">
+ <span ng-bind="item.username"></span>:
+ <span ng-bind="item.message"></span>
+ </li>
+ </ul>
+ <div>
+ Total Items: <span id="item-count" ng-bind="history.length"></span>
+ </div>
+ <div id="chatbox">
+ Status: <span id="status" ng-bind="status"></span><br />
+
+ <div ng-show="checked">
+ <input id="join" type="button" value="Join" ng-click="join()" />
+ <input id="new_username" ng-model="new_username" /><br />
+ </div>
+ <div ng-hide="checked">
+ <input id="send" type="button" value="Send" ng-click="send()" />
+ <input id="text" ng-model="message" /><br />
+ </div>
+ </div>
+ </body>
+ </html>
+
+We're using angular.js, which is Javascript framework that makes is easy to
+do your user interface work in a more model-view-controller manner. I'm not
+going to do details here since I'm still learning angular.js and my code is
+likely not the best possible approach. However if you know a bit of Javascript
+it should be pretty clear. There's a script section which defines some
+variables under a controller and sets events to listen for and things to do
+when those events happen.
+
+The second part is some HTML with the angular.js tags which indicate binds
+to the variables under the controller. One really cool part of angular.js is
+in how if the variables are changed, HTML that binds to them automatically
+updates. This saves you a lot of low level plumbing details where you need
+to have the event update the data, and then pass control to a function that
+changes the UI. Not only does angular.js save that boilerplate, you end up
+with controller code that is much less tightly bound to the trivial details of
+your HTML markup.
+
+So what happens here is that when someone clicks on the 'join' button, that
+creates a websocket connection and sends some json across that looks like this:
+
+ { 'new': '$name' }
+
+Where $name is the value entered into the C<new_username> input box. We also
+change the UI a bit, hide the Join button and replace it with a send message
+button. That way after a person joins she can send messages.
+
+Let's now look at the action that handles that websocket, bi-directional
+messaging:
+
+ sub ws :Path(/ws) {
+ my ($self, $ctx) = @_;
+ my $hs = Protocol::WebSocket::Handshake::Server->new_from_psgi($ctx->req->env);
+ my $hd = AnyEvent::Handle->new(
+ fh => $ctx->req->io_fh,
+ on_error => sub { warn "Error ".pop });
+
+ $hs->parse($hd->fh);
+ $hd->push_write($hs->to_string);
+ $hd->on_read(sub {
+ (my $frame = $hs->build_frame)->append($_[0]->rbuf);
+ while (my $message = $frame->next) {
+ my $decoded = decode_json $message;
+ if(my $user = $decoded->{new}) {
+ $decoded = {username=>$user, message=>"Joined!"};
+ foreach my $item ($self->history) {
+ $hd->push_write(
+ $hs->build_frame(buffer=>encode_json($item))
+ ->to_bytes);
+ }
+ }
+
+ $self->add_to_history($decoded);
+ foreach my $client($self->clients) {
+ $client->push_write(
+ $hs->build_frame(buffer=>encode_json($decoded))
+ ->to_bytes);
+ }
+ }
+ });
+
+ $self->add_client($hd);
+ }
+
+So there's a lot going on, let's break it down:
+
+ sub ws :Path(/ws) {
+ my ($self, $ctx) = @_;
+
+This is just the normal Catalyst stuff, should be no surprises.
+
+ my $hs = Protocol::WebSocket::Handshake::Server->new_from_psgi($ctx->req->env);
+ my $hd = AnyEvent::Handle->new(
+ fh => $ctx->req->io_fh,
+ on_error => sub { warn "Error ".pop });
+
+You might recall us creating the server end of the websocket protocol from the
+echo example. Again, this is a parser for the server end, not a server all by
+itself! The second bit we bless the raw IO socket into an L<AnyEvent::Handle>
+and set a callback for coping with errors.
+
+ $hs->parse($hd->fh);
+ $hd->push_write($hs->to_string);
+
+This slurps up the start of the websocket request (it actually modifies the
+filehandle by sucking up all the HTTP lines and the call for upgrading to a
+websocket connection.) We then add to the write queue the lines that are
+the server side of the 'handshake', which basically informs the client that yes
+indeed we can do websockets and finalizes the communication.
+
+The next bit is a callback we install onto the websocket to handle read events
+(in other words the event that happens when the client sends some traffic over the
+websocket).
+
+ $hd->on_read(sub {
+ (my $frame = $hs->build_frame)->append($_[0]->rbuf);
+ while (my $message = $frame->next) {
+ my $decoded = decode_json $message;
+ if(my $user = $decoded->{new}) {
+ $decoded = {username=>$user, message=>"Joined!"};
+ foreach my $item ($self->history) {
+ $hd->push_write(
+ $hs->build_frame(buffer=>encode_json($item))
+ ->to_bytes);
+ }
+ }
+
+Websockets are raw, so its up to you to declare your own data encoding and
+protocol. Most people just use JSON since that's easy and also pretty compatible.
+Data comes down from the client in websocket frames, so you need to iterate over
+those frames. Since I am sending everyone over one frame, I can just go ahead
+and decode the JSON, but there's nothing stopping you from building a more complex
+communication protocol. Remeber, its just a socket, and its low level and raw!
+
+I'm looking for incoming messages from clients, but in the case where I see the
+C<new> key exists, that's the code for 'new connected user'. So when that
+happens, we send a message like user => 'Joined!', and also we send to the newly
+joined user the full history of the chat. Obviously this isn't going to scale
+but limiting it to the last 100 lines would also be easy to do.
+
+ $self->add_to_history($decoded);
+ foreach my $client($self->clients) {
+ $client->push_write(
+ $hs->build_frame(buffer=>encode_json($decoded))
+ ->to_bytes);
+ }
+
+Here we take the incoming message, add it to the history and the we iterate
+over all the connected clients and send the message. Again, for very large
+numbers of connected clients you'd probably need to do something smarter here
+but this would be fine for even a few thousand connections.
+
+ $self->add_client($hd);
+ }
+
+In this last bit we make sure to add the just newly connected client to the big
+list of clients.
+
+Remember, this websocket stuff in L<Catalyst> is currently still new stuff, so
+yes there's not a lot of hand holding or sugar. Nothing stopping us from adding
+that, if it turns out people are actually building applications! But here you
+at least can give it a try!
+
+To see it all at once, here's the full controller all together:
+
+ package MyApp::Controller::Root;
+
+ use Moose;
+ use MooseX::MethodAttributes;
+ use AnyEvent::Handle;
+ use Protocol::WebSocket::Handshake::Server;
+ use JSON;
+
+ extends 'Catalyst::Controller';
+
+ has 'history' => (
+ is => 'bare',
+ traits => ['Array'],
+ isa => 'ArrayRef[HashRef]',
+ default => sub { +[] },
+ handles => {
+ history => 'elements',
+ add_to_history => 'push'});
+
+ has 'clients' => (
+ is => 'bare',
+ traits => ['Array'],
+ default => sub { +[] },
+ handles => {
+ clients => 'elements',
+ add_client => 'push'});
+
+
+ sub index :Path(/) {
+ my ($self, $c) = @_;
+ (my $url = $c->uri_for_action($self->action_for('ws')))
+ ->scheme('ws');
+
+ $c->stash(websocket_url => $url);
+ $c->forward($c->view('HTML'));
+ }
+
+ sub ws :Path(/ws) {
+ my ($self, $ctx) = @_;
+ my $hs = Protocol::WebSocket::Handshake::Server->new_from_psgi($ctx->req->env);
+ my $hd = AnyEvent::Handle->new(
+ fh => $ctx->req->io_fh,
+ on_error => sub { warn "Error ".pop });
+
+ $hs->parse($hd->fh);
+ $hd->push_write($hs->to_string);
+ $hd->on_read(sub {
+ (my $frame = $hs->build_frame)->append($_[0]->rbuf);
+ while (my $message = $frame->next) {
+ my $decoded = decode_json $message;
+ if(my $user = $decoded->{new}) {
+ $decoded = {username=>$user, message=>"Joined!"};
+ foreach my $item ($self->history) {
+ $hd->push_write(
+ $hs->build_frame(buffer=>encode_json($item))
+ ->to_bytes);
+ }
+ }
+
+ $self->add_to_history($decoded);
+ foreach my $client($self->clients) {
+ $client->push_write(
+ $hs->build_frame(buffer=>encode_json($decoded))
+ ->to_bytes);
+ }
+ }
+ });
+
+ $self->add_client($hd);
+}
+
+__PACKAGE__->meta->make_immutable;
+__PACKAGE__->config( namespace => '');
+
+=head1 Also See
+
+See full code example:
+
+L<https://github.com/perl-catalyst/2013-Advent-Staging/tree/master/Websocket-Chat>
+
+=head1 Summary
+
+Building on our knowledge of how L<Catalyst> exposes a low level, bi-directional
+socket based on the L<PSGI> specification, we explored using L<AnyEvent> to build
+a multiuser chat applications.
+
+=head1 Author
+
+John Napiorkowski L<email:jjnapiork at cpan.org>
+
+=cut
More information about the Catalyst-commits
mailing list