Hackで作る堅実なAPI

ytakeです

今回は弊社の一部で採用していたりする HHVM/Hack について
先日登壇したphperkaigiでもさらっと触れた堅実なアプリケーションの例を紹介します。

PHPのフレームワークにhackのコードを混ぜて開発することもできますが、
せっかくですので拙作のHack専用マイクロフレームワークNazgをベースに紹介しましょう。
本記事に連動したサンプルコードはこちら

APIについて

弊社ではAPIに HAL – Hypertext Application Language を採用しており、
古いシステムを移行したりということを日々行なっています。

各言語でHalに対応したライブラリがでており、
PHPにもいくつかライブラリがありますので簡単に導入することができます。

今回は下記の配列をhal+jsonで返却し、
かつ出力されるレスポンスが型通りかどうかを判定するミドルウェアを用意します。

<?hh

new ImmMap([
  'id' => 1234,
  'name' => 'ytake',
  'title' => 'type-assert for api response',
  'embedded' => [
    [
      'name' => 'HHVM/Hack',
      'url' => 'https://docs.hhvm.com/'
    ],
  ]
]);

この配列をhal+jsonで下記のレスポンスで返却するとします。

{
  "id":1234,
  "name":"ytake",
  "title":"type-assert for api response",
  "_links":{
    "self":{
      "href":"/","type":"application/json"
    }
  },
  "_embedded":{
    "enviroments":[
      {
        "name":"HHVM/Hack",
        "_links":{
          "self":{
            "href":"https://docs.hhvm.com/"
          }
        }
      }
    ]
  }
}

idはintですが、それ以外はstringとarrayであることがわかります
これをHackのshapeを使って定義します。

  const type embeddedLinks = shape(
    'name' => string,
    '_links' => shape(
      'self' => shape(
        'href' => string
      )
    )
  );

  const type hateoasStructure = shape(
    'id' => int,
    'name' => string,
    'title' => string,
    '_links' => shape(
      'self' => shape(
        'href' => string,
        'type' => string
      )
    ),
    '_embedded' => shape(
      'enviroments' => array<self::embeddedLinks>
    )
  );

goで開発している方はお気づきかもしれませんが、
Hackでもstrutに似ているように見えるかもしれません。

Hackにも現在非公式ではありますが、type_structureというものが用意されており、
構造体の型検査のように使うことができます。

https://github.com/facebook/hhvm/blob/master/hphp/hack/hhi/typestructure.hhi

この機能を使って実装してみましょう。
まずは配列をhal json対応にします。

Hal for HHVM/Hack

phpのライブラリを使うことで対応することができますが、
Hackはphpから離れていくというアナウンスもありましたので、
Hack専用のものを使う例です。
*FacebookのPHP/Hack実行環境「HHVM」、今後は対象をHackに限定へ

その方が余計なことを悩まずに済みます

これまた拙作のライブラリを使って実装します
全体的にfacebopok製のライブラリ以外のHackライブラリはあまりないので、
ないものは自分で作って公開することができますので楽しいです!

<?hh // strict

namespace App\Payload;

use Ytake\HHhal\Serializer\JsonSerializer;
use Ytake\HHhal\{Link, LinkResource, Serializer, HalResource};

class SampleResourcePayload {

  protected Vector<HalResource> $vec = Vector{};

  public function __construct(
    protected ImmMap<mixed, mixed> $resource
  ) {}

  public function payload(): array<mixed, mixed> {
    $map = $this->resource
      ->filterWithKey(($k, $v) ==> $k != 'embedded')
      ->toMap();
    $hal = new HalResource($map);
    $hal->withLink(new Link(
      'self',
      new Vector([
        new LinkResource('/', shape('type' => 'application/json'))
      ]),
    ));
    $embedded = $this->resource->get('embedded');
    if(is_array($embedded)) {
      foreach($embedded as $row) {
        $embeddedResource = new HalResource();
        foreach($row as $key => $value) {
          if($key === 'url') {
            $embeddedResource->withLink(
              new Link('self', new Vector([new LinkResource($value)]))
            );
            continue;
          }
          $embeddedResource->addResource(strval($key), $value);
        }
        $this->vec->add($embeddedResource);
      }
    }
    $hal = $hal->withEmbedded('enviroments', $this->vec);
    $serialize = new Serializer(new JsonSerializer(), $hal);
    return $serialize->toArray();
  }
}

せっかくなので strict(通称 漢) で実装しました。
配列を元にhal+jsonで出力できるようにシリアライズしている処理です。

Attributes を組み合わせれば、
phpのwilldurand/Hateoas のようにすることもできます。

これで出力の準備ができました。

Response with cache

お手製のNazgフレームワークはADRを採用しています。

まずはResponderを用意します。
PSR7に対応したzend-diactoros を組み合わせます。
phpライブラリを混ぜて開発する場合はstrictにすることができませんので、
partial で実装します。(デフォルト)

<?hh

namespace App\Responder;

use Psr\Http\Message\ResponseInterface;
use Zend\Diactoros\Response\JsonResponse;

final class IndexResponder {

  public function response(array<mixed, mixed> $payload): ResponseInterface {
    return new JsonResponse(
      $payload,
      \Nazg\Http\StatusCode::Ok,
      ['Content-Type' => ['application/hal+json']],
    );
  }
}

次hal+json変換処理を毎回走らせずにcacheして返却するようにします。
*logはお手製フレームワークがちゃんと動いてることを証明するために利用します

<?hh // strict

namespace App\AppService;

use Nazg\HCache\Element;
use Nazg\HCache\CacheProvider;
use Psr\Log\LoggerInterface;
use App\Payload\SampleResourcePayload;

final class CacheableArtcleCollection {

  protected string $id = "cacheable.article" ;

  public function __construct(
    private CacheProvider $cacheProvider,
    private LoggerInterface $logger
  ) {}

  public function run(): array<mixed, mixed> {
    if($this->cacheProvider->contains($this->id)) {
      $this->logger->info('cache.hit', ['message' => 'cache']);
      $fetch = $this->cacheProvider->fetch($this->id);
      return /* UNSAFE_EXPR */ $fetch;
    }
    $map = new ImmMap([
      'id' => 1234,
      'name' => 'ytake',
      'title' => 'type-assert for api response',
      'embedded' => [
        [
          'name' => 'HHVM/Hack',
          'url' => 'https://docs.hhvm.com/'
        ],
      ]
    ]);
    $payload = new SampleResourcePayload($map);
    $v = $payload->payload();
    $this->cacheProvider->save($this->id, new Element($v));
    return $v;
  }
}

次にActionクラスです。

<?hh // strict

namespace App\Action;

use App\Responder\IndexResponder;
use App\AppService\CacheableArtcleCollection;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;

final class IndexAction implements MiddlewareInterface {

  public function __construct(
    private IndexResponder $responder,
    private CacheableArtcleCollection $cache
  ) {}

  public function process(
    ServerRequestInterface $request,
    RequestHandlerInterface $handler,
  ): ResponseInterface {
    $resource = $this->cache->run();
    return $this->responder->response($resource);
  }
}

これで出力までの準備が整いました。
アプリケーションに必要なものを設定していきます。

routes

routes.global.phpでルートを記述します。

<?hh

return [
  \Nazg\Foundation\Service::ROUTES => ImmMap {
    \Nazg\Http\HttpMethod::GET => ImmMap {
      '/' => shape(
        'middleware' => ImmVector {
          App\Action\IndexAction::class
        },
      )
    },
  },
];

dependency injection

実装したクラスが利用する依存関係を記述していきます。
App\Module\ActionServiceModule を例とします

<?hh // strict

namespace App\Module;

use App\Action\IndexAction;
use App\Responder\IndexResponder;
use App\AppService\CacheableArtcleCollection;
use Ytake\HHContainer\Scope;
use Ytake\HHContainer\ServiceModule;
use Ytake\HHContainer\FactoryContainer;
use Nazg\HCache\CacheProvider;
use Psr\Log\LoggerInterface;

final class ActionServiceModule extends ServiceModule {
  <<__Override>>
  public function provide(FactoryContainer $container): void {
    $container->set(
      IndexAction::class,
      $container ==> new IndexAction(
        new IndexResponder(),
        $this->detectCacheableArticle($container->get(CacheableArtcleCollection::class))
      ),
      Scope::PROTOTYPE,
    );
    $container->set(
      CacheableArtcleCollection::class,
      $container ==> new CacheableArtcleCollection(
        $this->detectCacheProvider($container->get(CacheProvider::class)),
        $this->detectPsrLogger($container->get(LoggerInterface::class))
      ),
      Scope::PROTOTYPE
    );
  }

  protected function detectCacheProvider(mixed $instance): CacheProvider {
    invariant($instance instanceof CacheProvider, "implimantaion error");
    return $instance;
  }

  protected function detectPsrLogger(mixed $instance): LoggerInterface {
    invariant($instance instanceof LoggerInterface, "implimantaion error");
    return $instance;
  }

  protected function detectCacheableArticle(mixed $instance): CacheableArtcleCollection {
    invariant($instance instanceof CacheableArtcleCollection, "implimantaion error");
    return $instance;
  }
}

キャッシュは Nazg\HCache\CacheProvider インターフェースを実装しています
フレームワークのコンテナにはインターフェース名で登録されていますので(Laravelと同じ感じ)
それを指定します。(PSR-11準拠)

これで実行時にキャッシュを作成・経由して値が返却されます。
デフォルトのキャッシュはmemcachedです
(hhvmにエクステンションがデフォルトで含まれています)

この状態でhttpでアクセスすると、
冒頭のhal+jsonが返却されます

レスポンスを堅実に

戻り値が冒頭で紹介したshapeに沿って出力されるか検査するミドルウェアを作成します。

型チェックにはhhvm/type-assertを利用すると便利です。
型チェックミドルウェアとして実装すると下記のようになります。

<?hh // strict

namespace App\Middleware;

use Facebook\TypeAssert;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;

class ResponseAssertMiddleware implements MiddlewareInterface {

  const type embeddedLinks = shape(
    'name' => string,
    '_links' => shape(
      'self' => shape(
        'href' => string
      )
    )
  );

  const type hateoasStructure = shape(
    'id' => int,
    'name' => string,
    'title' => string,
    '_links' => shape(
      'self' => shape(
        'href' => string,
        'type' => string
      )
    ),
    '_embedded' => shape(
      'enviroments' => array<self::embeddedLinks>
    )
  );

  public function process(
    ServerRequestInterface $request,
    RequestHandlerInterface $handler,
  ): ResponseInterface {
    $response = $handler->handle($request);
    $decode = json_decode($response->getBody()->getContents(), true);
    TypeAssert\matches_type_structure(
      type_structure(self::class, 'hateoasStructure'),
      $decode,
    );
    return $response;
  }
}

ミドルウェアはpsr-15に準拠していますので、
レスポンスのbodyを取得し、
TypeAssert\matches_type_structure で型をチェックします。
hateoasStructure、embeddedLinksがレスポンスをshapeで表しています。
レスポンスに宣言した型と違うものが混入するとexceptionがスローされます。

このミドルウェアを動かすには依存関係をコンテナに登録し、routesに追記しなければなりません。

<?hh // stirct

use Psr\Log\LoggerInterface;
use Ytake\HHContainer\Scope;
use Ytake\HHContainer\ServiceModule;
use Ytake\HHContainer\FactoryContainer;
use App\Middleware\ResponseAssertMiddleware;

final class MiddlewareServiceModule extends ServiceModule {
  public function provide(FactoryContainer $container): void {
    $container->set(
      ResponseAssertMiddleware::class, 
      $container ==> new ResponseAssertMiddleware()
    );
  }
}

上記のように任意のServiceModuleクラスで登録します。
次に任意のrouteで作用するように、先に記述したroutes.global.phpに追記します。

    \Nazg\Http\HttpMethod::GET => ImmMap {
      '/' => shape(
        'middleware' => ImmVector {
          App\Middleware\ResponseAssertMiddleware::class, // ココ
          App\Action\IndexAction::class
        },
      )
    },

middlewareに記述した順番で挟み込むように実行されますので、
一番最後に型チェックを実行するようにActionクラスよりも先に起動させます。
これで型チェックが実行されますので、キャッシュを削除してから値を変更すると、
型エラーが返却されるようになります。

簡単ではありますが、Hackで作るアプリケーションの雰囲気が伝われば幸いです。

アイスタイルCTO 開発で好んで使う言語はPHP, Hack, Go, Scala 好きな財団はApacheソフトウェア財団 github twitter