PHP Websockets Tutorial
Websockets are not fully implemented yet so it is very hard to find a decent tutorial on the internet. I once found a tutorial(phpWebSockets by Moritz Wutz and http://srchea.com/blog/2011/12/build-a-real-time-application-using-html5-websockets/) that used classes to implement a pretty good WebSockets server. However that tutorial provided the code with bugs and not a lot of explanation as to how it works and how can you add more to it. I decided that if I wanted to make my own WS server my best bet would be to take that code and upgrade it so that it works and acts as I want it to. After I got the prototype working I decided I need to share my code with others so that they won't have to go through all that trouble again. This tutorial is not meant to teach you how to write your own server, merely it explains how my code works.
Firts off we need some basic HTML structure. This is just the code the original tutorial provided striped off of the unnecessary parts.
1
<div id="wrapper">
2
<div id="container">
3
<h1>WebSockets Client</h1>
4
<button id="disconnect">Disconnect</button>
5
<span id="status"></span>
6
<span id="ping"></span>
7
<div id="chatLog">
8
9
</div>
10
<br />
11
<input id="text" type="text" placeholder="type in message"/>
12
</div>
13
<div id="users">
14
15
</div>
16
</div>
Then the css which is nothing special, again almost left unchaged.
1
body{font-family:Arial, Helvetica, sans-serif;}
2
#container{
3
border:5px solid grey;
4
width:670px;
5
margin:0 auto;
6
padding:10px;
7
float:left;
8
}
9
#users {
10
float:right;
11
width:220px;
12
border: 2px solid grey;
13
padding: 2px 0 2px 4px;
14
}
15
#chatLog{
16
padding:5px;
17
border:1px solid black;
18
height: 400px;
19
overflow: auto;
20
}
21
#chatLog p {
22
margin:0;
23
}
24
.event{
25
color: #999999;
26
}
27
.warning{
28
font-weight:bold;
29
color: #CCC;
30
}
Javascript
I used JQuery library in this project.
1
$(document).ready(function()
2
{
3
$('#chatLog, input, button, #examples').hide();
4
if(!("WebSocket" in window)){
5
$('<p>Oh no, you need a browser that supports WebSockets. How about <a href="http://www.google.com/chrome">Google Chrome</a>?</p>').appendTo('#container');
6
}
7
else
8
{
9
//The user has WebSockets
10
var socket;
11
var host = "ws://109.255.177.28:8080";
12
var reconnect=true;
13
var ping;
14
var pingInt;
15
16
function wsconnect()
17
{
18
try{
19
socket = new WebSocket(host);
20
socket.onopen = function(){
21
status('<p class="event">Socket Status: '+socket.readyState+' (open)');
22
socket.send('login: '+name);
23
pingInt = setInterval(ping, 1000);
24
}
25
26
socket.onerror = function(error){
27
status('<p class="event">Socket Status: '+socket.readyState+' ('+error+')');
28
}
29
30
//receive message
31
socket.onmessage = function(msg){
32
var data = JSON.parse(msg.data);
33
if(data.message!=undefined)
34
{
35
message('<p class="message">'+data.message+'</p>');
36
}
37
if(data.ping!=undefined)
38
{
39
var time = new Date().getTime();
40
time = time - ping;
41
$('#ping').html('Ping: '+time+'ms');
42
}
43
if(data.users!=undefined)
44
{
45
var str = "";
46
var i;
47
for(i in data.users)
48
{
49
str += data.users[i]+'<br />';
50
}
51
$('#users').html('<h2>Users loged in:</h2>'+str);
52
}
53
}
54
55
socket.onclose = function(e){
56
clearInterval(pingInt);
57
if(reconnect){
58
wsconnect();
59
}
60
status('<p class="event">Socket Status: '+socket.readyState+' (Closed)');
61
}
62
63
} catch(exception){
64
message('<p>Error '+exception);
65
}
66
}
67
//End connect()
68
69
function ping()
70
{
71
if(reconnect)
72
{
73
ping = new Date().getTime();
74
socket.send('ping');
75
}
76
}
77
78
function send()
79
{
80
var text = $('#text').val();
81
if(text==""){
82
alert('Please enter a message');
83
return ;
84
}
85
try{
86
socket.send(text);
87
message('<p class="event">'+name+': '+text)
88
} catch(exception){
89
message('<p class="warning">'+exception);
90
}
91
$('#text').val("");//clear text input field
92
}
93
94
function message(msg)
95
{
96
$('#chatLog').append(msg);
97
$('#chatLog').scrollTop($('#chatLog')[0].scrollHeight);
98
}
99
100
function status(msg)
101
{
102
$('#status').html(msg+'</p>');
103
}
104
105
$('#text').keypress(function(event)
106
{
107
if (event.keyCode == '13') {
108
send();
109
}
110
});
111
112
$('#disconnect').click(function()
113
{
114
reconnect=false;//dont try to reconnect again
115
socket.close();
116
});
117
118
var name='';
119
while(name=='')
120
name = prompt("Your name:");
121
if(name!=null)
122
{
123
$('#chatLog, input, button, #examples').fadeIn("fast");
124
wsconnect();
125
}
126
}
127
});
Ok, so there's our client. First off we check if the browser supports WebSockets. We obviously need an address to which we will connect:
var
host
=
"ws://109.255.177.28:8080"
;
The function 'wsconnect' is the main function, when you call it the browser will try to estabilish connection.
Function named 'send' is called when there is a message to be send from the user to the server and 'socket.send(text);' takes care of that, the rest is just checking if there's any input.
The other functions are just for easier putting/getting data on our website and they are self explanatory.
socket.onmessage
= function(
msg
){
That method is called whenever a message comes from the server. I use JSON to pass data as it is the easient way to manage data transmitted between PHP and JavaScript IMHO. 'data' contains the data from server already encoded. The if statement checks what kind of data was received and carries out approbiate action(plain message, ping response, info about logged on users).
socket.onclose = function(e){
The onclose method is called if the connection has been terminated. If the connection was not terminated by user it tries to connect again.
PHP
The socket.class.php file and the daemon file was almost left untouched by me so I will not bother with explanation.
I will only discuss the most important parts of the main file.
1
private function run()
2
{
3
while(true)
4
{
5
$this->i++;
6
# because socket_select gets the sockets it should watch from $changed_sockets
7
# and writes the changed sockets to that array we have to copy the allsocket array
8
# to keep our connected sockets list
9
$changed_sockets = $this->allsockets;
10
11
# blocks execution until data is received from any socket
12
//wait 1ms(1000us) - should theoretically put less pressure on the cpu
13
$num_sockets = socket_select($changed_sockets,$write=NULL,$exceptions=NULL,0,1000);
14
15
# foreach changed socket...
16
foreach( $changed_sockets as $socket )
17
{
18
# master socket changed means there is a new socket request
19
if( $socket==$this->master )
20
{
21
# if accepting new socket fails
22
if( ($client=socket_accept($this->master)) < 0 )
23
{
24
$this->console('socket_accept() failed: reason: ' . socket_strerror(socket_last_error($client)));
25
continue;
26
}
27
# if it is successful push the client to the allsockets array
28
else
29
{
30
$this->allsockets[] = $client;
31
32
# using array key from allsockets array, is that ok?
33
# i want to avoid the often array_search calls
34
$socket_index = array_search($client,$this->allsockets);
35
$this->clients[$socket_index] = new stdClass;
36
$this->clients[$socket_index]->socket_id = $client;
37
38
$this->console($client . ' CONNECTED!');
39
}
40
}
41
# client socket has sent data
42
else
43
{
44
$socket_index = array_search($socket,$this->allsockets);
45
$user = $this->users[$socket_index];
46
47
# the client status changed, but theres no data ---> disconnect
48
$bytes = @socket_recv($socket,$buffer,2048,0);
49
if( $bytes === 0 )
50
{
51
$this->disconnected($socket);
52
}
53
# there is data to be read
54
else
55
{
56
# this is a new connection, no handshake yet
57
if( !isset($this->handshakes[$socket_index]) )
58
{
59
$this->do_handshake($buffer,$socket,$socket_index);
60
}
61
# handshake already done, read data
62
else
63
{
64
$action = $this->unmask($buffer);
65
if($action=='')
66
{
67
$this->disconnected($socket);
68
continue;
69
}
70
71
//that was some hack I forgot to properly comment and I dont know what it does
72
if($action==chr(3).chr(233))
73
{
74
$this->disconnected($socket);
75
continue;
76
}
77
78
$output = array();
79
if(($pos=strpos($action,'login'))===0 && $user=='')
80
{
81
$name = substr($action,$pos+7);
82
$this->users[$socket_index] = $name;
83
$this->console('Loged in as: '.$name);
84
}
85
else if($action=="ping")
86
{
87
$output['ping'] = true;
88
$this->send($socket, json_encode($output));
89
}
90
else
91
{
92
$skipSockets = array($this->master,$socket);
93
$them = array_diff($this->allsockets,$skipSockets);
94
$output['message'] = $user.': '.$action;
95
foreach($them as $sock)
96
{
97
$this->send($sock,json_encode($output));
98
}
99
}
100
101
}
102
}
103
}
104
}
105
106
$timeDiff = (microtime(true) - $this->lastTime)*1000;
107
//server messages
108
if($timeDiff>1000)//send messages out every 1000 ms
109
{
110
$output = array();
111
$this->lastTime = microtime(true);
112
$output['message'] = "Server Message".$this->i;
113
$output['users'] = $this->users;
114
$destinSockets = array_diff($this->allsockets,array($this->master));
115
foreach($destinSockets as $sock)
116
{
117
$this->send($sock, json_encode($output));
118
}
119
}
120
}
121
}
The main method is made up of an infinite while loop. The loop has two parts, listening to the sockets and sending messages out.
$num_sockets = socket_select($changed_sockets,$write=NULL,$exceptions=NULL,0,1000);
This is the line that listens on all sockets until timeout or until a sockets state has changed.
Inside a foreach loop it checks wheather the socket is a new connection or an existing one. If new it tries to estabilish a connection, if existing it takes the data off it. If there was no data
received then the connection is wrong so disconnect, else check if there is a handshake already done with this socket, if not then this must be a handshake data. If the handshake has already been done
then the data received must be an ordinary data with which you can do whatever you want but first you need to decode it:
$action = $this->unmask($buffer);
I will discuss the 'unmask' method in a second.
The second part of the while loop broadcasts the data. We don't want the badwidth to be flooded with our messages so I wrote a simple if statement that checks if there has passes more than 1ms since last time. The destination sockets must be all sockets except the master socket.
1
private function do_handshake($buffer,$socket,$socket_index)
2
{
3
list($resource,$host,$origin,$key) = $this->getheaders($buffer);
4
$retkey = base64_encode(sha1($key."258EAFA5-E914-47DA-95CA-C5AB0DC85B11",true));
5
$upgrade = "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {$retkey}\r\n\r\n";
6
$this->handshakes[$socket_index] = true;
7
socket_write($socket,$upgrade,strlen($upgrade));
8
$this->console("Done handshaking...\n");
9
}
The hanshake method is as simple as that. You extract the data from the handhsake that the client has sent. The 'getheaders' does that by using simple string methods. You take the key part and concatenate with the other part of the key. The second part of the key is always the same and is specified as a part of the WS specs. You need to sha1 and then base64 encode the whole thing. As 'Sec-WebSocket-Accept' you put the return key you just generated create a header for it and send the handshake back to the browser. You could send more info in the header but for that I suggest reading the specs.
The messages that are transmitted back and forth need to be encoded and decoded. There is a very complicated way that the specs say you need to obey. See this SO question
1
private function unmask($payload)
2
{
3
$length = ord($payload[1]) & 127;
4
5
if($length == 126) {
6
$masks = substr($payload, 4, 4);
7
$data = substr($payload, 8);
8
$len = (ord($payload[2]) << 8) + ord($payload[3]);
9
}
10
elseif($length == 127) {
11
$masks = substr($payload, 10, 4);
12
$data = substr($payload, 14);
13
$len = (ord($payload[2]) << 56) + (ord($payload[3]) << 48) + (ord($payload[4]) << 40) + (ord($payload[5]) << 32) +
14
(ord($payload[6]) << 24) + (ord($payload[7]) << 16) + (ord($payload[8]) << 8) + ord($payload[9]);
15
}
16
else {
17
$masks = substr($payload, 2, 4);
18
$data = substr($payload, 6);
19
$len = $length;
20
}
21
22
$text = '';
23
for ($i = 0; $i < $len; ++$i) {
24
$text .= $data[$i] ^ $masks[$i%4];
25
}
26
return $text;
27
}
The character at position 1 (i.e., the second byte of the message) is a combination of one bit denoting whether the message is masked (i.e., encoded) and seven bits denoting the payload (i.e., data) length. The first bit must always be 1 according to the specs because the message must always be masked. The next seven bits when converted to a decimal give you the length. However, because you can only go up to 127 with seven bits, there must be a way of getting the length of messages that are longer than that. According to the specs, if the message is longer than 125 characters then the seven bits marking the payload length must be 126, and the actual length is then stored in the 3rd and 4th bytes in the message. If the message is longer than 65,535 characters, then the seven bits marking the payload length must be 127, and the actual length is stored in the 3rd through 8th bytes. In order to convert the binary bits that make up the bytes into a decimal for the length, you have to use the bitwise operators as I have done in the solution linked above and in order to get the length stored in the last seven bits of the 2nd byte in the message, you need to use logical AND (i.e., the & operator) to essentially ignore the 1 set for the first bit used to denoted whether the message is masked or not. For details on how data is framed, please see the official WebSocket specs. Also, if you need info on what bitwise operators are and how to use them in PHP, please see the following: php.net/manual/en/language.operators.bitwise.php – HartleySan
1
private function encode($text)
2
{
3
// 0x1 text frame (FIN + opcode)
4
$b1 = 0x80 | (0x1 & 0x0f);
5
$length = strlen($text);
6
7
if($length <= 125)
8
$header = pack('CC', $b1, $length);
9
elseif($length > 125 && $length < 65536)
10
$header = pack('CCS', $b1, 126, $length);
11
elseif($length >= 65536)
12
$header = pack('CCN', $b1, 127, $length);
13
14
return $header.$text;
15
}
Encoding the data goes exactly the same way. The first byte is a never changing byte (in my case I set it in '$b1'). The second byte will either be 126, 127 or the length of the text if less than 126. You create a string out of those bytes and put it in front of your message.
Extras:
To run the WS server from a web browser on LINUX you can use this code:
1
$pid = exec('pidof php');
2
exec('kill -9 '.$pid);
3
exec('php -q server/startDaemon.php > output.txt &');
Just save this code as start.php and run this file in the browser. To stop use the same code without line 3. You can also run the startDaemon.php file in CLI.