“The SRV RR allows administrators to use several servers for a single domain, to move services from host to host with little fuss, and to designate some hosts as primary servers for a service and others as backups.”
I am currently working on a project that requires high availabilty of internal HTTP servers, with load balancing and failover. SRV would work very nicely for this, as it provides both priority and weight for the “target” provided by each record... except that HTTP is not one of the above mentioned services and, like most HTTP clients, the PHP client library used by the project (Zend_Http_Client) does not support SRV lookups. Fortunately, the Zend Framework is written in a highly modular fashion, and the solution turned out to be relatively simple. Zend_Http_Client uses “adapters” to make the actual connection to the server. Zend_Http_Client_Adapter_Socket and Zend_Http_Client_Adapter_Proxy are, for example, a couple of the included adapters. In order to make Zend_Http_Client SRV aware, it was necessary to write a new adapter, Mgw_Http_Client_Adapter_Srv. I believe it to be reasonably compliant with RFC 2782. It performs a lookup of SRV records for the "host" requested and then attempts to connect to each resource record's target in the order defined by the priority and weight. Upon attempting to connect, it will fail over to the next target if there is no route to the current target or if the connection to the current target times out. The adapter will also advance to the next target during the read process if a 503 (Server Too Busy) response is received. In that case, the next target is selected and the request is re-sent to the new target.
The adapter gracefully degrades in the event that no SRV records are found and, in that case, is nearly identical to Zend_Http_Client_Adapter_Socket. It can be used as follows:
$client = new Zend_Http_Client("http://app.example.com",
array(
'adapter' => 'Mgw_Http_Client_Adapter_Srv',
'timeout' => 1
)
);
$response = $client->request();
Note the short timeout of 1 second. When utilizing SRV records, we do not want to 'hang' on one of the servers waiting for the timeout. Rather, we want to go ahead and advance to the next server.
I am posting the full class below in hopes that it will be useful to others. Comments and suggestions are always welcome.
Mgw_Http_Client_Adapter_Srv:
require_once 'Zend/Uri/Http.php';
require_once 'Zend/Http/Client/Adapter/Interface.php';
/**
* A sockets based (stream_socket_client) adapter class for Zend_Http_Client
* that is DNS SRV (RFC 2782) resource record aware.
* Based on Zend_Http_Client_Adapter_Socket.
*/
class Mgw_Http_Client_Adapter_Srv implements Zend_Http_Client_Adapter_Interface
{
/**
* The socket for server connection
*
* @var resource|null
*/
protected $socket = null;
/**
* Remaining targets to try.
*
* @var array
*/
protected $target_hosts = null;
/**
* What host/port are we connected to?
*
* @var array
*/
protected $connected_to = array(null, null);
/**
* Secure connection required?
*
* @var boolean
*/
protected $secure = false;
/**
* Parameters array
*
* @var array
*/
protected $config = array(
'persistent' => false,
'ssltransport' => 'ssl',
'sslcert' => null,
'sslpassphrase' => null,
'advanceon503' => true
);
/**
* Request strings - will be set by write() and will be
* used by read() if it needs to resend the request due to a 503.
*
* @var string
*/
protected $request = null;
/**
* Request method - will be set by write() and might be used by read()
*
* @var string
*/
protected $method = null;
/**
* Adapter constructor, currently empty. Config is set using setConfig()
*
*/
public function __construct()
{
}
/**
* Set the configuration array for the adapter
*
* @param array $config
*/
public function setConfig($config = array())
{
if (! is_array($config)) {
require_once 'Zend/Http/Client/Adapter/Exception.php';
throw new Zend_Http_Client_Adapter_Exception(
'$config expects an array, ' . gettype($config) . ' recieved.');
}
foreach ($config as $k => $v) {
$this->config[strtolower($k)] = $v;
}
}
/**
* Connect to the remote server
*
* @param string $host
* @param int $port
* @param boolean $secure
*/
public function connect($host, $port = 80, $secure = false)
{
$this->secure = $secure;
$this->get_targets($host,$port,$secure);
$this->connect_next_target();
}
/**
* Grab and sort the targets from DNS
*
* @param string $host
* @param int $port
* @param boolean $secure
*/
public function get_targets($host,$port,$secure) {
$srv_records = $secure ?
dns_get_record("_https._tcp.".$host, DNS_SRV) : dns_get_record("_http._tcp.".$host, DNS_SRV);
if (empty($srv_records)) {
$this->target_hosts = array(array('target' => $host, 'port' => $port));
} else {
$this->target_hosts = self::srv_sort($srv_records);
}
}
/**
* Connect to the next target server
*/
public function connect_next_target()
{
// If we are already connected, dosconnect
if (is_resource($this->socket)) {
$this->close();
}
$context = stream_context_create();
if ($this->secure) {
if ($this->config['sslcert'] !== null) {
if (! stream_context_set_option($context, 'ssl', 'local_cert',
$this->config['sslcert'])) {
require_once 'Zend/Http/Client/Adapter/Exception.php';
throw new Zend_Http_Client_Adapter_Exception('Unable to set sslcert option');
}
}
if ($this->config['sslpassphrase'] !== null) {
if (! stream_context_set_option($context, 'ssl', 'passphrase',
$this->config['sslpassphrase'])) {
require_once 'Zend/Http/Client/Adapter/Exception.php';
throw new Zend_Http_Client_Adapter_Exception('Unable to set sslpassphrase option');
}
}
}
$flags = STREAM_CLIENT_CONNECT;
foreach ($this->target_hosts as $key => $target) {
// If the URI should be accessed via SSL, prepend the Hostname with ssl://
$host = ($this->secure ? $this->config['ssltransport'] : 'tcp') . '://' . $target['host'];
$port = $target['port'];
$this->socket = @stream_socket_client($host . ':' . $port,
$errno,
$errstr,
(int) $this->config['timeout'],
$flags,
$context);
if ($this->socket) {
// The connection was successful
break;
} else {
// Could not connect to this target; remove it
array_shift($this->target_hosts);
}
}
if (! $this->socket) {
$this->close();
require_once 'Zend/Http/Client/Adapter/Exception.php';
throw new Zend_Http_Client_Adapter_Exception(
'Unable to connect to any target host.');
}
// Set the stream timeout
if (! stream_set_timeout($this->socket, (int) $this->config['timeout'])) {
require_once 'Zend/Http/Client/Adapter/Exception.php';
throw new Zend_Http_Client_Adapter_Exception('Unable to set the connection timeout');
}
}
/**
* Send request to the remote server
*
* @param string $method
* @param Zend_Uri_Http $uri
* @param string $http_ver
* @param array $headers
* @param string $body
* @return string Request as string
*/
public function write($method, $uri, $http_ver = '1.1', $headers = array(), $body = '')
{
// Make sure we're properly connected
if (! $this->socket) {
require_once 'Zend/Http/Client/Adapter/Exception.php';
throw new Zend_Http_Client_Adapter_Exception('Trying to write but we are not connected');
}
// Save request method for later
$this->method = $method;
// Build request headers
$path = $uri->getPath();
if ($uri->getQuery()) $path .= '?' . $uri->getQuery();
$request = "{$method} {$path} HTTP/{$http_ver}\r\n";
foreach ($headers as $k => $v) {
if (is_string($k)) $v = ucfirst($k) . ": $v";
$request .= "$v\r\n";
}
// Add the request body
$request .= "\r\n" . $body;
$this->request = $request;
$this->write_request_to_socket();
return $request;
}
private function write_request_to_socket()
{
// Send the request
if (! @fwrite($this->socket, $this->request)) {
require_once 'Zend/Http/Client/Adapter/Exception.php';
throw new Zend_Http_Client_Adapter_Exception('Error writing request to server');
}
}
/**
* Read response from server
*
* @return string
*/
public function read()
{
// First, read headers only
$response = '';
$gotStatus = false;
while (($line = @fgets($this->socket)) !== false) {
$gotStatus = $gotStatus || (strpos($line, 'HTTP') !== false);
if ($gotStatus) {
$response .= $line;
if (rtrim($line) === '') break;
}
}
$statusCode = Zend_Http_Response::extractCode($response);
if ($statusCode == 503) {
print_r($this->target_hosts);
array_shift($this->target_hosts);
$this->connect_next_target();
$this->write_request_to_socket();
return $this->read();
}
// Handle 100 and 101 responses internally by restarting the read again
if ($statusCode == 100 || $statusCode == 101) return $this->read();
/**
* Responses to HEAD requests and 204 or 304 responses are not expected
* to have a body - stop reading here
*/
if ($statusCode == 304 || $statusCode == 204 ||
$this->method == Zend_Http_Client::HEAD) return $response;
// Check headers to see what kind of connection / transfer encoding we have
$headers = Zend_Http_Response::extractHeaders($response);
// If we got a 'transfer-encoding: chunked' header
if (isset($headers['transfer-encoding'])) {
if ($headers['transfer-encoding'] == 'chunked') {
do {
$line = @fgets($this->socket);
$chunk = $line;
// Figure out the next chunk size
$chunksize = trim($line);
if (! ctype_xdigit($chunksize)) {
$this->close();
require_once 'Zend/Http/Client/Adapter/Exception.php';
throw new Zend_Http_Client_Adapter_Exception('Invalid chunk size "' .
$chunksize . '" unable to read chunked body');
}
// Convert the hexadecimal value to plain integer
$chunksize = hexdec($chunksize);
// Read chunk
$left_to_read = $chunksize;
while ($left_to_read > 0) {
$line = @fread($this->socket, $left_to_read);
if ($line === false || strlen($line) === 0)
{
break;
} else {
$chunk .= $line;
$left_to_read -= strlen($line);
}
// Break if the connection ended prematurely
if (feof($this->socket)) break;
}
$chunk .= @fgets($this->socket);
$response .= $chunk;
} while ($chunksize > 0);
} else {
throw new Zend_Http_Client_Adapter_Exception('Cannot handle "' .
$headers['transfer-encoding'] . '" transfer encoding');
}
// Else, if we got the content-length header, read this number of bytes
} elseif (isset($headers['content-length'])) {
$left_to_read = $headers['content-length'];
$chunk = '';
while ($left_to_read > 0) {
$chunk = @fread($this->socket, $left_to_read);
if ($chunk === false || strlen($chunk) === 0)
{
break;
} else {
$left_to_read -= strlen($chunk);
$response .= $chunk;
}
// Break if the connection ended prematurely
if (feof($this->socket)) break;
}
// Fallback: just read the response until EOF
} else {
do {
$buff = @fread($this->socket, 8192);
if ($buff === false || strlen($buff) === 0)
{
break;
} else {
$response .= $buff;
}
} while (feof($this->socket) === false);
$this->close();
}
// Close the connection if requested to do so by the server
if (isset($headers['connection']) && $headers['connection'] == 'close') {
$this->close();
}
return $response;
}
/**
* Close the connection to the server
*
*/
public function close()
{
if (is_resource($this->socket)) @fclose($this->socket);
$this->socket = null;
}
/**
* Destructor: make sure the socket is disconnected
*
* If we are in persistent TCP mode, will not close the connection
*
*/
public function __destruct()
{
if ($this->socket) $this->close();
}
private static function srv_sort(array $recs) {
//See RFC 2782 for details on this sort algorithm
$srv_recs = array();
//first, organize by priority
foreach ($recs as $rr) {
$priorities[$rr['pri']][] = $rr;
}
ksort($priorities);
//now, randomly select records by weight
foreach ($priorities as $priority) {
//sort by weight, to get the zero weight items first
usort($priority,array(self,'srv_weight_cmp'));
while (count($priority)) {
//sum the weights, keeping a running total
$sum = 0;
foreach ($priority as $k => $v) {
$sum += $v['weight'];
$priority[$k]['weight_sum'] = $sum;
}
if ($sum == 0) {
shuffle($priority);
foreach ($priority as $k => $v) {
$srv_recs[] = array("host" => $v['target'], "port" => $v['port']);
unset($priority[$k]);
break;
}
} else {
$rand = rand(1,$sum);
foreach ($priority as $k => $v) {
if ($v['weight_sum'] >= $rand) {
$srv_recs[] = array("host" => $v['target'], "port" => $v['port']);
unset($priority[$k]);
break;
}
}
}
}
}
return $srv_recs;
}
private static function srv_weight_cmp($a,$b) {
if ($a['weight'] == $b['weight']) {
return 0;
}
return ($a['weight'] < $b['weight']) ? -1 : 1;
}
}
No comments:
Post a Comment