[go: up one dir, main page]

HTTP server applications in PHP based on Revolt

Learn how to build non-blocking, concurrent HTTP/1.1 and HTTP/2 server applications in PHP based on Revolt.

AMPHP is a collection of event-driven libraries for PHP designed with fibers and concurrency in mind. This package provides a non-blocking, concurrent HTTP/1.1 and HTTP/2 application server for PHP based on Revolt. Several features are provided in separate packages, such as the WebSocket component.

Features

Requirements

  • PHP 8.1+

Installation

This package can be installed as a Composer dependency.

composer require amphp/http-server

Additionally, you might want to install the nghttp2 library to take advantage of FFI to speed up and reduce the memory usage.

Usage

This library provides access to your application through the HTTP protocol, accepting client requests and forwarding those requests to handlers defined by your application which will return a response.

Incoming requests are represented by Request objects. A request is provided to an implementor of RequestHandler, which defines a handleRequest() method returning an instance of Response.

public function handleRequest(Request $request): Response

Request handlers are covered in greater detail in the RequestHandler section.

This HTTP server is built on top of the Revolt event-loop and the non-blocking concurrency framework Amp. Thus it inherits full support of all their primitives and it is possible to use all the non-blocking libraries built on top of Revolt.

In general, you should make yourself familiar with the Future concept, with coroutines, and be aware of the several combinator functions to really succeed at using the HTTP server.

Blocking I/O

Nearly every built-in function of PHP is doing blocking I/O, that means, the executing thread (mostly equivalent to the process in the case of PHP) will effectively be halted until the response is received. A few examples of such functions: mysqli_query, file_get_contents, usleep and many more.

A good rule of thumb is: Every built-in PHP function doing I/O is doing it in a blocking way, unless you know for sure it doesn’t.

There are libraries providing implementations that use non-blocking I/O. You should use these instead of the built-in functions.

We cover the most common I/O needs, such as network sockets, file access, HTTP requests and websockets, MySQL and Postgres database clients, and Redis. If using blocking I/O or long computations are necessary to fulfill a request, consider using the Parallel library to run that code in a separate process or thread.

Do not use any blocking I/O functions in the HTTP server.

// Here's a bad example, DO NOT do something like the following!

$handler = new ClosureRequestHandler(function () {
    sleep(5); // Equivalent to a blocking I/O function with a 5 second timeout

    return new Response;
});

// Start a server with this handler and hit it twice.
// You'll have to wait until the 5 seconds are over until the second request is handled.

Creating an HTTP Server

Your application will be served by an instance of HttpServer. This library provides SocketHttpServer, which will be suitable for most applications, built on components found in this library and in amphp/socket.

To create an instance of SocketHttpServer and listen for requests, minimally four things are required:

  • an instance of RequestHandler to respond to incoming requests,
  • an instance of ErrorHander to provide responses to invalid requests,
  • an instance of Psr\Log\LoggerInterface, and
  • at least one host+port on which to listen for connections.
<?php
use Amp\ByteStream;
use Amp\Http\HttpStatus;
use Amp\Http\Server\DefaultErrorHandler;
use Amp\Http\Server\Request;
use Amp\Http\Server\RequestHandler;
use Amp\Http\Server\Response;
use Amp\Http\Server\SocketHttpServer;
use Amp\Log\ConsoleFormatter;
use Amp\Log\StreamHandler;
use Monolog\Logger;
use Monolog\Processor\PsrLogMessageProcessor;

require __DIR__.'/vendor/autoload.php';

// Note any PSR-3 logger may be used, Monolog is only an example.
$logHandler = new StreamHandler(ByteStream\getStdout());
$logHandler->pushProcessor(new PsrLogMessageProcessor());
$logHandler->setFormatter(new ConsoleFormatter());

$logger = new Logger('server');
$logger->pushHandler($logHandler);

$requestHandler = new class() implements RequestHandler {
    public function handleRequest(Request $request) : Response
    {
        return new Response(
            status: HttpStatus::OK,
            headers: ['Content-Type' => 'text/plain'],
            body: 'Hello, world!',
        );
    }
};

$errorHandler = new DefaultErrorHandler();

$server = SocketHttpServer::createForDirectAccess($logger);
$server->expose('127.0.0.1:1337');
$server->start($requestHandler, $errorHandler);

// Serve requests until SIGINT or SIGTERM is received by the process.
Amp\trapSignal([SIGINT, SIGTERM]);

$server->stop();

The above example creates a simple server which sends a plain-text response to every request received.

SocketHttpServer provides two static constructors for common use-cases in addition to the normal constructor for more advanced and custom uses.

  • SocketHttpServer::createForDirectAccess(): Used in the example above, this creates an HTTP application server suitable for direct network access. Adjustable limits are imposed on connections per IP, total connections, and concurrent requests (10, 1000, and 1000 by default, respectively). Response compression may be toggled on or off (on by default) and request methods are limited to a known set of HTTP verbs by default.
  • SocketHttpServer::createForBehindProxy(): Creates a server appropriate for use when behind a proxy service such as nginx. This static constructor requires a list of trusted proxy IPs (with optional subnet masks) and an enum case of ForwardedHeaderType (corresponding to either Forwarded or X-Forwarded-For) to parse the original client IP from request headers. No limits are imposed on the number of connections to the server, however the number of concurrent requests are limited (1000 by default, adjustable or can be removed). Response compression may be toggled on or off (on by default). Request methods are limited to a known set of HTTP verbs by default.

If neither of these methods serve your application needs, the SocketHttpServer constructor may be used directly. This provides an enormous amount of flexibility in how incoming connections client connections are created and handled, but will require more code to create. The constructor requires the user to pass an instance of SocketServerFactory, used to create client Socket instances (both components of the amphp/socket library), and an instance of ClientFactory, which appropriately creates Client instances which are attached to each Request made by the client.

RequestHandler

Incoming requests are represented by Request objects. A request is provided to an implementor of RequestHandler, which defines a handleRequest() method returning an instance of Response.

public function handleRequest(Request $request): Response

Each client request (i.e., call to RequestHandler::handleRequest()) is executed within a separate coroutine so requests are automatically handled cooperatively within the server process. When a request handler waits on non-blocking I/O, other client requests are processed in concurrent coroutines. Your request handler may itself create other coroutines using Amp\async() to execute multiple tasks for a single request.

Usually a RequestHandler directly generates a response, but it might also delegate to another RequestHandler. An example for such a delegating RequestHandler is the Router.

The RequestHandler interface is meant to be implemented by custom classes. For very simple use cases or quick mocking, you can use CallableRequestHandler, which can wrap any callable and accepting a Request and returning a Response.

Middleware

Middleware allows pre-processing of requests and post-processing of responses. Apart from that, a middleware can also intercept the request processing and return a response without delegating to the passed request handler. Classes have to implement the Middleware interface for that.

Middleware generally follows other words like soft- and hardware with its plural. However, we use the term middlewares to refer to multiple objects implementing the Middleware interface.

public function handleRequest(Request $request, RequestHandler $next): Response

handleRequest is the only method of the Middleware interface. If the Middleware doesn’t handle the request itself, it should delegate the response creation to the received RequestHandler.

function stackMiddleware(RequestHandler $handler, Middleware ...$middleware): RequestHandler

Multiple middlewares can be stacked by using Amp\Http\Server\Middleware\stackMiddleware(), which accepts a RequestHandler as first argument and a variable number of Middleware instances. The returned RequestHandler will invoke each middleware in the provided order.

$requestHandler = new class implements RequestHandler {
    public function handleRequest(Request $request): Response
    {
        return new Response(
            status: HttpStatus::OK,
            headers: ["content-type" => "text/plain; charset=utf-8"],
            body: "Hello, World!",
        );
    }
}

$middleware = new class implements Middleware {
    public function handleRequest(Request $request, RequestHandler $next): Response
    {
        $requestTime = microtime(true);

        $response = $next->handleRequest($request);
        $response->setHeader("x-request-time", microtime(true) - $requestTime);

        return $response;
    }
};

$stackedHandler = Middleware\stackMiddleware($requestHandler, $middleware);
$errorHandler = new DefaultErrorHandler();

// $logger is a PSR-3 logger instance.
$server = SocketHttpServer::createForDirectAccess($logger);
$server->expose('127.0.0.1:1337');
$server->start($stackedHandler, $errorHandler);

ErrorHandler

An ErrorHander is used by the HTTP server when a malformed or otherwise invalid request is received. The Request object is provided if one constructed from the incoming data, but may not always be set.

public function handleError(
    int $status,
    ?string $reason = null,
    ?Request $request = null,
): Response

This library provides DefaultErrorHandler which returns a stylized HTML page as the response body. You may wish to provide a different implementation for your application, potentially using multiple in conjunction with a router.

Request

Constructor

It is rare you will need to construct a Request object yourself, as they will typically be provided to RequestHandler::handleRequest() by the server.

/**
 * @param string $method The HTTP method verb.
 * @param array<string>|array<string, array<string>> $headers An array of strings or an array of string arrays.
 */
public function __construct(
    private readonly Client $client,
    string $method,
    Psr\Http\Message\UriInterface $uri,
    array $headers = [],
    Amp\ByteStream\ReadableStream|string $body = '',
    private string $protocol = '1.1',
    ?Trailers $trailers = null,
)

Methods

public function getClient(): Client

Returns the Сlient sending the request

public function getMethod(): string

Returns the HTTP method used to make this request, e.g. "GET".

public function setMethod(string $method): void

Sets the request HTTP method.

public function getUri(): Psr\Http\Message\UriInterface

Returns the request URI.

public function setUri(Psr\Http\Message\UriInterface $uri): void

Sets a new URI for the request.

public function getProtocolVersion(): string

Returns the HTTP protocol version as a string (e.g. “1.0”, “1.1”, “2”).

public function setProtocolVersion(string $protocol)

Sets a new protocol version number for the request.

/** @return array<non-empty-string, list<string>> */
public function getHeaders(): array

Returns the headers as a string-indexed array of arrays of strings or an empty array if no headers have been set.

public function hasHeader(string $name): bool

Checks if given header exists.

/** @return list<string> */
public function getHeaderArray(string $name): array

Returns the array of values for the given header or an empty array if the header does not exist.

public function getHeader(string $name): ?string

Returns the value of the given header. If multiple headers are present for the named header, only the first header value will be returned. Use getHeaderArray() to return an array of all values for the particular header. Returns null if the header does not exist.

public function setHeaders(array $headers): void

Sets the headers from the given array.

/** @param array<string>|string $value */
public function setHeader(string $name, array|string $value): void

Sets the header to the given value(s). All previous header lines with the given name will be replaced.

/** @param array<string>|string $value */
public function addHeader(string $name, array|string $value): void

Adds an additional header line with the given name.

public function removeHeader(string $name): void

Removes the given header if it exists. If multiple header lines with the same name exist, all of them are removed.

public function getBody(): RequestBody

Returns the request body. The RequestBody allows streamed and buffered access to an InputStream.

public function setBody(ReadableStream|string $body)

Sets the stream for the message body

Using a string will automatically set the Content-Length header to the length of the given string. Setting an ReadableStream will remove the Content-Length header. If you know the exact content length of your stream, you can add a content-length header after calling setBody().

/** @return array<non-empty-string, RequestCookie> */
public function getCookies(): array

Returns all cookies in associative map of cookie name to RequestCookie.

public function getCookie(string $name): ?RequestCookie

Gets a cookie value by name or null.

public function setCookie(RequestCookie $cookie): void

Adds a Cookie to the request.

public function removeCookie(string $name): void

Removes a cookie from the request.

public function getAttributes(): array

Returns an array of all the attributes stored in the request’s mutable local storage.

public function removeAttributes(): array

Removes all request attributes from the request’s mutable local storage.

public function hasAttribute(string $name): bool

Check whether an attribute with the given name exists in the request’s mutable local storage.

public function getAttribute(string $name): mixed

Retrieve a variable from the request’s mutable local storage.

Name of the attribute should be namespaced with a vendor and package namespace, like classes.

public function setAttribute(string $name, mixed $value): void

Assign a variable to the request’s mutable local storage.

Name of the attribute should be namespaced with a vendor and package namespace, like classes.

public function removeAttribute(string $name): void

Removes a variable from the request’s mutable local storage.

public function getTrailers(): Trailers

Allows access to the Trailers of a request.

public function setTrailers(Trailers $trailers): void

Assigns the Trailers object to be used in the request.

Request Clients

Client-related details are bundled into Amp\Http\Server\Driver\Client objects returned from Request::getClient(). The Client interface provides methods to retrieve the remote and local socket addresses and TLS info (if applicable).

Response

The Response class represents an HTTP response. A Response is returned by request handlers and middleware.

Constructor

/**
 * @param int $code The HTTP response status code.
 * @param array<string>|array<string, array<string>> $headers An array of strings or an array of string arrays.
 */
public function __construct(
    int $code = HttpStatus::OK,
    array $headers = [],
    Amp\ByteStream\ReadableStream|string $body = '',
    ?Trailers $trailers = null,
)

Destructor

Invokes dispose handlers (i.e. functions that registered via onDispose() method).

Uncaught exceptions from the dispose handlers will be forwarded to the event loop error handler.

Methods

public function getBody(): Amp\ByteStream\ReadableStream

Returns the stream for the message body.

public function setBody(Amp\ByteStream\ReadableStream|string $body)

Sets the stream for the message body.

Using a string will automatically set the Content-Length header to the length of the given string. Setting an ReadableStream will remove the Content-Length header. If you know the exact content length of your stream, you can add a content-length header after calling setBody().

/** @return array<non-empty-string, list<string>> */
public function getHeaders(): array

Returns the headers as a string-indexed array of arrays of strings or an empty array if no headers have been set.

public function hasHeader(string $name): bool

Checks if given header exists.

/** @return list<string> */
public function getHeaderArray(string $name): array

Returns the array of values for the given header or an empty array if the header does not exist.

public function getHeader(string $name): ?string

Returns the value of the given header. If multiple headers are present for the named header, only the first header value will be returned. Use getHeaderArray() to return an array of all values for the particular header. Returns null if the header does not exist.

public function setHeaders(array $headers): void

Sets the headers from the given array.

/** @param array<string>|string $value */
public function setHeader(string $name, array|string $value): void

Sets the header to the given value(s). All previous header lines with the given name will be replaced.

/** @param array<string>|string $value */
public function addHeader(string $name, array|string $value): void

Adds an additional header line with the given name.

public function removeHeader(string $name): void

Removes the given header if it exists. If multiple header lines with the same name exist, all of them are removed.

public function getStatus(): int

Returns the response status code.

public function getReason(): string

Returns the reason phrase describing the status code.

public function setStatus(int $code, string | null $reason): void

Sets the numeric HTTP status code (between 100 and 599) and reason phrase. Use null for the reason phrase to use the default phrase associated with the status code.

/** @return array<non-empty-string, ResponseCookie> */
public function getCookies(): array

Returns all cookies in an associative map of cookie name to ResponseCookie.

public function getCookie(string $name): ?ResponseCookie

Gets a cookie value by name or null if no cookie with that name is present.

public function setCookie(ResponseCookie $cookie): void

Adds a cookie to the response.

public function removeCookie(string $name): void

Removes a cookie from the response.

/** @return array<string, Push> Map of URL strings to Push objects. */
public function getPushes(): array

Returns list of push resources in an associative map of URL strings to Push objects.

/** @param array<string>|array<string, array<string>> $headers */
public function push(string $url, array $headers): void

Indicate resources which a client likely needs to fetch. (e.g. Link: preload or HTTP/2 Server Push).

public function isUpgraded(): bool

Returns true if a detach callback has been set, false if none.

/** @param Closure(Driver\UpgradedSocket, Request, Response): void $upgrade */
public function upgrade(Closure $upgrade): void

Sets a callback to be invoked once the response has been written to the client and changes the status of the response to 101 Switching Protocols. The callback receives an instance of Driver\UpgradedSocket, the Request which initiated the upgrade, and this Response.

The callback may be removed by changing the status to something other than 101.

public function getUpgradeCallable(): ?Closure

Returns the upgrade function if present.

/** @param Closure():void $onDispose */
public function onDispose(Closure $onDispose): void

Registers a function that is invoked when the Response is discarded. A response is discarded either once it has been written to the client or if it gets replaced in a middleware chain.

public function getTrailers(): Trailers

Allows access to the Trailers of a response.

public function setTrailers(Trailers $trailers): void

Assigns the Trailers object to be used in the response. Trailers are sent once the entire response body has been set to the client.

Body

RequestBody, returned from Request::getBody(), provides buffered and streamed access to the request body. Use the streamed access to handle large messages, which is particularly important if you have larger message limits (like tens of megabytes) and don’t want to buffer it all in memory. If multiple people are uploading large bodies concurrently, the memory might quickly get exhausted.

Hence, incremental handling is important, accessible via the read() API of Amp\ByteStream\ReadableStream.

In case a client disconnects, the read() fails with an Amp\Http\Server\ClientException. This exception is thrown for both the read() and buffer() API.

ClientExceptions do not need to be caught. You may catch them if you want to continue, but don’t have to. The Server will silently end the request cycle and discard that exception then.

Instead of setting the generic body limit high, you should consider increasing the body limit only where needed, which is dynamically possible with the increaseSizeLimit() method on RequestBody.

RequestBody itself doesn’t provide parsing of form data. You can use amphp/http-server-form-parser if you need it.

Constructor

Like Request, it is rare to need to construct a RequestBody instance as one will be provided as part of the Request.

public function __construct(
    ReadableStream|string $stream,
    ?Closure $upgradeSize = null,
)

Methods

public function increaseSizeLimit(int $limit): void

Increases the body size limit dynamically to allow individual request handlers to handle larger request bodies than the default set for the HTTP server.

Trailers

The Trailers class allows access to the trailers of an HTTP request, accessible via Request::getTrailers(). null is returned if trailers are not expected on the request. Trailers::await() returns a Future which is resolved with an HttpMessage object providing methods to access the trailer headers.

$trailers = $request->getTrailers();
$message = $trailers?->await();

Bottlenecks

The HTTP server won’t be the bottleneck. Misconfiguration, use of blocking I/O, or inefficient applications are.

The server is well-optimized and can handle tens of thousands of requests per second on typical hardware while maintaining a high level of concurrency of thousands of clients.

But that performance will decrease drastically with inefficient applications. The server has the nice advantage of classes and handlers being always loaded, so there’s no time lost with compilation and initialization.

A common trap is to begin operating on big data with simple string operations, requiring many inefficient big copies. Instead, streaming should be used where possible for larger request and response bodies.

The problem really is CPU cost. Inefficient I/O management (as long as it is non-blocking!) is just delaying individual requests. It is recommended to dispatch simultaneously and eventually bundle multiple independent I/O requests via Amp’s combinators, but a slow handler will slow down every other request too. While one handler is computing, all the other handlers can’t continue. Thus it is imperative to reduce computation times of the handlers to a minimum.

Examples

Several examples can be found in the ./examples directory of the repository. These can be executed as normal PHP scripts on the command line.

php examples/hello-world.php

You can then access the example server at http://localhost:1337/ in your browser.

Security

If you discover any security related issues, please use the private security issue reporter instead of using the public issue tracker.

License

The MIT License (MIT). Please see LICENSE for more information.