PHP Socket szerver és chat átjáró Flash klienseknek

Először is ez nem egy részletes útmutató, hogy „Hogyan írjunk PHP Socket szervert és chat átjárót Flash kliensekhez 10 őerc alatt.” vagy bármi ehhez hasonlót.

Ez a cikket eredetileg angolul írtam meg, még tavaly nyáron, azért mert akkor fejeztük be egy Flash chat megoldást fejlesztését, aminek szerver oldalon PHP a backendje és ez biztosítja a chat átjárót is. Ebben a cikkben főleg kód részleteket fogok bemutatni, és néhány trükkös megoldást, mint például, hogy hogyan bírjuk rá a flasht, hogy 80-as porton kommunikáljon a szerverünkkel, de a legvégén az alkalmazás majdnem eredeti formájában le is tölthető.
Viszont, itt most csak és kizárólag PHP kódot fogok prezentálni, mivel a Flash kódokat nem én írtam, így nyilvánosságra sem hozhatom.

Amikor neki álltunk a fejlesztésnek kezdőpontként Raymond Fain PHP 5 Sockets with Flash 8 cimű cikkét tekintettük, és az alap ötlet is tőle származik.

Tehát ebben a cikkben megmutatom, hogyan csináltunk PHP5ben chat szervert, webszerver emulációval „policy-file-request” és „crossdomain.xml”-el lekérés kezeléssel, mind ezt Flash klienseknek xml-socket alapú kommunkációval.
A programunk csak egy az egyben kommunikációt valósít meg, igaz azt több szálon, de csevegő szobák mint olyanok nem léteznek benne. Tervezem, hogy majd egyszer neki ülök, és kibővítem, de ez sem ma lesz 🙂

Egy konzolban végtelenül futó CLI (command line) PHP-t fogunk tehát gyártani, ami önmaga lesz a webszerver is, azért fontos, hogy a beállított ipn, más szolgáltatás ne fusson a beállított porton, mert abból hiszti lesz.
Akkor lássunk is neki, és kezdjük el írni a végtelenül futó kis démonunkat.

Először is állítsuk be az ipt és a portot:

#!/usr/bin/php -q
<?php
 
set_time_limit(0);
ob_implicit_flush();
 
$address = '127.0.0.1';
$port = 80;

Ezután készítünk egy tömböt a bejővő socketekkel kapcsolatos adatok tárolására, mint pl (nick nevek), aztán pedig indul a socket készítés, és a bejövő adatok fogadása. Itt jegyezném meg, hogy minden amit echozom ebben a kódban logfileban kerül.

 $_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);

A SOMAXCONN konstans egy rendszer szintű változó, ami azt az értéket tartalmazza, hogy a szerver hány kapcsolat együttes kezelésére alkalmas. A *NIX világban ezt 2 féle képpen lehet beállítani, vagy kernel fordításkor, vagy sysctl jellegű rendszer változó manipulálással.

Most pedig csinálunk egy nagy loopot, ami az egész socket szerver lelke.

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);
      /**
         * Ide jön, hogy mit is csinálunk a sockettel... ;)
         *
         */
    }
}

A kód egészen eddig szinte teljesen megegyezett a fent említett Raymond Fain kódjával, tehát eddig még semm új nincs.
Ahova a végére betettem a kommentet, a többi kód ide fog kerülni, kivéve nyilván a fukció definiciókat.

Akkor most következik a webszerver emuláció. De miért is van erre szükség?

Mert úgy gondoltam, hogy kommunikáljunk 80-as porton, így ezt nem blokkolják a tűzfalak, és átmegy a routereken meg minden hasonlón. A probléma ott kezdődik, hogy Flash kliensből nem lehet 1024-es alatti portra xml socket kommunkikácót indítani. Kivéve akkor hogy ha ezt szerver oldalon engedélyezzük a cross-domain-policyben.
A probléma ebben csak az, hogy a kommunikációt és policy-file kiszolgálást is ugyanazon a porton kell megoldanunk.

A crossdomain policy fájl, mint tudjátok egy egyszerű XML file, ami a mi esetünkben így fog kinézni:

<?xml version="1.0"?>
<cross-domain-policy>
  <allow-access-from domain="*" to-ports="80" />
</cross-domain-policy>

Szóval amennyiben crossdomain.xml fájlt kérnek a szerverünktől, illetve policy-file-request érkezik, akkor ezt a választ kell adnunk.

Bőveben a crossdomain.xmlről itt olvashattok.

Kis kitérő után vissza a tárgyhoz. A loop elkezdése után vizsgálni fogjuk, hog mit találunk a bejövő adatokban, amit a $buffer nevű változóban tárolunk. Ha ebben találunk crossdomain policyre utaló stringet, akkor kiszolgáljuk a filet, és bontjuk a kapcsolatot:

 
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='<?xml version="1.0"?><cross-domain-policy><allow-access-from domain="*" to-ports="80" /></cross-domain-policy>';
 
  socket_write($socket,$contents);
  $contents="";
 
  $index = array_search($socket, $read_sockets);
  unset($read_sockets[$index]);
  socket_shutdown($socket, 2);
  socket_close($socket);
}

Azért zárjuk a kapcsolatot, mivel a Flash kliens, ilyenkor kiküldi a requestet, és többé nem fog írni erre a socketre, még akkor sem ha helyes választ kapott. Hanem ezt megszakítja, és újat fog nyitni amennyiben a válasz megfelelő.

A flashes móka után csináljuk egy kis kényelmi funkiót a szerverünkbe, valami olyasmit, amit a nagy webszerverek is tudnak, ez pedig a szerver státusz kijelzésére szolgáló server-status. Vagyis ha ráhívsz a szerver ipcímére böngészőből, és utánna csapod a /server-status elérési utat, akkor egy hasonló válasz fog érkezni:

OK
Clients: 34/128
Created: 2007-07-10 10:54:02
Uptime: 222 days

Ezután ignoráljuk a webes kereső robotok által keresett robots.txtre vonatkozó lekéréseket. Minden más POST, GET és HEAD jellegű lekérésre pedig generálunk egy standard HTTP 301 Moved Permanently es választ:

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("/robots.txt/i", $buffer))
  {
    //elnyelt lekérdezés :)
  }
  else
  {
    // web server hamisítás
    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);
}

Eddig megcsináltuk a szerver inicializálást, és indítást, a HTTP jellegű kérések kezelését, a policy fájlok kiszolgálását. Most foglalkozzunk egy kicsit a kliensekkel is is. Elöször lekezeljük azt a részt, amikor a kliens már valószinúleg kilépett, és már nem küld semmilyen adatot, az else ágban pedig a valódi kommunikációt bonyolító részek jönnek:

if (strlen($buffer) == 0)
{
  //felhasználói azonosító az adatbázisból
  $id=$_sockets[intval($socket)]['nick'];
 
  $index = array_search($socket, $read_sockets);
 
  unset($read_sockets[$index]); // takarítás
  unset($_sockets[intval($socket)]); // takarítás
  /**  
     * Abban az esetben ha daemont írunk, nem hagyhatunk szemetet a memóriában, ezért takarítani kell minden változót 
     * amire nincs már szükségünk.
     */
  @socket_shutdown($socket, 2);
  @socket_close($socket);
 
  $allclients = $read_sockets; // újra töltjük a megmaradt klienseket
 
  /**
     * A $socket most egy halott resource id -t tartalmat
     * de a send_Message funkciónak mint paraméter meg kell adni.
     */
 
  send_Message($allclients, "<quit aid=\"".$aid."\" />");
  echo "[".date('Y-m-d H:i:s')."] QUIT ".$id."\n";
}

És ami a valós kommunikációt műveli:

else
{
  $allclients = $read_sockets;
  array_shift($allclients);
 
  $piece = explode(" ",trim($buffer));
  $cmd = strtoupper($piece[0]);
}

Feltételezem, hogy a parancsok úgy néznek ki ahogyan azt ircn használnánk:
MSG kinek üzenet

A tartalamat részekre vágjuk, hoyg egyszerűbb legyen azonosítani, majd újra összeragasztjuk, ha message küldésről van szó, minden egyéb esetben elég lesz az első pár paraméter.

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;
}

Definiáltunk pár alap parancsot, ezzel most itt vége is a loop résznek.

Ezután készítsünk el a funkciókat, amikre hivatkoztunk a fenti switch ágban:

De mielőtt erre rátérünk elmesélek egy történetet. Egyszer volt holnem… Csak vicceltem 😉

Viszont azt elmondom, amíg irtuk a kommunikációs részt rájöttünk a flash socket kommunikáció egyik sarkallatos pontjára, még pedig az ascii 0 ás karakter féle mizériára. Ami arról szól, hogy az putput buffer csak akkor kerül értelmezésre flash kliens oldalon, hogy ha a sort egy ASCII 0 karakter terminálja.

Az angol nyelvű cikkemhez hozzászólok meg is jegyezték, hogy ez feature, és nem bug. Erről azt hiszem a Flash programozónak kellett volna tudnia, mivel erről van szó a Flash XML Socket dokumentációjában.

Tehát itt vannak a funkciók:

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;
    //Egyszerű authentikáció
 
    $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);

Fontos, hogy használjuk a mysql_free_result és a mysql_close függvényeket, mivel a normális webszerver oldalon intrepletált PHPknél a MySQLel kommunikáló szál terminálódik amikor az utolsó sor lefutása után, ez ebben az esetben a végtelen loop miatt nem történik meg. És ha nem zárjuk le, akkor a következő kapcsolódási kisérletnél timeouttal elfog szállni, nem csak a mysql_connect, hanem vele együtt az egész daemonunk is megy aludni.

  }
  else $s=0;
 
/**
   * A klienseknek XML formában válaszolunk, de sima szövegként várunk adatot
   */
 
  if ($s == 1)
  {
    	$out = "<identify aid=\"".$nick."\" name=\"".$name."\" />";
    	send_Message($allclients, "<login aid=\"".$nick."\" name=\"".$name."\" />");
    // Ez az üzenet minden kliensnek kimegy.
    echo "[".date('Y-m-d H:i:s')."] LOGIN ".$id."(".count($allclients)."/".SOMAXCONN.")\n";
  }
  else $out = "<error value=\"Already online.\" />";
 
  socket_write($socket, $out.chr(0)); // visszaírunk a kliensnek
}
 
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; // elvileg nem lehetnek duplikált nickek, de mivan ha mégis
             }
        }
 
        foreach($allclients as $client)
        {
            if (!empty($_sockets[$client]['nick']) && ($_sockets[$client]['nick'] == $id))
            {
              $_client = $client;
              $out = "<msg aid=\"".$_sockets[$socket]['nick']."\" time=\"".date("H:i:s")."\" msg=\"".$msg."\" from=\"".$_sockets[$client]['nick']."\" />";
            }
            elseif(empty($nicks[$id]))
            //már nincs online
            {
               //vissza a feladónak
               $_client = $socket;
               $out = "<error value=\"User is already left.\"/>";
            }
        }
    }
    else
    {
        //vissza a feladónak
        $_client = $socket;
        $out = "<error value=\"Not identified.\"/>";
    }
    if (!empty($out))
    {
       socket_write($socket, $out.chr(0)); //vissza saját magunknak
       socket_write($_client, $out.chr(0)); //és itt küldjük el a címzettnek
    }
}

Íme a funkció, ami minden csatlakozott kliensnek küld üzenetet, az alsó funkció pedig felhasználói listát generál.

    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 = "<nicklist>";
      foreach($allclients as $client)
      {
        if (!empty($_sockets[$client]['nick']) && ($_sockets[$client]['nick'] != ""))
        {
          $out .= "<nick aid=\"".$_sockets[$client]['nick']."\" name=\"".$_sockets[$client]['name']."\" />";
        }
      }
      $out .= "</nicklist>";
      socket_write($socket, $out.chr(0));
    }
?>

A mi kis délmounk most már készen áll bejelentkezés kezelésre, tud felhasználókat listázni, és üzenetet is küldeni.

Végeztünk is, a teljes ismeretett kód letölthető itt

A végére adok nektek egy kis ajándékot is.
Itt egy egyszerű BASH script, amely UNIX rendszerek alatt elindítja a daemont:.

#!/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 "Chat inditas"
fi

Nos, ennyi lett volna,
További jó kódolást 😉

A hozzászólások jelenleg nem engedélyezettek ezen a részen.