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:


<?php
namespace \AppServer\Gearman {
	class Worker {
		protected $_worker;
		protected $_class;
		function __construct($servers, $function, $class) {
			$this->_worker = new \GearmanWorker();
			$this->_worker->addServers($servers);
			$this->_worker->addFunction($function, array($this, "work"));
			$this->_class = $class;
		}
		function run() {
			$ret= $this->_worker->work();
			if ($this->_worker->returnCode() != GEARMAN_SUCCESS) {
				return false;
			} else {
				return true;
			}
		}
		function work(\GearmanJob $job) {
			$workload = json_decode(base64_decode($job->workload()), true);
			if (array_key_exists('server', $workload)) {
				$server = new \AppServer\HTTP\Server($workload['server']);
			}
			if (array_key_exists('get', $workload)) {
				$get = new \AppServer\HTTP\Get($workload['get']);
			}
			if (array_key_exists('post', $workload)) {
				$post = new \AppServer\HTTP\Post($workload['post']);
			}
			if (array_key_exists('cookie', $workload)) {
				$cookie = new \AppServer\HTTP\Cookie($workload['cookie']);
			}
			$response = new \AppServer\HTTP\Response();
			$obj = new $this->_class();
			$obj->run($server, $get, $post, $cookie, $response);
			$responsedata = array(
				"status"  => $response->getStatusCode(),
				"headers" => $response->getHeaders(),
				"body"    => $response->getBody()
				);
			return base64_encode(json_encode($responsedata));
		}
	}
}
namespace \AppServer\HTTP {
	interface Servlet {
		function run(\AppServer\HTTP\Server $server,
			\AppServer\HTTP\Get $get,
			\AppServer\HTTP\Post $post,
			\AppServer\HTTP\Cookie $cookie,
			\AppServer\HTTP\Response $response
			);
	}
	class Response {
		protected $_statusCode = 200;
		protected $_headers = array();
		protected $_cookies = array();
		protected $_body = "";
		function getStatusCode() {
			return $this->_statusCode;
		}
		function setStatusCode($code) {
			$this->_statusCode = $code;
		}
		function setHeader($name, $value, $replace = false) {
			if (!isset($this->_headers[$name]) || $replace) {
				$this->_headers[$name] = $value;
			}
		}
		function getHeader($name) {
			if (isset($this->_headers[$name])) {
				return $this->_headers[$name];
			} else {
				return null;
			}
		}
		function getHeaders() {
			return $this->_headers;
		}
		function clearHeaders() {
			$this->_headers = array();
		}
		function setCookie($key, $value = "", $expire = false, $path = "", $domain = "", $secure = false, $httponly = false) {
			$this->_cookies[$key] = array(
					"value" => $value,
					"expire" => $expire,
					"path" => $path,
					"domain" => $domain,
					"secure" => (bool)$secure,
					"httponly" => (bool)$httponly);
		}
		function getCookie($key) {
			if (in_array($key, $this->_cookies)) {
				return $this->_cookies[$key];
			} else {
				return null;
			}
		}
		function getCookies() {
			return $this->_cookies;
		}
		function setBody($content) {
			$this->_body = $content;
		}
		function getBody() {
			return $this->_body;
		}
		function clearBody() {
			$this->_body = "";
		}
	}
	abstract class Raw {
		protected $_data = array();
		function __construct($data) {
			$this->_data = $data;
		}
		function __get($key) {
			return $this->_data[$key];
		}
		function __set($key, $value) {
			return $this->_data[$key];
		}
		function __isset($key) {
			return isset($this->_data[$key]);
		}
		function __unset($key) {
			unset($this->_data[$key]);
		}
	}
	class Cookie extends Raw { }
	class Get extends Raw { }
	class Post extends Raw { }
	class Server extends Raw { }
}
namespace Application {
	class Test implements \AppServer\HTTP\Servlet {
		function run(\AppServer\HTTP\Server $server,
			\AppServer\HTTP\Get $get,
			\AppServer\HTTP\Post $post,
			\AppServer\HTTP\Cookie $cookie,
			\AppServer\HTTP\Response $response
			) {
			$response->setBody("Test");
		}
	}
}
namespace {
	$terminate = false;
	$worker = new \AppServer\Gearman\Worker(getenv('GEARMAN_SERVERS'), getenv('GEARMAN_FUNCTION'), getenv('CLASS'));
	while (!$terminate) {
		try {
			$worker->run();
		} catch (\Exception $e) { }
	}
}

Az ehhez tartozó webszerver oldali kód:


<?php
function getGearmanServers() {
	$servers=getenv("GEARMAN_SERVERS");
	$servers = explode(";", $servers);
	if (!count($servers)) {
		$servers = array("127.0.0.1:4730");
	}
	shuffle($servers);
	return $servers;
}
function getGearmanFunction() {
	$func=getenv("GEARMAN_FUNCTION");
	if (!$func) {
		$func = "serve";
	}
	return $func;
}
function hardFail($message) {
	header("HTTP/1.1 500 Internal Server Error");
	$errFile = dirname(__FILE__) . "/500.html";
	if (!file_exists($errFile) || !include($errFile)) {
		echo("<h1>Internal Server Error</h1>");
	}
	exit(500);
}
function processResult($result) {
	$statusCodeMap = array(
		100 => "Continue", 101 => "Switching Protocols",
		200 => "OK", 202 => "Accepted",
		203 => "Non-Authoritative Information",	204 => "No Content",
		205 => "Reset Content",	206 => "Partial Content",
		300 => "Multiple Choices", 301 => "Moved Permanently",
		302 => "Found",	303 => "See Other", 304 => "Not Modified",
		305 => "Use proxy", 307 => "Temporary Redirect",
		400 => "Bad Request", 401 => "Unauthorized",
		403 => "Forbidden", 404 => "Not Found",
		405 => "Method Not Allowed", 406 => "Not Acceptable",
		407 => "Proxy Authentication Required",
		408 => "Request Timeout", 409 => "Conflict", 410 => "Gone",
		411 => "Length Required", 412 => "Precondition Failed",
		413 => "Request Entity Too Large",
		414 => "Request-URI Too Long", 415 => "Unsupported Media Type",
		416 => "Request Range Not Satisfiable",
		417 => "Expectation Failed", 500 => "Internal Server Error",
		501 => "Bad Gateway", 503 => "Service Unavailable",
		504 => "Gateway Timeout", 505 => "HTTP Version Not Supported"
	);
	if (array_key_exists($result['status'], $statusCodeMap)) {
		header("HTTP/1.1 " . $result['status'] . " " . $statusCodeMap[$result['status']]);
	} else {
		header("HTTP/1.1 200 OK");
	}
	foreach ($result['headers'] as $key => $value) {
		header($key . ": " . $value);
	}
	echo($result['body']);
}
$func = getGearmanFunction();
$gc = new GearmanClient();
$gc->addServers(implode(",", getGearmanServers()));
$response = $gc->do($func, base64_encode(json_encode(array("server" => $_SERVER, "get" => $_GET, "post" => $_POST, "cookie" => $_COOKIE))));
$response = json_decode(base64_decode($response), true);
if (is_array($response)) {
	processResult($response);
} else {
	hardFail("Invalid response");
}

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