😀

Laravelにおけるinterfaceを使った実装〜bindメソッドによるDI〜

2024/04/07に公開

はじめに

私が今の現場に参画した時につまづいたLaravelにおけるinterfaceの使い方についてまとめます。
以下の方々に読んでいただきたいです。

  • laravelの現場に参画予定の人。
  • エンジニアになりたての人
  • オブジェクト指向初心者

特に大規模プロジェクトなどではクラス間の結合を疎結合にするために必ず使われている実装パターンだと思います。

interfaceとは??

PHPにおけるインターフェースは、クラスが実装しなければならないメソッドを定義するための仕組みです。インターフェースを実装したクラスは必ずそのインターフェースのメソッドを全て実装しなければなりません。こうすることでクラス間で共通の振る舞いを保証することできます。

interface Loggable {
    public function log($message);
    public function sendNotification($recipient, $subject, $body);
}

class EmailNotifier implements Loggable {
    public function log($message) {
        // メールでログを出力するロジック
    }

    public function sendNotification($recipient, $subject, $body) {
        // メールを送信するロジック
    }
}

interfaceを使うメリット

私の中でinterfaceを使うメリットとしては大きく二つあると思っています。一つは「クラス間を疎結合」にすることができる点。もう一つはより堅牢に作ることができる点です。
特に二つ目がポイントで、interfaceの本質は「そのメソッドを持っていなければならない」というルールを暗黙の了解にせず、絶対的なルールにすることにあります。

まずは使用しないとどうなるか、以下の例を見ていきましょう。

class FileLogger{
    public function log($message) {
        // ファイルにログを記録するロジック
    }
}
class UserManager {
    private $logger;

    public function __construct(FileLogger $logger) {
        $this->logger = $logger;
    }

    public function registerUser($username, $email) {
        // ユーザー登録ロジック
        $this->logger->log("User registered: $username, $email");
    }
}

// 依存性を注入して UserManager クラスのインスタンスを作成
$fileLogger = new FileLogger();
$userManager = new UserManager($fileLogger);

// UserManager クラスを使用してユーザーを登録
$userManager->registerUser("john_doe", "john@example.com");

上記はユーザー登録の際に、FileLoggerクラスを使ってファイルにログを残すコードとなります。
DIを用いてFileLoggerクラスを外側から注入しているので、疎結合という観点では十分かもしれませんね。
では、今後の改修でファイルにログを残すのではなく、DBに保存したいとなった場合、以下のような変更が加わるでしょう。

class DatabaseLogger{
    public function log($message) {
        // データベースにログを記録するロジック
    }
}
class UserManager {
    private $logger;

    public function __construct(DatabaseLogger $logger) {
        $this->logger = $logger;
    }

    public function registerUser($username, $email) {
        // ユーザー登録ロジック
        $this->logger->log("User registered: $username, $email");
    }
}

// 依存性を注入して UserManager クラスのインスタンスを作成
$databaseLogger = new DatabaseLogger();
$userManager = new UserManager($databaseLogger);

// UserManager クラスを使用してユーザーを登録
$userManager->registerUser("john_doe", "john@example.com");

変更点としては、コンストラクタのタイプヒンティングとDIするクラスがDatabaseLoggerになったことですね。
ロジック的には何の問題もないのですが、こちらのコードは隠れた不具合のもとになったり、プロジェクトが大きくなるにつれて良くない影響を及ぼす可能性があります。

このコードの問題

このコードの問題点はUserManagerクラスに変更が及んでしまっている点ですね。この例ではタイプヒントのみですが、DatabaseLoggerのlog()のメソッド名が別の名前であった場合、$this->logger->log("User registered: $username, $email");にも影響が及んでしまいます。
上記の例で言うlog()はあくまで実装者の私が変更範囲が少なくなるように既存のメソッド名と同じ名前をつけただけで、これが実際の開発では別の実装者が他の名前をつけてしまうかもしれません。

class DatabaseLogger{
    // 他の実装者がメソッド名変更
    public function writeLog($message) {
        // データベースにログを記録するロジック
    }
}
class UserManager {
    private $logger;

    public function __construct(DatabaseLogger $logger) {
        $this->logger = $logger;
    }

    public function registerUser($username, $email) {
        // ここにも変更が必要
        $this->logger->writeLog("User registered: $username, $email");
    }
}

interfaceの活用

上記の問題を解決するのがinterfaceです。上記の例をinterfaceを用いて実装し直すと以下のようになります。

// インターフェースの定義
interface Logger {
    public function log($message);
}

// Logger インターフェースを実装するファイルログクラスの実装
class FileLogger implements Logger {
    public function log($message) {
        // ファイルにログを記録するロジック
    }
}

// Logger インターフェースを実装するデータベースログクラスの実装
class DatabaseLogger implements Logger {
    public function log($message) {
        // データベースにログを記録するロジック
    }
}

// クラスが Logger インターフェースに依存する場合、DI を使用して依存性を注入する
class UserManager {
    private $logger;

    public function __construct(Logger $logger) {
        $this->logger = $logger;
    }

    public function registerUser($username, $email) {
        // ユーザー登録ロジック
        $this->logger->log("User registered: $username, $email");
    }
}

// 依存性を注入して UserManager クラスのインスタンスを作成
$fileLogger = new FileLogger();
$userManager1 = new UserManager($fileLogger);
$databaseLogger = new DatabaseLogger();
$userManager2 = new UserManager($databaseLogger);

// UserManager クラスを使用してユーザーを登録
// ファイルにログが残る
$userManager1->registerUser("john_doe", "john@example.com");
// DBにログが残る
$userManager2->registerUser("john_doe", "john@example.com");

このように、interfaceを用いることで、DIされたクラス(FileLoggerクラスとDatabaseLoggerクラス)がlog()を持っていることが確約されます。これでクラスを使う側であるUserManagerクラスでは何も気にすることなくlogメソッドを呼ぶことができるようになります。
また、これ以降別の場所にlogを出力する新しいクラスが追加になったとしても、interfaceを満たすクラスを外側からDIすることによって、使う側であるUserManagerクラスには何の変更も及ばない設計にすることができます。

Laravelにおける依存性の解決方法

じゃあこれ結局Laravelでどうやって使うんだろう?というのが本題になります。
Laravelの場合はcontrollerのコンストラクタでタイプヒントにクラスを指定することで、Laravelが自動的にDIしてくれる仕組みがありましたね。これをinterfaceを使った実装に置き換えてみます。

controller

<?php

namespace App\Http\Controllers\Admin;

use App\Http\Controllers\Controller;
use App\Logs\Interface\Logger;

class UserController extends Controller
{
    private $logger;

    public function __construct(Logger $logger)
    {
        $this->logger = $logger;
    }


    public function store($request)
    {
        // 登録処理
        $this->logger->log('ユーザー登録完了');
    }
}

interface

<?php

namespace App\Logs\Interface;

interface Logger
{
    public function log($message);
}

interfaceの実装クラス

<?php

namespace App\Logs;

use App\Logs\Interface\Logger;

class FileLogger implements Logger
{
    public function log($message)
    {
        // ファイルにログを記録するロジック
    }
}

しかし、interfaceは具体的な実装を持たないので、これに実装クラスをDIする必要があります。
Laravelで依存性を解決するにはbind()を使うことができます。主にサービスプロバイダ内で使用します。

app/Providers/AppServiceProvider.php

<?php

namespace App\Providers;

use App\Logs\FileLogger;
use App\Logs\Interface\Logger;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     */
    public function register(): void
    {
        //
    }

    /**
     * Bootstrap any application services.
     */
    public function boot(): void
    {
        $this->app->bind(
            Logger::class,
            FileLogger::class
        );
    }
}

上記のように第一引数にinterface,第二引数に実装クラスを指定することでサービスプロバイダが自動的に依存関係を解決してくれるようになります。これで実装は完了です・

まとめ

  • interfaceを用いることで疎結合で堅牢な設計にすることができる。
  • Laravelで依存関係を解決したいときはbind()を用いるべし!

特にこのbindメソッドとinterfaceは私が現場に参画した際に一番最初に沼った箇所でした。これを読んでいる新たなLaravelデベロッパーに同じ轍を踏んでほしくない、そんな思いで書きました。Larave最高!!

Discussion