マイクロフレームワークっぽく使う Symfony 4 事始め 3 ~DIコンテナ編~

Symfony 4 入門。前回の記事は以下

マイクロフレームワークっぽく使う Symfony 4 事始め 2 ~ルーティング編~

Symfony 4 の DIコンテナ(サービスコンテナ)

今回は Symfony 4 の DIコンテナ(サービスコンテナ) について説明します。

ある程度のサイズのアプリケーションを構築する際にはコントローラとビュー(テンプレート)だけで処理が完結する事は少なく、具体的なロジックを別のクラスに移したり、データベースの読み書き部分を更に別のクラスに移したりといった設計をするかと思います。DIコンテナはそうしたオブジェクト間の依存関係に対して Dependency Injection (依存性注入) をしやすくするための仕組みです。

例えば機能Aに関する諸々を提供するサービスクラスAが更に別のオブジェクトや変数を必要とする(依存性がある)場合に、それらをサービスクラスA上で直接newせずにDIコンテナの機能を利用してサービスクラスAに注入する事で依存性が薄まり、テストコードが書きやすくなるなどのメリットが出てきます。

Symfony 4 のDI機能については config ディレクトリ内の services.yaml で定義されています。こちらを開いてみると以下のような定義が有ると思います。

services:
    # default configuration for services in *this* file
    _defaults:
        autowire: true      # Automatically injects dependencies in your services.
        autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.

    # makes classes in src/ available to be used as services
    # this creates a service per class whose id is the fully-qualified class name
    App\:
        resource: '../src/*'
        exclude: '../src/{DependencyInjection,Entity,Migrations,Tests,Kernel.php}'

services.yaml 内の services: 内に自分で定義を書いていく事でも定義できるのですが autowire という機能によりその辺りはあまり意識せずに設定フリーで動かして行く事が出来ます。初期設定では App\ 名前空間配下であり ディレクトリは /src/ 配下の 「DependencyInjection」「Entity」「Migrations」「Tests」以外のディレクトリに切られたクラスであれば、自動でDIコンテナにクラスが登録されます。

DIコンテナに登録されたサービスは利用したいクラスのコンストラクタでコンストラクタインジェクション(コンストラクタの引数からクラスのフィールドに注入)したり、メソッドインジェクション(メソッドの引数からメソッド内に注入)したりして利用します。

例えばコントローラでルーティングを定義する場合に、リクエスト内容を利用するために引数に Request オブジェクトを受け取っていたと思います。このようにクラスの型を定義しておくことでDIコンテナが自動で必要なサービスを受け渡してくれます。

    /**
     * @Route("/books/detail/", name="book_detail_for_query")
     */
    public function bookDetailActionForQuery(Request $req)
    {
        $book_id = $req->query->get('book_id');

        return $this->render('books/detail.html.twig', [
            'book_id' => $book_id,
            'book_detail' => 'dumy text'
        ]);
    }

実際に利用してみる

これを自前で作成したサービスクラスで試してみます。以下のサービスクラスを定義します。
名前空間: App\Service
ファイル: src/Service/SampleService.php

<?php
namespace App\Serivce;

class SampleService
{
    public function helloWorld(): string
    {
        return 'hello world.';
    }

}

これをコントローラ上のルーティングから利用してみます。まずはメソッド(関数)インジェクションを試してみます。

    /**
     * @Route("/service/useService1", methods={"GET"})
     */
    public function useSerivce1(\App\Service\SampleService $sampleService)
    {
        $result = $sampleService->helloWorld();
        
        return $this->render('/serivce/use_service.html.twig', [
            'body' => $result
        ]);
    }

Twigテンプレートは以下です。bodyに渡した変数を表示するだけです。

<!DOCTYPE html>
<html lang="ja">
    <head>
        <meta charset="UTF-8">
        <title>{{ title|default('default title')}}</title>
    </head>
    <body>
        {{ body }}
    </body>
</html>

やっている事は引数に使いたいオブジェクトの型を指定しているだけです。これだけで Symfony のDIコンテナが SampleService のオブジェクトを注入してくれます。PHPビルトインサーバーを起動しWebブラウザから確認してみます。

しっかりサービスクラスのオブジェクトが利用できています。

次はコンストラクタでのインジェクションを試してみます。クラス内で恒常的に使うものはこちらの方がいいでしょう。

    // 利用するサービスクラスをコンストラクタインジェクションする
    private $sampleService;

    public function __construct(\App\Service\SampleService $sampleService)
    {
        $this->sampleService = $sampleService;
    }

    /**
     * @Route("/service/useService2", methods={"GET"})
     */
    public function useSerivce2()
    {
        $result = $this->sampleService->helloWorld();
        
        return $this->render('/service/use_service.html.twig', [
            'body' => $result
        ]);
    }

コンストラクタの引数でDIコンテナから受け取ったオブジェクトをフィールドに保存しておいて使います。書き方としてはあまり変わらないですね。

自動で登録され注入できる物の一覧はSymfonyのコンソールコマンド debug:autowiring 、自動登録以外もすべて含めての一覧は debug:container から確認できます。実際に試してみると結構いろいろなものが出てくると思います。例えば Request 以外にも、セッション(SessionInterface)であったり、ログファイルに書き込むロガー(LoggerInterface)であったりデータベース接続が設定済みである場合のデフォルトコネクションを取得したり出来ます。

また、上記の SessionInterface や LoggerInterface のように、インタフェースを実装したクラスである場合インタフェース名で注入するオブジェクトを指定する事が出来ます。インタフェース名で指定した場合でも SymfonyのDIコンテナは実際に実装した方のオブジェクトを渡してくれます。

設定値の注入

クラス名を持たない値を注入したい事が有ります。例えばメールを送信するクラスを初期化する際に送信元アドレスを固定で渡す仕様だったとします。

<?php
namespace App\Service;

class MailService
{
    private $from_address;

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

from_addressはメールアドレスを文字列として渡す仕様です。これだと型での指定ができませんし string 型を指定しても仕方が有りません。このような場合に services.yaml での手動定義が必要になってきます。parameters にパラメータを定義しておき、 services 内で「どのサービスの」「どの変数名に」「どのパラメータを渡すか」を指定します。

parameters:
    from_address: 'xxx@yyy.zzz'

services:

    # 既存で記載されているもろもろを中略

    App\Service\MailService:
        arguments:
            $from_address: '%from_address%'

この定義を追加する事で App\Serivce\MailService の引数 $from_address に対してパラメータ from_address を渡す事が出来ます。serivces.yaml での手動設定では他にも以下のような物に対して解決が行えます。

  • 一つのインタフェースに対して複数の実装が有る場合にどれを注入するか。
  • デフォルトではない方のDB接続を注入したい。
  • etc..

コンテナから直接取得する

コンテナに登録済みのオブジェクトは直接取得する事も出来ます。依存性は高くなりますがそうしたい場合もあるかと思いますので記載しておきます。

$sampleService = $this->container->get('App\Service\SampleService');
$from_address = $this->getParameter('from_address');

なお、上記の $this->container->get() で取りに行く方法はコントローラ内では制限が有り利用できないのでご注意ください。

実際のユースケースとしてはPHPUnitのテストコードを書く際に利用する事が多いです。PHPUnitテストコード内に対してはコンストラクタインジェクション、メソッドインジェクションを使った自動注入が機能しません。通常利用する TestCase クラスを継承した KernelTestCase を継承してテストを実装するとSymfonyのカーネルとDIコンテナを起動する事が可能になるので、そこから必要なオブジェクトを取得したり出来るようになります。これについては後でまた説明したいです。

その他の諸々

Symfony 4 をマイクロフレームワークとして使う場合、このDIコンテナ(サービスコンテナ)機能(とComposerによるオートロードの仕組み)がアプリケーションの機能を組み立てる骨組みになってくるかと思います。他にも説明していない様々な機能が有りますので、普通のインジェクション機能では解決できないものが出てきた場合は公式のドキュメントを参照してみてください。

Service Container (Symfony Docs)

ここまでのコードの状態は以下を参照してください。
https://github.com/lf-uraku-yuki/symfony4_tutorial/tree/tutorial03

次回はDB接続について書きたいです。