YelpCampをクリーンアーキテクチャで再構築する③ - 拡張編

これまでのパートでは、YelpCamp をクリーンアーキテクチャとして再構築し、ドメイン層・ユースケース層を中心に、外部依存から切り離すところまでを進めてきました。クリーンアーキテクチャの重要な原則の一つに、「UI・DB・フレームワークの変更が、ビジネスロジックに影響しない構造を作る」という考え方があります。そこで今回は、既存のユースケースやドメイン層には手を加えずに、Repository の実装を PDO から Eloquent に置き換えてみます。

この差し替えが問題なく行えるのであれば、現在の設計が適切に依存関係を分離できていることの、一つの確認となります。

依存関係を確認します

まず最初に、実装する機能の依存関係を確認します。実際のデータベース (Infrastructure 層) とインターフェイスの構成は下図のようになっています。

インターフェイスファイルは次のようになっています。

<?php

declare(strict_types=1);

namespace Domain\Repositories;

use Domain\Entities\User;

interface UserRepositoryInterface
{
    /**
     * ユーザー名で検索します
     *
     * @return User|null
     */
    public function findByUsername(string $username): ?User;

    /**
     * ユーザーを永続化(新規作成)
     *
     * 挿入後 User を返します
     *
     * @return User 保存後のユーザーエンティティ
     */
    public function save(User $user): User;
}

それぞれのファイルが配置されている位置は以下のようになっています。今回の記事では「PdoUserRepository.php」の位置に「EloquentUserRepository.php」という実装を追加します。

<app>
└─ src/
   ├─ adapter
   ├─ application
   ├─ domain
   │  ├─ Entities
   │  └─ Repositories
   │     └─ UserRepositoryInterface.php
   └─ infrastructure
      ├─ Bootstrap
      ├─ Db
      │  └─ User
      │     └─ PdoUserRepository.php
      └─ Http
Eloquent を使用して Infrastructure (データベース) 層を実装します
・Eloquent をインストールします

まず最初に Eloquent をインストールします。VSCode に Ctrl + @ と入力して、表示されたコンソール画面で以下のコマンドを実行して Eloquent パッケージをインストールします。(Laravel はインストールしません。)

composer require illuminate/database
・データベースを実装します

次にデータベース操作クラスを実装していきます。ドメイン層で定義したインターフェイス「UserRepositoryInterface」を実装したクラス「EloquentUserRepository.php」を作成します。実際のコードは以下のようになっています。データの保存は「ConnectionInterface」を使用して行います。

<?php

declare(strict_types=1);

namespace Infrastructure\Db\User;

use Domain\Entities\User;
use Domain\Repositories\UserRepositoryInterface;
use Illuminate\Database\ConnectionInterface;

final class EloquentUserRepository implements UserRepositoryInterface
{
    public function __construct(
        private readonly ConnectionInterface $db,
    ) {}

    public function findByUsername(string $username): ?User
    {
        /** @var object{
         *     username: string,
         *     email: string,
         *     password: string
         * }|null $row
         */
        $row = $this->db->table('users')
            ->where('username', $username)
            ->first();

        if ($row === null) {
            return null;
        }

        return new User(
            username: (string) $row->username,
            email: (string) $row->email,
            passwordHash: (string) $row->password,
        );
    }

    public function save(User $user): User
    {
        $this->db->table('users')->insert([
            'username' => $user->username(),
            'email'    => $user->email(),
            'password' => $user->passwordHash(),
        ]);

        return new User(
            username: $user->username(),
            email: $user->email(),
            passwordHash: $user->passwordHash(),
        );
    }
}

ここで少し補足ですが、Eloquent を使用する場合は、以下のような静的な呼び出し方が一般的です。

$user = EloquentUserModel::where('username', $username)->first();

この呼び出し方法を使用するには、Eloquent の「Model」クラスを継承したクラスを定義する必要があります。

<?php

declare(strict_types=1);

namespace Infrastructure\Db\User;

use Illuminate\Database\Eloquent\Model;

/**
 * @property string $username
 * @property string $email
 * @property string $password
 */
final class EloquentUserModel extends Model
{
    protected $table = 'users';

    // timestamps を使ってないなら false
    public $timestamps = false;

    // 一括代入する場合に必要
    protected $fillable = ['username', 'email', 'password'];
}

Laravel を使用している場合は「Active Record」パターンと呼ばれるこの方式が非常に便利ですが、クリーンアーキテクチャに当てはめると

  • グローバル呼び出しのため依存が見えづらい
  • 差し替えしづらいためテストが難しくなる
  • データ操作用のモデル定義が冗長

などのデメリットが出てきてしまうため、今回は ConnectionInterface を使用する「Data Mapper」パターンと呼ばれる方式にしました。(静的な呼び出し方法の場合でも、最終的には ConnectionInterface が使用されています。)

・Eloquent 呼び出し処理を追加します。

まず、「Capsule Manager」という軽量な ORM ラッパーを使用して、単独で Eloquent を使用できるようにします。このラッパークラスの「bootEloquent」という関数を呼び出すと Eloquent がアプリの中で使用できるようなります。(Laravel なしで実行可能です。)

/**
 * Capsule を作って返す(EloquentRepository が依存するのは PDO じゃなく Capsule/Connection)
 *
 * @param array{dsn:string,user:string|null,pass:string|null} $dbConfig
 */
function build_eloquent_capsule(array $dbConfig): \Illuminate\Database\Capsule\Manager
{
    static $capsule = null;

    if ($capsule instanceof \Illuminate\Database\Capsule\Manager) {
        return $capsule;
    }

    $dsnInfo = parse_mysql_dsn($dbConfig['dsn']);

    $capsule = new \Illuminate\Database\Capsule\Manager();
    $capsule->addConnection([
        'driver'    => 'mysql',
        'host'      => $dsnInfo['host'],
        'port'      => $dsnInfo['port'] ?? 3306,
        'database'  => $dsnInfo['dbname'],
        'username'  => $dbConfig['user'] ?? '',
        'password'  => $dbConfig['pass'] ?? '',
        'charset'   => $dsnInfo['charset'] ?? 'utf8mb4',
        'collation' => 'utf8mb4_unicode_ci',
        'prefix'    => '',
    ]);
    
    // Active Record を使えるようにする場合はコメントをはずす 
    // $capsule->setAsGlobal();

    // ここが “副作用” なので一度だけ呼び出したい
    $capsule->bootEloquent();

    return $capsule;
}

この関数は同一リクエスト内で複数回呼ばれても動作しますが、接続情報などのリソースが無駄になるので以下の関数で初回だけ呼ばれるようにしておきます。

/**
 * @param array{dsn:string,user:string|null,pass:string|null} $dbConfig
 */
function build_eloquent_connection(array $dbConfig): ConnectionInterface
{
    static $connection = null;

    if ($connection instanceof ConnectionInterface) {
        return $connection;
    }

    $capsule = build_eloquent_capsule($dbConfig); // ここは副作用を伴うので一度だけ呼び出す
    $connection = $capsule->getConnection();

    return $connection;
}

また、「config.php」から返される接続文字列を Eloquent 用に変換する関数「parse_mysql_dsn」も作成しておきます。

/**
 * PDO DSN (mysql:host=...;port=...;dbname=...;charset=...) を分解
 *
 * @return array{host: string, port: int|null, dbname: string, charset: string|null}
 */
function parse_mysql_dsn(string $dsn): array
{
    $dsn = preg_replace('/^mysql:/', '', $dsn) ?? '';

    $parts = [];
    foreach (explode(';', $dsn) as $chunk) {
        $chunk = trim($chunk);
        if ($chunk === '' || !str_contains($chunk, '=')) {
            continue;
        }
        [$k, $v] = array_map('trim', explode('=', $chunk, 2));
        $parts[$k] = $v;
    }

    $host = $parts['host'] ?? '127.0.0.1';
    $dbname = $parts['dbname'] ?? '';
    $charset = $parts['charset'] ?? null;

    $port = null;
    if (isset($parts['port']) && $parts['port'] !== '' && ctype_digit($parts['port'])) {
        $port = (int) $parts['port'];
    }

    return [
        'host' => $host,
        'port' => $port,
        'dbname' => $dbname,
        'charset' => $charset,
    ];
}

ベースアプリの設定ファイル「config.php」を以下のように修正して、データベース操作クラスを切り替えられるようにします。

return [
    'db' => [
        'driver' => 'eloquent',
        'dsn'  => 'mysql:host=localhost;port=' . MYSQL_PORT . ';dbname=YelpCampCleanArch;charset=utf8mb4',
        'user' => 'clean_arch',
        'pass' => 'password',
    ],
];

「build_router」関数を修正して、実際のクラスの作成を「build_user_repository」関数に移譲します。

/**
 * ルーターまで含めて「全部組み立てて返す」コンポジション
 *
 * @param array{
 *     db: array{
 *         driver?: string,
 *         dsn: string,
 *         user: string|null,
 *         pass: string|null,
 *     }
 * } $config
 */
function build_router(array $config): Router
{
    $userRepository = build_user_repository($config['db']);
    $registerUc     = build_user_register_usecase($userRepository);
    $userController = build_user_controller($registerUc);

    $routes = [
        'POST register' => [$userController, 'register'],
    ];

    return new Router($routes);
}

「build_user_repository」関数で設定内容に対応したクラスを生成するようにします。

/**
 * @param array{
 *   driver?: string,
 *   dsn: string,
 *   user: string|null,
 *   pass: string|null,
 * } $dbConfig
 */
function build_user_repository(array $dbConfig): UserRepositoryInterface
{
    $driver = strtolower((string)($dbConfig['driver'] ?? 'pdo'));

    return match ($driver) {
        'pdo'      => new PdoUserRepository(build_pdo($dbConfig)),
        'eloquent' => new EloquentUserRepository(build_eloquent_connection($dbConfig)),
        default    => throw new RuntimeException('Unknown DB driver: ' . $driver),
    };
}

作成したファイルを以下の位置に保存しておきます。

<app>
└─ src/
   ├─ adapter
   ├─ application
   ├─ domain
   └─ infrastructure
      ├─ Bootstrap
      ├─ Db
      │  └─ User
      │     ├─ EloquentUserRepository.php
      │     └─ PdoUserRepository.php
      └─ Http
すべてのコードはこちら
<?php

declare(strict_types=1);

namespace Infrastructure\Bootstrap;

use PDO;
use Domain\Repositories\UserRepositoryInterface;
use Application\UseCases\User\UserRegisterUseCase;
use Application\UseCases\User\UserRegisterUseCaseInterface;
use Adapter\Controllers\UserController;
use Infrastructure\Http\Router;
use Infrastructure\Db\User\PdoUserRepository;
use Infrastructure\Db\User\EloquentUserRepository;
use RuntimeException;
use Illuminate\Database\ConnectionInterface;

/** 
 * @param array{
 *   driver?: string,
 *   dsn: string,
 *   user: string|null,
 *   pass: string|null,
 * } $config
 */
function build_pdo(array $config): PDO
{
    return new PDO(
        $config['dsn'],
        $config['user'],
        $config['pass'],
        [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
    );
}

/**
 * PDO DSN (mysql:host=...;port=...;dbname=...;charset=...) を分解
 *
 * @return array{host: string, port: int|null, dbname: string, charset: string|null}
 */
function parse_mysql_dsn(string $dsn): array
{
    $dsn = preg_replace('/^mysql:/', '', $dsn) ?? '';

    $parts = [];
    foreach (explode(';', $dsn) as $chunk) {
        $chunk = trim($chunk);
        if ($chunk === '' || !str_contains($chunk, '=')) {
            continue;
        }
        [$k, $v] = array_map('trim', explode('=', $chunk, 2));
        $parts[$k] = $v;
    }

    $host = $parts['host'] ?? '127.0.0.1';
    $dbname = $parts['dbname'] ?? '';
    $charset = $parts['charset'] ?? null;

    $port = null;
    if (isset($parts['port']) && $parts['port'] !== '' && ctype_digit($parts['port'])) {
        $port = (int) $parts['port'];
    }

    return [
        'host' => $host,
        'port' => $port,
        'dbname' => $dbname,
        'charset' => $charset,
    ];
}

/**
 * Capsule を作って返す(EloquentRepository が依存するのは PDO じゃなく Capsule/Connection)
 *
 * @param array{dsn:string,user:string|null,pass:string|null} $dbConfig
 */
function build_eloquent_capsule(array $dbConfig): \Illuminate\Database\Capsule\Manager
{
    static $capsule = null;

    if ($capsule instanceof \Illuminate\Database\Capsule\Manager) {
        return $capsule;
    }

    $dsnInfo = parse_mysql_dsn($dbConfig['dsn']);

    $capsule = new \Illuminate\Database\Capsule\Manager();
    $capsule->addConnection([
        'driver'    => 'mysql',
        'host'      => $dsnInfo['host'],
        'port'      => $dsnInfo['port'] ?? 3306,
        'database'  => $dsnInfo['dbname'],
        'username'  => $dbConfig['user'] ?? '',
        'password'  => $dbConfig['pass'] ?? '',
        'charset'   => $dsnInfo['charset'] ?? 'utf8mb4',
        'collation' => 'utf8mb4_unicode_ci',
        'prefix'    => '',
    ]);
    
    // Active Record を使えるようにする場合はコメントをはずす 
    // $capsule->setAsGlobal();

    // ここが “副作用” なので一度だけ呼び出したい
    $capsule->bootEloquent();

    return $capsule;
}

/**
 * @param array{dsn:string,user:string|null,pass:string|null} $dbConfig
 */
function build_eloquent_connection(array $dbConfig): ConnectionInterface
{
    static $connection = null;

    if ($connection instanceof ConnectionInterface) {
        return $connection;
    }

    $capsule = build_eloquent_capsule($dbConfig); // ここは副作用を伴うので一度だけ呼び出す
    $connection = $capsule->getConnection();

    return $connection;
}

/**
 * @param array{
 *   driver?: string,
 *   dsn: string,
 *   user: string|null,
 *   pass: string|null,
 * } $dbConfig
 */
function build_user_repository(array $dbConfig): UserRepositoryInterface
{
    $driver = strtolower((string)($dbConfig['driver'] ?? 'pdo'));

    return match ($driver) {
        'pdo'      => new PdoUserRepository(build_pdo($dbConfig)),
        'eloquent' => new EloquentUserRepository(build_eloquent_connection($dbConfig)),
        default    => throw new RuntimeException('Unknown DB driver: ' . $driver),
    };
}

function build_user_register_usecase(UserRepositoryInterface $repo): UserRegisterUseCaseInterface
{
    return new UserRegisterUseCase($repo);
}

function build_user_controller(UserRegisterUseCaseInterface $uc): UserController
{
    return new UserController($uc);
}

/**
 * ルーターまで含めて「全部組み立てて返す」コンポジション
 *
 * @param array{
 *     db: array{
 *         driver?: string,
 *         dsn: string,
 *         user: string|null,
 *         pass: string|null,
 *     }
 * } $config
 */
function build_router(array $config): Router
{
    $userRepository = build_user_repository($config['db']);
    $registerUc     = build_user_register_usecase($userRepository);
    $userController = build_user_controller($registerUc);

    $routes = [
        'POST register' => [$userController, 'register'],
    ];

    return new Router($routes);
}

以上で Eloquent の実装は終了です。

動作確認を行います

リファクタリングが終了しましたので、実際に動作確認を行います。
VSCode のターミナル画面に「php -S localhost:8080」などと入力して、PHP の簡易サーバーを起動します。ブラウザーを起動して URL 欄に「localhost:8080/register」と入力して、新しいユーザーを登録します。

特に問題なく登録が完了してログイン状態になりました。

Xdebug が使用できる環境であれば、データ操作ファイル (src\infrastructure\Db\User\EloquentUserRepository.php) を開いて、「findByUsername」関数あたりにブレークポイントをセットして、デバッグ実行すると Eloquent 経由でクエリが実行されていることを確認できます。(Xdebug のセットアップ方法はお手数ですが、WSL2 の場合は こちらの記事、MAMP の場合は こちらの記事 などをご参照ください。)

データベースも問題ないようです。

mysql> use YelpCampCleanArch;
Database changed
mysql> select * from users;
+----------+-----------------+--------------------------------------------------------------+
| username | email           | password                                                     |
+----------+-----------------+--------------------------------------------------------------+
| test     | test@test       | $2y$10$VY0ST5jYSJqdcYJTDBm7q.WeipDtkSb2y1P6jgPZj1/zmKExCE.ii |
| test2    | test2@test2.com | $2y$10$iJ0SJ4L5vMNUJSROvnefyuaRxPiciykxx9w7OJy3/yx6IjYlGAni2 |
| test3    | test3@test3.com | $2y$10$ZDRNdIixn.sySKgmHUyVMOg6gkLKO/xh2Sdvy49i0b4qZ4qyjpIhe |
+----------+-----------------+--------------------------------------------------------------+
3 rows in set (0.00 sec)

mysql>

PHPStan も特に問題ないようです。(今回は MAMP 上で実行しています。)

今回は以下のファイルを修正または追加することで、内側の層は何も修正せずにデータベース機能を変更することができました。

<app>
└─ src/
   ├─ adapter
   ├─ application
   ├─ domain
   └─ infrastructure
      ├─ Bootstrap
      │  └─ container.php
      ├─ Db
      │  └─ User
      │     ├─ EloquentUserRepository.php
      │     └─ PdoUserRepository.php
      └─ Http
まとめ

本シリーズでは、アプリケーション設計の整理と拡張性の確認までを扱いました。実際のデプロイや CI/CD については、別シリーズにて「完成したアプリを運用に載せる」という観点で扱う予定です。

以上です。