のんラボ

S3みたいなストレージサーバーっぽいものを自前で用意する⑤【DI / Repogitory実装】

2022/04/11 2022/05/15 S3みたいなストレージサーバーっぽいものを自前で用意する⑤【DI / Repogitory実装】

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 を利用します。

copied.$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 メソッドの中身はこんな感じ

copied.<?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 クラスを定義しておく

現状では拡張するような要素がないので、ただのラッパークラスになってしまいますが、作成しておきましょう。

copied.<?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 クラスのただのラッパーですね。

あとは頻出する SELECTINSERT などをやりやすくするために専用メソッドを用意しています。

BindValuesnullable なのでキモくてしょうがないですね。

どうにかしようと頑張ったのですが、いい案が思いつかずにこのまま放置してしまっています。

ちなみに余談ですが、この弊害が execute メソッドのこちらのコード

copied.if ($bindValues && !$bindValues->isEmpty()) {
    foreach ($bindValues as $bindKey => $bindValue) {
        $statement->bindValue($bindKey, $bindValue);
    }
}

この条件式キモすぎてなんとかしたい... Iteratable$bindValues$bindValues->isEmpty() の確認が必要かどうかというのは議論の余地がありますが、可読性を高めるという意味ではあってもいいのでしょうか?

そして BindValues クラスはこんな感じ。

copied.<?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のパラメータに利用する bindValuebindParam で利用するパラメータマップを配列ではなくクラス型で表現するために作成しておきました。

地味に便利です。 実装することをおすすめします。


最後に、この Model を利用するときに PDO を利用するので PDO をDIしておきます。

copied.$container->add(Nonz250\Storage\App\Foundation\Model\Model::class)
    ->addArgument(PDO::class);

いや、便利ですね。

次にRepositoryの実装です。

とはいってもガッツリビジネスロジックなので詳細なコードはGitHubを見て欲しいです。

一例として、 CreateClient ユースケースを見てみましょう。

このコードについての解説は記事にしていないので、後日記事にし直すかもしれません。

簡単に言うと、このAPIエンドポイントを利用するためには認証が必要なのですが、その認証に利用するためのクライアントを作成するためのエンドポイントです。ちなみに、このサービスにOAuth2を実装するつもりは無いので認可の仕組みは実装されていません。

copied.<?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(),
        ];
    }
}

このユースケースを利用するために必要なクラスは ClientFactoryInterfaceClientRepositoryInterface を実装したクラスです。


この2つのクラスも league/container を利用して ClientServiceProvider でDIされています。

copied.<?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 にあるサービスコンテナ機能です。


話を戻します。

copied.try {
    $this->clientRepository->beginTransaction();
    $this->clientRepository->create($client);
    $this->clientRepository->commit();
} catch (Throwable $e) {
    $this->clientRepository->rollback();
    throw new CreateClientException('Failed to create client.');
}

メインになるロジックはここですね。

copied.$client = $this->clientFactory->newClient($inputPort->appName(), $inputPort->clientEmail());

Client エンティティを作成する処理をFactoryパターンで実装しているだけなので割愛します。(もしかしたら別の話で記事にするかも)

create メソッドの中身を見てみます。
より具体的に言うと ClientRepository の中身です。

copied.<?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);
    }
}

この ClientRepositoryRepository という親クラスがあります。ここは苦渋の決断的クラスになっています

copied.<?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 を保存する処理が書かれています。

copied.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 について書こうと思っています。

そのときはよしなに。

.