Simple PHP Websocket Application with Ratchet [Part 2 – Frontend]

11. February 2016 Blog 4

This is the second part of this post. If you missed the first part, we wrote all the PHP code for the WebSocket server and got it running. We are going to work on the frontend in this post. If you haven’t read part one, I would read it first.

Simple PHP Websocket Application with Ratchet [Part 1 – Server Side]

First, let’s go to our project directory and create a new directory in it called web. We will use Bower to get some dependencies such as jQuery and Bootstrap. Run the following to initialize a Boer config file:

bower init

Once done, we should have a bower.json that looks like this:

{
  "name": "PickMe",
  "authors": [
    "Nate Denlinger <test@test.com>"
  ],
  "description": "A simple pickme app",
  "main": "",
  "moduleType": [],
  "license": "MIT",
  "homepage": "",
  "private": true,
  "ignore": [
    "**/.*",
    "node_modules",
    "bower_components",
    "test",
    "tests"
  ],
  "dependencies": {
    "jquery": "^2.2.0",
    "bootstrap": "^3.3.6"
  }
}

At this point you should have jQuery and Bootstrap downloaded into web/bower_components. Now we simply need to create our index.html in the web directory. Here is what my final index.html looks like.

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <!-- The above 3 meta tags *must* come first in the head; any other head content must come *after* these tags -->
    <title>People Picker</title>

    <!-- Bootstrap -->
    <link href="bower_components/bootstrap/dist/css/bootstrap.min.css" rel="stylesheet">
    <link href="style.css" rel="stylesheet">

    <!-- HTML5 shim and Respond.js for IE8 support of HTML5 elements and media queries -->
    <!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
    <!--[if lt IE 9]>
      <script src="https://oss.maxcdn.com/html5shiv/3.7.2/html5shiv.min.js"></script>
      <script src="https://oss.maxcdn.com/respond/1.4.2/respond.min.js"></script>
    <![endif]-->
</head>

<body>
    <div class="container-fluid" id="main">
        <h1>Picker</h1>

        <div id="nonadmin">
            Your Avatar: <img id="my_avatar" src="" />
        </div>

        <div id="admin" style="display: none;">
            <button class="btn btn-primary" id="pick">Pick</button>
            <button class="btn btn-primary" id="reset">Reset</button>
        </div>
        <div id="waiting_room">
            <h2>WHO IS HERE</h2><br>
            <h4>Current Connections: <span id="number">0</span></h4><br>
            <div id="waiting_room_members"></div>
        </div>

        <div class="modal fade" tabindex="-1" role="dialog" id="winner">
            <div class="modal-dialog">
                <div class="modal-content">
                    <div class="modal-body">
                        <h1>And the winner is...</h1>
                        <img src="" id="winning_avatar" />
                        <h1 id="win_message"></h1>
                    </div>
                </div><!-- /.modal-content -->
            </div><!-- /.modal-dialog -->
        </div><!-- /.modal -->
    </div>
    <!-- jQuery (necessary for Bootstrap's JavaScript plugins) -->
    <script src="bower_components/jquery/dist/jquery.min.js"></script>
    <!-- Include all compiled plugins (below), or include individual files as needed -->
    <script src="bower_components/bootstrap/dist/js/bootstrap.min.js"></script>

    <script>

        $(document).ready(function(){
            var is_admin = false;
            var hash = window.location.hash.substring(1);
            if(hash == 'admin') {
                $('#admin').show();
                $('#nonadmin').hide();
                is_admin = true;
            }

            var conn = new WebSocket('ws://localhost:8282');
            var body = $('body');
            var waiting = $('#waiting_room_members');
            conn.onopen = function(e) {
                sendMsg({event:'connect',is_admin: is_admin});
            };

            conn.onmessage = function(e) {
                var data = JSON.parse(e.data);
                if(data.event == 'connect') {
                    eventConnect(data);
                } else if (data.event == 'pick') {
                    eventPick(data);
                } else if (data.event == 'reset') {
                    eventReset();
                } else if (data.event == 'connected') {
                    $('#my_avatar').attr('src',data.avatar);
                }
            };

            var eventReset = function() {
                body.css('background-color','white');
                $('#winner').modal('hide');
                $('body').removeClass('modal-open');
                $('.modal-backdrop').remove();
            };
            var eventPick = function(data) {
                $('#winner').modal('show');
                $('#winning_avatar').attr('src',data.winning_avatar);
                if(!is_admin) {
                    if (data.winner) {
                        body.css('background-color', 'green');
                        $('#win_message').html('YOU WIN!');
                    } else {
                        body.css('background-color', 'red');
                        $('#win_message').html("Sorry you weren't picked.");
                    }
                }
            };
            var eventConnect = function(data) {
                waiting.html('');
                var html = '';
                var num_users = 0;
                for(key in data.clients) {
                    if(!data.clients.hasOwnProperty(key)) continue;
                    if(data.clients[key].is_admin == false) {
                        num_users++;
                        html += "<img src='" + data.clients[key].avatar + "' />";
                    }
                }
                waiting.html(html);
                $('#number').html(num_users);
            };

            var sendMsg = function(obj) {
                conn.send(JSON.stringify(obj));
            };

            $('#pick').click(function(){
                sendMsg({event:'pick'});
            });
            $('#reset').click(function(){
                sendMsg({event:'reset'});
            });
        });
    </script>
</body>

</html>

Let’s go through the JavaScript code. Sorry. I’m not going to go through the HTML. First off, let me warn you I am by no means a frontend guru. While I can do frontend code I spend much more time on the server side.

First thing I do is create a variable called is_admin that by default is set to false. I check the URL, and if the URL has #admin at the end then I know it is an administrator. I then display the admin controls and hide the non-admin stuff, which is just the user’s avatar, as there is no need for an admin to have an avatar. That is what this section of the code is doing:

var is_admin = false;
var hash = window.location.hash.substring(1);
if(hash == 'admin') {
    $('#admin').show();
    $('#nonadmin').hide();
    is_admin = true;
}

Next we need to connect to our WebSocket server. So we use:

var conn = new WebSocket('ws://localhost:8282');
var body = $('body');
var waiting = $('#waiting_room_members');
conn.onopen = function(e) {
    sendMsg({event:'connect',is_admin: is_admin});
};

First, we use the native JS WebSocket object and pass in the address of the WebSocket server. Then we store that in a variable conn. This will initiate the onOpen() method on our WebSocket server. Once it receives a response from the server saying it has connected correctly, the conn.onopen event fires. Here is where we send our first message to the server. This event is the connect event. It lets the server know if it is an admin user or not. There are also two other jQuery selectors in this section just so I don’t have to reselect them each time. Nothing special.

The next section of code is the message listener for conn. This gets fired every time conn receives a message back from the server. We specified four different events in our server code: connect, pick, reset, and connected. Here, I just check to see what type of event it is and either call a function or run some code. Here is that section of code:

conn.onmessage = function(e) {
    var data = JSON.parse(e.data);
    if(data.event == 'connect') {
        eventConnect(data);
    } else if (data.event == 'pick') {
        eventPick(data);
    } else if (data.event == 'reset') {
        eventReset();
    } else if (data.event == 'connected') {
        $('#my_avatar').attr('src',data.avatar);
    }
};

First thing I do is get the message from the object returned in the callback as e. The data sent by the server is stored in e.data, which by default is a string. So we need to use JSON.parse() to convert it to an object we can work with. Next, check to see which event it is by looking at data.event. I split connect, pick, and reset off into other functions so that the code is more readable. The connected event only gets called once when a user first connects, and it simply returns the user’s avatar. I find the #my_avatar element and set its src to the URL returned. Now let’s look at the eventConnect() function. Here is the code for it:

var eventConnect = function(data) {
    waiting.html('');
    var html = '';
    var num_users = 0;
    for(key in data.clients) {
        if(!data.clients.hasOwnProperty(key)) continue;
        if(data.clients[key].is_admin == false) {
            num_users++;
            html += "<img src='" + data.clients[key].avatar + "' />";
        }
    }
    waiting.html(html);
    $('#number').html(num_users);
};

Remember, the connect event gets sent by the server when a new user connects or when a user disconnects. All this function does is update the display of users connected. We reset the HTML of the #waiting DOM element to nothing. Then, we cycle through the list of clients and check if they are an admin or a user. If they are a user, we simply create an <img> with their avatar as the source. We also keep track of the number of normal users so we can display that also. Finally, we update the #waiting element with the generated HTML and update the HTML that displays the number of users connected, which is #number. Now let’s look at the eventPick() function:

var eventPick = function(data) {
    $('#winner').modal('show');
    $('#winning_avatar').attr('src',data.winning_avatar);
    if(!is_admin) {
        if (data.winner) {
            body.css('background-color', 'green');
            $('#win_message').html('YOU WIN!');
        } else {
            body.css('background-color', 'red');
            $('#win_message').html("Sorry you weren't picked.");
        }
    }
};

First, we tell the #winner Bootstrap modal to show and set the #winning_avatar image src to the winning_avatar sent by the server. Next, I check if the current user is an admin or not as the display is a little different for them. The admin’s screen doesn’t turn green or red as they never win or lose, and we don’t need to say “YOU WIN!” or “Sorry…”. If the user isn’t an admin then we check to see if they are a winner or not and either change the background color to green or red, and give them a message letting them know if they won or not. Finally, let’s look at the eventReset() function.

var eventReset = function() {
    body.css('background-color','white');
    $('#winner').modal('hide');
    $('body').removeClass('modal-open');
    $('.modal-backdrop').remove();
};

All we do here is reset the background color to white, hide the winner modal and a few other fixes to make sure the Bootstrap modal backdrop disappears. Alright. Last little bit of code:

var sendMsg = function(obj) {
    conn.send(JSON.stringify(obj));
};
$('#pick').click(function(){
    sendMsg({event:'pick'});
});
$('#reset').click(function(){
    sendMsg({event:'reset'});
});

These functions only get called when an admin clicks either the “Pick” button or the “Reset” button. Each listener listens for a click and then uses the sendMsg() function to send either a pick or reset event. sendMsg() also just needs to make sure it converts it to JSON before it sends it to the server using our conn and using the send() function.

Thats it! We are done. I’ve added some CSS to mine to make the UI a little more user friendly. You can see that and all the rest of code in my GitHub repo.

You can view a live example here and you can view the admin here.

Hopefully that will help you get your feet wet with PHP WebSockets.


4 thoughts on “Simple PHP Websocket Application with Ratchet [Part 2 – Frontend]”

  • 1
    eduardo on February 7, 2017 Reply

    Hi Nate, very nice and clarifying post!
    Many of my doubts got answered, just one keep bugging my mind, about performance.
    I read one quora and stackoverflow issues about performance and number of concurrent users, some are saying that node + mongo is the best fit.
    How about you?
    Are you storing some data in MySql? How many users do you think this model of app can handle.
    I am working on a large scale chat platform for more than 50.000 unique users / month.
    I would love hearing you experience.

    • 2
      pitchinnate on February 7, 2017 Reply

      I also recently built a large chat client with PHP/Ratchet. It was able to handle a pretty large load even on a AWS nano server (1 CPU/512MB of RAM).

      It is hard to load test a websocket application. I did figure out how to use JMeter and had a couple hundred connections all sending and receiving messages without a problem.

      In this chat application yes I was storing all messages in a MySQL database. However, you basically are only doing writes to the database. The only time you have to read from the database is when someone first logs in and needs to get a history of the messages. Other than that, messages come in and then simply get echoed to other clients, no need to read from the database.

      Before I started on my chat application I did some research to make sure it was a good idea to use PHP. One thing I found is that Slack which is a huge chat platform is also built on PHP. That really helped put my mind at ease. It wasn’t worth it to me to get proficient with node and mongo. I try to always avoid premature optimization.

      This is a good read: https://slack.engineering/taking-php-seriously-cf7a60065329#.sjznslk3n

  • 3
    eduardo on February 8, 2017 Reply

    Thank you for your absolutely quick reply good t know that Slack run on MySql, I believe that any SQL Server well structured can work fine and fast.

    Do you have a clue if Zopim and other onpage chats uses socktes? Some clearly are using ajax. Do you know if is a browser issue or a Firewall issue, some companies may close some no usual ports.

    Just another simple question that I did not find an clear answer: how about ratchet and some simple socket server? In a few lines of code we can easly deploy a socket server to handle incoming and out coming messages. What is the clear benefit of using Ratchet?

    • 4
      pitchinnate on February 8, 2017 Reply

      I would guess that almost all chat platforms today run on Websockets. In the past I have built chat apps with AJAX also, that utilize long polling. However, this method is insanely heavy on server resources compared to websockets. I can’t imagine there would be many left that would rely on ajax/long polling technology.

      I utilize port 80/443 for my websockets, then I use nginx to Proxy pass it to the actual websocket server running. The first websocket application I built had the exact problem you described so I found out how to use the proxy method.

      Ratchet simply provides you with a nice library to handle setting up and running the WebSocket server. Can you do it yourself without it? Sure, but why recreate the wheel? It has years worth of development and testing on it. Once again I try to avoid premature optimization.

Leave a Reply

Your email address will not be published. Required fields are marked *