├── .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 | --------------------------------------------------------------------------------