89
77

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

PHP/Laravel インターフェースの使い方

Last updated at Posted at 2024-04-29

皆さんはPHPの開発現場でインターフェースを使うことはありますか?
文法や使い方はわかるけど、どういう時に使うのがいいのか分からない人も多いと思います。

この記事は初心者向けに具体的な例も交えてインターフェースの使い方を紹介したいと思います。

インターフェースとは

インターフェースは「境界面」「接点」の意味を持つ言葉です。文脈により意味合いが変わってくる言葉ですが、今回はPHPのオブジェクトインターフェースについてなるべく分かりやすく解説します。

インターフェースはシグネチャのみ定義

インターフェースは特定の機能を提供するための入力と出力の仕様を定義します。

interface Hoge
{
    public function foo(string $bar): bool;
}

関数名+引数+戻り値の型の組み合わせのことを シグネチャ と呼びます。
インターフェースはシグネチャのみの仕様を定義するので具体的な実装は定義しません。

インターフェースを契約したクラスを作ってみよう

インターフェースを契約(implements)してクラスを定義できます。
クラスを定義する際はインターフェースに定義されているシグネチャは必ずすべて実装する必要があります。

まずは銀行の入出金を例にクラス図を作成します。

銀行 インターフェースには 入金出金 メソッドが定義されています。
銀行インターフェースを契約した 三井住友銀行みずほ銀行 クラスは必ず両方のメソッドを定義する必要があります。

PHPのコードで書くと次のような感じです。

<?php

interface 銀行
{
    public function 入金(int $金額): void;
    public function 出金(int $金額): void;
}

final class 三井住友銀行 implements 銀行
{
    public function 入金(int $金額): void
    {
        // 実際の処理を書く
    }

    public function 出金(int $金額): void
    {
        // 実際の処理を書く
    }
}

final class みずほ銀行 implements 銀行
{
    public function 入金(int $金額): void
    {
        // 実際の処理を書く
    }

    public function 出金(int $金額): void
    {
        // 実際の処理を書く
    }
}

コードにした事でインターフェースを使うのメリットがいくつか見えてきたと思います。

例えば、 三菱UFJ銀行 など新しい銀行クラスを追加する場合でも 銀行 インターフェースに従う限り、既存のコードに容易に統合できます。
別の銀行が追加された場合でも同じインターフェースで実装しているので、特に大きな変更を加えることなく、一貫性のあるコードになります。

インターフェースを使用するとコードの一貫性、柔軟性、拡張性が向上します。

インターフェース使ってモックと差し替える

例としてQiitaAPIを実行するクライアントをLaravelで実装する形を考えてみます。

Qiita APIの GET: /api/v2/items 記事の一覧を作成日時の降順で返すというAPIがあります。

インターフェースを使ってQiitaApiクライアントを表現したいと思います。

QiitaApiClient インターフェースの getItems(GetItemsRequest) GetItemsResponse はQiitaAPIの記事の一覧を取得する(GET /api/v2/items)に対応したメソッドです。

GetItemsRequestGetItemsResponse はLaravelフレームワークとは関係なくシンプルなDTOクラスです。

ConcreteQiitaApiClient は実際にQiitaAPIを実行する実装クラスです。
Concrete というプレフィックスが付くクラス名は、インターフェースの具体的な実装を示すために使用しています。

MockQiitaApiClient はQiitaAPIのモッククラスです。
ローカル環境やテスト実行時などでは実際にAPIを実行したくない場合があります。
そんな時はモッククラスに差し替えることで GET /api/v2/items QiitaAPIを実行することなく、動作を確認できます。

GetLaravelArticleListUseCaseQiitaApiClient インターフェースに依存していて、APIを呼び出すクライアントコードです。

インターフェースに依存していることで、クライアントコードを書き換えることなくモッククラスと差し替えができるようになっていることがインターフェースを使う利点です。

言い換えると if (config('app.env') === 'testing')のように環境によって動作が変わるような条件をクライアントコードの中に書く必要がなくなるのでコードがシンプルになります。

実際のコード

長くなるので呼び飛ばして良いですが、コードがあった方がよりイメージしやすいと思ったので載せておきます。
インターフェースの話から逸れますが補足も書いてます。

app/Api/Qiita/QiitaApiClient.php
<?php  
  
declare(strict_types=1);
  
namespace App\Api\Qiita;
  
use App\Api\Qiita\GetItems\GetItemsRequest;
use App\Api\Qiita\GetItems\GetItemsResponse;
  
interface QiitaApiClient  
{
    public function getItems(GetItemsRequest $request): GetItemsResponse;
}

QiitaAPIを表現するインターフェースです。
新しく呼び出したいAPIを追加したくなったらどんどんメソッドを追加していくイメージです。

app/Api/Qiita/ConcreteQiitaApiClient.php
<?php  
  
declare(strict_types=1);
  
namespace App\Api\Qiita;
  
use App\Api\Qiita\GetItems\GetItemsRequest;
use App\Api\Qiita\GetItems\GetItemsResponse;
use App\Api\Qiita\GetItems\ItemList;
use Illuminate\Support\Facades\Http;
  
final class ConcreteQiitaApiClient implements QiitaApiClient  
{
    private const ENDPOINT = 'https://qiita.com/api/v2';
  
    public function getItems(GetItemsRequest $request): GetItemsResponse  
    {
        $apiUrl = self::ENDPOINT . '/items?' . http_build_query([  
            'page' => $request->page,  
            'per_page' => $request->perPage,  
            'query' => $request->query,  
        ]);
  
        $response = Http::get(url: $apiUrl);
  
        return new GetItemsResponse(itemList: ItemList::create($response->json()));
    }  
}

QiitaApiClient インターフェースを契約した ConcreteQiitaApiClient 実装クラスです。
Http::get(url: $apiUrl) ここで実際にQiitaAPIを実行していますね。
LaravelのHTTPクライアントがめっちゃ便利っすね!

app/Api/Qiita/MockQiitaApiClient.php
<?php  
  
declare(strict_types=1);
  
namespace App\Api\Qiita;
  
use App\Api\Qiita\GetItems\GetItemsRequest;
use App\Api\Qiita\GetItems\GetItemsResponse;
use App\Api\Qiita\GetItems\ItemList;
  
final readonly class MockQiitaApiClient implements QiitaApiClient  
{
    public function __construct(private ItemList $itemList)  
    {
    }  
  
    public static function create(array $itemList): self  
    {
        return new self(ItemList::create($itemList));
    }  
  
    public function getItems(GetItemsRequest $request): GetItemsResponse  
    {
        return new GetItemsResponse(itemList: $this->itemList);
    }  
}

QiitaApiClient インターフェースを契約した ConcreteQiitaApiClient モッククラスです。

モックなのでQiitaAPIは実行されませんし、 getItems の中身もシンプルです。
要は引数と戻り値の型が合ってればいいんです。

テストの時の差し替える際にインスタンスを作りやすいよう create メソッドを追加したりしてます。

app/Api/Qiita/GetItems/GetItemsRequest.php
<?php  
  
declare(strict_types=1);
  
namespace App\Api\Qiita\GetItems;
  
final readonly class GetItemsRequest  
{
    public function __construct(  
        public int $page,  
        public int $perPage,  
        public string $query,  
    ) {
    }  
}

QiitaAPIの GET /api/v2/items には次のようなパラメータを受け付けます。

  • page
    • ページ番号 (1から100まで)
    • Example: 1
    • Type: string
    • Pattern: /^[0-9]+$/
  • per_page
    • 1ページあたりに含まれる要素数 (1から100まで)
    • Example: 20
    • Type: string
    • Pattern: /^[0-9]+$/
  • query
    • 検索クエリ
    • Example: "qiita user:Qiita"
    • Type: string

入出力の型が定義されてるとコード見ただけで、APIを実際に叩かずともどういった値を受け渡ししているのかがわかるので型があると安心です。

app/Api/Qiita/GetItems/GetItemsResponse.php
<?php  
  
declare(strict_types=1);
  
namespace App\Api\Qiita\GetItems;
  
final readonly class GetItemsResponse  
{
    public function __construct(  
        public ItemList $itemList,  
    ) {
    }  
}
app/Api/Qiita/GetItems/Item.php
<?php  
  
declare(strict_types=1);
  
namespace App\Api\Qiita\GetItems;
  
final readonly class Item  
{
    public function __construct(  
        public string $id,  
        public string $title,  
        public string $url,  
    ) {
    }  
  
    public static function create(array $item): self  
    {
        return new self(  
            $item['id'],  
            $item['title'],  
            $item['url'],  
        );
    }  
}
app/Api/Qiita/GetItems/ItemList.php
<?php  
  
declare(strict_types=1);
  
namespace App\Api\Qiita\GetItems;
  
use IteratorAggregate;
use ArrayIterator;
use ReturnTypeWillChange;
  
final readonly class ItemList implements IteratorAggregate  
{
    public function __construct(private array $attributes)  
    {
    }  
  
    public static function create(array $itemList): self  
    {
        return new self(array_map(fn (array $item) => Item::create($item), $itemList));
    }  
  
    /**  
     * @return Item[]|ArrayIterator  
     */    #[ReturnTypeWillChange]  
    public function getIterator(): array|ArrayIterator  
    {
        return new ArrayIterator($this->attributes);
    }  
}
app/UseCase/GetLaravelArticleListUseCase.php
<?php  
  
declare(strict_types=1);
  
namespace App\UseCase;
  
use App\Api\Qiita\GetItems\GetItemsRequest;
use App\Api\Qiita\QiitaApiClient;
  
final readonly class GetLaravelArticleListUseCase  
{
    public function __construct(private QiitaApiClient $client)  
    {
    }  
  
    public function get(): array  
    {
        $request = new GetItemsRequest(1, 10, 'Laravel');
        $response = $this->client->getItems($request);
  
        $itemList = [];
        foreach ($response->itemList as $item) {
            $itemList[] = [$item->id, $item->title, $item->url];
        }  
  
        return $itemList;
    }  
}

クライアントコードですね。
だいぶ適当なんですが、Laravelの記事を10件取得するというユースケースですね。

QiitaApiClient インターフェースをコンストラクタインジェクションしています。
中身が ConcreteQiitaApiClient なのか MockQiitaApiClient なのかは意識していないですね。

Laravelフレームワークのコード

Laravel側のコードです。

routes/web.php
<?php

Route::get('laravel/articles', [LaravelArticlesIndexController::class, 'index']);

ルーティングは適当にこう書いてます。

app/Http/Controllers/LaravelArticlesIndexController.php
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\UseCase\GetLaravelArticleListUseCase;
use Illuminate\Http\JsonResponse;

final class LaravelArticlesController extends Controller  
{
    public function index(GetLaravelArticleListUseCase $useCase): JsonResponse
    {
        return new JsonResponse($useCase->get());
    }
}

コントローラもクライアントコード実行してJSONにして返してます。
GetLaravelArticleListUseCase をメソッドインジェクションで $useCase インスタンスを受け取っています。

app/Providers/AppServiceProvider.php
<?php

declare(strict_types=1);

namespace App\Providers;

use App\Api\Qiita\QiitaApiClient;
use App\Api\Qiita\ConcreteQiitaApiClient;
use Illuminate\Support\ServiceProvider;

final class AppServiceProvider extends ServiceProvider  
{
    public array $bindings = [
        QiitaApiClient::class => ConcreteQiitaApiClient::class,
    ];
}

ここでインターフェースに対応する実装クラスを結合しています。
設定をしておかないと次のような依存解決ができなくてエラーが出ます。

Target [App\Api\Qiita\QiitaApiClient] is not instantiable while building [App\UseCase\GetLaravelArticleListUseCase].

ここで依存関係を設定していることでメソッドインジェクションや、コンストラクタインジェクションの時にインスタンスを注入してくれます。

シンプルな結合であれば $bindings プロパティの配列に追加するだけで良いですが、本番環境以外の時はモックに差し替えたいみたいな場合はこんな感じに書くといいですね!

app/Providers/AppServiceProvider.php
<?php

declare(strict_types=1);

namespace App\Providers;

use App\Api\Qiita\MockQiitaApiClient;
use App\Api\Qiita\QiitaApiClient;
use App\Api\Qiita\ConcreteQiitaApiClient;
use Illuminate\Support\ServiceProvider;

final class AppServiceProvider extends ServiceProvider  
{
    public function register(): void
    {
        if ($this->app->environment() === 'production') {
            $this->app->bind(QiitaApiClient::class, ConcreteQiitaApiClient::class);
        } else {
            $this->app->bind(QiitaApiClient::class, fn () => MockQiitaApiClient::create([]));
        }
    }
}

テストコード

tests/Feature/UseCase/GetLaravelArticleListUseCaseTest.php
<?php

declare(strict_types=1);

namespace Tests\Feature\UseCase;

use App\Api\Qiita\MockQiitaApiClient;
use App\Api\Qiita\QiitaApiClient;
use App\UseCase\GetLaravelArticleListUseCase;
use Tests\TestCase;

final class GetLaravelArticleListUseCaseTest extends TestCase  
{
    public function testSuccess(): void  
    {
        // テストデータを用意
        $itemList = [  
            ['id' => '123', 'title' => 'モックタイトル1', 'url' => 'https://qiita.com/Qiita/items/11111111111111111111'],  
            ['id' => '456', 'title' => 'モックタイトル1', 'url' => 'https://qiita.com/Qiita/items/22222222222222222222'],  
            ['id' => '789', 'title' => 'モックタイトル1', 'url' => 'https://qiita.com/Qiita/items/33333333333333333333'],  
        ];

        // 期待するデータ
        $expectedItemList = [
            ['123', 'モックタイトル1', 'https://qiita.com/Qiita/items/11111111111111111111'],  
            ['456', 'モックタイトル1', 'https://qiita.com/Qiita/items/22222222222222222222'],  
            ['789', 'モックタイトル1', 'https://qiita.com/Qiita/items/33333333333333333333'],  
        ];

        // モックと差し替え
        $this->app->bind(QiitaApiClient::class, fn () => MockQiitaApiClient::create($itemList));

        // クライアントコードの実行
        $useCase = $this->app->make(GetLaravelArticleListUseCase::class);
        $actualItemList = $useCase->get();

        // テスト
        $this->assertSame($expectedItemList, $actualItemList);
    }  
}

$this->app->bindQiitaApiClient インターフェースを MockQiitaApiClient モッククラスと差し替えしています。
もちろんですが、クライアントコードを実行する前に差し替える必要がありますね。

$this->app->make でクラアイントコードのインスタンスを生成しています。この時にインスタンスの依存解決してくれます。

記事の一覧を取得するコードですが、実行するたびに新規記事情報が出てきてしまっては安定したテストが書けません。副作用のあるAPIだと尚のことテストで実行したくありません。

インターフェースを使うことでテストコードが書きやすくなります。

まとめ

  • インターフェースは特定の機能を提供するための入力と出力の仕様
  • インターフェースはシグネチャ(関数名+引数+戻り値)の集まり
  • インターフェースと契約したクラスは定義されたシグネチャを必ず全て実装する
  • インターフェースを使用するとコードの一貫性、柔軟性、拡張性が向上する
89
77
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
89
77

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?