суббота, 22 ноября 2008 г.

PHP сокет сервер и чат-шлюз для флеш-клиентов

(перевод с англ., источник: PHP Socket server and Chat Gateway for Flash clients)

Первоначально, я не планировал писать пошаговое руководство "PHP сокет сервер и чат шлюз для флеш клиентов за 10 минут", или что-то похожее.

Я закончил разработку руководства флеш-чата, использующего PHP в качестве бэкэнда и шлюза. Я опубликовал свой код и некоторые решения в качестве примера как флеш-приложение общается с сервером по 80 порту.

Я не включил флеш-часть в свой документ, т.к. ее делал мой коллега и друг. Я не могу опубликовать этот код.

Для общих сведений, как мы это делали, Вы можете почитать руководство kirupa.com - PHP 5 Sockets with Flash 8.

Таким образом, в этой статье я покажу вам наши решения чат-сервера на PHP 5, флеш-клиентах, эмулятора веб-сервера, использующего кросс-доменную политику из файла crossdomain.xml, отдаваемому по запросу, и общение с сервером, базирующемуся на xml-сообщениях. Этот пример только показывает, как создать многопользовательский чат с приватным общением. Т.е. вы можете разговаривать сразу с несколькими людьми, но не в чат-комнатах (каналах).

Будем использовать PHP из командной строки, т.к. в полноценном веб-сервере нет необходимости.

Для начала, нам необходимо создать демон (фоновый процесс в Windows), без временного лимита на исполнения. Скрипт в данном случае выполняется до конца света, или до первой перезагрузки :). Также мы установим IP адрес и порт для прослушивания.

#!/usr/bin/php -q set_time_limit(0); ob_implicit_flush(); $address = '127.0.0.1'; $port = 80; Давайте мы созданим массив для входящих подключений (если вы используете его для чата, в нем можно будет хранить ники). Далее создаем сокет. Все echo сообщения в нашем примере идут в лог-файл. $_sockets = array(); if (($master = socket_create(AF_INET, SOCK_STREAM, SOL_TCP)) < 0) { echo "socket_create() failed, reason: " . socket_strerror($master) . "\n"; } socket_set_option($master, SOL_SOCKET,SO_REUSEADDR, 1); if (($ret = socket_bind($master, $address, $port)) < 0) { echo "socket_bind() failed, reason: " . socket_strerror($ret) . "\n"; } if (($ret = socket_listen($master, 5)) < 0) { echo "socket_listen() failed, reason: " . socket_strerror($ret) . "\n"; } else { $started=time(); echo "[".date('Y-m-d H:i:s')."] SERVER CREATED ( MAXCONN:".SOMAXCONN." ) \n"; echo "[".date('Y-m-d H:i:s')."] Listening on ".$address.":".$port."\n"; } $read_sockets = array($master);
SOMAXCONN является переменной ядра. Она устанавливает количество подключений, которые может обработать ваш сервер. В Unix она может быть установлена на уровне ядра и скорректирована командой sysctl.

После этого мы создаем бесконечный цикл для обработки запросов

while (true) {
$changed_sockets = $read_sockets;
$num_changed_sockets = socket_select($changed_sockets, $write = NULL, $except = NULL, NULL);

foreach($changed_sockets as $socket) {
if ($socket == $master) {
if (($client = socket_accept($master)) < 0) {
echo "socket_accept() failed: reason: " . socket_strerror($msgsock) . "\n";
continue;
} else {
array_push($read_sockets, $client);
echo "[".date('Y-m-d H:i:s')."] CONNECTED "."(".count($read_sockets)."/".SOMAXCONN.")\n";
}
}
else
{
$bytes = @socket_recv($socket, $buffer, 2048, 0);
/*

Here comes the core... ;)

*/
}
}

Это основа. До данного момента, этот код такой же, как в публикации Raymond Fain в socketShell.php, которая упоминалась выше.
Как я уже говорил, мы будем править еще часть кода.

Я буду цитировать каждый кусок кода в следующей части. Мы начнем с определения функций, которые описываются вне бесконечного цикла.

Сначала мы сделаем эмулятор веб-сервера. Зачем нам это нужно?

Суть системы состоит в том, чтобы общаться по 80-му порту, т.к. файрволлы не будут блокировать сообщения а нем. Но проблема заключается в том что, как вы знаете, флеш по умолчанию общается по 1024 порту. Что неприемлемо из-за политик безопастности.

Есть способ, чтобы "научить" флеш общаться по любому порту, которому вы хотите. Это называется кросс-доменной политикой.

Политики хранятся в XML-формате, который выглядит в нашем случае так:





Я храню ее в переменной. Мой демон, обслуживающий сокеты, получает запрос от флеш.
В ответ мы отдаем файл кроссдоменной политики, что означает, что мы разрешаем подключение по 80 порту.

Если вы хотите подробно почитать о кроссдоменных политиках, зайдите на http://www.crossdomainxml.org/

Ваш код после socket_recv будет выглядеть так:

if (preg_match("/policy-file-request/i", $buffer) || preg_match("/crossdomain/i", $buffer)) {
echo "[".date('Y-m-d H:i:s')."] CROSSDOMAIN.XML REQUEST\n";
$contents='';

socket_write($socket,$contents);
$contents="";

$index = array_search($socket, $read_sockets);
unset($read_sockets[$index]);
socket_shutdown($socket, 2);
socket_close($socket);
}

Мы закрываем сокет, поскольку это флеш-запрос на файл кроссдоменной политики. Получив данный файл, флеш-приложение закрывает соединение и переподключается на "правильном" сокете.

После обработчика для флеш-клиентов, создадим подробные сообщения о состоянии сервера (состоянии демона и /server-status). Они могут быть просмотрены, если набрать в браузере IP адрес (и порт, если он отличный от 80). Набрав http://127.0.0.1/server-status, мы будем получать такую информацию:

OK
Clients: 48/128
Created: 2007-07-10 10:54:02
Uptime: 16 days

Далее. Я игнорирую запросы favicon.ico, потому что если будет сделан запрос из браузера, он автоматически запросит данную информацию и это может вызывать какие-нибудь ошибки кода.

И последнее. Я эмулирую веб-серверную часть, чтобы перенаправить все GET и POST запросы с HTTP заголовками, которые идут не через сокет.
Мы также сделаем запись в журнале запросов и создадим переадресацию HTTP.

Итак, давайте посмотри, что мы должны добавить в существующий код, чтобы он заработал описанным выше образом:

elseif (( preg_match("/GET/", $buffer) || preg_match("/POST/", $buffer)) && preg_match("/HTTP/", $buffer))
{
if (preg_match("//server-status/i", $buffer))
{
$uptime = floor((time()-$started)/86400);

socket_write($socket,"OK\n");
socket_write($socket,"Clients: ".count($read_sockets)."/".SOMAXCONN."\n");
socket_write($socket,"Created: ".date('Y-m-d H:i:s',$started)."\n");
socket_write($socket,"Uptime: ".$uptime." days\n");
echo "[".date('Y-m-d H:i:s')."] STATUS REQUEST\n";
}
elseif (preg_match("/favicon.ico/i", $buffer))
{
//ignore :)
}
else
{
// fake web server
socket_write($socket,"HTTP/1.1 301 Moved Permanently\n");
socket_write($socket,"Server: PHP Chat Server by DjZoNe - http://djz.hu/\n");
socket_write($socket,"Date: ".date("d, j M Y G:i:s T\n"));
socket_write($socket,"Last-Modified: ".date("d, j M Y G:i:s T\n"));
socket_write($socket,"Location: http://djz.hu/\n");
}
$index = array_search($socket, $read_sockets);
unset($read_sockets[$index]);
@socket_shutdown($socket, 2);
@socket_close($socket);
}


Пока что мы сделали часть, стартующую сокет-сервер и перенаправляющую HTTP-запросы. В следующей части мы напишем обработчик ситуации, когда сокет закрывается и пользователь отключается.

if (strlen($buffer) == 0) {
// мы берем уникальный ID пользователя из базы данных
$id=$_sockets[intval($socket)]['nick'];

$index = array_search($socket, $read_sockets);

unset($read_sockets[$index]); // we clean up
unset($_sockets[intval($socket)]); // we clean up our own data
// cleaning up is essential when creating a daemon
// we can't leave junk in the memory
@socket_shutdown($socket, 2);
@socket_close($socket);

$allclients = $read_sockets; // перезагружаем активных клиентов

// $socket is now pointing to a dead resource id
// but the send_Message() function will need it, I'll explain later

send_Message($allclients, "");
echo "[".date('Y-m-d H:i:s')."] QUIT ".$id."\n";
}

И вот сейчас реальная сокет-коммуникация:

else {
$allclients = $read_sockets;
array_shift($allclients);

$piece = explode(" ",trim($buffer)); // we strip out all unwanted data
$cmd = strtoupper($piece[0]);
}

Мы используем несколько команд IRC-протокола.
MSG сообщение
IDENTIFY ник пароль
LIST

Мы разбиваем сообщение на куски, и склеиваем его после того, как только определим первые несколько аргументов.

if (!empty($piece[1])) $content = $piece[1];

switch ($cmd) {
case "IDENTIFY":
$id = trim($piece[1]);
$passwd = trim($piece[2]);
send_Identify($allclients, $socket, $id, $passwd);
break;

case "MSG":
$id = trim($piece[1]);
$msg="";
foreach ($piece as $key=>$val)
{
if ($key > "1") $msg.=$val." ";
}
$msg = trim($msg);
send_Msg($allclients, $socket, $id, $msg);
break;

case "LIST":
list_Users($allclients, $socket);
break;
}

Мы сделали вызовы команд.
До сих пор мы создавали цикл.
Сейчас мы будем создавать функции. Снаружи.

Я хочу рассказать вам историю.... Просто шутка ;)
Мы сделали socket_write и все. Мы не сможем получить через сокет информацию, пока не поместим символ ASCII 0 в конец

буфера. Т.е. мы вводим ноль ASCII после каждого socket_write. Думаю, для XML-сокет коммуникации через Flash это просто.

Если мы посмотрим назад в "эмулятор веб-сервера", мы просто добавим одну строчку.

Вот вам несколько функций, которые мы используем для авторизации, отправки сообщений и т.д.

function send_Identify($allclients, $socket, $id, $passwd)
{
global $_sockets;
$nicks = array();

$dbconf = new DATABASE_CONFIG;

$db_host = $dbconf->host;
$db_base = $dbconf->database;
$db_login = $dbconf->login;
$db_password = $dbconf->password;

foreach ($_sockets as $_socket)
{
foreach ($_socket as $key=>$val)
{
if (empty($nicks[$val])) $nicks[$val]=1;
else $nicks[$val]=$nicks[$val]+1;
}
}

if (empty($nicks[$id]))
{
$s=1;
// Here will be a simple authentication.

$link = mysql_connect($db_host, $db_login, $db_password);
if (!$link) die("Could not connect:" . mysql_error() . "\n");

$db_selected = mysql_select_db($db_base, $link);
if (!$db_selected) die("Can't use $db_base :" . mysql_error() . "\n");

$result = mysql_query("SELECT nick FROM members WHERE id='".intval($id)."' AND password='".crypt($passwd)."' AND

active='1' LIMIT 1");
$data = mysql_fetch_array($result);
$name = $data['name'];
$_sockets[intval($socket)]=array('id'=>$id, 'nick'=>$name);

mysql_free_result($result);
mysql_close($link);

После использования SQL-соединения его необходимо закрыть. Это важно, т.к. соединение будет закрываться по тайм-ауту и

демон умрет.

}
else $s=0;

// We'll answer to the flash in XML form.
// But we receive in plain text format.

if ($s == 1)
{
$out = "";
send_Message($allclients, "");
// this goes to all active, identified clients
echo "[".date('Y-m-d H:i:s')."] LOGIN ".$id."(".count($allclients)."/".SOMAXCONN.")\n";
}
else $out = "";

socket_write($socket, $out.chr(0)); // write back to the client
}



function send_Msg($allclients,$socket,$id,$msg)
{
global $_sockets;

if (!empty($_sockets[intval($socket)]))
{
$nicks = array(); //amig fut a parancs ebben vannak a nickek.

foreach ($_sockets as $_socket)
{
foreach ($_socket as $key=>$val)
{
// this check's the onliners
if (empty($nicks[$val])) $nicks[$val]=1;
else $nicks[$val]=$nicks[$val]+1; // we shouldn't have duplicated nicks, but what if...
}
}

foreach($allclients as $client)
{
if (!empty($_sockets[$client]['nick']) && ($_sockets[$client]['nick'] == $id))
{
$_client = $client;
$out = "
from=\"".$_sockets[$client]['nick']."\" />";
}
elseif(empty($nicks[$id]))
//not online or something similar
{
//backto the sender
$_client = $socket;
$out = "";
}
}
}
else
{
//backto the sender
$_client = $socket;
$out = "";
}
if (!empty($out))
{
socket_write($socket, $out.chr(0)); //send to back ourself. we have to handle it in flash
socket_write($_client, $out.chr(0)); //send to the recipient
}
}

Сейчас мы создали функцию, которая посылает сообщение всем присоединенным клиентам. Далее покажем, как получить список всех пользователей.

function send_Message($allclients, $socket, $buf) {
global $_sockets;

foreach($allclients as $client) {
@socket_write($client, $buf.chr(0));
}
}

function list_Users($allclients,$socket) {
global $_sockets;
$out = "";
foreach($allclients as $client) {
if (!empty($_sockets[$client]['nick']) && ($_sockets[$client]['nick'] != "")) {
$out .= "";
}
}
$out .= "
";
socket_write($socket, $out.chr(0));
}
?>

В данный момент наш демон обрабатывает три основные команды - идентификацию, получение списка пользователей и отправку

сообщений. Это то, что я обещал вам в самом начале. Вы можете доработать код. Например, мы не имеем функции для смена ника (команды /nick в IRC) и т.д.

Это все. Исходник может быть загружен с здесь.

А в конце у меня для Вас подарок. Это маленький BASH скрипт для запуска демона:

#!/bin/sh
if [ "X$1" = "Xstart" ] ; then
chmod +x /var/www/chat/phpircgateway.php
/var/www/chat/phpircgateway.php >> /var/log/chat/chat.log &
echo "Starting chat"
fi
Отправить комментарий