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

2022/04/11 2022/05/15 #S3 #ストレージサーバー

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

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

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

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

ちなみに余談ですが、この弊害が 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のパラメータに利用する bindValuebindParam で利用するパラメータマップを配列ではなくクラス型で表現するために作成しておきました。

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


最後に、この 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(),
        ];
    }
}

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


この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);
    }
}

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

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

そのときはよしなに。

.

のん

所属 : 株式会社スマレジ 開発部

YouTube : のんラボ

Twitter : @nonz250

Github : nonz250

Qiita : @nonz250

My Qiita posts My Qiita contributions My Qiita followers

主にPHPを使用し、サーバーサイドを担当。最近はフロントにも興味津々。

なにかを作ったりいじったりするのが好きで、個人開発なども行っている。

趣味はバイクアイコン画像は大抵愛車の「Z250」である。友達にアイコン描いてもらえて嬉しい。

PHP / Laravel / CakePHP2 / CakePHP3 / Vue / Nuxt / C# / etc...

Tags

#のんラボ #Laravel #Vue #個人開発 #ブログ #プログラミング #javascript #Html5 #WEBサービス #Twitter #今年の抱負メーカー #勉強方法 #PWA #モバイルアプリ #Android #ツーリング #バイクに乗るエンジニア #Z250 #秋吉台 #能登半島 #バイク #冒険 #東尋坊 #Squid #リバースプロキシ #hosts #axios #cropper #AdSense #Bootstrap #MySQL #高速化 #トドTask #Telescope #デバッグ #composer #テスト #セキュリティ #POSレジ #スマレジ #本部機能 #バリデーション #入力チェック #Mac #Chrome #テスト駆動開発 #開発手法 #UI #デザイン #WEBサイト #機能美 #PHP #Laravel 6 #コメント #バージョンアップ #vue-cli #localhost #BIツール #売上分析 #TANAX #MFK250 #ツアーシェルケース2 #RESTful #API #REST API #実務的 #PHP Tech Tutor #Smaregi Tech Talk #勉強会 # ブログ #CakePHP3 #CSRF #VSCode #開発環境 #CakePHP3.0 #さくらのレンタルサーバー #モジュールモード #シェル #メール #Gmail #relay #OGP #エラーページ #抱負 #家庭教師 #ドメイン駆動設計 #DDD #読書会 #那智の滝 #伊勢志摩 #伊勢志摩スカイライン #フロント #三方五湖 #レインボーライン #ボーイスカウト・ルール #プログラマが知るべき97のこと #リファクタリング #ユビキタス言語 #車輪の再発明 #マイクロサービス #デプロイ #QA #laravel-mix #Tips #storybook #@storybook/addon-actions #昇降デスク #コードレス #書斎 #オフィス #リモートワーク #働き方 #エラーハンドリング #スマレジ4 #pixel 5 #レビュー #スマレコ #TDD #RSS #404 #高山ダム #ラーツー #React #Nuxt #node_modules #エラー #インポート #設定方法 #環境構築 #Docker #フォレストパーク神野山 #学生向け #PR #採用 #Node.js #npm #しまなみ海道 #youtube #CSS #IE #SLA #Rust #千里浜なぎさドライブウェイ #千里浜 #インサイドセールス #曽爾高原 #無線LAN #ポートフォリオ #バルカンS #納車 #Next.js #チームビルディング #リーダー #悩み #Github Actions #Marp #ネタ #サーバー移行 #S3 #ストレージサーバー #RFC 7807 #Digest #認証 #Xdebug