Writing a Chat Application in (less than) 50 (readable!) Lines!

Joel Berger

About this talk

Thank You

servercentral.com

About me

  • Ph.D. in Physics
  • Work at ServerCentral
  • That guy that loves Perl
  • That guy that loves PostgreSQL
  • That guy that is learning to tolerate JS
  • On the core development team of Mojolicious

Let's Make a Chat Client

Intentions

  • Minimal working code
  • Not the best code
  • Show how the components interact

What is Mojolicious?

  • An amazing real-time web framework
  • A powerful web development toolkit
  • Designed from the ground up
  • ... based on years of experience developing Catalyst
  • Portable
  • No non-core dependencies
  • Batteries included
  • Real-time and non-blocking
  • Native Websocket integration
  • 8711 lines of code in lib
  • 11512 tests (94.1% coverage)
  • Easy to install (secure, only takes one minute!)
curl -L https://cpanmin.us | perl - -M https://cpan.metacpan.org -n Mojolicious

Getting Help


use Mojolicious::Lite;
use experimental 'signatures';

get '/' => 'chat';

my %subscribers;

websocket '/channel' => sub ($c) {
  $c->inactivity_timeout(3600);

  # Add controller to hash of subscribers
  $subscribers{$c} = $c;

  # Forward messages from the browser to all subscribers
  $c->on(message => sub ($c, $message) {
    $_->send($message) for values %subscribers;
  });

  # Remove controller from subscribers on close
  $c->on(finish => sub ($c, @) { delete $subscribers{$c} });
};

app->start;
__DATA__

@@ chat.html.ep
<form onsubmit="sendChat(this.children[0]); return false"><input></form>
<div id="log"></div>
<script>
var ws  = new WebSocket('<%= url_for('channel')->to_abs %>');
ws.onmessage = function (e) {
  document.getElementById('log').innerHTML += '<p>' + e.data + '</p>';
};
function sendChat(input) { ws.send(input.value); input.value = '' }
</script>
    

ex/mojo_basic_chat.pl


websocket '/channel' => sub ($c) {
  $c->inactivity_timeout(3600);

  # Add controller to hash of subscribers
  $subscribers{$c} = $c;

  # Forward messages from the browser to all subscribers
  $c->on(message => sub ($c, $message) {
    $_->send($message) for values %subscribers;
  });

  # Remove controller from subscribers on close
  $c->on(finish => sub ($c, @) { delete $subscribers{$c} });
};    

ex/mojo_basic_chat.pl


<form onsubmit="sendChat(this.children[0]); return false"><input></form>
<div id="log"></div>
<script>
var ws  = new WebSocket('<%= url_for('channel')->to_abs %>');
ws.onmessage = function (e) {
  document.getElementById('log').innerHTML += '<p>' + e.data + '</p>';
};
function sendChat(input) { ws.send(input.value); input.value = '' }
</script>    

ex/mojo_basic_chat.pl


use Mojo::Base -strict;

use Test::More;
use Test::Mojo;

require './ex/mojo_basic_chat.pl';

my $t = Test::Mojo->new;

$t->get_ok('/')->status_is(200);

$t->websocket_ok('/channel')
  ->send_ok('is this thing on?')
  ->message_ok
  ->message_is('is this thing on?')
  ->finish_ok;

done_testing;
    

ex/mojo_basic_chat.t

Scaling Out

Single Threaded Server

Multi-process Server

Multi-process Server

PostgreSQL Database

  • "The world's most advanced open source database"
  • Lots of nice features, including ...
  • Native JSON types (competitive with nosql)
  • Message broker capability

Add a Postgres Message Broker


websocket '/channel' => sub ($c) {
  $c->inactivity_timeout(3600);

  # Add controller to hash of subscribers
  $subscribers{$c} = $c;

  # Forward messages from the browser to all subscribers
  $c->on(message => sub ($c, $message) {
    $_->send($message) for values %subscribers;
  });

  # Remove controller from subscribers on close
  $c->on(finish => sub ($c, @) { delete $subscribers{$c} });
};    

ex/mojo_basic_chat.pl


helper pg => sub { state $pg = Mojo::Pg->new('postgresql://test:test@/test') };

websocket '/channel' => sub ($c) {
  $c->inactivity_timeout(3600);

  # Forward messages from the browser to PostgreSQL
  $c->on(message => sub ($c, $message) {
    $c->pg->pubsub->notify(mojochat => $message);
  });

  # Forward messages from PostgreSQL to the browser
  my $cb = sub ($pubsub, $message) { $c->send($message) };
  $c->pg->pubsub->listen(mojochat => $cb);

  # Remove callback from PG listeners on close
  $c->on(finish => sub ($c, @) {
    $c->pg->pubsub->unlisten(mojochat => $cb);
  });
};    

ex/mojo_pg_chat.pl

Front End Framework

Why?

  • DOM binding/updating
  • Composable/reusabled components
  • Separation of concerns

Vue.js

  • Shallow learning curve
  • Unobtrusive (gradual inclusion)
  • DOM binding is automatic
  • Great documentation

Substitute Vue.js


<form onsubmit="sendChat(this.children[0]); return false"><input></form>
<div id="log"></div>
<script>
var ws  = new WebSocket('<%= url_for('channel')->to_abs %>');
ws.onmessage = function (e) {
  document.getElementById('log').innerHTML += '<p>' + e.data + '</p>';
};
function sendChat(input) { ws.send(input.value); input.value = '' }
</script>    

ex/mojo_basic_chat.pl


<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.0.5/vue.js"></script>
<div id="chat">
  <form @submit.prevent="send"><input v-model="current"></form>
  <div id="log"><p v-for="m in messages">{{m}}</p></div>
</div>
<script>
var ws = new WebSocket('<%= url_for('channel')->to_abs %>');
var vm = new Vue({
  el: '#chat',
  data: {
    current: '',
    messages: [],
  },
  methods: {
    send: function() {
      ws.send(this.current);
      this.current = '';
    },
  },
});
ws.onmessage = function (e) { vm.messages.push(e.data) };
</script>    

ex/vue_chat.pl

Add a Feature (username)


<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.0.5/vue.js"></script>
<div id="chat">
  Username: <input v-model="username"><br>
  Send: <input @keydown.enter="send" v-model="current"><br>
  <div id="log">
    <p v-for="m in messages">{{m.username}}: {{m.message}}</p>
  </div>
</div>
<script>
var ws = new WebSocket('<%= url_for('channel')->to_abs %>');
var vm = new Vue({
  el: '#chat',
  data: {
    current: '',
    messages: [],
    username: '',
  },
  methods: {
    send: function() {
      var data = {username: this.username, message: this.current};
      ws.send(JSON.stringify(data));
      this.current = '';
    },
  },
});
ws.onmessage = function (e) { vm.messages.push(JSON.parse(e.data)) };
</script>    

ex/vue_chat_user.pl

Finally


use Mojolicious::Lite;
use Mojo::Pg;
use experimental 'signatures';

helper pg => sub { state $pg = Mojo::Pg->new('postgresql://test:test@/test') };

get '/' => 'chat';

websocket '/channel' => sub ($c) {
  $c->inactivity_timeout(3600);

  # Forward messages from the browser to PostgreSQL
  $c->on(message => sub ($c, $message) {
    $c->pg->pubsub->notify(mojochat => $message);
  });

  # Forward messages from PostgreSQL to the browser
  my $cb = sub ($pubsub, $message) { $c->send($message) };
  $c->pg->pubsub->listen(mojochat => $cb);

  # Remove callback from PG listeners on close
  $c->on(finish => sub ($c, @) {
    $c->pg->pubsub->unlisten(mojochat => $cb);
  });
};

app->start;
__DATA__

@@ chat.html.ep
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.0.5/vue.js"></script>
<div id="chat">
  Username: <input v-model="username"><br>
  Send: <input @keydown.enter="send" v-model="current"><br>
  <div id="log">
    <p v-for="m in messages">{{m.username}}: {{m.message}}</p>
  </div>
</div>
<script>
var ws = new WebSocket('<%= url_for('channel')->to_abs %>');
var vm = new Vue({
  el: '#chat',
  data: {
    current: '',
    messages: [],
    username: '',
  },
  methods: {
    send: function() {
      var data = {username: this.username, message: this.current};
      ws.send(JSON.stringify(data));
      this.current = '';
    },
  },
});
ws.onmessage = function (e) { vm.messages.push(JSON.parse(e.data)) };
</script>
    

ex/vue_chat_user.pl

That's 47 lines

(generated using David A. Wheeler's 'SLOCCount')

Thank you!

Any Questions?

Vue Components


Vue.component('chat-msg', {
  template: '<p>{{username}}: {{message}}</p>',
  props: {
    username: { type: String, required: true },
    message:  { type: String, default: '' },
  },
});    

ex/vue_chat_comp.pl


Vue.component('chat-entry', {
  template: '<input @keydown.enter="message" v-model="current">',
  data: function() { return { current: '' } },
  methods: {
    message: function() {
      this.$emit('message', this.current);
      this.current = '';
    },
  },
});    

ex/vue_chat_comp.pl


<div id="chat">
  Username: <input v-model="username"><br>
  Send: <chat-entry @message="send"></chat-entry><br>
  <div id="log">
    <chat-msg v-for="m in messages" :username="m.username" :message="m.message"></chat-msg>
  </div>
</div>    

ex/vue_chat_comp.pl


var vm = new Vue({
  el: '#chat',
  data: { messages: [], username: '', ws: null },
  methods: {
    connect: function() {
      var self = this;
      self.ws = new WebSocket('<%= url_for('channel')->to_abs %>');
      self.ws.onmessage = function (e) { self.messages.push(JSON.parse(e.data)) };
    },
    send: function(message) {
      var data = {username: this.username, message: message};
      this.ws.send(JSON.stringify(data));
    },
  },
  created: function() { this.connect() },
});    

ex/vue_chat_comp.pl

Finally


use Mojolicious::Lite;
use Mojo::Pg;
use experimental 'signatures';

helper pg => sub { state $pg = Mojo::Pg->new('postgresql://test:test@/test') };

get '/' => 'chat';

websocket '/channel' => sub ($c) {
  $c->inactivity_timeout(3600);

  # Forward messages from the browser to PostgreSQL
  $c->on(message => sub ($c, $message) {
    $c->pg->pubsub->notify(mojochat => $message);
  });

  # Forward messages from PostgreSQL to the browser
  my $cb = sub ($pubsub, $message) { $c->send($message) };
  $c->pg->pubsub->listen(mojochat => $cb);

  # Remove callback from PG listeners on close
  $c->on(finish => sub ($c, @) {
    $c->pg->pubsub->unlisten(mojochat => $cb);
  });
};

app->start;
__DATA__

@@ chat.html.ep
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.0.5/vue.js"></script>
<div id="chat">
  Username: <input v-model="username"><br>
  Send: <chat-entry @message="send"></chat-entry><br>
  <div id="log">
    <chat-msg v-for="m in messages" :username="m.username" :message="m.message"></chat-msg>
  </div>
</div>
<script>
Vue.component('chat-entry', {
  template: '<input @keydown.enter="message" v-model="current">',
  data: function() { return { current: '' } },
  methods: {
    message: function() {
      this.$emit('message', this.current);
      this.current = '';
    },
  },
});
Vue.component('chat-msg', {
  template: '<p>{{username}}: {{message}}</p>',
  props: {
    username: { type: String, required: true },
    message:  { type: String, default: '' },
  },
});
var vm = new Vue({
  el: '#chat',
  data: { messages: [], username: '', ws: null },
  methods: {
    connect: function() {
      var self = this;
      self.ws = new WebSocket('<%= url_for('channel')->to_abs %>');
      self.ws.onmessage = function (e) { self.messages.push(JSON.parse(e.data)) };
    },
    send: function(message) {
      var data = {username: this.username, message: message};
      this.ws.send(JSON.stringify(data));
    },
  },
  created: function() { this.connect() },
});
</script>
    

ex/vue_chat_comp.pl