ugrás a tartalomhoz

Írjunk alkalmazás szervert PHP-ban!

janoszen · 2010. Szep. 7. (K), 08.50

Azt hiszem, nem vagyok egyedül azzal az érzéssel, hogy módfelett böki a csőrömet a PHP-nak az a tulajdonsága, hogy bármilyen szuper rendszert is írunk, az inicializálás minden egyes lekérdezésre lefut. Az alternatív platformokon (Java, Python stb.) már találtak erre megoldásokat, de a PHP-s világban úgy tűnik, ez egyelőre nem szempont. Akit mégis zavar, tartson velem, nézzünk egy kisérleti megvalósítást.

Mi is az az alkalmazás szerver?

Először is tisztázzuk, hogy a Zend által „marketingbullshitelt” Zend Platform az nem application server, az egy sima webszerver PHP támogatással, csókolom.

Miután ezt leszögeztük, nézzük, hogy működik egy igazi application server.

  1. A szerver elindul daemonként.
  2. Betölti az alkalmazásunk kódját a memóriába.
  3. Példányosítja az alkalmazásunk egy megadott osztályát.
  4. Amikor jön egy lekérdezés, meghívja az említett osztály egy függvényét.
  5. Miután véget ért a lekérdezés, nem bontja le az osztályunkat.

Mint látható, ez gyökeres ellentétben van a PHP szokásos futásával, ahol a lekérdezés végével a betöltött osztályok kitakarodnak a memóriából, és hiába az opcode cache, a programnak újból le kell futnia, ami értékes processzoridőt jelent. Meg merném kockáztatni, hogy frameworktől függően a kód ¼ és ¾ közötti része fut le tökéletesen fölöslegesen.

Követelmények a megvalósítással szemben

Nézzük, hogy mire lesz szükségünk, ha PHP-ban szeretnénk valami hasonlót alkotni.

  • Vagy írnunk kell egy saját webszervert, vagy valamilyen megoldást kell találnunk arra, hogyan injektáljuk be a webszerverre érkező lekérdezéseket.
  • Daemonként kell futnia.
  • Valamilyen úton-módon biztosítani kell a multiprocesszálás lehetőségét, ha kisérleti fázison túl akarjuk vinni a dolgot.

Ha a feladatot minél tisztábban szerettem volna megvalósítani, valószínűleg C-ben írtam volna modult a FastCGI handlerhez. Az egyszerűség kedvéért viszont inkább majdnem natív PHP-s megoldásokat kerestem:

  • Mivel a HTTP requestek feldolgozása dupla munka lenne, és szinte biztos, hogy hibát vétenénk benne, úgy döntöttem, hogy a daemonunk egy Gearman job szerveren keresztül kapja meg a kéréseket. A webszerverben mindössze egyetlen egy PHP fájl fut, ami a környezeti változókat ($_SERVER, $_GET, $_POST, $_COOKIE) szerializálja egy JSON tömbbe, majd beteszi a Gearman queue-ba. Ennek mellékhatásaként az egész rendszer szépen fog skálázódni, feltéve hogy a sessionök megosztását megoldjuk.
  • A daemonizált futás és a multiprocessing natívan is megoldható PHP-ból, de akár supervisord-t is használhatunk hozzá. Ha a natív megoldást használjuk, érdemes figyelni arra, hogy az alkalmazás inicializálása a forkolás után történjen, hogy minden szálnak meglegyen a saját MySQL kapcsolata. Erről részletesebben az Nagy terhelésű rendszerek fejlesztése c. cikksorozatban olvashattok.

Buktatók

Tekintettel arra, hogy itt az eddig ismert szekvenciális rövid lefutású programból hirtelen hosszú lefutású daemon-szál lesz, nem feltétlenül lehet a meglevő programokat hatékonyan átültetni erre az architektúrára. Nézzünk néhány buktatót.

  • Gondoljuk végig, mik azok a szolgáltatások, amik több, mint egy requestig élhetnek (pl. MySQL kapcsolat)! Ezek nélkül szinte semmi értelme az application servernek.
  • Felejtsük el az autoloadert! Az autoloader egy workaround a PHP performancia problémájára és itt lassítja a feldolgozást, nem gyorsítja. Az alkalmazásunk összes kódját töltsük be akkor, amikor a daemon meghívja azt!
  • A globális változókra, singletonokra nagyon figyeljünk, mert azok a program futása után is ott maradhatnak! A memory leakeket próbáljuk meg felderíteni és kiküszöbölni!
  • Ha egy változóra (osztály példányra stb.) nincs szükségünk, lehetőleg töröljük azt!
  • Gondoljuk végig, hogy melyek az alkalmazásunk olyan részei, amiket kvázi globálisként deklaráltunk eddig, viszont csak egy lekérdezésre érvényesek! (Valószínűleg a session kezelés az egyik ilyen.)
  • MySQL kapcsolatnál figyeljünk arra, hogy egy kapcsolat maximum 8 óráig élhet, ezután MySQL server has gone away hibaüzenetet kapunk. A disconnecteket kezelni kell, különben csúnya, megtalálhatatlan hibák lépnek föl!

Proof of Concept

Mindezen intelmek után nézzünk egy proof-of-concept megvalósítást. Kicsit savanyú, kicsit sárga, de a mienk. Ne tessék production kódban használni, csak az elmélet bizonyítására készült:

  1. <?php  
  2. namespace \AppServer\Gearman {  
  3.     class Worker {  
  4.         protected $_worker;  
  5.         protected $_class;  
  6.         function __construct($servers$function$class) {  
  7.             $this->_worker = new \GearmanWorker();  
  8.             $this->_worker->addServers($servers);  
  9.             $this->_worker->addFunction($functionarray($this"work"));  
  10.             $this->_class = $class;  
  11.         }  
  12.         function run() {  
  13.             $ret$this->_worker->work();  
  14.             if ($this->_worker->returnCode() != GEARMAN_SUCCESS) {  
  15.                 return false;  
  16.             } else {  
  17.                 return true;  
  18.             }  
  19.         }  
  20.         function work(\GearmanJob $job) {  
  21.             $workload = json_decode(base64_decode($job->workload()), true);  
  22.             if (array_key_exists('server'$workload)) {  
  23.                 $server = new \AppServer\HTTP\Server($workload['server']);  
  24.             }  
  25.             if (array_key_exists('get'$workload)) {  
  26.                 $get = new \AppServer\HTTP\Get($workload['get']);  
  27.             }  
  28.             if (array_key_exists('post'$workload)) {  
  29.                 $post = new \AppServer\HTTP\Post($workload['post']);  
  30.             }  
  31.             if (array_key_exists('cookie'$workload)) {  
  32.                 $cookie = new \AppServer\HTTP\Cookie($workload['cookie']);  
  33.             }  
  34.             $response = new \AppServer\HTTP\Response();  
  35.             $obj = new $this->_class();  
  36.             $obj->run($server$get$post$cookie$response);  
  37.             $responsedata = array(  
  38.                 "status"  => $response->getStatusCode(),  
  39.                 "headers" => $response->getHeaders(),  
  40.                 "body"    => $response->getBody()  
  41.                 );  
  42.             return base64_encode(json_encode($responsedata));  
  43.         }  
  44.     }  
  45. }  
  46. namespace \AppServer\HTTP {  
  47.     interface Servlet {  
  48.         function run(\AppServer\HTTP\Server $server,  
  49.             \AppServer\HTTP\Get $get,  
  50.             \AppServer\HTTP\Post $post,  
  51.             \AppServer\HTTP\Cookie $cookie,  
  52.             \AppServer\HTTP\Response $response  
  53.             );  
  54.     }  
  55.     class Response {  
  56.         protected $_statusCode = 200;  
  57.         protected $_headers = array();  
  58.         protected $_cookies = array();  
  59.         protected $_body = "";  
  60.         function getStatusCode() {  
  61.             return $this->_statusCode;  
  62.         }  
  63.         function setStatusCode($code) {  
  64.             $this->_statusCode = $code;  
  65.         }  
  66.         function setHeader($name$value$replace = false) {  
  67.             if (!isset($this->_headers[$name]) || $replace) {  
  68.                 $this->_headers[$name] = $value;  
  69.             }  
  70.         }  
  71.         function getHeader($name) {  
  72.             if (isset($this->_headers[$name])) {  
  73.                 return $this->_headers[$name];  
  74.             } else {  
  75.                 return null;  
  76.             }  
  77.         }  
  78.         function getHeaders() {  
  79.             return $this->_headers;  
  80.         }  
  81.         function clearHeaders() {  
  82.             $this->_headers = array();  
  83.         }  
  84.         function setCookie($key$value = ""$expire = false, $path = ""$domain = ""$secure = false, $httponly = false) {  
  85.             $this->_cookies[$key] = array(  
  86.                     "value" => $value,  
  87.                     "expire" => $expire,  
  88.                     "path" => $path,  
  89.                     "domain" => $domain,  
  90.                     "secure" => (bool)$secure,  
  91.                     "httponly" => (bool)$httponly);  
  92.         }  
  93.         function getCookie($key) {  
  94.             if (in_array($key$this->_cookies)) {  
  95.                 return $this->_cookies[$key];  
  96.             } else {  
  97.                 return null;  
  98.             }  
  99.         }  
  100.         function getCookies() {  
  101.             return $this->_cookies;  
  102.         }  
  103.         function setBody($content) {  
  104.             $this->_body = $content;  
  105.         }  
  106.         function getBody() {  
  107.             return $this->_body;  
  108.         }  
  109.         function clearBody() {  
  110.             $this->_body = "";  
  111.         }  
  112.     }  
  113.     abstract class Raw {  
  114.         protected $_data = array();  
  115.         function __construct($data) {  
  116.             $this->_data = $data;  
  117.         }  
  118.         function __get($key) {  
  119.             return $this->_data[$key];  
  120.         }  
  121.         function __set($key$value) {  
  122.             return $this->_data[$key];  
  123.         }  
  124.         function __isset($key) {  
  125.             return isset($this->_data[$key]);  
  126.         }  
  127.         function __unset($key) {  
  128.             unset($this->_data[$key]);  
  129.         }  
  130.     }  
  131.     class Cookie extends Raw { }  
  132.     class Get extends Raw { }  
  133.     class Post extends Raw { }  
  134.     class Server extends Raw { }  
  135. }  
  136. namespace Application {  
  137.     class Test implements \AppServer\HTTP\Servlet {  
  138.         function run(\AppServer\HTTP\Server $server,  
  139.             \AppServer\HTTP\Get $get,  
  140.             \AppServer\HTTP\Post $post,  
  141.             \AppServer\HTTP\Cookie $cookie,  
  142.             \AppServer\HTTP\Response $response  
  143.             ) {  
  144.             $response->setBody("Test");  
  145.         }  
  146.     }  
  147. }  
  148. namespace {  
  149.     $terminate = false;  
  150.     $worker = new \AppServer\Gearman\Worker(getenv('GEARMAN_SERVERS'), getenv('GEARMAN_FUNCTION'), getenv('CLASS'));  
  151.     while (!$terminate) {  
  152.         try {  
  153.             $worker->run();  
  154.         } catch (\Exception $e) { }  
  155.     }  
  156. }  

Az ehhez tartozó webszerver oldali kód:

  1. <?php  
  2. function getGearmanServers() {  
  3.     $servers=getenv("GEARMAN_SERVERS");  
  4.     $servers = explode(";"$servers);  
  5.     if (!count($servers)) {  
  6.         $servers = array("127.0.0.1:4730");  
  7.     }  
  8.     shuffle($servers);  
  9.     return $servers;  
  10. }  
  11. function getGearmanFunction() {  
  12.     $func=getenv("GEARMAN_FUNCTION");  
  13.     if (!$func) {  
  14.         $func = "serve";  
  15.     }  
  16.     return $func;  
  17. }  
  18. function hardFail($message) {  
  19.     header("HTTP/1.1 500 Internal Server Error");  
  20.     $errFile = dirname(__FILE__) . "/500.html";  
  21.     if (!file_exists($errFile) || !include($errFile)) {  
  22.         echo("<h1>Internal Server Error</h1>");  
  23.     }  
  24.     exit(500);  
  25. }  
  26. function processResult($result) {  
  27.     $statusCodeMap = array(  
  28.         100 => "Continue", 101 => "Switching Protocols",  
  29.         200 => "OK", 202 => "Accepted",  
  30.         203 => "Non-Authoritative Information",  204 => "No Content",  
  31.         205 => "Reset Content",  206 => "Partial Content",  
  32.         300 => "Multiple Choices", 301 => "Moved Permanently",  
  33.         302 => "Found",  303 => "See Other", 304 => "Not Modified",  
  34.         305 => "Use proxy", 307 => "Temporary Redirect",  
  35.         400 => "Bad Request", 401 => "Unauthorized",  
  36.         403 => "Forbidden", 404 => "Not Found",  
  37.         405 => "Method Not Allowed", 406 => "Not Acceptable",  
  38.         407 => "Proxy Authentication Required",  
  39.         408 => "Request Timeout", 409 => "Conflict", 410 => "Gone",  
  40.         411 => "Length Required", 412 => "Precondition Failed",  
  41.         413 => "Request Entity Too Large",  
  42.         414 => "Request-URI Too Long", 415 => "Unsupported Media Type",  
  43.         416 => "Request Range Not Satisfiable",  
  44.         417 => "Expectation Failed", 500 => "Internal Server Error",  
  45.         501 => "Bad Gateway", 503 => "Service Unavailable",  
  46.         504 => "Gateway Timeout", 505 => "HTTP Version Not Supported"  
  47.     );  
  48.     if (array_key_exists($result['status'], $statusCodeMap)) {  
  49.         header("HTTP/1.1 " . $result['status'] . " " . $statusCodeMap[$result['status']]);  
  50.     } else {  
  51.         header("HTTP/1.1 200 OK");  
  52.     }  
  53.     foreach ($result['headers'as $key => $value) {  
  54.         header($key . ": " . $value);  
  55.     }  
  56.     echo($result['body']);  
  57. }  
  58. $func = getGearmanFunction();  
  59. $gc = new GearmanClient();  
  60. $gc->addServers(implode(",", getGearmanServers()));  
  61. $response = $gc->do($funcbase64_encode(json_encode(array("server" => $_SERVER"get" => $_GET"post" => $_POST"cookie" => $_COOKIE))));  
  62. $response = json_decode(base64_decode($response), true);  
  63. if (is_array($response)) {  
  64.     processResult($response);  
  65. else {  
  66.     hardFail("Invalid response");  
  67. }  

Megjegyzés: azért base64 encode-oltam a kommunikációt, mert a jelek szerint a béta állapotban levő PECL Gearman csomagnak problémái vannak a JSON válaszban a } jellel. Van natív PHP-s Gearman osztály, azt azonban nem teszteltem.

Namost, a kis feldolgozónkat ezek után így kell indítani:

$ GEARMAN_SERVERS=127.0.0.1 GEARMAN_FUNCTION=siteneve CLASS="\Application\Test" php minified.php

Letesztelni az index.php parancssori futtatásával futjuk:

$ GEARMAN_SERVERS=127.0.0.1 GEARMAN_FUNCTION=siteneve php index.php

Ha webszerverből szeretnénk indítani, akkor a következő a GEARMAN_SERVERS és GEARMAN_FUNCTION változókat SetEnvvel be kell állítani.

Konklúzió, linkek

Mindez a gyakorlatban működik. Hogy hoz-e sebességbeli növekedést, mindenkinek magának kell eldöntenie. A Gearman egy stabil jószág, ennél sokkal, sokkal többet tud. Írjátok meg a tapasztalataitokat!

Hasznos linkek:

 
1

Szép

alippai · 2010. Szep. 7. (K), 10.11
Látom szebb cikkek jelennek meg itt. :)
Ha már ilyen magasságokban járunk érdemes esetleg meglesni ezt:
http://code.google.com/p/kargo-event/
2

Le a kalappal proclub!

Thoer · 2010. Szep. 7. (K), 10.49
Szép munka!
3

A Gearmant még soha nem

virág · 2010. Szep. 7. (K), 12.16
A Gearmant még soha nem próbáltam ki. Eddig :) Jó kis hiánypótló cikk ez is, kössz!
4

Köszönöm, úgy érzem, okosabb

prom3theus · 2010. Szep. 7. (K), 14.24
Köszönöm, úgy érzem, okosabb lettem.
5

klassz

td · 2010. Szep. 7. (K), 21.59
Rég jártam már itt, erre rögtön egy ilyen cikk fogad. :) Érdekes ötlet. Köszi a cikket!
6

Gyere gyakrabban

janoszen · 2010. Szep. 7. (K), 23.49
Akkor itt az alkalom, gyere gyakrabban. :) Ha megmondod, miről olvasnál szívesen, szerintem, megpróbálunk eleget tenni neki.
7

autoload

Hodicska Gergely · 2010. Szep. 19. (V), 19.52
Felejtsük el az autoloadert! Az autoloader egy workaround a PHP performancia problémájára és itt lassítja a feldolgozást, nem gyorsítja. Az alkalmazásunk összes kódját töltsük be akkor, amikor a daemon meghívja azt!


Az autoload az sohasem performancia problémákat volt hivatott megoldani, és minden esetben lassabb, mint egy fix include.
8

valoszinuleg lazy loadingot

Tyrael · 2010. Szep. 19. (V), 22.03
valoszinuleg lazy loadingot akart irni Janos.

Tyrael