├── .gitignore
├── INSTALL.md
├── install.sh
├── manifests
└── socket_io.pp
├── application
├── data
│ └── note.sql
├── src
│ └── EasyBib
│ │ └── SockBit
│ │ ├── Repository
│ │ ├── NoteRepository.php
│ │ └── SqlNoteRepository.php
│ │ └── Message
│ │ └── NoteAnnouncer.php
├── composer.json
├── sockbitd.php
└── composer.lock
├── index.html
├── package.json
├── LICENSE
├── index.js
├── README.md
├── socketio_server.js
├── Vagrantfile
└── docs
└── blogpost.md
/.gitignore:
--------------------------------------------------------------------------------
1 | /.vagrant
2 | /application/*.sqlite3
3 | /node_modules
4 | vendor
5 |
--------------------------------------------------------------------------------
/INSTALL.md:
--------------------------------------------------------------------------------
1 | # Installation
2 |
3 | Requires [Vagrant](http://www.vagrantup.com/)
4 |
5 | ```bash
6 | $ vagrant up
7 | $ vagrant ssh -c /vagrant/install.sh
8 | ```
9 |
10 |
--------------------------------------------------------------------------------
/install.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | rm /vagrant/application/sockbit.sqlite3
4 | sqlite3 /vagrant/application/sockbit.sqlite3 < /vagrant/application/data/note.sql
5 | cd ~
6 | curl -sS https://getcomposer.org/installer | php
7 | cd /vagrant/application && ~/composer.phar install
8 | cd /vagrant && npm install
9 |
10 |
--------------------------------------------------------------------------------
/manifests/socket_io.pp:
--------------------------------------------------------------------------------
1 | package {'openssh-server':}
2 | package {'git-core':}
3 | package {'build-essential':}
4 | package {'gcc':}
5 | package {'tree':}
6 | package {'rabbitmq-server':}
7 | package {'sqlite3':}
8 | package {'php5-dev':}
9 | package {'php5-cli':}
10 | package {'php5-sqlite':}
11 | package {'nodejs':}
12 | package {'npm':}
13 |
--------------------------------------------------------------------------------
/application/data/note.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE note (
2 | id INTEGER PRIMARY KEY,
3 | text TEXT,
4 | project_id INTEGER
5 | );
6 |
7 | INSERT INTO note VALUES (
8 | null,
9 | 'Hi there, here''s one note!',
10 | 1
11 | );
12 |
13 | INSERT INTO note VALUES (
14 | null,
15 | 'Oy, here''s another note!',
16 | 1
17 | );
18 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | SockBit (Socket.IO + RabbitMQ) demo
5 |
6 |
7 |
8 | My Note Project
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/application/src/EasyBib/SockBit/Repository/NoteRepository.php:
--------------------------------------------------------------------------------
1 | =5.4",
13 | "videlalvaro/php-amqplib": "2.*"
14 | },
15 | "minimum-stability": "dev",
16 | "prefer-stable": true,
17 | "autoload": {
18 | "psr-0": {
19 | "EasyBib\\SockBit\\": "src/"
20 | }
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/application/src/EasyBib/SockBit/Message/NoteAnnouncer.php:
--------------------------------------------------------------------------------
1 | channel = $connection->channel();
16 | $this->channel->exchange_declare(self::CHANNEL_NAME, 'fanout', false, false, false);
17 | }
18 |
19 | public function __destruct()
20 | {
21 | $this->channel->close();
22 | }
23 |
24 | public function announce($announcementType, array $data)
25 | {
26 | $json = json_encode([$announcementType, $data]);
27 | printf("Broadcasting to rabbit %s: %s\n", self::CHANNEL_NAME, $json);
28 | $message = new AMQPMessage($json);
29 | $this->channel->basic_publish($message, self::CHANNEL_NAME);
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2014, Yitzchak Schaffer
2 | All rights reserved.
3 |
4 | Redistribution and use in source and binary forms, with or without
5 | modification, are permitted provided that the following conditions are met:
6 |
7 | * Redistributions of source code must retain the above copyright notice, this
8 | list of conditions and the following disclaimer.
9 |
10 | * Redistributions in binary form must reproduce the above copyright notice,
11 | this list of conditions and the following disclaimer in the documentation
12 | and/or other materials provided with the distribution.
13 |
14 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
15 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
16 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
17 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
18 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
19 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
20 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
21 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
22 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
23 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
24 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | var socket = io();
2 | var projectId = 1;
3 | var noteTextarea = document.querySelector('div[data-note-id="1"] textarea');
4 |
5 | var noteContainer = document.getElementById('notes');
6 | var textareas = {};
7 |
8 | var addNote = function(note) {
9 | var textarea = document.createElement('TEXTAREA');
10 | textarea.dataset.noteId = note.id;
11 | textarea.value = note.text;
12 | noteContainer.appendChild(textarea);
13 | textareas[note.id] = textarea;
14 |
15 | textarea.addEventListener('blur', (function(note) {
16 | return function() {
17 | console.log('sending update to server for note ' + note.id + ': ' + this.value);
18 | socket.emit('update_note', {
19 | note_id: note.id,
20 | project_id: projectId,
21 | text: this.value
22 | });
23 | };
24 | })(note));
25 | }
26 |
27 | var initializeNotes = function(msg) {
28 | if (msg.project_id != projectId) {
29 | return;
30 | }
31 |
32 | console.log('loading notes from server');
33 | socket.removeListener('project_notes', initializeNotes);
34 |
35 | var notes = msg.notes;
36 | for (var i in notes) {
37 | var note = notes[i];
38 | addNote(notes[i]);
39 | }
40 | };
41 |
42 | socket.on('note_updated', function(msg) {
43 | console.log('updating note from server', msg);
44 | textareas[msg.id].value = msg.text;
45 | });
46 |
47 | socket.on('project_notes', initializeNotes);
48 | socket.emit('get_notes', {project_id: projectId});
49 |
--------------------------------------------------------------------------------
/application/src/EasyBib/SockBit/Repository/SqlNoteRepository.php:
--------------------------------------------------------------------------------
1 | pdo = new PDO('sqlite:/vagrant/application/sockbit.sqlite3');
14 | $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
15 | }
16 |
17 | /**
18 | * @param int $projectId
19 | * @return array
20 | */
21 | public function getAll($projectId)
22 | {
23 | $projectId = $this->sanitizeId($projectId);
24 | $q = 'SELECT id, text, project_id FROM note WHERE project_id = ?';
25 | $stmt = $this->pdo->prepare($q);
26 | $stmt->execute([$projectId]);
27 | return $stmt->fetchAll(PDO::FETCH_ASSOC);
28 | }
29 |
30 | /**
31 | * @param int $noteId
32 | * @param array $data
33 | * @return array
34 | */
35 | public function update($noteId, array $data)
36 | {
37 | $text = isset($data['text']) ? $data['text'] : '';
38 | $noteId = $this->sanitizeId($noteId);
39 | $q = 'UPDATE note SET text = ? WHERE id = ?';
40 | $stmt = $this->pdo->prepare($q);
41 | $stmt->execute([$text, $noteId]);
42 |
43 | $q = 'SELECT id, text, project_id FROM note WHERE id = ?';
44 | $stmt = $this->pdo->prepare($q);
45 | $stmt->execute([$noteId]);
46 |
47 | return $stmt->fetch(PDO::FETCH_ASSOC);
48 | }
49 |
50 | private function sanitizeId($rawId)
51 | {
52 | return (int) $rawId;
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/application/sockbitd.php:
--------------------------------------------------------------------------------
1 | function($data) use ($noteRepo, $announcer) {
17 | $result = $noteRepo->update($data['note_id'], $data);
18 | $announcer->announce('note_updated', $result);
19 | },
20 | 'get_notes' => function($data) use ($noteRepo, $announcer) {
21 | $projectId = $data['project_id'];
22 | $announcer->announce('project_notes', [
23 | 'project_id' => $projectId,
24 | 'notes' => $noteRepo->getAll($projectId),
25 | ]);
26 | },
27 | ];
28 |
29 | $job = function ($message) use ($noteRepo, $jobs) {
30 | $messageText = $message->body;
31 | echo 'Processing message: ' . $messageText . "\n";
32 | $decodedMessage = json_decode($messageText, true);
33 |
34 | if ($decodedMessage) {
35 | $jobName = $decodedMessage[0];
36 |
37 | if (!isset($jobs[$jobName])) {
38 | echo "Invalid message\n";
39 | } else {
40 | $toExecute = $jobs[$jobName];
41 | $toExecute($decodedMessage[1]);
42 | }
43 | } else {
44 | echo "Invalid message\n";
45 | }
46 |
47 | $message->delivery_info['channel']->basic_ack($message->delivery_info['delivery_tag']);
48 | };
49 |
50 | $jobsChannel = $rabbitConnection->channel();
51 |
52 | $jobsChannel->queue_declare('sockbit_work', false, true, false, false);
53 | $jobsChannel->basic_qos(null, 1, null);
54 | $jobsChannel->basic_consume('sockbit_work', '', false, false, false, false, $job);
55 |
56 | echo "listening for jobs on rabbit\n";
57 |
58 | while (count($jobsChannel->callbacks)) {
59 | $jobsChannel->wait();
60 | }
61 |
62 | $jobsChannel->close();
63 | $rabbitConnection->close();
64 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # SockBit: a Socket.IO + RabbitMQ proof of concept
2 |
3 | ## Blog post
4 |
5 | This code accompanies my blog post at http://dev.imagineeasy.com/post/91224992649/the-realtime-web-with-socket-io-and-rabbitmq
6 | which I have [copied into the `docs` directory in this repo](docs/blogpost.md).
7 |
8 | ## Summary
9 |
10 | This project shows how Socket.IO can be used as a browser-server message
11 | transport layer, forwarding application instructions into a messaging
12 | queue (RabbitMQ) from which a daemon-based application core can pop jobs
13 | off and perform them.
14 |
15 | In addition to allowing Socket.IO to scale without need for shared storage
16 | or syncing within Socket.IO, it also completely decouples the application
17 | logic from this browser-server message transport. This frees us from the
18 | need to implement the transport and application layers on the same platform
19 | or in the same language, as well as allowing for change down the road. We
20 | can now write the application in PHP (as here), or Hack, or Go ...
21 |
22 | We can also replace Socket.IO with the Ruby implementation of Faye, or the
23 | Python implementation of Autobahn, without affecting the application core.
24 |
25 | In this example, when the user changes the textarea value in the browser,
26 | the following occurs:
27 |
28 | * Browser sends `note_updated` message to server
29 | * Server queues an update job in RabbitMQ
30 | * Application process pops job and executes update
31 | * Application process broadcasts the update via RabbitMQ to the listening
32 | Socket.IO instances
33 | * Socket.IO notifies browsers of the update
34 |
35 | ### Not implemented
36 |
37 | * Access control
38 | * Error handling
39 | * http://en.wikipedia.org/wiki/Operational_transformation for edit conflicts
40 | * Socket.IO rooms, i.e. only forwarding the broadcasts to the browsers
41 | viewing the affected project
42 |
43 | ## Running
44 |
45 | ```bash
46 | $ vagrant ssh
47 |
48 | # in vagrant shell, start your processes, e.g.:
49 | $ cd /vagrant/
50 | $ nodejs socketio_server.js 8080 &
51 | $ nodejs socketio_server.js 8081 &
52 | $ php application/sockbit.php &
53 | ```
54 |
55 | Now point separate browser sessions to
56 |
57 | * http://33.33.34.101:8080
58 | * http://33.33.34.101:8081
59 |
--------------------------------------------------------------------------------
/application/composer.lock:
--------------------------------------------------------------------------------
1 | {
2 | "_readme": [
3 | "This file locks the dependencies of your project to a known state",
4 | "Read more about it at http://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file"
5 | ],
6 | "hash": "8e8af1d3816ca00c8c646cc81cb269c9",
7 | "packages": [
8 | {
9 | "name": "videlalvaro/php-amqplib",
10 | "version": "v2.4.0",
11 | "source": {
12 | "type": "git",
13 | "url": "https://github.com/videlalvaro/php-amqplib.git",
14 | "reference": "d8294736e47340e8d96f37d788b15f578bfc084f"
15 | },
16 | "dist": {
17 | "type": "zip",
18 | "url": "https://api.github.com/repos/videlalvaro/php-amqplib/zipball/d8294736e47340e8d96f37d788b15f578bfc084f",
19 | "reference": "d8294736e47340e8d96f37d788b15f578bfc084f",
20 | "shasum": ""
21 | },
22 | "require": {
23 | "ext-bcmath": "*",
24 | "php": ">=5.3.0"
25 | },
26 | "require-dev": {
27 | "phpunit/phpunit": "3.7.*"
28 | },
29 | "suggest": {
30 | "ext-sockets": "Use AMQPSocketConnection"
31 | },
32 | "type": "library",
33 | "autoload": {
34 | "psr-0": {
35 | "PhpAmqpLib": ""
36 | }
37 | },
38 | "notification-url": "https://packagist.org/downloads/",
39 | "license": [
40 | "LGPL-2.1"
41 | ],
42 | "authors": [
43 | {
44 | "name": "Alvaro Videla"
45 | }
46 | ],
47 | "description": "This library is a pure PHP implementation of the AMQP protocol. It's been tested against RabbitMQ.",
48 | "homepage": "https://github.com/videlalvaro/php-amqplib/",
49 | "keywords": [
50 | "message",
51 | "queue",
52 | "rabbitmq"
53 | ],
54 | "time": "2014-06-21 09:27:57"
55 | }
56 | ],
57 | "packages-dev": [],
58 | "aliases": [],
59 | "minimum-stability": "dev",
60 | "stability-flags": [],
61 | "platform": {
62 | "php": ">=5.5"
63 | },
64 | "platform-dev": []
65 | }
66 |
--------------------------------------------------------------------------------
/socketio_server.js:
--------------------------------------------------------------------------------
1 | var app = require('express')();
2 | var http = require('http').Server(app);
3 | var io = require('socket.io')(http);
4 | var amqp = require('amqplib');
5 | var when = require('when');
6 |
7 | app.get('/', function(req, rsp) {
8 | rsp.sendfile('index.html');
9 | });
10 |
11 | app.get('/index.js', function(req, rsp) {
12 | rsp.sendfile('index.js');
13 | });
14 |
15 | var jobsQueueName = 'sockbit_work',
16 | announceQueueName = 'sockbit_announce',
17 | jobsChannel;
18 |
19 | var prepRabbitAnnounceChannel = function(conn) {
20 | return conn.createChannel().then(function(channel) {
21 | channel.assertExchange(announceQueueName, 'fanout', {durable: false}).then(function() {
22 | return channel.assertQueue('', {exclusive: true});
23 | }).then(function(queueOk) {
24 | announceQueue = queueOk.queue;
25 | return channel.bindQueue(announceQueue, announceQueueName, '');
26 | });
27 |
28 | return channel;
29 | });
30 | };
31 |
32 | var forwardJob = function(jobName, socket) {
33 | console.log('registering forward of ' + jobName + ' job requests to rabbit');
34 |
35 | socket.on(jobName, function(message) {
36 | var jobString = JSON.stringify([jobName, message]);
37 | console.log('forwarding job to rabbit: ' + jobString);
38 | console.log('sending job to rabbit');
39 | jobsChannel.sendToQueue(jobsQueueName, new Buffer(jobString), {deliveryMode:true});
40 | });
41 | };
42 |
43 | amqp.connect('amqp://localhost').then(function(conn) {
44 | when(conn.createChannel()).then(function(ch) {
45 | jobsChannel = ch;
46 | return ch.assertQueue(jobsQueueName, {durable: true});
47 | }).then(function() {
48 | return conn.createChannel();
49 | }).then(function(announceChannel) {
50 | announceChannel.assertExchange(announceQueueName, 'fanout', {durable: false});
51 | return announceChannel;
52 | }).then(function(announceChannel) {
53 | var queueOk = announceChannel.assertQueue('', {exclusive: true});
54 | return [announceChannel, queueOk];
55 | }).then(function(objs) {
56 | var announceChannel = objs[0];
57 | announceQueue = objs[1].queue;
58 | announceChannel.bindQueue(announceQueue, announceQueueName, '');
59 | return [announceChannel, announceQueue];
60 | }).then(function(objs) {
61 | var announceChannel = objs[0];
62 | var announceQueue = objs[1];
63 |
64 | announceChannel.consume(announceQueue, function(message) {
65 | var update = JSON.parse(message.content);
66 | var announcementName = update[0];
67 | var data = update[1];
68 | console.log('receiving announcement from rabbit: ' + message.content);
69 | console.log('sending update to browser clients');
70 | io.emit(announcementName, data);
71 | }, {noack: true});
72 | }).then(function() {
73 | console.log('listening for announcements from rabbit');
74 |
75 | io.on('connection', function(socket) {
76 | console.log('a user connected');
77 |
78 | forwardJob('update_note', socket);
79 | forwardJob('get_notes', socket);
80 | });
81 | });
82 | });
83 |
84 | var port = process.argv[2];
85 |
86 | http.listen(port, function() {
87 | console.log('listening on port ' + port);
88 | });
89 |
--------------------------------------------------------------------------------
/Vagrantfile:
--------------------------------------------------------------------------------
1 | # -*- mode: ruby -*-
2 | # vi: set ft=ruby :
3 |
4 | # Vagrantfile API/syntax version. Don't touch unless you know what you're doing!
5 | VAGRANTFILE_API_VERSION = "2"
6 |
7 | Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
8 | # All Vagrant configuration is done here. The most common configuration
9 | # options are documented and commented below. For a complete reference,
10 | # please see the online documentation at vagrantup.com.
11 |
12 | # Every Vagrant virtual environment requires a box to build off of.
13 | config.vm.box = "ubuntu/trusty64"
14 |
15 | # Disable automatic box update checking. If you disable this, then
16 | # boxes will only be checked for updates when the user runs
17 | # `vagrant box outdated`. This is not recommended.
18 | # config.vm.box_check_update = false
19 |
20 | # Create a forwarded port mapping which allows access to a specific port
21 | # within the machine from a port on the host machine. In the example below,
22 | # accessing "localhost:8080" will access port 80 on the guest machine.
23 | # config.vm.network "forwarded_port", guest: 80, host: 8080
24 |
25 | # Create a private network, which allows host-only access to the machine
26 | # using a specific IP.
27 | config.vm.network "private_network", ip: "33.33.34.101"
28 |
29 | # Create a public network, which generally matched to bridged network.
30 | # Bridged networks make the machine appear as another physical device on
31 | # your network.
32 | # config.vm.network "public_network"
33 |
34 | # If true, then any SSH connections made will enable agent forwarding.
35 | # Default value: false
36 | # config.ssh.forward_agent = true
37 |
38 | # Share an additional folder to the guest VM. The first argument is
39 | # the path on the host to the actual folder. The second argument is
40 | # the path on the guest to mount the folder. And the optional third
41 | # argument is a set of non-required options.
42 | # config.vm.synced_folder "../data", "/vagrant_data"
43 |
44 | # Provider-specific configuration so you can fine-tune various
45 | # backing providers for Vagrant. These expose provider-specific options.
46 | # Example for VirtualBox:
47 | #
48 | # config.vm.provider "virtualbox" do |vb|
49 | # # Don't boot with headless mode
50 | # vb.gui = true
51 | #
52 | # # Use VBoxManage to customize the VM. For example to change memory:
53 | # vb.customize ["modifyvm", :id, "--memory", "1024"]
54 | # end
55 | #
56 | # View the documentation for the provider you're using for more
57 | # information on available options.
58 |
59 | # Enable provisioning with CFEngine. CFEngine Community packages are
60 | # automatically installed. For example, configure the host as a
61 | # policy server and optionally a policy file to run:
62 | #
63 | # config.vm.provision "cfengine" do |cf|
64 | # cf.am_policy_hub = true
65 | # # cf.run_file = "motd.cf"
66 | # end
67 | #
68 | # You can also configure and bootstrap a client to an existing
69 | # policy server:
70 | #
71 | # config.vm.provision "cfengine" do |cf|
72 | # cf.policy_server_address = "10.0.2.15"
73 | # end
74 |
75 | # Enable provisioning with Puppet stand alone. Puppet manifests
76 | # are contained in a directory path relative to this Vagrantfile.
77 | # You will need to create the manifests directory and a manifest in
78 | # the file default.pp in the manifests_path directory.
79 | #
80 | config.vm.provision "puppet" do |puppet|
81 | puppet.manifests_path = "manifests"
82 | puppet.manifest_file = "socket_io.pp"
83 | end
84 |
85 | # Enable provisioning with chef solo, specifying a cookbooks path, roles
86 | # path, and data_bags path (all relative to this Vagrantfile), and adding
87 | # some recipes and/or roles.
88 | #
89 | # config.vm.provision "chef_solo" do |chef|
90 | # chef.cookbooks_path = "../my-recipes/cookbooks"
91 | # chef.roles_path = "../my-recipes/roles"
92 | # chef.data_bags_path = "../my-recipes/data_bags"
93 | # chef.add_recipe "mysql"
94 | # chef.add_role "web"
95 | #
96 | # # You may also specify custom JSON attributes:
97 | # chef.json = { mysql_password: "foo" }
98 | # end
99 |
100 | # Enable provisioning with chef server, specifying the chef server URL,
101 | # and the path to the validation key (relative to this Vagrantfile).
102 | #
103 | # The Opscode Platform uses HTTPS. Substitute your organization for
104 | # ORGNAME in the URL and validation key.
105 | #
106 | # If you have your own Chef Server, use the appropriate URL, which may be
107 | # HTTP instead of HTTPS depending on your configuration. Also change the
108 | # validation key to validation.pem.
109 | #
110 | # config.vm.provision "chef_client" do |chef|
111 | # chef.chef_server_url = "https://api.opscode.com/organizations/ORGNAME"
112 | # chef.validation_key_path = "ORGNAME-validator.pem"
113 | # end
114 | #
115 | # If you're using the Opscode platform, your validator client is
116 | # ORGNAME-validator, replacing ORGNAME with your organization name.
117 | #
118 | # If you have your own Chef Server, the default validation client name is
119 | # chef-validator, unless you changed the configuration.
120 | #
121 | # chef.validation_client_name = "ORGNAME-validator"
122 | end
123 |
--------------------------------------------------------------------------------
/docs/blogpost.md:
--------------------------------------------------------------------------------
1 | # The Realtime Web with Socket.IO and RabbitMQ
2 |
3 | *cross-posted from http://dev.imagineeasy.com/post/91224992649/the-realtime-web-with-socket-io-and-rabbitmq*
4 |
5 | I have been thinking a lot about the best way to implement a realtime
6 | collaborative web app. WebSockets is the maturing tech of choice for this, and
7 | although there still seem to be some issues in terms of support, things are
8 | improving. Internet Explorer has support as of version 10. Mobile carriers and
9 | some institutional firewalls might implement some network-level impediments,
10 | but [apparently it's possible to serve WebSockets over port 443 and largely
11 | escape this
12 | problem](http://blog.hekkers.net/2012/12/09/websockets-and-mobile-network-operators/).
13 |
14 | WebSockets inherently offers a two-way connection between a single browser
15 | session and the server; the broader context of what other clients might be
16 | connected to the application (such as in a collaborative setting) is outside of
17 | the scope of WebSockets. There are several solutions already out there for
18 | broadcasting across sessions; the most interesting to my mind are
19 | [Socket.IO](http://socket.io/) (node.js), [Faye](http://faye.jcoglan.com)
20 | (node.js and Ruby), and [Autobahn](http://autobahn.ws) (node.js, Python and
21 | more). I decided to use Socket.IO to begin my investigation, based on
22 |
23 | * the high visibility of Socket.IO as per Google Trends,
24 | * a [high-profile adoption by the Trello team](http://blog.fogcreek.com/the-trello-tech-stack/), as well as
25 | * their fairly well-documented falling-out
26 | * and my personal preference at first glance for its API.
27 |
28 | **Disclaimer:** This is my first experience working with node.js, so
29 |
30 | * don't assume I know what I'm doing, and copy-n-paste my code
31 | * go easy :}
32 |
33 | ## Scalability
34 |
35 | As I was proceeding with research, the one class of thing that kept popping up
36 | in blogs, etc. was scalability issues. I was not really interested in solving
37 | those problems within Socket.IO or figuring out if it is still an issue with
38 | the current release; for now I'm just maintaining [a list of articles about the
39 | topic](https://pinboard.in/u:yitznewton/t:socketio/t:scalability).
40 |
41 | Instead, I looked for a way to incorporate Socket.IO into our stack without
42 | putting any scaling pressure on it directly.
43 |
44 | ## Solution: use Socket.IO as a dumb transport
45 |
46 | My experimental solution is to maintain a cluster of Socket.IO processes whose
47 | only function is to maintain the browser connections, and shuttle messages to
48 | and from a message queue. With this arrangement, each Socket.IO process is
49 | independent of the others, so there is no shared backend or storage to scale.
50 | These messages constitute both incoming job requests (e.g. "hey server, update
51 | a particular entity"), and outgoing announcements ("hey everyone, entity 123
52 | has been updated"). I chose [RabbitMQ](http://www.rabbitmq.com/) as the
53 | messaging server for this experiment.
54 |
55 | **Caveat:** I have not yet devised a testing plan to compare this architecture
56 | with a "plain" Socket.IO setup, so I can't say whether this actually makes
57 | anything better, only that it avoids any poor design that might have hampered
58 | multi-process Socket.IO installations.
59 |
60 | ## Bonus: forced decoupling of application code
61 |
62 | An extremely useful bonus involved the application code, as distinct from the
63 | transport layer. This insight might even be worth more to me than any
64 | Socket.IO-specific benefits.
65 |
66 | I had been concerned, with these node.js-based server scenarios, about the idea
67 | of implementing an application server in JavaScript. Our team's server-side
68 | specialists (including me) don't have deep experience with node or JS in
69 | general, and with my bias toward type safety features, I personally have
70 | misgivings about writing complex things in JavaScript, as opposed to our usual
71 | mode of somewhat-type-safe PHP. (I discovered that
72 | [@vanbosse](http://twitter.com/vanbosse) utilized this same concept in [his
73 | post dealing with a very similar application of
74 | RabbitMQ](http://vanbosse.be/blog/detail/pub-sub-with-rabbitmq-and-websocket).)
75 |
76 | When we treat all requests and responses as generic, context-agnostic messages,
77 | and let the *application core* pull them off a queue and "report back" in this
78 | generic way (as opposed to specialized ones like `HttpRequest`/`HttpResponse`),
79 | we force ourselves to decouple the application code quite starkly from the
80 | transport layer.
81 |
82 | In most "MVC framework" situations like working with Rails or Symfony, the best
83 | you can hope for is to consciously keep your code decoupled from the web
84 | framework you're using, and that takes discipline. Here, however, our point of
85 | departure is a "message-in, message-out" application -- pretty much perfectly
86 | echoing the Uncle Bobbian [Clean
87 | Architecture](http://blog.8thlight.com/uncle-bob/2012/08/13/the-clean-architecture.html).
88 | (See also [Gary Bernhardt's
89 | talk](https://www.destroyallsoftware.com/talks/boundaries) on boundaries for
90 | more architecture porn.) Not only have we made ourselves implement an ideal
91 | interface for testability, we have also freed ourselves to make open decisions
92 | about the messaging stack, and even change our minds later. Socket.IO not
93 | working out? Just swap in Faye, and all you need to do is write a new messaging
94 | adapter for the Faye-RabbitMQ interface. The application itself is unchanged.
95 |
96 | ## Proof of concept: SockBit
97 |
98 | To demonstrate this approach, I have created a [prototype note-editing
99 | application, SockBit](https://github.com/yitznewton/sockbit). In function, it
100 | is sort of like an unfinished 5% of Trello. It uses this stack to allow users
101 | to edit notes collaboratively in realtime; when one user edits a note and blurs
102 | the input, the change is sent to the server, where it is persisted, and then
103 | pushed up to other clients. Here's a screencast of me demonstrating it.
104 |
105 | http://www.youtube.com/watch?v=Z8BHrZUPKI0
106 |
107 | ## Conclusion
108 |
109 | This was a really fun exercise, and has served as a great demonstration of the
110 | possibilities of Clean Architecture in action. The next step will be to see if
111 | I can test the scalability of this architecture in comparison with a straight
112 | Socket.IO version. My next stop will be checking out [PhantomJS at
113 | scale](http://sorcery.smugmug.com/2013/12/17/using-phantomjs-at-scale/).
114 |
--------------------------------------------------------------------------------