S3みたいなストレージサーバーっぽいものを自前で用意する⑤【DI / Repogitory実装】
こんにちは。のんです。
前回に引き続き自前でストレージサーバーを開発していこうと思います。
GitHubプロジェクトはこちら
RepositoryというかまずはDIの話
Repositoryを実装するのですが、まずはDependency injection.(依存性注入)を簡単にできるようにすることになります。
これはクリーンアーキテクチャにおけるユースケース層を実装するのにも深く関わってくるので早めに解決しておきましょう。ちなみにLaravelでいうサービスプロバイダーですね。
依存性注入の話をここでするつもりは無いです。
他の有用なブログなどたくさんありますので詳しい話はそちらにお任せしようかな?と思います。
league/container
を利用する
では、早速実装していこうと思います。フレームワークを利用しないというルールを設けましたが、自作するのはなんか無駄な気がするので、ライブラリを利用します。
league/router
と同様に league/container
を利用します。
このライブラリを利用することでDIを簡単に実装できるようになります。
Repositoryを実装する
PDO
を設定する
WEBアプリなので、MySQLを利用しています。なので主に利用されるデータストアはDBです。
PHPでDBを操作するライブラリはこの世にたくさんあり、どれも便利なのですが、このストレージサーバーは複雑な動作を求めるようなWEBアプリではないのでSQLを直接書いてしまおうと思います。
というわけでPDOをそのまま利用します。
PDOクラスは
にもあるように、接続先情報をコンストラクタに注入する必要があります。しかしこれを毎回やるのは面倒なので league/container
を利用します。
$container = new League\Container\Container();
$container->add(PDO::class)
->addArgument(sprintf(
'mysql:dbname=%s;host=%s;port=%s',
Nonz250\Storage\App\Foundation\App::env('DB_NAME'),
Nonz250\Storage\App\Foundation\App::env('DB_HOST'),
Nonz250\Storage\App\Foundation\App::env('DB_PORT')
))
->addArgument(Nonz250\Storage\App\Foundation\App::env('DB_USERNAME'))
->addArgument(Nonz250\Storage\App\Foundation\App::env('DB_PASSWORD'));
このようにこのライブラリでは Container
クラスに addArgument
メソッドが実装されており、クラスをインスタンス化するときに注入する値を宣言することができます。
これをアプリが起動するときに必ず発火するrootファイルに書いておきます。
ちなみに、 Nonz250\Storage\App\Foundation\App::env('DB_NAME')
の env
メソッドの中身はこんな感じ
<?php
declare(strict_types=1);
namespace Nonz250\Storage\App\Foundation;
final class App
{
// 省略
public static function env(string $key, $default = '')
{
return $_ENV[$key] ?? $default;
}
// 省略
}
ただ、環境変数に存在する値を取得するだけの関数ですね。なければ default
値を取得します。
将来的に拡張できるように Model
クラスを定義しておく
現状では拡張するような要素がないので、ただのラッパークラスになってしまいますが、作成しておきましょう。
<?php
declare(strict_types=1);
namespace Nonz250\Storage\App\Foundation\Model;
use PDO;
use PDOException;
use PDOStatement;
class Model
{
protected PDO $pdo;
public function __construct(PDO $pdo)
{
$this->pdo = $pdo;
}
public function beginTransaction(): void
{
if (!$this->pdo->beginTransaction()) {
throw new PDOException('Failed begin transaction.');
}
}
public function commit(): void
{
if (!$this->pdo->commit()) {
throw new PDOException('Failed commit.');
}
}
public function rollBack(): void
{
if (!$this->pdo->rollBack()) {
throw new PDOException('Failed roll back.');
}
}
public function execute(string $sql, ?BindValues $bindValues = null): PDOStatement
{
$statement = $this->pdo->prepare($sql);
if ($statement === false) {
throw new PDOException('Failed prepared query.');
}
if ($bindValues && !$bindValues->isEmpty()) {
foreach ($bindValues as $bindKey => $bindValue) {
$statement->bindValue($bindKey, $bindValue);
}
}
if (!$statement->execute()) {
throw new PDOException('Failed execute statement.');
}
return $statement;
}
public function select(string $sql, ?BindValues $bindValues = null): array
{
try {
$statement = $this->execute($sql, $bindValues);
} catch (PDOException $e) {
throw new PDOException('Failed execute select.');
}
$result = $statement->fetchAll(PDO::FETCH_ASSOC);
if ($result === false) {
throw new PDOException('Failed fetch all.');
}
return $result;
}
public function insert(string $sql, BindValues $bindValues): void
{
try {
$this->execute($sql, $bindValues);
} catch (PDOException $e) {
throw new PDOException('Failed execute select.');
}
}
}
コードを読めばわかりますが、前述したように PDO
クラスのただのラッパーですね。
あとは頻出する SELECT
や INSERT
などをやりやすくするために専用メソッドを用意しています。
BindValues
が nullable
なのでキモくてしょうがないですね。
どうにかしようと頑張ったのですが、いい案が思いつかずにこのまま放置してしまっています。
ちなみに余談ですが、この弊害が execute
メソッドのこちらのコード
if ($bindValues && !$bindValues->isEmpty()) {
foreach ($bindValues as $bindKey => $bindValue) {
$statement->bindValue($bindKey, $bindValue);
}
}
この条件式キモすぎてなんとかしたい... Iteratable
な $bindValues
で $bindValues->isEmpty()
の確認が必要かどうかというのは議論の余地がありますが、可読性を高めるという意味ではあってもいいのでしょうか?
そして BindValues
クラスはこんな感じ。
<?php
declare(strict_types=1);
namespace Nonz250\Storage\App\Foundation\Model;
use ArrayIterator;
use InvalidArgumentException;
use IteratorAggregate;
final class BindValues implements IteratorAggregate
{
private array $values = [];
public function bindValue(string $key, $value): self
{
$this->validate($key);
$this->values[$key] = $value;
return $this;
}
private function validate(string $key): void
{
if ($key === '') {
throw new InvalidArgumentException('Key is required.');
}
if (array_key_exists($key, $this->values)) {
throw new InvalidArgumentException('Key already exists.');
}
}
public function isEmpty(): bool
{
return count($this->values) === 0;
}
public function toArray(): array
{
return $this->values;
}
public function getIterator(): ArrayIterator
{
return new ArrayIterator($this->values);
}
}
このクラスはSQLのパラメータに利用する bindValue
や bindParam
で利用するパラメータマップを配列ではなくクラス型で表現するために作成しておきました。
地味に便利です。 実装することをおすすめします。
最後に、この Model
を利用するときに PDO
を利用するので PDO
をDIしておきます。
$container->add(Nonz250\Storage\App\Foundation\Model\Model::class)
->addArgument(PDO::class);
いや、便利ですね。
次にRepositoryの実装です。
とはいってもガッツリビジネスロジックなので詳細なコードはGitHubを見て欲しいです。
一例として、 CreateClient
ユースケースを見てみましょう。
このコードについての解説は記事にしていないので、後日記事にし直すかもしれません。
簡単に言うと、このAPIエンドポイントを利用するためには認証が必要なのですが、その認証に利用するためのクライアントを作成するためのエンドポイントです。ちなみに、このサービスにOAuth2を実装するつもりは無いので認可の仕組みは実装されていません。
<?php
declare(strict_types=1);
namespace Nonz250\Storage\App\Domain\Client\Command\CreateClient;
use Nonz250\Storage\App\Domain\Auth\ClientRepositoryInterface;
use Nonz250\Storage\App\Domain\Client\ClientFactoryInterface;
use Nonz250\Storage\App\Domain\Client\Exceptions\CreateClientException;
use Throwable;
final class CreateClient implements CreateClientInterface
{
private ClientFactoryInterface $clientFactory;
private ClientRepositoryInterface $clientRepository;
public function __construct(
ClientFactoryInterface $clientFactory,
ClientRepositoryInterface $clientRepository
) {
$this->clientFactory = $clientFactory;
$this->clientRepository = $clientRepository;
}
public function process(CreateClientInputPort $inputPort): array
{
$client = $this->clientFactory->newClient($inputPort->appName(), $inputPort->clientEmail());
try {
$this->clientRepository->beginTransaction();
$this->clientRepository->create($client);
$this->clientRepository->commit();
} catch (Throwable $e) {
$this->clientRepository->rollback();
throw new CreateClientException('Failed to create client.');
}
return [
'clientId' => (string)$client->clientId(),
'clientSecret' => (string)$client->clientSecret(),
'appName' => (string)$client->appName(),
'clientEmail' => (string)$client->clientEmail(),
];
}
}
このユースケースを利用するために必要なクラスは ClientFactoryInterface
と ClientRepositoryInterface
を実装したクラスです。
この2つのクラスも league/container
を利用して ClientServiceProvider
でDIされています。
<?php
declare(strict_types=1);
namespace Nonz250\Storage\App\Provider;
use League\Container\ServiceProvider\AbstractServiceProvider;
use Nonz250\Storage\App\Adapter\Auth\ClientRepository;
use Nonz250\Storage\App\Domain\Auth\ClientRepositoryInterface;
use Nonz250\Storage\App\Domain\Auth\Command\DigestAuth\DigestAuth;
use Nonz250\Storage\App\Domain\Auth\Command\DigestAuth\DigestAuthInterface;
use Nonz250\Storage\App\Domain\Client\ClientFactory;
use Nonz250\Storage\App\Domain\Client\ClientFactoryInterface;
use Nonz250\Storage\App\Domain\Client\Command\CreateClient\CreateClient;
use Nonz250\Storage\App\Domain\Client\Command\CreateClient\CreateClientInterface;
use Nonz250\Storage\App\Foundation\Model\Model;
use Nonz250\Storage\App\Http\Auth\AuthMiddleware;
use Nonz250\Storage\App\Http\CreateClient\CreateClientAction;
use Psr\Log\LoggerInterface;
class ClientServiceProvider extends AbstractServiceProvider
{
public function provides(string $id): bool
{
$services = [
AuthMiddleware::class,
CreateClientAction::class,
];
return in_array($id, $services, true);
}
public function register(): void
{
$this->getContainer()
->add(ClientRepositoryInterface::class, ClientRepository::class)
->addArgument(Model::class);
$this->getContainer()
->add(DigestAuthInterface::class, DigestAuth::class)
->addArgument(ClientRepositoryInterface::class);
$this->getContainer()
->add(AuthMiddleware::class)
->addArguments([
LoggerInterface::class,
DigestAuthInterface::class,
]);
$this->getContainer()
->add(CreateClientAction::class)
->addArguments([
LoggerInterface::class,
CreateClientInterface::class,
]);
$this->getContainer()
->add(ClientFactoryInterface::class, ClientFactory::class);
$this->getContainer()
->add(CreateClientInterface::class, CreateClient::class)
->addArgument(ClientFactoryInterface::class)
->addArgument(ClientRepositoryInterface::class);
}
}
詳しくは
に書かれています。 league/container
にあるサービスコンテナ機能です。
話を戻します。
try {
$this->clientRepository->beginTransaction();
$this->clientRepository->create($client);
$this->clientRepository->commit();
} catch (Throwable $e) {
$this->clientRepository->rollback();
throw new CreateClientException('Failed to create client.');
}
メインになるロジックはここですね。
$client = $this->clientFactory->newClient($inputPort->appName(), $inputPort->clientEmail());
は Client
エンティティを作成する処理をFactoryパターンで実装しているだけなので割愛します。(もしかしたら別の話で記事にするかも)
create
メソッドの中身を見てみます。
より具体的に言うと ClientRepository
の中身です。
<?php
declare(strict_types=1);
namespace Nonz250\Storage\App\Adapter\Auth;
use Nonz250\Storage\App\Domain\Auth\ClientRepositoryInterface;
use Nonz250\Storage\App\Domain\Client\Client;
use Nonz250\Storage\App\Domain\Client\ValueObject\AppName;
use Nonz250\Storage\App\Domain\Client\ValueObject\ClientEmail;
use Nonz250\Storage\App\Domain\Client\ValueObject\ClientSecret;
use Nonz250\Storage\App\Foundation\Exceptions\DataNotFoundException;
use Nonz250\Storage\App\Foundation\Model\BindValues;
use Nonz250\Storage\App\Foundation\Repository;
use Nonz250\Storage\App\Shared\ValueObject\ClientId;
final class ClientRepository extends Repository implements ClientRepositoryInterface
{
public function findById(ClientId $clientId): Client
{
$sql = 'SELECT * FROM `clients` WHERE id = :client_id';
$bindValues = new BindValues();
$bindValues->bindValue(':client_id', (string)$clientId);
$clients = $this->model->select($sql, $bindValues);
if (count($clients) === 0) {
throw new DataNotFoundException(sprintf('%s is not found.', ClientId::NAME));
}
$client = $clients[0];
return new Client(
new ClientId($client['id']),
new ClientSecret($client['secret']),
new AppName($client['app_name']),
new ClientEmail($client['email']),
);
}
public function create(Client $client): void
{
$sql = 'INSERT INTO `clients` (`id`, `secret`, `app_name`, `email`) VALUE (:client_id, :client_secret, :app_name, :email)';
$bindValues = new BindValues();
$bindValues->bindValue(':client_id', (string)$client->clientId());
$bindValues->bindValue(':client_secret', (string)$client->clientSecret());
$bindValues->bindValue(':app_name', (string)$client->appName());
$bindValues->bindValue(':email', (string)$client->clientEmail());
$this->model->insert($sql, $bindValues);
}
}
この ClientRepository
は Repository
という親クラスがあります。ここは苦渋の決断的クラスになっています。
<?php
declare(strict_types=1);
namespace Nonz250\Storage\App\Foundation;
use Nonz250\Storage\App\Foundation\Model\Model;
class Repository implements RepositoryInterface
{
protected Model $model;
public function __construct(Model $model)
{
$this->model = $model;
}
public function beginTransaction(): void
{
$this->model->beginTransaction();
}
public function commit(): void
{
$this->model->commit();
}
public function rollback(): void
{
$this->model->rollBack();
}
}
うーん...微妙w
トランザクション管理をModel経由ではなくRepository経由で行いたくてこのような実装をしました。
まぁ、 Model
と Repository
はアプリケーション層とユースケース層で別なので、きっちり分けて書かれているという言い訳でギリギリセーフと言えなくもないかもしれませんw
ユースケースで利用されている create
メソッドでは clients
テーブルに渡された Client
を保存する処理が書かれています。
public function create(Client $client): void
{
$sql = 'INSERT INTO `clients` (`id`, `secret`, `app_name`, `email`) VALUE (:client_id, :client_secret, :app_name, :email)';
$bindValues = new BindValues();
$bindValues->bindValue(':client_id', (string)$client->clientId());
$bindValues->bindValue(':client_secret', (string)$client->clientSecret());
$bindValues->bindValue(':app_name', (string)$client->appName());
$bindValues->bindValue(':email', (string)$client->clientEmail());
$this->model->insert($sql, $bindValues);
}
クライアントを作成する処理だけがRepositoryではないので、他にもたくさんのRepositoryが存在しますが、その一例として紹介しました。詳しくはGitHubを見てください。
最後に
こんな感じでRepositoryを実装してみました。
GitHubをみればわかるとは思いますが、実は基本機能の部分は全て実装し終えています。
この記事で出てきたFactoryについても解説する必要はあるかもしれません。気が向いたら書こうかな。
それか、実際の機能の解説するときのついでに書くかもしれません。
次回は Logging
について書こうと思っています。
そのときはよしなに。
.