皆さんは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)に対応したメソッドです。
GetItemsRequest
と GetItemsResponse
はLaravelフレームワークとは関係なくシンプルなDTOクラスです。
ConcreteQiitaApiClient
は実際にQiitaAPIを実行する実装クラスです。
Concrete
というプレフィックスが付くクラス名は、インターフェースの具体的な実装を示すために使用しています。
MockQiitaApiClient
はQiitaAPIのモッククラスです。
ローカル環境やテスト実行時などでは実際にAPIを実行したくない場合があります。
そんな時はモッククラスに差し替えることで GET /api/v2/items
QiitaAPIを実行することなく、動作を確認できます。
GetLaravelArticleListUseCase
は QiitaApiClient
インターフェースに依存していて、APIを呼び出すクライアントコードです。
インターフェースに依存していることで、クライアントコードを書き換えることなくモッククラスと差し替えができるようになっていることがインターフェースを使う利点です。
言い換えると if (config('app.env') === 'testing')
のように環境によって動作が変わるような条件をクライアントコードの中に書く必要がなくなるのでコードがシンプルになります。
実際のコード
長くなるので呼び飛ばして良いですが、コードがあった方がよりイメージしやすいと思ったので載せておきます。
インターフェースの話から逸れますが補足も書いてます。
<?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を追加したくなったらどんどんメソッドを追加していくイメージです。
<?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クライアントがめっちゃ便利っすね!
<?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
メソッドを追加したりしてます。
<?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を実際に叩かずともどういった値を受け渡ししているのかがわかるので型があると安心です。
<?php
declare(strict_types=1);
namespace App\Api\Qiita\GetItems;
final readonly class GetItemsResponse
{
public function __construct(
public ItemList $itemList,
) {
}
}
<?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'],
);
}
}
<?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);
}
}
<?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側のコードです。
<?php
Route::get('laravel/articles', [LaravelArticlesIndexController::class, 'index']);
ルーティングは適当にこう書いてます。
<?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
インスタンスを受け取っています。
<?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
プロパティの配列に追加するだけで良いですが、本番環境以外の時はモックに差し替えたいみたいな場合はこんな感じに書くといいですね!
<?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([]));
}
}
}
テストコード
<?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->bind
で QiitaApiClient
インターフェースを MockQiitaApiClient
モッククラスと差し替えしています。
もちろんですが、クライアントコードを実行する前に差し替える必要がありますね。
$this->app->make
でクラアイントコードのインスタンスを生成しています。この時にインスタンスの依存解決してくれます。
記事の一覧を取得するコードですが、実行するたびに新規記事情報が出てきてしまっては安定したテストが書けません。副作用のあるAPIだと尚のことテストで実行したくありません。
インターフェースを使うことでテストコードが書きやすくなります。
まとめ
- インターフェースは特定の機能を提供するための入力と出力の仕様
- インターフェースはシグネチャ(関数名+引数+戻り値)の集まり
- インターフェースと契約したクラスは定義されたシグネチャを必ず全て実装する
- インターフェースを使用するとコードの一貫性、柔軟性、拡張性が向上する