Symfony」タグアーカイブ

マイクロフレームワークっぽく使う Symfony 4 事始め 4 ~RDB接続編~

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

SymfonyでのRDB接続

Symfonyフレームワークでは公式で用意されているレシピではDoctrine (Doctrine DBAL / Doctrine ORM)に対応しています。Doctrine ORMではORマッピングを利用したRDB処理、Doctrine DBALではPHP PDOをラップしたようなものになっています。Doctrine DBALではPDO同様にMySQL / MariaDB / PostgreSQL 等に対して SQL を実行する以外に、SQLを生成するクエリビルダ―の機能も有ります。

また、DoctrineDBALの代わりに他の illuminate/database などを利用できるレシピも公開されているようです。

今回は簡単に使い始められる Doctrine DBAL で説明していきます。RDBには第1回でも説明したようにMySQL 8.0を利用しています。データは symfony_test DBを作成し、以下のような books テーブルを用意しています。

Doctrine DBAL の導入

これまでの実装でcomposer.jsonのrequire部分は以下のようになっています。

    "require": {
        "php": "^7.1.3",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "sensio/framework-extra-bundle": "^5.2",
        "symfony/console": "4.2.*",
        "symfony/debug-pack": "^1.0",
        "symfony/dotenv": "4.2.*",
        "symfony/flex": "^1.1",
        "symfony/framework-bundle": "4.2.*",
        "symfony/maker-bundle": "^1.11",
        "symfony/twig-bundle": "4.2.*",
        "symfony/web-server-bundle": "4.2.*",
        "symfony/yaml": "4.2.*"
    }

Doctrine DBALを利用するには symfony/orm-pack を導入します。名前から分かるようにDoctrine ORM / DBAL の機能はセットで導入されます。

composer require symfony/orm-pack

これで必要なパッケージ群が導入されました。

次にDB接続設定を行います。上記のログの最後にも出ている .env ファイル内に接続情報を記載します。Symfonyプロジェクト直下にある .env ファイルを開き、以下の部分を編集します。

DATABASE_URL=mysql://db_user:db_password@127.0.0.1:3306/db_name

db_user, db_password, 接続先ホストとポート、DB名を適宜変更したら保存します。なお、MySQLでの設定になっていますが、PostgreSQLなどに接続したい場合はこれも上記ログに出ている config/packages/doctrine.yaml で設定します。現在は以下のようになっています。

doctrine:
    dbal:
        # configure these for your database server
        driver: 'pdo_mysql'
        server_version: '5.7'
        charset: utf8mb4
        default_table_options:
            charset: utf8mb4
            collate: utf8mb4_unicode_ci

        url: '%env(resolve:DATABASE_URL)%'
    orm:
        auto_generate_proxy_classes: true
        naming_strategy: doctrine.orm.naming_strategy.underscore
        auto_mapping: true
        mappings:
            App:
                is_bundle: false
                type: annotation
                dir: '%kernel.project_dir%/src/Entity'
                prefix: 'App\Entity'
                alias: App

この設定の中で ‘%env(resolve:DATABASE_URL)%’ という部分が .env ファイルで設定した値が投入される部分になります。この yaml 設定ファイル自体に各種接続情報を定義する事も可能ですが、.envファイルに環境ごとの設定を入れておくことで環境ごとに切り替えをしやすくするような仕組みになっています。なお、.envファイルは初期状態だとGitリポジトリへのコミット対象ですが、 .env.local といったファイルを作ると .env.local は最初から Git Ignore されており、設定的にもそちらが優先されるようになっていますので開発環境などでは .env.local を使った方が良いかもしれません。

doctrine.yaml でのdriverの指定はデフォルトではPDO MySQLになっているのでPostgreSQLの場合はここをPDO PostgreSQLを使う設定に変更しておきます。

実際にRDBにアクセスしてみる

実際にDoctrine DBALを利用してRDBから値を取り出してみます。

リポジトリ

RDBへのアクセスはコントローラやサービスなどからも可能でべた書き出来るのですが、今回はアクセスするためのリポジトリとなるクラスを作成します。/src/Repository ディレクトリを作成し、BooksRepository.php を作成します。

<?php

namespace App\Repository\Impl;

use App\Repository\BooksRepository;

class BooksRepositoryImpl implements BooksRepository
{

    /** @var \Doctrine\DBAL\Connection connection */
    protected $db;

    /** @var Psr\Log\LoggerInterface logger */
    protected $logger;

    public function __construct(\Doctrine\DBAL\Connection $con, \Psr\Log\LoggerInterface $logger)
    {
        $this->db = $con;
        $this->logger = $logger;
    }

    public function get(int $book_id)
    {
        $sql = "SELECT * FROM books WHERE book_id = :book_id";

        $stmt = $this->db->prepare($sql);
        $stmt->bindValue("book_id", $book_id);
        $stmt->execute();

        $book = $stmt->fetch();

        return $book;
    }
}

DoctrineDBALではSELECT時に連想配列やオブジェクト、任意のクラスを元にしたオブジェクトなど様々な方法で値を返す事が出来ますが、今回はデフォルトの連想配列のまま扱います。変更する場合は Doctrine\DBAL\Driver\Statement::setFetchMode を呼び出します。

また、上記の例ではSQLの文字列に :book_id というプレースホルダを利用し、bindValue 関数で値を設定しています。この際に値が自動でエスケープされるので値を埋め込む際はプレースホルダを使うようにしましょう。また、DoctrineDBAL にはクエリビルダ機能が有るので、簡単なSQLであればクエリビルダを使って行ってもいいと思います。

コントローラ

順番通りに進めていれば Controller/RootController.php に以下の定義が既にあるはずです。無ければ追加します。

    /**
     * @Route("/books/detail/{book_id}/", name="book_detail", 
     * requirements={"book_id"="\d+"})
     */
    public function bookDetailAction($book_id)
    {
        return $this->render('books/detail.html.twig', [
            'book_id' => $book_id,
            'book_detail' => 'dumy text'
        ]);
    }

これをサービスクラスの呼び出しを追加します。サービスクラスに処理の中核となる部分を移し、コントローラではルーティングと送信されたデータの読み込み、ビューの呼び出しに専念させます(今回はIDを受け取ってDBからSELECTするだけですが)。

    /**
     * @Route("/books/detail/{book_id}/", name="book_detail", 
     * requirements={"book_id"="\d+"})
     */
    public function bookDetailAction($book_id, BooksService $booksService)
    {
        $book = $booksService->bookDetail($book_id);

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

サービス

コントローラから呼び出され諸々の処理を行うサービスクラスを作ります。今回はリポジトリから値を受け取るだけです。/src/Service ディレクトリを作成し、BooksService.php を作成します。

<?php
namespace App\Service;

use App\Repository\BooksRepository;

class BooksService
{
    private $booksRepository;

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

    public function bookDetail(int $book_id)
    {
        return $this->booksRepository->get($book_id);
    }
}

これでコントローラ、サービス、リポジトリの準備は完了です。該当URLにアクセスすると
RootController::bookDetailAction
BooksService:: bookDetail
BooksRepositoryImpl:: get
という段階を踏んでアクセスされます。最後にビュー detail.html.twig を調整しておきます。

{% extends 'base.html.twig' %}

{% block title %}Book Detail{% endblock %}

{% block body %}

<h1>Book Detail ID:{{ book_id }}</h1>
<p>
    <div>書籍名: {{ book_detail.book_name }}</div>
    <div>価格: {{ book_detail.book_price|number_format }}円</div>
    <div>発売日: {{ book_detail.book_release }}</div>
</p>

{% endblock %}

ではSymfonyのサーバーを起動し、アクセスを確認してみます。
php bin\console server:run
でビルトインサーバーを起動し、Webブラウザから http://127.0.0.1:8000/books/detail/1/ にアクセスします。

DBから取得した値を表示できました。導入方法としてはこのような感じでRDBに接続する事が出来ます。導入~SELECTまでの説明ですが、INSERTやUPDATE、DELETEもググりながら簡単に実装できるかと思います。とはいえDoctrine に関する日本語情報ってあまりないので増えてほしいなぁ……。

ここまでのソースの状態は以下を参照
https://github.com/lf-uraku-yuki/symfony4_tutorial/tree/tutorial04

なお、MySQL 8.0ではデフォルトの認証方法が変わった事も有り、「The server requested authentication method unknown to the client”」というエラーになる場合が有ります、これはググれば出てきます該当ユーザーの認証方法を caching_sha2_password から mysql_native_password を使った物に変更する事で回避可能です。

次回はログ出力について

マイクロフレームワークっぽく使う 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接続について書きたいです。

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

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

前回までは Symfony 4.2 のインストールからTwigテンプレートを使ったWebページを一つ表示するところまでを作りました。今回はコントローラ上にルーティングを増やしていきます。

参考にすべきページは以下

Create your First Page in Symfony (Symfony Docs)
Routing (Symfony Docs)
Controller (Symfony Docs)

結構ボリュームが有るので説明してない分は参照先の公式ドキュメントを参照してください。

ルーティング

前回作成したRootControllerの定義を再確認します。

<?php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Routing\Annotation\Route;

class RootController extends AbstractController
{
    /**
     * @Route("/root", name="root")
     */
    public function index()
    {
        return $this->render('root/index.html.twig', [
            'controller_name' => 'RootController',
        ]);
    }
}

PHPDocに記載の有る @Route 定義がアノテーションによるルーティングです。”/root”にマッチするURLが来た場合にこの index 関数が実行されます。nameはユニーク(他とかぶりが無い)である必要が有るので注意してください。

結果はResponseオブジェクト( Symfony\Bundle\FrameworkBundle\Controller\Response)をreturnで返します。$this->render() を呼び出すとTwigでレンダリングした結果をResponseオブジェクトとして返してくれます。

ルーティングを増やす

    /**
     * @Route("/root", name="root")
     */
    public function index()
    {
        return $this->render('root/index.html.twig', [
            'controller_name' => 'RootController',
        ]);
    }

    /**
     * @Route("/root/demo", name="root_demo")
     */
    public function demoAction()
    {
        return $this->render('root/index.html.twig', [
            'controller_name' => 'RootController::demo',
        ]);
    }

@Routeアノテーションの定義された関数を増やすします。Routeアノテーションの一つ目の引数にパス、2つ目に入力する名前は他とのかぶりが無いものにします。

引数のあるルーティング

例えば /books/detail/20/ のようなページを作りたいと思います。20の部分は可変とします。その場合パスを指定する際に{変数名}を指定するとその名前で関数の引数に変数を受け取る事が出来ます。また、例えばこの引数が必ず数字で構成される場合などは requirements の定義に変数名と正規表現の対応を記述しておくと、数字である場合のみ処理されるような制約設定が可能です。

    /**
     * @Route("/books/detail/{book_id}/", name="book_detail", 
     * requirements={"book_id"="\d+"})
     */
    public function bookDetailAction($book_id)
    {
        return $this->render('books/detail.html.twig', [
            'book_id' => $book_id,
            'book_detail' => 'dumy text'
        ]);
    }

テンプレート books/detai.html.twig は以下のように定義。受け取ったIDをそのまま表示します。

{% extends 'base.html.twig' %}

{% block title %}Book Detail{% endblock %}

{% block body %}

<h1>Book Detail ID:{{ book_id }}</h1>
<p>
    {{ book_detail }}
</p>

{% endblock %}

Webブラウザで http://127.0.0.1:8000/books/detail/20/ にアクセスしてみます。

リクエスト内容を受け取る

SymfonyにはDI(Dependency Injection : 依存性注入)の仕組みが有り、サービスクラス(≒アプリが行う処理のコア部分のクラス)のコンストラクタであったり引数で有ったりにSymfonyが認識しているクラス名や引数名を指定すると自動で必要なオブジェクトを渡してくれます。リクエスト内容を受け取るには Request のオブジェクトを受け取ります。

    /**
     * @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'
        ]);
    }

受け取った引数 $req から book_id を取り出しています。Webブラウザで http://127.0.0.1:8000/books/detail/?book_id=33 にアクセスしてみます。

HTTPメソッドに応じてルーティングする

基本的には先ほどと同じですが @Route の定義に methods={“POST”} を追加しています。これによりPOSTであった場合のルーティングを行っています。

    /**
     * @Route("/books/edit/", name="book_edit_exec", methods={"POST"})
     */
    public function bookEditExecAction(Request $req)
    {
        return $this->render('books/detail.html.twig', [
            'book_id' => 0,
            'book_detail' => '編集に成功しました'
        ]);
    }

複数のHTTPメソッドに一つの関数で対応する場合は
methods={“POST”, “PUT”}
のように定義します。

なお、先にメソッド指定のないルーティングを行った後に同じパス指定でPOSTに限定したルーティングを書いてもPOSTでのアクセスは先に定義しているメソッド縛り無しの方が呼ばれて適切に動作しません。同じパスでHTTPメソッド違いのルーティングを作る場合はGETについても適切に定義してください。

404 Not Found を返す

NotFoundHttpException 例外を発生させる事で HTTP 404 Not Found の処理を行います。直接newしてnewしてthrowしてもいいですが、$this-> createNotFoundException を使って生成する事が出来ます。この時例外は return ではなく throw する事に気を付けてください。

throw $this->createNotFoundException('見つからないようです');

設定したメッセージが表示されています。

呼び出し先のコードを読めばわかりますが NotFoundException を生成して返しているだけです。この NotFoundException を更に追うと HttpException の第1引数に 404 を渡して例外を生成している事が確認できます。 404以外のステータスコードを返したい時は HttpException を直接扱えばよさそうですね。HttpExceptionを生成する際は第1引数にステータスコードを渡してやります。

throw new \Symfony\Component\HttpKernel\Exception\HttpException(
            500, 'インターナルなサーバーのエラー');

リダイレクトする

$this->redirect(‘URL’) を使う方法と $this->redirectToRoute (‘ROOT’, []) を使う方法が有ります。前者はURLを直接渡す普通のリダイレクト、後者はアノテーションでルーティングを記述する際に指定する name で指定してリダイレクトします。また、redirectToRootの場合は第2引数で連想配列などを渡しておくと自動でURLのクエリ文字列をつけ足したりなど良い感じに処理してくれます。

$this->redirect('/books/detail?book_id=20');
$this->redirectToRoute('book_detail', ['book_id' => 20]);

例えば目的のパスの定義が /books/{book_id}/ というような場合でも変数名を合わせて第2引数に渡しておけば、例えば /books/50/ といったページにリダイレクトしてくれます。

// book_detail の定義が /books/{book_id}/ だったとしてもちゃんと引数が渡る
$this->redirectToRoute('book_detail', ['book_id' => 20]);

JSONを返す

$this->json() を使います。1つ目の引数に返したいデータ(例えば連想配列など)、必要であれば2つ目の引数にHTTPステータスコードを渡します。デフォルトはHTTP 200で返します。

        return $this->json([
            'データ1' => 100,
            'データ2' => 200
        ]);

ここまででだいたいのルーティングは作る事が出来るんじゃないかと思います。また、引数を取る際にデフォルト値を設定したい場合とかも公式のドキュメントに方法が書いてありますのでご参照ください。コントローラクラスは無秩序に1ファイルに増やしていく事も可能ですが、適宜クラスを分けた方が分かりやすいかと思います。

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

また、ルーティングがどうなっているかは php bin/console debug:route で一覧表示する事が出来きます。

次回はサービスとDIとデータベースについて書こうと思います。

続きの記事が出来ました(2019年1月31日)