HHVM/Hackで作る独立コアレイヤパターン

ytakeです。

Clean Architecutre?

堅実なアプリケーション作り
みなさんが目指すものではありますが、
これを実現するための方法はたくさんあります。

PHPの現場でもおなじみの新原さんが下記のブログを執筆しました。
独立したコアレイヤパターン

クリーンアーキテクチャをわかりやすく解説されていて、
まさに開発現場で活きる考え方と実践方法で、
お使いのフレームワークを問わず導入することができると思います。
DDDを前提としたもの以外の、
一般的な手続き型の実装であっても利用することができる具体例があり、
多くのPHPアプリケーションで取り入れることができます。

今回、Hackを利用したアプリケーションでこのパターンを採用した場合に、
どうやって実装するのか、をHackの機能も併せて解説します。

PHPの現場では自分も数回登場していますので、ぜひお聞きください!

サンプルコード

サンプルコードは下記で公開していますので、
併せてご覧ください。

コアレイヤ

サービスのポートを用意します。
// strict はHackの厳格モードを意味します。

<?hh // strict

namespace Example\Account\Usecase\GetAccount;

use Example\Account\Domain\Entity\Account;
use Example\Account\Domain\ValueObject\AccountNumber;

final class GetAccount {

  public function __construct(
    private GetAccountQueryPort $query
  ) {}

  public function execute(AccountNumber $accountNumber): Account {
    return $this->query->findAccount($accountNumber);
  }
}

phpのコードと大きな差はありませんが、大きな差はコンストラクタへの記法くらいです。
これは Constructor Parameter Promotion
というHackで用意されている機能です。

サービスレイヤ

次は GetAccount ユースケースが要求している GetAccountQueryPort に対するアダプタ実装です。

<?hh // strict

namespace App\AppAdapter;

use App\Storage\AccountStorage;
use Example\Account\Usecase\GetAccount\GetAccount;
use Example\Account\Domain\Entity\Account;
use Example\Account\Domain\ValueObject\AccountNumber;
use Example\Account\Usecase\GetAccount\GetAccountQueryPort;
use Example\Account\Domain\Exception\NotFoundException;

use function sprintf;

final class GetAccountAdapter implements GetAccountQueryPort {

  public function __construct(
    private AccountStorage $account
  ) {}

  public function findAccount(AccountNumber $accountNumber): Account {
    $result = $this->account->retrieveAccount($accountNumber->getValue());
    if ($result instanceof Account) {
      return $result;
    }
    throw new NotFoundException(
      sprintf(
        'account_number %s not found', 
        $accountNumber->getValue()
      )
    );
  }
}

このコードもphpのコードと大きな差はありません。
このアプリケーションは拙作のHack専用フレームワーク製ですが、
DIコンテナが組み込まれているため、インスタンス生成指定方法を
多くのphpフレームワークと同等に記述することができます。

<?hh // strict

namespace App\Module;

use Ytake\HHContainer\Scope;
use Ytake\HHContainer\ServiceModule;
use Ytake\HHContainer\FactoryContainer;
use App\AppAdapter\GetAccountAdapter;
use App\Storage\AccountStorage;
use Example\Account\Usecase\GetAccount\GetAccount;

final class ApplicationServiceModule extends ServiceModule {

  <<__Override>>
  public function provide(FactoryContainer $container): void {
    $container->set(
      GetAccount::class,
      $container ==> new GetAccount(new GetAccountAdapter(new AccountStorage())),
      Scope::PROTOTYPE,
    );
  }
}

このサンプルアプリケーションではDBを利用していませんが、
GetAccountAdapterがあることによって、
どんなデータベースやデータストレージでも構いません。

AccountStorageクラス

コアレイヤーパターン外のものですが、
Hackの実装コードを紹介します。

このサンプルにはHackのMap、Vectorを使ったデータ取得処理があります。

<?hh // strict

namespace App\Storage;

use Example\Account\Domain\Entity\Account;
use function strval;
use function intval;

final class AccountStorage {

  private ImmVector<Map<string, mixed>> $store = ImmVector {
    Map{
      'id' => 1,
      'account_number' => 'A00001',
      'email'          => 'a@example.com',
      'name'           => 'Mike'
    },
    Map{
      'id' => 2,
      'account_number' => 'B00001',
      'email'          => 'b@example.com',
      'name'           => 'John'
    },
  };

  public function retrieveAccount(string $accountNumber): ?Account {
    $v = $this->store->filter(
      $row ==> $row->get('account_number') === $accountNumber
    )->get(0);
    if($v instanceof Map) {
      return Account::ofByShape(
        shape(
          'account_number' => strval($v->get('account_number')),
          'email' => strval($v->get('email')),
          'name' => strval($v->get('name')),
        )
      );
    }
    return null;
  }
}

private ImmVector<Map<string, mixed>> これはイミュータブルVectorと、
Mapの組み合わせです。
Hackではあまり配列を利用することがないため、
所謂Collectionクラスを簡単に操作することができます。

filter

Collectionの操作には豊富なメソッドがたくさん用意されています。
Scalaなどに慣れている方にはお馴染みの記法です。

    $v = $this->store->filter(
      $row ==> $row->get('account_number') === $accountNumber
    )->get(0);

Shape

shapeを使って、配列のフィールドに対して型指定をすることができます。

shape自体は下記として定義しています。

namespace Example\Account\Domain;

type ShapeAccount = shape(
  'account_number' => string,
  'email' => string,
  'name' => string,
);

利用時にはshapeでそのまま記述します。

      return Account::ofByShape(
        shape(
          'account_number' => strval($v->get('account_number')),
          'email' => strval($v->get('email')),
          'name' => strval($v->get('name')),
        )
      );

Entityなどのクラスでは、以下のように利用することができます。
Shapes::idxはshapeのフィールドから値を取得するメソッドです。
非常に堅いアプリケーションになるのがわかると思います。

final class Account {

  public function __construct(
    private AccountNumber $accountNumber,
    private Email $email,
    private string $name,
  ) { }

  public function accountNumber(): AccountNumber {
    return $this->accountNumber;
  }

  public function email(): Email {
    return $this->email;
  }

  public function name(): string {
    return $this->name;
  }

  public static function ofByShape(ShapeAccount $shape): Account {
    return new self(
      new AccountNumber(Shapes::idx($shape, 'account_number', '')),
      new Email(Shapes::idx($shape, 'email', '')),
      Shapes::idx($shape, 'name', ''),
    );
  }
}

Generics

HackではGenericsを利用することができます。
ValueObjectやDTOといったオブジェクトを簡単に表現できます。

<?hh // strict

namespace Example\Account\Domain\ValueObject;

<<__ConsistentConstruct>>
abstract class AbstractValue<T> {

  public function __construct(protected T $value) {
    $this->validate($value);
  }

  public function getValue(): T {
    return $this->value;
  }

  protected function validate(T $t): void {}
}

<<__ConsistentConstruct>>もHackの機能で、
継承されるコンストラクタに制約をかけることができます。
ConsistentConstruct

値の検査が必要な場合は次のようになります。

<?hh // strict

namespace Example\Account\Domain\ValueObject;

use function filter_var;
use const FILTER_VALIDATE_EMAIL;

final class Email extends AbstractValue<string> {

  <<__Override>>
  protected function validate(string $t): void {
    invariant(
      filter_var($t, FILTER_VALIDATE_EMAIL),
      'Invalid email: %s',
      $t
    );
  }
}

値表現だけであれば、次のようになります。

<?hh // strict

namespace Example\Account\Domain\ValueObject;

final class Number extends AbstractValue<int> {

}

ルーティング+ユースケース

ADRを採用しているフレームワークとなっていますので、
ルーティングに対応したActionクラスは次の通りです。
コンスタラクタに指定したインスタンスは、
前述のServceiModuleクラスに記述すると、
DIコンテナが実行時にインスタンス生成を行います。

<?hh // strict

namespace App\Action;

use App\Payload\AccountPayload;
use App\Responder\AccountResponder;
use Example\Account\Usecase\GetAccount\GetAccount;
use Example\Account\Domain\ValueObject\AccountNumber;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;

use function strval;

final class AccountAction implements MiddlewareInterface {

  public function __construct(
    private AccountResponder $responder,
    private GetAccount $usecase,
  ) {}

  public function process(
    ServerRequestInterface $request,
    RequestHandlerInterface $handler,
  ): ResponseInterface {
    return $this->responder->emit(
      new AccountPayload(
        $this->usecase->execute(
          new AccountNumber(
            strval($request->getAttribute('accountNumber'))
          )
        )
      )
    );
  }
}

ルーティングはフレームワークで用意されていますので、
enumとImmMap、shapeで記述します。

use Nazg\Http\HttpMethod;

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

今回のサンプルにはRepositoryなどはありませんが(小さいので用意していません)、
ビジネスロジックであるコアのレイヤーはフレームワークの知識が入り込むことはなく、
フレームワークとアプリケーションが分離された状態となります。

クリーンなアーキテクチャを表現する手法はたくさんありますので、
いろんな手法を試してみてください。

クリーンアーキテクチャ(The Clean Architecture翻訳)

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