TCHDBServerを修正

大晦日のエントリでPUTのリクエストエンティティ取得が遅いと書きましたが、原因はサーバの実装がRFC 2616/HTTP 1.1に準拠していなかったことでした。つまり全面的に僕が悪かった、と。orz
pecl_http (というかcURL) はPUTメソッドのリクエストを送信するときはヘッダに"Expect: 100-continue"を加えるようになっているのですが、先のサーバではそれを無視していたため、cURLが"100 Continue"を待ちきれずにエンティティを送信して、たまたまうまく動いているように見えていた、というわけでした。
で、ちゃんと"Expect: 100-continue"に対して"100 Continue"を返すようにした修正版。

<?php
class TCHDBRequestException extends RuntimeException {}

class TCHDBServer
{
    /**
     * A map of HTTP responce codes and HTTP responce status messages.
     */
    static private $statuses = array(
        100 => 'Continue',
        101 => 'Switching Protocols',
        200 => 'OK',
        201 => 'Created',
        202 => 'Accepted',
        203 => 'Non-Authoritative Information',
        204 => 'No Content',
        205 => 'Reset Content',
        206 => 'Partial Content',
        300 => 'Multiple Choices',
        301 => 'Moved Permanently',
        302 => 'Moved Temporarily',
        303 => 'See Other',
        304 => 'Not Modified',
        305 => 'Use Proxy',
        400 => 'Bad Request',
        401 => 'Unauthorized',
        402 => 'Payment Required',
        403 => 'Forbidden',
        404 => 'Not Found',
        405 => 'Method Not Allowed',
        406 => 'Not Acceptable',
        407 => 'Proxy Authentication Required',
        408 => 'Request Timed-out',
        409 => 'Conflict',
        410 => 'Gone',
        411 => 'Length Required',
        412 => 'Precondition Failed',
        413 => 'Request Entity Too Large',
        414 => 'Request-URI Too Large',
        415 => 'Unsupported Media Type',
        500 => 'Internal Server Error',
        501 => 'Not Implemented',
        502 => 'Bad Gateway',
        503 => 'Service Unavailable',
        504 => 'Gateway Time-out',
        505 => 'HTTP Version not supported',
    );

    /**
     * An instance of TCHDB.
     *
     * @var TCHDB
     */
    private $hdb;

    /**
     * A server socket resource.
     *
     * @var resource
     */
    private $sock;

    /**
     * A client socket resource.
     *
     * @var resource
     */
    private $conn;

    /**
     * A timeout value in seconds.
     *
     * @var int
     */
    private $timeoutSec = 1;

    /**
     * A timeout value in milliseconds.
     *
     * @var int
     */
    private $timeoutUsec = 0;

    /**
     * A maximum length of request entities.
     *
     * @var int
     */
    private $maxLength = -1;

    /**
     * Constructor.
     *
     * @param TCHDB $hdb
     * @param string $host
     * @param int $port
     * @param array $options
     * @return void
     * @throws RuntimeException
     */
    public function __construct(TCHDB $hdb, $host = 'localhost', $port = 8080, array $options = array())
    {
        $this->hdb = $hdb;

        if (strpos($host, '::') !== false) {
            $local_host = sprintf('tcp://[%s]:%d', $host, $port);
        } else {
            $local_host = sprintf('tcp://%s:%d', $host, $port);
        }

        $this->sock = stream_socket_server($local_host, $errno, $errstr, STREAM_SERVER_BIND | STREAM_SERVER_LISTEN);
        if (!$this->sock) {
            throw new RuntimeException($errstr, $errno);
        }

        if (isset($options['timeoutSec'])) {
            $this->timeoutSec = (int)$options['timeoutSec'];
        }
        if (isset($options['timeoutUsec'])) {
            $this->timeoutUsec = (int)$options['timeoutUsec'];
        }
        if (isset($options['maxLength'])) {
            $this->maxLength = (int)$options['maxLength'];
        }
        if ($this->maxLength <= 0) {
            $this->maxLength = PHP_INT_MAX;
        }
    }

    /**
     * Run a hash database server.
     *
     * @return void
     */
    public function run()
    {
        while ($this->conn = stream_socket_accept($this->sock, -1.0)) {
            try {
                stream_set_timeout($this->conn, $this->timeoutSec, $this->timeoutUsec);

                /**
                 * Receive and parse HTTP headers.
                 */
                $headers_string = '';
                $line = '';
                while (!feof($this->conn) && ($line = fgets($this->conn)) !== false) {
                    if (strlen(trim($line)) == 0) {
                        break;
                    }
                    $headers_string .= $line;
                }

                if ($line === false) {
                    $info = stream_get_meta_data($this->conn);
                    $code = $info['timed_out'] ? 408 : 400;
                    throw new TCHDBRequestException(self::$statuses[$code], $code);
                }

                $headers = HttpUtil::parseHeaders($headers_string);
                //print_r($headers);

                if (!$headers ||
                    !isset($headers['Request Method']) ||
                    !isset($headers['Request Url']) ||
                    !isset($headers['Host']))
                {
                    throw new TCHDBRequestException(self::$statuses[400], 400);
                }

                /**
                 * Decode an URL.
                 */
                $key = ltrim($headers['Request Url'], '/');
                if (strlen($key) == 0) {
                    throw new TCHDBRequestException(self::$statuses[400], 400);
                }
                $key = rawurldecode($key);

                /**
                 * Handle a request.
                 */
                switch (strtoupper($headers['Request Method'])) {
                  case 'GET':
                    $this->handleGet($key);
                    break;
                  case 'PUT':
                    $this->handlePut($key, $headers);
                    break;
                  case 'DELETE':
                    $this->handleDelete($key);
                    break;
                  default:
                    $this->returnStatus(405);
                }
            } catch (TCHDBRequestException $e) {
                $this->returnStatus($e->getCode(), $e->getMessage());
            } catch (Exception $e) {
                $this->returnStatus(500, $e->getMessage());
            }
            fclose($this->conn);
        }
    }

    /**
     * Handle a GET request.
     *
     * @param string $key
     * @return void
     * @throws TCException
     */
    private function handleGet($key)
    {
        if (($data = $this->hdb->get($key)) === null) {
            $this->returnStatus(404, "'$key' not found.");
        } else {
            $this->returnResponse(200, 'application/octet-stream', $data);
        }
    }

    /**
     * Handle a PUT request.
     *
     * @param string $key
     * @param array $headers
     * @return void
     * @throws TCHDBRequestException, TCException
     */
    private function handlePut($key, array $headers)
    {
        /**
         * Check for "Content-Length" header.
         */
        $length = -1;

        foreach ($headers as $name => $value) {
            if (!strcasecmp('Content-Length', $name)) {
                if (!is_string($value)) {
                    throw new TCHDBRequestException(self::$statuses[400], 400);
                }
                $length = (int)$value;
                break;
            }
        }

        if ($length < 0) {
            throw new TCHDBRequestException(self::$statuses[411], 411);
        } elseif ($length > $this->maxLength) {
            throw new TCHDBRequestException(self::$statuses[413], 413);
        }

        /**
         * Check for "Except: 100-continue" header.
         */
        foreach ($headers as $name => $value) {
            if (!strcasecmp('Expect', $name)) {
                if (!is_string($value)) {
                    throw new TCHDBRequestException(self::$statuses[400], 400);
                }
                if (stripos($value, '100-continue') !== false) {
                    $this->returnStatus(100);
                    usleep(1000);
                }
                break;
            }
        }

        /**
         * Read the request entity.
         */
        $body = '';

        while (strlen($body) < $length && !feof($this->conn)) {
            if (($buf = fread($this->conn, $length - strlen($body))) === false) {
                break;
            }
            $body .= $buf;
        }

        if (strlen($body) != $length) {
            $info = stream_get_meta_data($this->conn);
            $code = $info['timed_out'] ? 408 : 400;
            throw new TCHDBRequestException(self::$statuses[$code], $code);
        }

        /**
         * Put the request entity.
         */
        $this->hdb->put($key, $body);

        $url = 'http://' . $headers['Host'] . $headers['Request Url'];
        $this->returnResponse(201, 'text/plain', $url, array('Location' => $headers['Request Url']));
    }

    /**
     * Handle a DELETE request.
     *
     * @param string $key
     * @return void
     * @throws TCException
     */
    private function handleDelete($key)
    {
        $this->hdb->out($key);

        $this->returnStatus(200);
    }

    /**
     * Send a simple HTTP response.
     *
     * @param int $code
     * @param string $body
     * @return void
     */
    private function returnStatus($code, $body = null)
    {
        if ($body === null) {
            $body = self::$statuses[$code];
        }
        $this->returnResponse($code, 'text/plain', $body);
    }

    /**
     * Send a HTTP response.
     *
     * @param int $code
     * @param string $mimetype
     * @param string $body
     * @param array $headers
     * @return void
     */
    private function returnResponse($code, $mimetype = 'text/plain', $body = '', array $headers = array())
    {
        $msg = new HttpMessage();
        $msg->setType(HttpMessage::TYPE_RESPONSE);
        $msg->setResponseCode($code);
        $msg->setResponseStatus(self::$statuses[$code]);
        $headers['Content-Type'] = (string)$mimetype;
        $headers['Content-Length'] = (string)strlen($body);
        $msg->setHeaders($headers);
        $msg->setBody($body);
        fwrite($this->conn, $msg->toString());
    }
}

/**
 * Run the test server.
 */
if (PHP_SAPI == 'cli' && realpath($_SERVER['SCRIPT_FILENAME']) == __FILE__) {
    $hdb = new TCHDB();
    $hdb->open('/tmp/server-test.hdb', TCHDB::OWRITER | TCHDB::OCREAT);
    $server = new TCHDBServer($hdb);
    $server->run();
}

クライアントも"100 Continue"を受け取ったら続きを送信するように修正。

<?php
$base_url = 'http://localhost:8080/';
$key = 'hoge';
$url = $base_url . $key;
$data = 'Tokyo Cabinet';

function response_dump(HttpMessage $res)
{
    printf("Code: %d\n", $res->getResponseCode());
    printf("Body: %s\n", $res->getBody());
}

echo "GET:\n";
$req = new HttpRequest($url, HttpRequest::METH_GET);
$res = $req->send();
response_dump($res);
echo "\n";

echo "PUT:\n";
$req = new HttpRequest($url, HttpRequest::METH_PUT);
$req->setPutData($data);
//$req->setPutFile(__FILE__);
$res = $req->send();
if ($res->getResponseCode() == 100) {
    $res = $req->send();
}
response_dump($res);
echo "\n";

echo "GET:\n";
$req = new HttpRequest($url, HttpRequest::METH_GET);
$res = $req->send();
response_dump($res);
echo "\n";

echo "DELETE:\n";
$req = new HttpRequest($url, HttpRequest::METH_DELETE);
$res = $req->send();
response_dump($res);
echo "\n";

echo "GET:\n";
$req = new HttpRequest($url, HttpRequest::METH_GET);
$res = $req->send();
response_dump($res);
echo "\n";

これでPUTもまともに使えるようになりました。めでたしめでたし。