Posted at

HackにおけるHTTP Request and Response Interfaces

PHPのHTTPメッセージを扱うためのインターフェース PSR-7

PHPを普段開発で利用されている方であれば、

普段からよく利用されているのではないでしょうか。


これまでのHackとPSR-7

Hack界隈でもこれまでは、PSR-7のインターフェースを扱うために

hhiファイルを利用して実装するケースがほとんどでした。

hack-psr/psr7-http-message-hhi


PSR-7 / hhi

なぜhhiファイルを用意する必要があるかというと、

それはこれまでもHackの型システムについて紹介したように、

PHPの型はHackの型として利用できないため、

TypeScriptの型定義ファイルライクにHackの型定義ファイルを作成し、

PHPのPSR-7対応ライブラリをHackで使う必要があったためです。

現在のPHPで意識することがないarrayの型宣言など、

Hack向けに記述する必要があるため、hhiでは、getHeadersなどを筆頭に下記の様に指定する必要があります。

    public function getHeaders(): array<string, array<string>>;

PHPのルーターライブラリで、実質デファクトスタンダードといってもいいくらいの

nikic/fast-routeに、

hhiファイルが同梱されているのは、そうした経緯があったためです。


HackRouter

そんな中、HHVMの開発チームでHack向けのRouterライブラリが用意されました。(facebook/hack-router)

このライブラリは当初nikic/fast-routeを継承し、Hackのコードに混入させて作られていましたが、

0.10で依存しない様に変更されました。

このライブラリについて簡単にみてみましょう。

<?hh // strict

namespace Facebook\HackRouter;

use namespace HH\Lib\Dict;
use function Facebook\AutoloadMap\Generated\is_dev;

abstract class BaseRouter<+TResponder> {

abstract protected function getRoutes(
): ImmMap<HttpMethod, ImmMap<string, TResponder>>;
final public function routeRequest(
HttpMethod $method,
string $path,
): (TResponder, ImmMap<string, string>) {
$resolver = $this->getResolver();
try {
list($responder, $data) = $resolver->resolve($method, $path);
$data = Dict\map($data, $value ==> \urldecode($value));
return tuple($responder, new ImmMap($data));
} catch (NotFoundException $e) {
foreach (HttpMethod::getValues() as $next) {
if ($next === $method) {
continue;
}
try {
list($responder, $data) = $resolver->resolve($next, $path);
if ($method === HttpMethod::HEAD && $next === HttpMethod::GET) {
$data = Dict\map($data, $value ==> \urldecode($value));
return tuple($responder, new ImmMap($data));
}
throw new MethodNotAllowedException();
} catch (NotFoundException $_) {
continue;
}
}
throw $e;
}
}

final public function routePsr7Request(
\Psr\Http\Message\RequestInterface $request,
): (TResponder, ImmMap<string, string>) {
$method = HttpMethod::coerce($request->getMethod());
if ($method === null) {
throw new MethodNotAllowedException();
}
return $this->routeRequest($method, $request->getUri()->getPath());
}

protected function getResolver(): IResolver<TResponder> {
// Don't use <<__Memoize>> because that can be surprising with subclassing
static $resolver = null;
if ($resolver !== null) {
return $resolver;
}
if (is_dev()) {
$routes = null;
} else {
$routes = \apc_fetch(__FILE__.'/cache');
if ($routes === false) {
$routes = null;
}
}
if ($routes === null) {
$routes = Dict\map(
$this->getRoutes(),
$method_routes ==> PrefixMatching\PrefixMap::fromFlatMap(
dict($method_routes),
),
);
if (!is_dev()) {
\apc_store(__FILE__.'/cache', $routes);
}
}
return new PrefixMatchingResolver($routes);
}
}

<+TResponder> はどのようなルートでレスポンスを返却するかを表すGenericsです。

このクラスを継承するクラスが自由に指定することができます。

利用例としては下記の通りです。

type TResponder = (function(dict<string, string>):string);

final class BaseRouterExample extends BaseRouter<TResponder> {
<<__Override>>
protected function getRoutes(
): ImmMap<HttpMethod, ImmMap<string, TResponder>> {
return ImmMap {
HttpMethod::GET => ImmMap {
'/' =>
($_params) ==> 'Hello, world',
'/user/{user_name}' =>
($params) ==> 'Hello, '.$params['user_name'],
},
HttpMethod::POST => ImmMap {
'/' => ($_params) ==> 'Hello, POST world',
},
};
}
}


Symfony 4: End of HHVM support

Requestを受け取り、PSR-7のインターフェースを実装したインスタンスを渡すと、

routePsr7Requestメソッドを介してマッチするルートを取得する様になっていました。

SymfonyがHHVMの動作サポートをやめたあたりから、

多くのPHPのライブラリがHHVM上でのテスト・サポートをやめ、

HHVM上で扱いやすく、

完全に動作するクリーンなzendframework/zend-diactoros が利用されていました。

PHPのライブラリがコード上に現れると、

そのクラスファイルはstrictに指定することができないため、

実装時にTypechekcerによる型チェックがそこまで行われず、

PSR-7 hhiファイルに記述した通りの型で扱う様に確実に実装する必要がありました。

そんな中でHHVMの今後について発表された様に (Ending PHP Support, and The Future Of Hack , HHVM 3.30)

PHPのインターフェースやライブラリを利用する意味もなくなり、

専用のインターフェースが用意される様になりました。


これからのHack HTTP Request And Response Interfaces

数ヶ月前にPSR-7のインターフェースに近い形で、Hack専用のHTTP関連のインターフェースが正式にリリースされました。

hhvm/hack-http-request-response-interfaces

READMEにもあるように、これに伴いHackの機能をフル活用したHTTP関連の実装が行える様になりました。

PSR-7 was designed for PHP, not Hack, and some descisions do not fit smoothly with Hack's type system.

3.30がリリースされる直前にfacebook/hack-routerもこのインターフェースを使う様に変更されたため、

PHPのライブラリを用いて開発に利用する場面が少なくなりつつあります。


PSR-7とのちがい

最初にPSR-7とhhiについて、MessageInterfaceの一部を紹介しました。

そのMessageInterfaceですが、hhvm/hack-http-request-response-interfacesでは下記の様に変更されました。

<?hh // strict

namespace Facebook\Experimental\Http\Message;

interface MessageInterface {

public function getProtocolVersion(): string;

public function withProtocolVersion(string $version): this;

public function getHeaders(): dict<string, vec<string>>;

public function hasHeader(string $name): bool;

public function getHeader(string $name): vec<string>;

public function getHeaderLine(string $name): string;

public function withHeader(string $name, vec<string> $values): this;

public function withHeaderLine(string $name, string $value): this;

public function withAddedHeader(string $name, vec<string> $values): this;

public function withAddedHeaderLine(string $name, string $value): this;

public function withoutHeader(string $name): this;
}

getHeadersメソッドをはじめとして、Hackに最適化された型が扱われる様になり、

またstrictで実装することが可能になりました。

public function getHeaders(): dict<string, vec<string>>;

多くのインターフェースが同様に最適化されました。

このインターフェースに対応したPHPライブラリのアダプターライクなHackライブラリを作る場合は、

配列などをvecやdictといったHack Arraysに変換することで対応させることができますが、

strictに対応できず、HHVM4.0以降で利用できなくなる可能性もありますので、

そうしたライブラリを用意するメリットはあまりないかもしれません。

当然これにあわせてHTTPメソッドもEnumとして用意されました。

各ライブラリで定義する必要がなくなったため、Hackにとっては大きなメリットでもあります。

enum HTTPMethod: string {

PUT = 'PUT';
GET = 'GET';
POST = 'POST';
HEAD = 'HEAD';
PATCH = 'PATCH';
TRACE = 'TRACE';
DELETE = 'DELETE';
OPTIONS = 'OPTIONS';
CONNECT = 'CONNECT';
}

インターフェースのメソッドも下記の通りEnumを扱う様に変更されています。

  public function getMethod(): HTTPMethod;

public function withMethod(HTTPMethod $method): this;

これに合わせて実装時にparse_urlなどもHackの型システムに合わせても良いでしょう。

<?hh // strict

type ParsedUrlShape = shape(
?'scheme' => string,
?'host' => string,
?'port' => ?int,
?'user' => string,
?'pass' => string,
?'path' => string,
?'query' => string,
?'fragment' => string
);

use type Facebook\Experimental\Http\Message\UriInterface;
use function parse_url;

trait UrlParseTrait {
require implements UriInterface;
public function parseUrl(string $url): ParsedUrlShape {
$parsed = parse_url($url);
if(!$parsed) {
return shape();
}
return shape(
'scheme' => $parsed['scheme'] ?? '',
'host' => $parsed['host'] ?? '',
'port' => $parsed['port'] ?? null,
'user' => $parsed['user'] ?? '',
'pass' => $parsed['pass'] ?? '',
'path' => $parsed['path'] ?? '',
'query' => $parsed['query'] ?? '',
'fragment' => $parsed['fragment'] ?? ''
);
}
}


StreamInterfaceの削除

もう一つの違いとして、PSR-7に存在しているStreamInterfaceがこのインターフェースでは削除されています。

PSR-7のインターフェースではMessageInterfaceに下記の様にメソッドが用意されていました。

public function withBody(StreamInterface $body);

これに対してHackではRequestとResponseそれぞれのインターフェースでBodyの扱いが分割されて用意されています。


Facebook\Experimental\Http\Message\RequestInterface

use namespace HH\Lib\Experimental\IO;

// 省略
/**
* Gets the body of the message.
*/

public function getBody(): IO\ReadHandle;
/**
* Return an instance with the specified message body.
*
* This method MUST be implemented in such a way as to retain the
* immutability of the message, and MUST return a new instance that has the
* new body stream.
*/

public function withBody(IO\ReadHandle $body): this;



Facebook\Experimental\Http\Message\RequestInterface

use namespace HH\Lib\Experimental\IO;

// 省略
/**
* Gets the body of the message.
*/

public function getBody(): IO\WriteHandle;
/**
* Return an instance with the specified message body.
*
* This method MUST be implemented in such a way as to retain the
* immutability of the message, and MUST return a new instance that has the
* new body stream.
*/

public function withBody(IO\WriteHandle $body): this;


このHH\Lib\Experimental\IO\WriteHandle, HH\Lib\Experimental\IO\ReadHandleとは何でしょうか?

これらは現在hhvm/hsl-experimentalで用意されているインターフェースで、

将来的にhhvm/hslか、またはhhvm本体で用意されるもの(予定)です。

これらのインターフェースを実装したインスタンスは、

hsl-experimentalで下記メソッドを通じて返却されます。


HH\Lib\_Private\PipeHandle.php

<?hh // strict

namespace HH\Lib\_Private;

use namespace HH\Lib\Experimental\IO;

final class PipeHandle extends NativeHandle {
public static function createPair(): (this, this) {
/* HH_IGNORE_ERROR[2049] intentionally not in HHI */
/* HH_IGNORE_ERROR[4107] intentionally not in HHI */
list($r, $w) = Native\pipe() as (resource, resource);
return tuple(new self($r), new self($w));
}
}



HH\Lib\_Private\StdioHandle.php

<?hh // strict

namespace HH\Lib\_Private;

use namespace HH\Lib\Experimental\IO;

final class StdioHandle extends NativeHandle {
<<__Memoize>>
public static function serverError(): IO\WriteHandle {
// while the documentation says to use the STDERR constant, that is
// conditionally defined
/* HH_IGNORE_ERROR[2049] __PHPStdLib */
/* HH_IGNORE_ERROR[4107] __PHPStdLib */
return new self(\fopen('php://stderr', 'w'));
}
<<__Memoize>>
public static function serverOutput(): IO\WriteHandle {
/* HH_IGNORE_ERROR[2049] __PHPStdLib */
/* HH_IGNORE_ERROR[4107] __PHPStdLib */
return new self(\fopen('php://stdout', 'w'));
}
<<__Memoize>>
public static function serverInput(): IO\ReadHandle {
/* HH_IGNORE_ERROR[2049] __PHPStdLib */
/* HH_IGNORE_ERROR[4107] __PHPStdLib */
return new self(\fopen('php://stdin', 'r'));
}
<<__Memoize>>
public static function requestInput(): IO\ReadHandle {
/* HH_IGNORE_ERROR[2049] __PHPStdLib */
/* HH_IGNORE_ERROR[4107] __PHPStdLib */
return new self(\fopen('php://input', 'r'));
}
<<__Memoize>>
public static function requestOutput(): IO\WriteHandle{
/* HH_IGNORE_ERROR[2049] __PHPStdLib */
/* HH_IGNORE_ERROR[4107] __PHPStdLib */
return new self(\fopen('php://output', 'w'));
}
<<__Memoize>>
public static function requestError(): IO\WriteHandle {
/* HH_FIXME[2049] deregistered PHP stdlib */
/* HH_FIXME[4107] deregistered PHP stdlib */
if (\php_sapi_name() !== "cli") {
throw new IO\InvalidHandleException(
"requestError is not supported in the current execution mode"
);
}
return self::serverError();
}
}


これらのライブラリが継承しているクラスを少し見てみましょう。

<?hh // strict

namespace HH\Lib\_Private;

use namespace HH\Lib\{Experimental\IO, Str};

<<__Sealed(FileHandle::class, PipeHandle::class, StdioHandle::class)>>
abstract class NativeHandle implements IO\ReadHandle, IO\WriteHandle {

// 省略
final public function rawReadBlocking(?int $max_bytes = null): string {
if ($max_bytes is int && $max_bytes < 0) {
throw new \InvalidArgumentException(
'$max_bytes must be null, or >= 0',
);
}
if ($max_bytes === 0) {
return '';
}
/* HH_IGNORE_ERROR[2049] __PHPStdLib */
/* HH_IGNORE_ERROR[4107] __PHPStdLib */
$result = \stream_get_contents($this->impl, $max_bytes ?? -1);
if ($result === false) {
throw new IO\ReadException();
}
return $result as string;
}

final public async function readAsync(
?int $max_bytes = null,
): Awaitable<string> {
if ($max_bytes is int && $max_bytes < 0) {
throw new \InvalidArgumentException(
'$max_bytes must be null, or >= 0',
);
}
$data = '';
while (($max_bytes === null || $max_bytes > 0) && !$this->isEndOfFile()) {
$chunk = $this->rawReadBlocking($max_bytes);
$data .= $chunk;
if ($max_bytes !== null) {
$max_bytes -= Str\length($chunk);
}
if ($max_bytes === null || $max_bytes > 0) {
await $this->selectAsync(\STREAM_AWAIT_READ | \STREAM_AWAIT_ERROR);
}
}
return $data;
}
// 省略
}

StreamInterfaceで用意されていたようなメソッドや、Streamの扱いについてHackに最適化されたものが用意されているのがわかります。

例えばStreamから読み込むReadは同期・非同期とユースケースに合わせて選択できる様になっています。

ReadとWriteに分割されているため、Request Bodyの内容を変更したり、

Response Bodyの内容を変更する場合は、PHPのライブラリと異なる実装が必要になりますが、

StdioHandleクラスや、PipeHandleクラスのメソッドをみてわかる通り、

Memoizeとなっていて入出力の共有が行える様になっています。

それぞれのメソッドは下記の様な関数を通じて利用できるようにもなっています。

namespace HH\Lib\Experimental\IO;

use namespace HH\Lib\_Private;

/** Create a pair of handles, where writes to the `WriteHandle` can be
* read from the `ReadHandle`.
*/

function pipe_non_disposable(): (ReadHandle, WriteHandle) {
return _Private\PipeHandle::createPair();
}

これらを利用すると、HackのRequestInterfaceを実装したクラスは次の様に用途に合わせて

Handleクラスが利用でき、

Handle自体を変更する場合は、withBodyなどで入れ替えることができます。

<?hh 

list($r, $w) = IO\pipe_non_disposable();
$w->rawWriteBlocking('baz');
$request = new Request(Message\HTTPMethod::GET, new Uri('/'), $r)
// echo 'baz'
echo $request->getBody()->rawReadBlocking();

PSR-7実装のライブラリとStream関連の扱いが大きく異なるということを理解さえしておけば、

いろんなケースで利用することができる様になっています。


対応ライブラリ

当然これにあわせてHackのインターフェースに合わせたライブラリをHackのユーザーが開発していますので、

実際に開発で利用できる状態になっていると言えるでしょう。

 

- usox/hackttp

- ytake/hungrr

対応しているライブラリも多くないことから、

このインターフェースを実装したライブラリを開発してみてはいかがでしょうか?