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(AccountNumberaccountNumber): 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(AccountNumberaccountNumber): 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(stringaccountNumber): ?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 Emailemail,
private string name,
) { }
public function accountNumber(): AccountNumber {
returnthis->accountNumber;
}
public function email(): Email {
return this->email;
}
public function name(): string {
returnthis->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 {
returnthis->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 GetAccountusecase,
) {}
public function process(
ServerRequestInterface request,
RequestHandlerInterfacehandler,
): 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などはありませんが(小さいので用意していません)、
ビジネスロジックであるコアのレイヤーはフレームワークの知識が入り込むことはなく、
フレームワークとアプリケーションが分離された状態となります。
クリーンなアーキテクチャを表現する手法はたくさんありますので、
いろんな手法を試してみてください。