LoginSignup
13
14

More than 1 year has passed since last update.

The Fast Track - 基礎から最速で学ぶ Symfony5 入門 を読み解いた!

Last updated at Posted at 2020-06-01

参考資料

入門って書いてあるのにだいぶ苦労したわ...
image.png
https://leanpub.com/symfony5-nyumon
これドル払なのに日本語だ!(衝撃)

なんでservice.ymlってなんだよって思ったら 【Symfony】サービスコンテナを実装する がわかりやすいね

サービスを必要とするオブジェクトに対して、外から渡すことにとって、疎統合な設計を実現します。

開発環境

PHP 7.4.6
PhpStorm(PhpStormが勝手にやってくれるインポートなどの部分は省略していることが多いので違うエディタでやってる人は注意)

PHP拡張を確認

php.iniで以下を有効にする
intl, xsl, gd, openssl, sodium, curl, mbstring

amqp

  1. Download the latest stable release from here https://pecl.php.net/package/amqp for your PHP version (TS, x64);
  2. Unpack the archive and copy the php_amqp.dll file to your extensions directory (ex. C:\php\ext);
  3. Copy the rabbitmq.X.dll (ex. rabbitmq.4.dll) file to C:\Windows\System;
  4. Update your php.ini file and add "extension=amqp" at the end of extensions list;
  5. check the list php -m

確認

次のコマンドを実行して、コンピューターに必要なすべての要件が
満たされていることを確認します。
php> symfony book:check-requirements
image.png
- pdo_pgsqlはPostgreSQL
- redisはNoSQL

のため無視。

:question:
僕はMySQL使うし、posgre:question::question:ってあたりでハッとしたんだけど、このコマンド。。。公式ドキュメント用にビルトインされた確認コマンドっぽいな。

プロジェクトの作成

戒め: 日本語を含むパスにプロジェクトを作成してはならない(bin/consoleコマンドなどが通らなくなってハマる)

php> composer create-project symfony/skeleton guestbook "5.1.*"
php> cd guestbook
guestbook> composer require symfony/requirements-checker

├── bin/ よく使うCLI コマンドのconsole
├── config/ 設定
├── public/ Webルートのディレクトリ
├── src/ コードが入る場所
├── var/ キャッシュやログ
├── vendor/ Composer によって
インストールされたすべてのパッケージ
├── composer.json
├── composer.lock
└── symfony.lock

ローカルのWebサーバの起動

guestbook> php -S 127.0.0.1:8000 -t public
https://127.0.0.1:8000 

image.png
image.png

確認(checkにアクセス)

http://localhost:8000/check.php
image.png
image.png

PHP accelerato

PHP入れただけの状態でほんとの一発目にアプリを作ろうとしてcheck.phpを見てみると、
1. PHP accelerator を入れろ!
2. php.ini のキャッシュサイズを5M以上にしろ!
などと書いてある
https://www.php.net/manual/ja/opcache.installation.php#opcache.installation.bundled

Windows の場合は zend_extension=C:\path\to\php_opcache.dll を使います。

php.ini(変更前)
;opcache.enable=1
;opcache.enable_cli=0
php.ini(変更後)
opcache.enable=1
opcache.enable_cli=1
php.ini(最終行に追記)
zend_extension=C:\php\ext\php_opcache.dll

キャッシュサイズを5M以上に

php.ini(変更前)
;realpath_cache_size = 4096k
php.ini(変更後)
realpath_cache_size = 5M

確認

guestbook> php -v
  PHP 7.4.6 (cli) (built: May 12 2020 11:38:54) ( ZTS Visual C++ 2017 x64 )
  Copyright (c) The PHP Group
  Zend Engine v3.4.0, Copyright (c) Zend Technologies
      with Zend OPcache v7.4.6, Copyright (c), by Zend Technologies

サーバーログを追う

guestbook> symfony server:log

調査系のライブラリを入れる

guestbook> composer require --dev symfony/profiler-pack

maker-bundle

make:controller などのコマンドが使えるようになります

guestbook> composer require symfony/maker-bundle --dev

annotation

guestbook> composer require doctrine/annotations

コントローラの作成

guestbook> php bin/console make:controller ConferenceController

gifの配置

under-construction.gif
http://clipartmag.com/images/website-under-construction-image-6.gif
public/images/under-construction.gif に保存

コントローラの加工

ConferenceController.php
<?php

namespace App\Controller;

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

class ConferenceController extends AbstractController
{
    /**
     * @Route("/", name="home")
     */
    public function index()
    {
        return new Response("<html><body><img src='/images/under-construction.gif'/> </body></html>");
    }
}

確認

guestbook> php -S 127.0.0.1:8000 -t public
https://127.0.0.1:8000 

image.png
image.png

クエリー文字列が含まれていたら、挨拶

ConferenceController.php
<?php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\VarDumper\VarDumper;

class ConferenceController extends AbstractController
{
    /**
     * @Route("/", name="home")
     * @param Request $request
     * @return Response
     */
    public function index(Request $request)
    {
        $greet = '';
        VarDumper::dump($request->query->get('hello'));
        if ($name = $request->query->get('hello')) {
            $greet = sprintf('<h1>Hello %s!</h1>', htmlspecialchars($name));
        }
        return new Response("<html lang='ja'><body>$greet<img src='/images/under-construction.gif' alt='under-const'/> </body></html>");
    }
}

確認

なんでかしらんけどクエリストリングつきのリクエストのときは localhost でアクセスしないと「ページが表示できません」になるんだよな...このへんが関係してそう
Invalid request (Unsupported SSL request)
XSS

guestbook> php -S 127.0.0.1:8000 -t public
http://localhost:8000/?hello=yoshi

image.png
image.png

データベースをセットアップする

データベース作成

Console(doctrineという名のORMです)
guestbook> composer require doctrine
.env(DATABASE_URLを変更。todo-listというdbをつくる宣言)
DATABASE_URL=mysql://root:rootpw@127.0.0.1:3306/guestbook_database_1
Console
guestbook> php bin/console doctrine:database:create

image.png

エンティティ(モデル)作成

Console
guestbook> symfony console make:entity Conference
property name Field type Field length nullable
city string 255 no
year string 4 no
isInternational boolean no

image.png

migration

Console
guestbook> php bin/console make:migration
guestbook> php bin/console doctrine:migrations:migrate

  Application Migrations

  (※テーブル構造いまから変えちゃっていっすか?)
  WARNING! You are about to execute a database migration that could result in schema changes and data loss. Are you sure you wish to continue? (y/n)
    > y

image.png

もうひとつエンティティを作成

Console
guestbook> symfony console make:entity Comment
property name Field type Field length nullable
author string 255 no
text text no
email string 255 no
createdAt datetime no
photoFilename string 255 yes
Console
guestbook> php bin/console make:migration
guestbook> php bin/console doctrine:migrations:migrate

エンティティ間の関連付け

カンファレンスとコメントのエンティティは連携する必要があります。カンファンレスはn個のコメントを持ちますので、one-to-many の関連となります。Conference クラスにリレーションを追加するために、make:entity コマンドをもう一度使いましょう

Console
guestbook> symfony console make:entity Conference
property name Field type related to add field name allowed null Do you want to automatically delete
comments OneToMany Comment (default) no yes

管理者用のバックエンドをセットアップする

easyadmin-bundleのインストール

guestbook> composer require easycorp/easyadmin-bundle

make:admin:dashboard

guestbook> php bin/console make:admin:dashboard
 Which class name do you prefer for your Dashboard controller? [DashboardController]:
 >

 Which directory do you want to generate "DashboardController" in? [src/Controller/Admin/]:
 >

 (※src/ControllerにAdminフォルダが無ぇーぞ。作って!)
 [ERROR] The given directory does not exist. Type in the path of an existing directory relative to your project root(e.g. src/Controller/Admin/)                                                                                   

 Which directory do you want to generate "DashboardController" in? [src/Controller/Admin/]:
 >

 [OK] Your dashboard class has been successfully generated. 

(※なぜか出来上がったコントローラ guestbook/src/Controller/Admin/DashboardController.php が構文エラーを起こしている(3行目の最後にバックスラッシュ)ので修正する)

確認

guestbook> php -S 127.0.0.1:8000 -t public
http://127.0.0.1:8000/admin

image.png

You have successfully installed EasyAdmin 3 in your application!
You are seeing this example page because you haven't configured the start page of your Dashboard. To do that, open the following file in your editor: src\Controller\Admin\DashboardController.php
コレが見えてるってことは準備が足りない。

dashboard調整

実は直前のコマンドの最後に Next steps として、こんなことが書いてある

Next steps:
Run "make:admin:crud" to generate CRUD controllers and link them from the Dashboard.

以下コマンドは「0」を選択して Comment テーブル用のCRUDを作成しますが、「1」も同じようにやってください。

guestbook> php bin/console make:admin:crud
 Which Doctrine entity are you going to manage with this CRUD controller?:
  [0] App\Entity\Comment
  [1] App\Entity\Conference
 > 0
 Which directory do you want to generate the CRUD controller in? [src/Controller/Admin/]:
 >
 Namespace of the generated CRUD controller [App\Controller\Admin\]:
 >
 [OK] Your CRUD controller class has been successfully generated.

DashboardController調整

DashboardControllerに戻ってきて
1. CrudUrlGeneratorにカーソルをあててImport
2. OneOfYourCrudControllerをCommentCrudControllerに変更

guestbook/src/Controller/Admin/DashboardController.php
    public function index(): Response
    {
        // redirect to some CRUD controller
        $routeBuilder = $this->get(CrudUrlGenerator::class)->build();

-        return $this->redirect($routeBuilder->setController(OneOfYourCrudController::class)->generateUrl());
+        return $this->redirect($routeBuilder->setController(CommentCrudController::class)->generateUrl());
    }

コメントテーブルの管理画面みたいなのができて、レコードの追加ができるようになっている
image.png

サイドメニューにテーブルリストを表示する

guestbook/src/Controller/Admin/DashboardController.php
    public function configureMenuItems(): iterable
    {
        yield MenuItem::linktoDashboard('Dashboard', 'fa fa-home');
+        yield MenuItem::linkToCrud('Comment', 'fas fa-database', Comment::class);
+        yield MenuItem::linkToCrud('Conference', 'fas fa-database', Conference::class);
        // yield MenuItem::linkToCrud('The Label', 'icon class', EntityClass::class);
    }

画面からテーブルを操作できるようになった
image.png

FK項目を表示する

CommentテーブルのFK項目(Commentテーブルにあるconference_id)は表示されないが、「どのカンファレンスのコメントなんだい?」を表現するために調整が必要。

guestbook/src/Controller/Admin/CommentCrudController.php
<?php

namespace App\Controller\Admin;

use App\Entity\Comment;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;

class CommentCrudController extends AbstractCrudController
{
    public static function getEntityFqcn(): string
    {
        return Comment::class;
    }

    /*(※初期状態のお手本コードは消していいよ)
    public function configureFields(string $pageName): iterable
    {
        return [
            IdField::new('id'),
            TextField::new('title'),
            TextEditorField::new('description'),
        ];
    }
    */

    (※Entityのfieldnameを使用する
    public function configureFields(string $pageName): iterable
    {
        return [
            TextField::new('author'),
            TextField::new('text'),
            TextField::new('email'),
            DateField::new('createdAt'),
            TextField::new('photoFilename'),
            AssociationField::new('conference')
        ];
    }
}

確認

「クラスApp \ Entity \ ConferenceのFKオブジェクトを文字列に変換できませんでした」
image.png

Conferenceエンティティにカンファレンス名を表示する処理を追加

__toString(): PHP は「__」で始まる関数名を特殊関数として取り置きしてあります。クラスが文字列に変換される際の動作を決めることができます。たとえば echo $obj; としたときに何を表示させるかといったことです。

guestbook/src/Entity/Conference.php
<?php

namespace App\Entity;

use App\Repository\ConferenceRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity(repositoryClass=ConferenceRepository::class)
 */
class Conference
{
    
    
    
    public function __toString(): string
    {
        return $this->city.' '.$this->year;
    }
}

Commentエンティティにemailを表示する処理を追加

同じように、クラスが文字列に変換される際の動作を追加します

guestbook/src/Entity/Comment.php
<?php

namespace App\Entity;

use App\Repository\CommentRepository;
use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity(repositoryClass=CommentRepository::class)
 */
class Comment
{
    
    
    
    public function __toString(): string
    {
        return (string) $this->getEmail();
    }
}

確認

image.png
image.png

サブスクライバーとはイベントリスナーのこと

(※あとでリファクタリングされます)
src配下に「Subscribers」という名前のディレクトリを作成し、PHPClassを新規作成
image.png
image.png

property name Field type
Name CommentSubscribers
Namespace App\Subscribers
File name (自動)
Directory (自動)
Template (自動)
File extension (自動)
Implements Symfony...EventSubscriberInterface
guestbook/src/Subscribers/CommentSubscribers.php
<?php


namespace App\Subscribers;


use App\Entity\Comment;
use DateTime;
use EasyCorp\Bundle\EasyAdminBundle\Event\BeforeEntityPersistedEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

class CommentSubscribers implements EventSubscriberInterface
{

    /**
     * @inheritDoc
     */
    public static function getSubscribedEvents()
    {
        // TODO: Implement getSubscribedEvents() method.
        return [
            // レコード追加する直前のイベントリスナー
            BeforeEntityPersistedEvent::class => ['setCreatedAtOfComment']
        ];
    }

    /**
     * @param BeforeEntityPersistedEvent $event
     * 追加したイベントリスナーの処理の中身
     */
    public function setCreatedAtOfComment(BeforeEntityPersistedEvent $event)
    {
        $entity = $event->getEntityInstance();

        if(!($entity instanceof Comment)) {
            return;
        }

        $entity->setCreatedAt(new DateTime('NOW'));
    }
}

(※php.iniのdate.timezoneAsia/Tokyoにしてphpstorm再起動しないと時間がおかしくなるからね)

createdAtを非表示に

サブスクライバーがレコード追加の直前にNow()してくれるので管理者がcreatedAtを入力する必要がなくなった!

guestbook/src/Controller/Admin/CommentCrudController.php
<?php
    
    
    
class CommentCrudController extends AbstractCrudController
{
    
    
    
    public function configureFields(string $pageName): iterable
    {
        return [
            TextField::new('author'),
            TextField::new('text'),
            TextField::new('email'),
-            DateField::new('createdAt'),
            TextField::new('photoFilename'),
            AssociationField::new('conference')
        ];
    }
}

サイドメニューのリンクを変える

「Dashboard」のリンクを押すとCommentテーブルが表示されていたけど実質意味はないので、ドキュメントルートにリンクさせる(まぁdashboard用のテンプレートを表示してグラフとか追加して「ダッシュボードっぽい画面をデザインする」のが本来かもしれんけどね)
image.png

guestbook/src/Controller/Admin/DashboardController.php
<?php
    
    
    
class DashboardController extends AbstractDashboardController
{
    
    
    
    public function configureMenuItems(): iterable
    {
-        yield MenuItem::linktoDashboard('Dashboard', 'fa fa-home');
+        yield MenuItem::linktoRoute('back to home', 'fa fa-home', 'home');
        yield MenuItem::linkToCrud('Comment', 'fas fa-database', Comment::class);
        yield MenuItem::linkToCrud('Conference', 'fas fa-database', Conference::class);
    }
}

確認

image.png
image.png
image.png

コントローラーで Twig を使う

guestbook/templates/conference/index.html.twig
{% extends 'base.html.twig' %}
{% block title %}Conference Guestbook{% endblock %}
{% block body %}
    <h2>Give your feedback!</h2>
    {% for conference in conferences %}
        <h4>{{ conference }}</h4>
    {% endfor %}
{% endblock %}
guestbook/src/Controller/ConferenceController.php
<?php

namespace App\Controller;

use App\Repository\ConferenceRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Twig\Environment;

class ConferenceController extends AbstractController
{
    /**
     * @Route ("/", name="home")
     * @param Environment $twig
     * @param ConferenceRepository $conferenceRepository
     * @return Response
     */
    public function index(Environment $twig, ConferenceRepository
    $conferenceRepository)
    {
        return new Response($twig->render('conference/index.html.twig', [
            'conferences' => $conferenceRepository->findAll(),
        ]));
    }
}

image.png
image.png

カンファレンスページを作成する

各カンファレンスには、コメントを一覧表示する専用のページが必要です。新しいページを作成するには、コントローラーを追加してルート定義を行い、関連付けたテンプレートを置きます。src/Controller/ConferenceController.php にshow() メソッドを追加してください

guestbook/src/Controller/ConferenceController.php
<?php
    
    
    
class ConferenceController extends AbstractController
{
    
    
    
    /**
     * @Route("/conference/{id}", name="conference")
     * @param Environment $twig
     * @param Conference $conference
     * @param CommentRepository $commentRepository
     */
    public function show(Environment $twig, Conference $conference,
        CommentRepository $commentRepository)
    {
        return new Response($twig->render('conference/show.html.twig', [
            'conference' => $conference,
            'comments' => $commentRepository->findBy(['conference' =>
                $conference], ['createdAt' => 'DESC']),
            ]));
    }
}

このshowメソッドには、これまでにはなかった特別な振る舞いがあります。メソッドにConference インスタンスの注入が必要です。データベースに複数あるかもしれません。Symfony ではリクエストのパスで受け取る{id} によってどのレコードを取得するのかを決めることができます( “ id“ はデータベースのconference テーブルのプライマリーキーです)。カンファレンスに関連付けられたコメントを取得するには、検索条件を第一引数に取るfindBy() メソッドを使います。最後のステップとしてファイルtemplates/conference/show.html.twigを作成します。

このテンプレートでは、Twig フィルターを呼び出すために|記法(※Djangoで言うテンプレートタグ)を使っています。フィルターは値の変換をします。comments|lengthはコメントの数を返しcomment.createdAt|format_datetime(locale='ja')はヒューマンリーダブルな表現で日付をフォーマットします。/conference/1最初の カンファレンスにアクセスして次のエラーを確認してください

guestbook/templates/conference/show.html.twig(新規)
{% extends 'base.html.twig' %}

{% block title %}Conference Guestbook - {{ conference }}{% endblock %}

{% block body %}
    <h2>{{ conference }} Conference</h2>
    {% if comments|length > 0 %}
        {% for comment in comments %}
            {% if comment.photofilename %}
<img src="{{ asset('uploads/photos/' ~ comment.photofilename)
}}" />
            {% endif %}
            <h4>{{ comment.author }}</h4>
            <small>
                {{ comment.createdAt|format_datetime(locale='ja') }}
            </small>
            <p>{{ comment.text }}</p>
        {% endfor %}
    {% else %}
        <div>No comments have been posted yet for this conference.</div>
    {% endif %}
{% endblock %}

確認

※記述が足りない??

キィィィィ!勘弁してくれ昼寝するはめになったぞ。プレーンな環境で作らなかったんか?(ハンカチを噛む)
image.png

guestbook> composer require sensio/framework-extra-bundle

確認

※記述が足りない??

キィィィィ!勘弁してくれ昼寝するはめになったぞ。プレーンな環境で作らなかったんか?(ハンカチを噛む)
image.png
format_datetime

The format_datetime filter is part of the IntlExtension which is not installed by default. Install it first:
format_datetimeフィルターは、デフォルトではインストールされないIntlExtensionの一部です。最初にインストールしてください
$ composer req twig/intl-extra
Then, use the twig/extra-bundle on Symfony projects or add the extension explicitly on the Twig environment:
次に、Symfonyプロジェクトでtwig / extra-bundleを使用するか、Twig環境で明示的に拡張機能を追加します

STEP1_intl-extraのインストール
guestbook> symfony composer require twig/intl-extra
STEP2_Twig環境で明示的に拡張機能を追加します(これがわけわからんかったんだけど)
use Twig\Extra\Intl\IntlExtension;
>$twig = new \Twig\Environment(...);
$twig->addExtension(new IntlExtension());
guestbook/src/Controller/ConferenceController.php(こういうことなのね)
<?php
    
    
    
class ConferenceController extends AbstractController
{
    
    
    
    public function show(Environment $twig, Conference $conference, CommentRepository $commentRepository)
    {
+        $twig->addExtension(new IntlExtension());
        return new Response($twig->render('conference/show.html.twig', [
            'conference' => $conference,
            'comments' => $commentRepository->findBy(['conference' =>
                $conference], ['createdAt' => 'DESC']),
            ]));
    }
}

確認

たかだか日付表示させるのに何時間かけさせるねん...
image.png
image.png

カンファレンスページをリンクする

guestbook/templates/conference/index.html.twig
{% extends 'base.html.twig' %}
{% block title %}Conference Guestbook{% endblock %}
{% block body %}
    <h2>Give your feedback!</h2>
    {% for conference in conferences %}
        <h4>{{ conference }}</h4>
+        <p><a href="{{ path('conference', { id: conference.id }) }}">View</a></p>
    {% endfor %}
{% endblock %}

確認

リンクでカンファレンス単品のページにも移動できた
image.png
image.png

コメントのページネーション

コメントリポジトリでgetCommentPaginator() メソッドを作成します。このメソッドはカンファレンスとオフセット(開始点)からコメントのPaginator を返します。テンプレートのページネーションを管理するには、Doctrine コレクションの代わりに Doctrine Paginator をTwig に渡します

DQL組み立て

全コメントの数を2で割って最初の要素を返す、みたいなSQLやな

guestbook/src/Repository/CommentRepository.php
<?php
    
    
    
class CommentRepository extends ServiceEntityRepository
{
+    public const PAGINATOR_PER_PAGE = 2;
+
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Comment::class);
    }

+    public function getCommentPaginator(Conference $conference, int $offset): Paginator
+    {
+        $query = $this->createQueryBuilder('c')
+            ->andWhere('c.conference = :conference')
+            ->setParameter('conference', $conference)
+            ->orderBy('c.createdAt', 'DESC')
+            ->setMaxResults(self::PAGINATOR_PER_PAGE)
+            ->setFirstResult($offset)
+            ->getQuery();
+
+        return new Paginator($query);
+    }
    
    
    
}

コントローラの修正

コントローラーはリクエストのクエリー文字列($request->query) からoffset を整数として(getInt()) 取得します。デフォルトは0です。previous とnext のオフセットはDoctrine Paginator から得られる全情報から算出されます。

guestbook/src/Controller/ConferenceController.php
<?php
    
    
    
class ConferenceController extends AbstractController
{
    
    
    
-   public function show(Environment $twig, Conference $conference, CommentRepository $commentRepository)
+   public function show(Request $request, Environment $twig, Conference $conference, CommentRepository $commentRepository)
    {
        $twig->addExtension(new IntlExtension());

+       $offset = max(0, $request->query->getInt('offset', 0));
+       $paginator = $commentRepository->getCommentPaginator($conference, $offset);

        return new Response($twig->render('conference/show.html.twig', [
            'conference' => $conference,
-           'comments' => $commentRepository->findBy(['conference' => $conference], ['createdAt' => 'DESC']),
+           'comments' => $paginator,
+           'previous' => $offset - CommentRepository::PAGINATOR_PER_PAGE,
+           'next' => min(count($paginator), $offset + CommentRepository::PAGINATOR_PER_PAGE),
            ]));
    }
}

テンプレートの修正

最後に、テンプレートを更新して次ページ、前ページへのリンクを追加しましょう

guestbook/templates/conference/show.html.twig
{% extends 'base.html.twig' %}

{% block title %}Conference Guestbook - {{ conference }}{% endblock %}

{% block body %}
    <h2>{{ conference }} Conference</h2>
    {% if comments|length > 0 %}
+       <div>There are {{ comments|length }} comments.</div>
+        
        {% for comment in comments %}
            {% if comment.photofilename %}
                <img src="{{ asset('uploads/photos/' ~ comment.photofilename) }}"  alt="photofilename"/>
            {% endif %}
            <h4>{{ comment.author }}</h4>
            <small>
                {{ comment.createdAt|format_datetime(locale='ja') }}
            </small>
            <p>{{ comment.text }}</p>
        {% endfor %}
+       {% if previous >= 0 %}
+           <a href="{{ path('conference', { id: conference.id, offset: previous }) }}">Previous</a>
+       {% endif %}
+       {% if next < comments|length %}
+           <a href="{{ path('conference', { id: conference.id, offset: next }) }}">Next</a>
+       {% endif %}
    {% else %}
        <div>No comments have been posted yet for this conference.</div>
    {% endif %}
{% endblock %}

確認

2コメントづつのページネーションが確認できました!
image.png
image.png

コントローラーをリファクタリングする

ConferenceController の両方のメソッドがTwig 環境を引数として取っていることに気がつくでしょうか。各メソッドでインジェクトするのではなく、コンストラクターインジェクションを使うようにしましょう(引数のリストが短くなり冗長性がなくなります)

コンストラクターインジェクション

コンストラクターインジェクションは DIの話でもあるしロックマンの話でもある のでそちらも参考にすること。あらかじめ初期設定で能力を自分自身のものにするために読み込んでおくってこっちゃ

guestbook/src/Controller/ConferenceController.php
<?php
    
    
    
class ConferenceController extends AbstractController
{
+   private $twig;

+   public function __construct(Environment $twig)
+   {
+       $this->twig = $twig;
+   }

    /**
     * @Route ("/", name="home")
     */
-   public function index(Environment $twig, ConferenceRepository $conferenceRepository)
+   public function index(ConferenceRepository $conferenceRepository)
    {
-       return new Response($twig->render('conference/index.html.twig', [
+       return new Response($this->twig->render('conference/index.html.twig', [
            'conferences' => $conferenceRepository->findAll(),
            ]));
    }

    /**
     * @Route("/conference/{id}", name="conference")
     */
-   public function show(Request $request, Environment $twig, Conference $conference, CommentRepository $commentRepository)
+   public function show(Request $request, Conference $conference, CommentRepository $commentRepository)
    {
-       $twig->addExtension(new IntlExtension());
+       $this->twig->addExtension(new IntlExtension());

        $offset = max(0, $request->query->getInt('offset', 0));
        $paginator = $commentRepository->getCommentPaginator($conference, $offset);

-       return new Response($twig->render('conference/show.html.twig', [
+       return new Response($this->twig->render('conference/show.html.twig', [
            'conference' => $conference,
            'comments' => $paginator,
            'previous' => $offset - CommentRepository::PAGINATOR_PER_PAGE,
            'next' => min(count($paginator), $offset + CommentRepository::PAGINATOR_PER_PAGE),
            ]));
    }
}

Webサイトのヘッダーを追加する

ここで base.html.twig には conferences という変数が増えますがコントローラからは変数が引き渡されません。

guestbook/templates/base.html.twig
<!DOCTYPE html>
<html lang="ja">
    <head>
        <meta charset="UTF-8">
        <title>{% block title %}Welcome!{% endblock %}</title>
        {% block stylesheets %}{% endblock %}
    </head>
    <body>
+       <header>
+           <h1><a href="{{ path('home') }}">Guestbook</a></h1>
+           <ul>
+           {% for conference in conferences %}
+               <li><a href="{{ path('conference', { id: conference.id }) }}">{{ conference }}</a></li>
+           {% endfor %}
+           </ul>
+           <hr />
+       </header>
        {% block body %}{% endblock %}
        {% block javascripts %}{% endblock %}
    </body>
</html>

make:subscriberでサブスクライバーを実装する

conferences という変数にはここで、dbからSELECTされた値が入ります。サブスクライバーとは、vbでいうと「イベント」、JavaScriptでいうと「イベントリスナー」です。 ControllerEvent はコントローラーが呼ばれる直前に発火しますのでそのタイミングでグローバル変数に SQL の結果を仕込んでしまえばええやん。

guestbook> symfony console make:subscriber TwigEventSubscriber
  Suggested Events:
   :
   :
   :
  * kernel.controller (Symfony\Component\HttpKernel\Event\ControllerEvent)
   :
   :
   :

  What event do you want to subscribe to?:
  > kernel.controller

  created: src/EventSubscriber/TwigEventSubscriber.php
  Success! 
guestbook/src/EventSubscriber/TwigEventSubscriber.php
<?php
   
   
   
class TwigEventSubscriber implements EventSubscriberInterface
{
+   private $twig;
+   private $conferenceRepository;
+   
+   public function __construct(Environment $twig, ConferenceRepository $conferenceRepository)
+   {
+       $this->twig = $twig;
+       $this->conferenceRepository = $conferenceRepository;
+   }

    public function onKernelController(ControllerEvent $event)
    {
-       // ...
+       $this->twig->addGlobal('conferences', $this->conferenceRepository->findAll());
    }

    public static function getSubscribedEvents()
    {
        return [
            'kernel.controller' => 'onKernelController',
        ];
    }
}

確認

※カンファレンステーブルのカンファレンスを2つに増やしてみた
image.png
image.png

最初に作ったサブスクライバーをリファクタリングします

(※最初に作ったサブスクライバー)
SubscribersフォルダとEventSubscriberフォルダ、似たようなフォルダがデキてしまったので統合します。作り終えたらSubscribersフォルダは消しちゃっていいです(Subscribers/CommentSubscribers.phpごと)

guestbook> symfony console make:subscriber CommentSubscribers
  Suggested Events:
   :
   :
   :

  What event do you want to subscribe to?:
 > EasyCorp\Bundle\EasyAdminBundle\Event\BeforeEntityPersistedEvent

 created: src/EventSubscriber/CommentSubscribersSubscriber.php
  Success!
guestbook/src/EventSubscriber/CommentSubscribersSubscriber.php(処理の中身はコピペで移植できる)
<?php
  
  
class CommentSubscribersSubscriber implements EventSubscriberInterface
{
    public function onBeforeEntityPersistedEvent(BeforeEntityPersistedEvent $event)
    {
-       // ...
+       $entity = $event->getEntityInstance();
+
+       if(!($entity instanceof Comment)) {
+           return;
+       }
+
+       $entity->setCreatedAt(new DateTime('NOW'));
    }

    public static function getSubscribedEvents()
    {
        return [
            BeforeEntityPersistedEvent::class => 'onBeforeEntityPersistedEvent',
        ];
    }
}

年と都市でカンファレンスをソートする

年でカンファレンスのリストをソートすることで見やすくなるかもしれません。カンファレンスの一覧を取得してソートするカスタムメソッドを作成することもできますが、findAll() メソッドのデフォルトの実装をオーバーライドして、全ての場所でソートされた状態で取得できるようにしてみましょう

ASCとかDESCとかにして試してみましょう

guestbook/src/Repository/ConferenceRepository.php
<?php
   
   
   
class ConferenceRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Conference::class);
    }

+   public function findAll()
+   {
+       return $this->findBy([], ['year' => 'ASC', 'city' => 'ASC']);
+   }
+
   
   
   
}

Doctrineオブジェクトのライフサイクルを管理する

新しくコメントをした際には、自動的に現在の日時が createdAt としてセットされると良いですね。Doctrine は、データベースに追加されるときや更新されるときといったライフサイクルにおいてオブジェクトやプロパティを操作するいろいろな方法があります。

※あれ?そのサブスクライバ作ったぞ??:thinking:って思ってたけど、その作ったサブスクライバはこの教科書の表現がわからなかったときに調べたついでに作っちゃったってのがオチだったみたい。ってことはまぁ、これ設定したあとにCommentSubscribersSubscriber.phpは消してもいいでしょう。

ライフサイクルコールバックを定義する

サービスの依存が必要なく、エンティティを1つしか操作しないときは、エンティティクラスにコールバック(※CRUDアクション時の自動発火)を定義すると良いでしょう。

@ORM\PrePersist は、最初にデータベースに保存されたときにトリガーとして呼ばれるイベントです(HasLifecycleCallbacks()の宣言が必要)。このイベントの際に setCreatedAtValue() メソッドが呼ばれ、現在の日時がcreatedAt プロパティにセットされます。

guestbook/src/Entity/Comment.php
<?php
   
   
   
/**
 * @ORM\Entity(repositoryClass=CommentRepository::class)
+ * @ORM\HasLifecycleCallbacks()
 */
class Comment
{
   
   
   
    public function setCreatedAt(DateTimeInterface $createdAt): self
    {
        $this->createdAt = $createdAt;

        return $this;
    }

+   /**
+    * @ORM\PrePersist
+    */
+   public function setCreatedAtValue()
+   {
+       $this->createdAt = new DateTime();
+   }
+
    public function getConference(): ?Conference
   
   
   
}

カンファンレンスへスラッグを追加する(廃止)

(注意:スラッグのパート Entity Listener がうまくいかないのでここはトバし。ユニーク制約のところを単独で参考にするのはOK)

スラッグとはエイリアスのこと

/conference/1 といったカンファレンスのURL は特に意味はありません。これはデータベースのプライマリーキーといった実装の詳細に依るものになっています。代わりに /conference/paris-2020 といったURL はどうですか?こちらの方が良いですね。paris-2020Conferenceslugと呼んでいます。Conferenceslug プロパティを追加しましょう(255 文字の長さで not null です)

migrationしたときに既存レコードのslugの値がNOTNULL制約のせいでmigrationが終わらないからmigrationファイルを手で書き直せ、みたいなことが書かれている(p.141)が特に起こらなかったな。。。

guestbook> php bin/console make:entity Conference
guestbook> php bin/console make:migration
guestbook> php bin/console doctrine:migrations:migrate

Conferenceエンティティにユニーク制約

各カンファレンスを探すためにスラッグを使うようにしたので、Conferenceエンティティを修正して、スラッグがデータベース上でユニークになるようにしましょう

<?php
   :
   :
   :
/**
 * @ORM\Entity(repositoryClass=ConferenceRepository::class)
+ * @UniqueEntity("slug")
 */
class Conference
{
   :
   :
   :
    /**
-     * @ORM\Column(type="string", length=255)
+     * @ORM\Column(type="string", length=255, unique=true)
     */
    private $slug;
   :
   :
   :
}
guestbook> php bin/console make:migration
guestbook> php bin/console doctrine:migrations:migrate

※ここで @UniqueEntity エラーと格闘中

解決!わかった!「@ORM」が足りんのや
/**
 * @ORM\Entity(repositoryClass=ConferenceRepository::class)
+ * @ORM\Table(uniqueConstraints={@ORM\UniqueConstraint(name="conference_unique", columns={"slug"})})
 */
class Conference
{
   
   
   
guestbook> php bin/console make:migration
guestbook> php bin/console doctrine:migrations:migrate

スラッグを生成する

URL は、ASCII 文字以外を変換する必要があり、正しくスラッグを生成することは、英語圏以外の言語にとって難しいです。例えば、é をe に変換する必要があります。車輪の再発明をせずにSymfony のString コンポーネントを使いましょう。 文字列からスラッグを生成する方法が実装されています

guestbook> composer require symfony/string

Conference クラスに、カンファレンスの情報からスラッグを生成す
るcomputeSlug() メソッドを追加します

guestbook/src/Entity/Conference.php
   
   
   
        return $this->id;
    }

+   public function computeSlug(SluggerInterface $slugger)
+   {
+       if (!$this->slug || '-' === $this->slug) {
+           $this->slug = (string) $slugger->slug((string) $this)->lower();
+       }
+   }

    public function getCity(): ?string
    {
        return $this->city;
   
   
   

computeSlug() メソッドは、現在のスラッグが何も指定していないか - と値が渡ったときのみ動作します。- の値は、バックエンドでカンファレンスを追加するときにスラッグが必須となるので使用します。空ではないこの特別な値でアプリケーションにスラッグを自動生成させることができます。

Entity Listener

createdAt プロパティのように slug も更新時に computeSlug() メソッドを呼べば自動的にセットされるようにした方が良いですね。このメソッドは SluggerInterface の実装に依存していますので、以前のように prePersist イベントに追加することはできません。代わりに Doctrineエンティティのリスナーを作成しましょう

eventEntity Listener は異なり、指定されたエンティティに対してのみ呼び出されます。
同じイベント(たとえば、複数の @PrePersist )に対する複数のメソッドの実行順序は保証されません。

guestbook/src/EntityListener/ConferenceEntityListener.php(新規)
<?php

namespace App\EntityListener;

use App\Entity\Conference;
use Doctrine\ORM\Event\LifecycleEventArgs;
use Symfony\Component\String\Slugger\SluggerInterface;

class ConferenceEntityListener
{
    private $slugger;
    public function __construct(SluggerInterface $slugger)
    {
        $this->slugger = $slugger;
    }
    public function prePersist(Conference $conference, LifecycleEventArgs $event)
    {
        $conference->computeSlug($this->slugger);
    }
    public function preUpdate(Conference $conference, LifecycleEventArgs $event)
    {
        $conference->computeSlug($this->slugger);
    }
}

新しくカンファレンスが追加されたとき perPersist() と、更新されたとき preUpdated() に、スラッグは更新されます。

※記述が足りない??

キィィィィ!勘弁してくれ昼寝するはめになったぞ。プレーンな環境で作らなかったんか?(ハンカチを噛む)
まぁたしかに Entity から ConferenceEntityListener への指定は必要だよね

guestbook/src/Entity/Conference.php
<?php
   
   
   
/**
 * @ORM\Entity(repositoryClass=ConferenceRepository::class)
 * @ORM\Table(uniqueConstraints={@ORM\UniqueConstraint(name="conference_unique", columns={"slug"})})
+ * @ORM\EntityListeners({"App\EntityListener\ConferenceEntityListener"})
 */
class Conference
{
   
   
   

image.png
待てよ?これもしかしてメインルーチン(どっかのコントローラ)にこういうの置かないと行けないんじゃない?エンティティだけで完結しねーじゃねーか:weary:
やめたやめた!ロールバックさ :wink:

// register a entity listener.
$listener = $container->get('xxxx_listener');
$em->getConfiguration()->getEntityListenerResolver()->register($listener);

フォームでフィードバックを受ける

カンファレンスの参加者からフィードバックをしてもらうようにしましょう。HTML フォームからコメントを投稿できるようにしましょう。

フォームタイプを生成する

Maker バンドルを使ってフォームクラスを生成します

guestbook> php bin/console make:form

  The name of the form class (e.g. VictoriousPizzaType):
  > CommentFormType

  The name of Entity or fully qualified model class name that the new form will be bound to (empty for none):
  > Comment

  created: src/Form/CommentFormType.php
  Success!

App\Form\CommentFormType クラスは App\Entity\Comment エンティティのフォームを定義します。FormTypeは、モデルに紐付けられたフォームフィールドです。投稿されたデータとモデルクラスのプロパティの変換を行います。デフォルトでは、Symfonyは Comment エンティティのメタデータ(Doctrine のメタデータ)を使用して各フィールドの設定を推測します。例えば text フィールドはデータベースで大きなカラムとして定義されているので textarea が使われます。

フォームを表示する

ユーザーにフォームを表示するには、コントローラーでフォームを作成し、それをテンプレートに渡しましょう。フォームタイプを直接生成してはいけません。代わりに createForm() メソッドを使用してください。このメソッドは AbstractController で実装されており、フォーム作成を簡単にしています。フォームをテンプレートに渡すには createView() メソッドを使い、テンプレートのためのフォーマットに変換します。

guestbook/src/Controller/ConferenceController.php
   
   
   
    public function show(Request $request, Conference $conference, CommentRepository $commentRepository)
    {
+       $comment = new Comment();
+       $form = $this->createForm(CommentFormType::class, $comment);
+
        $this->twig->addExtension(new IntlExtension());

        $offset = max(0, $request->query->getInt('offset', 0));
        $paginator = $commentRepository->getCommentPaginator($conference, $offset);

        return new Response($this->twig->render('conference/show.html.twig', [
            'conference' => $conference,
            'comments' => $paginator,
            'previous' => $offset - CommentRepository::PAGINATOR_PER_PAGE,
            'next' => min(count($paginator), $offset + CommentRepository::PAGINATOR_PER_PAGE),
+           'comment_form' => $form->createView(),
            ]));
    }
   
   
   

テンプレートにフォームを表示するには、Twig の関数の form を使います。

guestbook/templates/conference/show.html.twig
   :
   :
   :
    {% endif %}

+   <h2>Add your own feedback</h2>
+   {{ form(comment_form) }}

{% endblock %}

ブラウザのカンファレンスページを再読み込みすると、フォームの各フィールドがHTML に表示されているはずです(データタイプはモデルから派生しています)。 form() 関数はフォームタイプで定義された全ての情報を元にHTMLフォームを生成します。ファイルアップロードのフィールドがある際には、<form> タグに enctype=multipart/form-data も追加します。さらに、投稿時にエラーがあった際にはエラーメッセージを表示します。デフォルトのテンプレートを上書きすれば、カスタマイズも可能ですが、このプロジェクトではまずこのままでいきましょう。

image.png

フォームタイプをカスタマイズする

フォームフィールドは、モデルから設定されていますが、フォームタイプを直接修正してデフォルトの設定をカスタマイズすることも可能です(※例えば隠し変数を持つなど)

   :
   :
   :
class CommentFormType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
-           ->add('author')
+           ->add('author', null, [
                'label' => 'Your name',
            ])

            ->add('text')
-           ->add('email')
-           ->add('createdAt')
-           ->add('photoFilename')
-           ->add('conference')
+           ->add('email', EmailType::class)
+           ->add('photo', FileType::class, [
+               'required' => false,
+               'mapped' => false,
+               'constraints' => [
+                   new Image(['maxSize' => '1024k'])
+               ],
+           ])
+           ->add('submit', SubmitType::class)
        ;
    }
   :
   :
   :

※記述が足りない??

キィィィィ!勘弁してくれ昼寝するはめになったぞ。プレーンな環境で作らなかったんか?(ハンカチを噛む)

(※こういうとこ抜けてんだよなー)
guestbook> composer require symfony/validator doctrine/annotations

new Image にカーソルを当ててみて、Sourceが別のフォルダを見てたりすると、インストール漏れだったりするので、不具合探しの参考にしてくれよな!
image.png

確認

image.png
image.png

モデルをバリデートする

フォームは、コメントをした人のメールアドレスでは、email 入力を使用し、ほとんどのフィールドを required とします。また、hidden フィールドの _token フィールドでCSRF対策をしています。cURL などのHTTP クライアントを使用するなどしてHTML バリデーションが効かないときは、サーバまで無効なデータが到達してしまいます。Comment データモデルにもバリデーション制約を追加する必要があります。

guestbook/src/Entity/Comment.php
class Comment
{
    /**
     * @ORM\Id()
     * @ORM\GeneratedValue()
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
     * @ORM\Column(type="string", length=255)
+    * @Assert\NotBlank
     */
    private $author;

    /**
     * @ORM\Column(type="text")
+    * @Assert\NotBlank
     */
    private $text;

    /**
     * @ORM\Column(type="string", length=255)
+    * @Assert\NotBlank
+    * @Assert\Email
     */
    private $email;

フォームを処理する

これでフォームを表示する準備ができました。フォームを送信してコントローラーでデータベースに情報を永続化する処理をします。

guestbook/src/Controller/ConferenceController.php
   
   
   
class ConferenceController extends AbstractController
{
    private $twig;
+   private $entityManager;

-   public function __construct(Environment $twig)
+   public function __construct(Environment $twig, EntityManagerInterface $entityManager)
    {
        $this->twig = $twig;
+       $this->entityManager = $entityManager;
    }
   
   
   
    public function show(Request $request, Conference $conference, CommentRepository $commentRepository)
    {
        $comment = new Comment();
        $form = $this->createForm(CommentFormType::class, $comment);
+       $form->handleRequest($request);
+       if ($form->isSubmitted() && $form->isValid()) {
+           $comment->setConference($conference);
+           $this->entityManager->persist($comment);
+           $this->entityManager->flush();
+           return $this->redirectToRoute('conference', ['id' => $conference->getId()]);
+       }

        $this->twig->addExtension(new IntlExtension());
   
   
   

確認

バリデートしてるのはブラウザなのでマイグレーションとかは必要なし
image.png

フォームを投稿すると、投稿された内容で Comment オブジェクトが更新されます。フォームからは取り除きましたが、カンファレンスは、URL で既に決まっています。フォームのバリデーションができなかった際は、ページを表示しますが、フォームには先程投稿した内容やエラーメッセージが入っているので、ユーザーに表示することができます。フォームを試してみましょう。正しく動きデータベースに格納されるはずです(管理者バックエンドで確認してください)。しかし、まだ写真が正しく扱えていません。コントローラーで処理をしていないので正しく動きません。

ファイルをアップロードする

アップロードされた写真は、カンファレンスページで表示できるように Web からアクセスできるローカルのディスクに保存されるべきです。public/uploads/photos ディレクトリ以下にしましょう。

guestbook/src/Controller/ConferenceController.php
   
   
   
-   public function show(Request $request, Conference $conference, CommentRepository $commentRepository)
+   public function show(Request $request, Conference $conference, CommentRepository $commentRepository, string $photoDir)
    {
        $comment = new Comment();
        $form = $this->createForm(CommentFormType::class, $comment);
        $form->handleRequest($request);
        if ($form->isSubmitted() && $form->isValid()) {
            $comment->setConference($conference);
+           if ($photo = $form['photo']->getData()) {
+               $filename = bin2hex(random_bytes(6)).'.'.$photo->guessExtension();
+               try {
+                   $photo->move($photoDir, $filename);
+               } catch (FileException $e) {
+                   // unable to upload the photo, give up
+               }
+               $comment->setPhotoFilename($filename);
+           }
            $this->entityManager->persist($comment);
   
   
   

写真のアップロードを行うのに、ファイルにランダムな名前を付けます。そして、アップロードされたファイルを写真格納ディレクトリの最終的な場所に移動します。そして、ファイル名はコメントエンティティに格納します。show() メソッドに、サービスではなく文字列として $photoDir が引数として追加されています。Symfony のコンテナは、サービス以外にパラメーターを格納することができます。パラメーターは、サービスを設定するためのスカラー値です。これらのパラメーターは、明示的にサービスにインジェクトもできますし、名前で紐付かせることもできます。

guestbook/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.
        bind:
            $photoDir: "%kernel.project_dir%/public/uploads/photos"
   
   
   

bind の設定があると、$photoDir 引数でSymfony が値をインジェクトしてくれます。写真の代わりにPDF ファイルをアップロードしてみてください。エラーメッセージが表示されるはずです。デザインは適用していないのでかっこよくはないですが、Webサイトのデザインをする際に綺麗にします。フォームの全ての要素のスタイルを1行の設定で変更するようにします。

確認

image.png

※記述が足りない??

キィィィィ!勘弁してくれ昼寝するはめになったぞ。プレーンな環境で作らなかったんか?(ハンカチを噛む)

guestbook> composer require symfony/mime

image.png

php.ini(コメントアウトされているのを有効にする)
extension=fileinfo

確認

PDFのアップロードがはじかれること
image.png
写真をアップロードできること
image.png
image.png
image.png

アップロードされた写真をGitから除外する

gitignoreに以下を追加

guestbook/.gitignore
+ /public/uploads

管理者のバックエンドをセキュアにする

管理者のバックエンドのインターフェースは、信頼された人からのみアクセス可能であるべきです。Symfony のセキュリティコンポーネントを使用して、Web サイトをセキュアにします。Twig と同様に、セキュリティコンポーネントは既に他の依存パッケージが使用しておりインストールされていますが、明示的に composer.json ファイルに追加しましょう

security-bundleは上から流してきたなら入っているはず
guestbook> composer require symfony/security-bundle

Userエンティティを定義する

参加者がWebサイトに自分のアカウントを作成することはできないですが、ここでは管理者のために正しく機能する認証システムを作成しましょう。そのために、Webサイトの管理者のユーザーを一つだけ用意します。最初のステップは、User エンティティを定義することです。混乱を避けるためにここでは Admin ディレクトリを使います。Symfony のセキュリティ認証システムで Admin エンティティを使用するためには、password プロパティなどの要件が必要になります。make:entity ではなく、専用の make:user コマンドを使用して Admin エンティティを作成してください。

インタラクティブな質問に次のように答えてください: 管理者をDoctrine に格納したいので(yes)、管理者のユニークな表示名を (username) 、そして各ユーザーがパスワードを1つ持つことに(yes)と。生成されたクラスには、getRole(), eraseCredentials() メソッドの他にも Symfony の認証システムで必要なものが入っています。Admin ユーザーにさらにプロパティを追加したければ、make:entity を使用してください。

guestbook> php bin/console make:user Admin
  Do you want to store user data in the database (via Doctrine)? (yes/no) [yes]:
  >

  Enter a property name that will be the unique "display" name for the user (e.g. email, username, uuid) [email]:
  > username

  Will this app need to hash/check user passwords? Choose No if passwords are not needed or will be checked/hashed by some other system (e.g. a single sign-on server).

  Does this app need to hash/check user passwords? (yes/no) [yes]:
  >

  created: src/Entity/Admin.php
  created: src/Repository/AdminRepository.php
  updated: src/Entity/Admin.php
  updated: config/packages/security.yaml

  Success! 

EasyAdmin のように __toString() メソッドを追加しましょう

guestbook/src/Entity/Admin.php
   
   
   
        return $this;
    }

+   public function __toString(): string
+   {
+       return $this->username;
+   }

    /**
     * @see UserInterface
     */
   
   
   

Admin エンティティを生成するだけでなく、このコマンドは、認証システムとエンティティのワイヤリングのためのセキュリティ設定を更新します

※と書いてあるが、どうやら設定されているようだ ↓

guestbook/config/packages/security.yaml
security:
+    encoders:
+        App\Entity\Admin:
+            algorithm: auto
+
     # https://symfony.com/doc/current/security.html#where-do-users-come-fromuser-providers
     providers:
-        in_memory: { memory: null }
+        # used to reload user from session & other features (e.g. switch_user)
+        app_user_provider:
+            entity:
+                class: App\Entity\Admin
+                property: username
     firewalls:
         dev:
             pattern: ^/(_(profiler|wdt)|css|images|js)/

Symfony にパスワードをエンコードするのに一番有効なアルゴリズムを選択させましょう(これは時が経つと変更されていくものです)。マイグレーションを生成して、データベースをmigrateします

guestbook> php bin/console make:migration
guestbook> php bin/console doctrine:migrations:migrate

管理者ユーザーのパスワードを生成する

guestbook> php bin/console security:encode-password

  Symfony Password Encoder Utility
  ================================

  Type in your password to be encoded:
  >

  ------------------ ---------------------------------------------------------------------------------------------------
  Key                Value                                                                                             
  ------------------ ---------------------------------------------------------------------------------------------------
  Encoder used       Symfony\Component\Security\Core\Encoder\MigratingPasswordEncoder
  Encoded password   $argon2id$v=19$m=65536,t=4,p=1$J5TeTwzxmlVnzYSWpp7J7w$H0zwMauEl0yY4vfzEAOG1zqdLOtcp0ZlF+bxK6pecj4
  ------------------ ---------------------------------------------------------------------------------------------------

  ! [NOTE] Self-salting encoder used: the encoder generated its own built-in salt.                                       

  [OK] Password encoding succeeded 

管理者を作成する

次の SQL で管理者ユーザーを追加してください。パスワードの値の $ 符号は全てエスケープしましょう。
$ symfony run psql -c "INSERT INTO admin (id, username, roles, password) VALUES (nextval('admin_id_seq'), 'admin', '[\"ROLE_ADMIN\"]', '\$argon2id\$v=19\$m=65536,t=4,p=1\$BQG+jovPcunctc30xG5PxQ\$TiGbx451NK

e?:flushed: あれなの?パスワード作ってからSQLで登録するのか。なんか因果が逆やな:thinking:
俺(symfony)のほうだけ準備整ったよ!って?

僕は下のようにMySQLWorkbenchのUI上から値いれました。えー、これはやいとこ「メールを送り返したらユーザー登録完了」みたいなのほしいな
image.png

セキュリティ認証を設定する

管理者ユーザーができましたので、管理者のバックエンドをセキュアにすることができます。Symfony は複数の認証の方法をサポートしていますが、ここでは、昔から人気のあるフォーム認証システムを使いましょう。 make:auth コマンドを実行しセキュリティ設定を更新し、ログインテンプレートを作成し、認証システムを作成しましょう
1 を選択し、ログインフォーム認証システムを生成し AppAuthenticator とし、コントローラーを SecurityController と命名し logout URL を生成しましょう( yes )。このコマンドはセキュリティ設定を更新し生成されるクラスとワイヤリングします。

guestbook> php bin/console make:auth
  What style of authentication do you want? [Empty authenticator]:
    [0] Empty authenticator
    [1] Login form authenticator
  > 1

  The class name of the authenticator to create (e.g. AppCustomAuthenticator):
  > AppAuthenticator

  Choose a name for the controller class (e.g. SecurityController) [SecurityController]:
  >

  Do you want to generate a '/logout' URL? (yes/no) [yes]:
  >

  created: src/Security/AppAuthenticator.php
  updated: config/packages/security.yaml
  created: src/Controller/SecurityController.php
  created: templates/security/login.html.twig

  Success! 

  Next:
  - Customize your new authenticator.
  - Finish the redirect "TODO" in the App\Security\AppAuthenticator::onAuthenticationSuccess() method.
  - Review & adapt the login template: templates/security/login.html.twig.

コマンド出力のヒントにあるように、ログインが成功した際にユーザーをリダイレクトするように onAuthenticationSuccess() メソッドにあるルートをカスタマイズする必要があります。

guestbook/src/Security/AppAuthenticator.php
   
   
   
    public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey)
    {
        if ($targetPath = $this->getTargetPath($request->getSession(), $providerKey)) {
            return new RedirectResponse($targetPath);
        }

        // For example : return new RedirectResponse($this->urlGenerator->generate('some_route'));
-       throw new \Exception('TODO: provide a valid redirect inside '.__FILE__);
+       return new RedirectResponse($this->urlGenerator->generate('easyadmin'));
    }
   
   
   

次のコマンドでルート名とパスの関連を表示することができるので、EasyAdmin ルートがeasyadmin ということがわかります。

guestbook> php bin/console debug:router
 -------------------------- -------- -------- ------ -----------------------------------
  Name                       Method   Scheme   Host   Path                              
 -------------------------- -------- -------- ------ -----------------------------------
  _preview_error             ANY      ANY      ANY    /_error/{code}.{_format}
  _wdt                       ANY      ANY      ANY    /_wdt/{token}
  _profiler_home             ANY      ANY      ANY    /_profiler/
  _profiler_search           ANY      ANY      ANY    /_profiler/search
  _profiler_search_bar       ANY      ANY      ANY    /_profiler/search_bar
  _profiler_phpinfo          ANY      ANY      ANY    /_profiler/phpinfo
  _profiler_search_results   ANY      ANY      ANY    /_profiler/{token}/search/results
  _profiler_open_file        ANY      ANY      ANY    /_profiler/open
  _profiler                  ANY      ANY      ANY    /_profiler/{token}
  _profiler_router           ANY      ANY      ANY    /_profiler/{token}/router
  _profiler_exception        ANY      ANY      ANY    /_profiler/{token}/exception
  _profiler_exception_css    ANY      ANY      ANY    /_profiler/{token}/exception.css
  admin                      ANY      ANY      ANY    /admin
  home                       ANY      ANY      ANY    /
  conference                 ANY      ANY      ANY    /conference/{id}
  app_login                  ANY      ANY      ANY    /login
  app_logout                 ANY      ANY      ANY    /logout
 -------------------------- -------- -------- ------ -----------------------------------

認可アクセスコントロールのルールを追加する

セキュリティシステムは2つのパートによって構成されています。認証と認可です。管理者ユーザーを作成した際に ROLE_ADMIN ロールを与えています。 access_control にルールを追加して ROLE_ADMIN ロールを持ったユーザーのみが /admin セクションにアクセスできるようにしましょう
access_control のルールは正規表現でアクセスを制限します /admin から始まるURLにアクセスされると、セキュリティシステムは、ログインしているユーザーが ROLE_ADMIN ロールがあるかチェックします。

guestbook/config/packages/security.yaml
   
   
   
    # Easy way to control access for large sections of your site
    # Note: Only the *first* access control that matches will be used
    access_control:
-       # - { path: ^/admin, roles: ROLE_ADMIN }
+         - { path: ^/admin, roles: ROLE_ADMIN }
        # - { path: ^/profile, roles: ROLE_USER }

ログインフォームで認証する

これで、管理者のバックエンドへのアクセスを試みると、ログインページにリダイレクトされ、ログインとパスワードの入力を促されるはずです。

guestbook> php -S 127.0.0.1:8000 -t public
https://127.0.0.1:8000/admin 

admin と先ほどエンコードしたパスワードを使ってログインしてください。そのままSQL をコピーしていたなら、そのパスワードの値はadmin です。EasyAdmin は自動的に Symfony の認証システムを検知します

image.png
毎回パスワードブレイクみたいな状態になるのよね
image.png
まぁでも入れたのでヨシ!
image.png
image.png

"ログアウト" リンクをクリックしてください。これで、管理者のバックエンドはセキュアな状態になります。

NOTE:
make:registration:form コマンドを使えば、より高機能な認証システムを作成することができます。

APIでスパム対策をする

ロボットやスパマーなど誰でもフィードバックを投稿すること可能な状態ですので、CAPTCHA を追加したり、サードパーティのAPI を使用して、ロボットからの投稿から保護することを考えます。Akismet を使用することにしましょう。ここでは、アウトオブバンドに AkismetAPI を呼ぶ方法を説明します。

Akismetに登録する

akismet.com の無料アカウントに登録し、Akismet API Key を取得します。

ちょっと英語やしー、クレジットカード登録求められるしで怖いなー。ちょっときついからこのセクションは無視。スパムコメントはこないものと仮定しましょうw

テストをする

アプリケーションにどんどん機能を追加し始めているので、テストについて話す適切なタイミングでしょう。面白いことに、このチャプターでテストを書いている時に私はバグを見つけました。Symfony は、PHPUnit を使ってユニットテストをしています。インストールしておきましょう

guestbook> composer require --dev symfony/phpunit-bridge

  How to test? 

   * Write test cases in the tests/ folder
   * Run php bin/phpunit

ユニットテストを書く

SpamChecker が、最初にテストを書くクラスです。ユニットテストを生成します

あぁ~ん、テストの内容が Akismet API についてか。ってことは無理やな。

コントローラーのファンクショナルテストを書く

「処理のテスト」はできなくてもブラウザのレスポンステストとかはできるはずだ。

コントローラーのテストは一般的なPHP のクラスのテストとは少し異なります。コントローラーのテストでは、HTTP リクエストのコンテキスト内で実行する必要があるからです。ファンクショナルテストで必要になる依存パッケージをインストールしてください

guestbook> composer require --dev symfony/browser-kit symfony/css-selector

Conference コントローラーのファンクショナルテストを作成してください

guestbook> php bin/console make:functional-test

  The name of the functional test class (e.g. DefaultControllerTest):
  > Controller\ConferenceControllerTest

  created: tests/Controller/ConferenceControllerTest.php

  Success!

  Next: Open your new test class and start customizing it.
  Find the documentation at https://symfony.com/doc/current/testing.html#functional-tests
guestbook/tests/Controller/ConferenceControllerTest.php
<?php

namespace App\Tests\Controller;

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

class ConferenceControllerTest extends WebTestCase
{
-   public function testSomething()
+   public function testIndex()
    {
        $client = static::createClient();
-       $crawler = $client->request('GET', '/');
+       $client->request('GET', '/');

        $this->assertResponseIsSuccessful();
-       $this->assertSelectorTextContains('h1', 'Hello World');
+       $this->assertSelectorTextContains('h2', 'Give your feedback');
    }
}

最初のテストは、ホームページが HTTP Response200 を返すか調べることです。$client 変数は、ブラウザをシミュレートします。サーバーへのHTTP 呼び出しをするのではなく、Symfony アプリケーションを直接呼び出します。この方法を使うことの利点は次の通りです。クライアントとサーバーの間の往復をしないので処理が速くなることです。そして、各HTTPリクエストの後のサービスの状態を調べるテストが可能になることです。PHPUnit のみならず、さらにassertResponseIsSuccessful のようなアサーションを使うことで確認作業が楽になります。Symfony によって定義されたこういったアサーションはたくさんあります。

INFO:
ルーターから生成するのではなく、/ をURL として使ってきました。エンドユーザーのURL をテストとするため、故意にそうしていました。ルートパスを変更すると、テストは失敗するようになります。そして、失敗することが、サーチエンジンやWeb サイトにリンクがあった際に、古いURL を新しいURL にリダイレクトさせるようにするべきということに気づくリマンドになります。

なんか1回目はphpunitの依存パッケージがインストールされるけど...まぁいっか
guestbook> php bin/phpunit tests/Controller/ConferenceControllerTest.php

  PHPUnit 7.5.20 by Sebastian Bergmann and contributors.

  Testing App\Tests\Controller\ConferenceControllerTest
  .                                                                   1 / 1 (100%)

  Time: 610 ms, Memory: 10.00 MB

  OK (1 test, 3 assertions)

テストが失敗した際は、レスポンスオブジェクトを調べると良いです。$client->getResponse() でレスポンスオブジェクトを取得しecho してどうなっているか確認してください。

フィクスチャを定義する

コメントの一覧、ページネーション、フォーム投稿のテストをするには、データをデータベースへ投入する必要があります。そして、テスト実行の間同じデータにしておきたいです。このニーズを満たしてくれるフィクスチャの出番です。Doctrine Fixture bundle をインストールしてください

いまいちテキストに書いてあるインストールとofficialHPに書いてあるのが違うのはどういう了見なんや!?
guestbook> composer require --dev doctrine/doctrine-fixtures-bundle

インストールすると、src/DataFixtures/ ディレクトリとサンプルクラスが作成されますので、カスタマイズしてください。ここでは、カンファレンスを2つ、コメントを1つ追加します。フィクスチャをロードすると、管理者ユーザーも含め、全てのデータは削除されます。フィクスチャに管理者ユーザーも追加しておきましょう

guestbook/src/DataFixtures/AppFixtures.php
   :
   :
   :
- use Doctrine\Common\Persistence\ObjectManager;
+ use Doctrine\Persistence\ObjectManager;
   :
   :
   :
class AppFixtures extends Fixture
{
+   private $encoderFactory;
+
+   public function __construct(EncoderFactoryInterface $encoderFactory)
+   {
+       $this->encoderFactory = $encoderFactory;
+   }

    public function load(ObjectManager $manager)
    {
        // $product = new Product();
        // $manager->persist($product);
+       $amsterdam = new Conference();
+       $amsterdam->setCity('Amsterdam');
+       $amsterdam->setYear('2019');
+       $amsterdam->setIsInternational(true);
+      $manager->persist($amsterdam);

+       $paris = new Conference();
+       $paris->setCity('Paris');
+       $paris->setYear('2020');
+       $paris->setIsInternational(false);
+       $manager->persist($paris);

+       $comment1 = new Comment();
+       $comment1->setConference($amsterdam);
+       $comment1->setAuthor('Fabien');
+       $comment1->setEmail('fabien@example.com');
+       $comment1->setText('This was a great conference.');
+       $manager->persist($comment1);

+       $admin = new Admin();
+       $admin->setRoles(['ROLE_ADMIN']);
+       $admin->setUsername('admin');
+       $admin->setPassword($this->encoderFactory->getEncoder(Admin::class)->encodePassword('admin', null));
+       $manager->persist($admin);

        $manager->flush();
    }
}

フィクスチャをロードする

フィクスチャをデータベースへロードしてください。データベースに格納されている全てのデータを削除するので、注意してください。

CAUTION:
デフォルトでは、load コマンドはデータベースをパージし、すべてのテーブルからすべてのデータを削除します。フィクスチャのデータを追加するには、--append オプションを追加します。

guestbook> php bin/console doctrine:fixtures:load

ファンクショナルテスト内でWebサイトをクロールする

これまで見てきたように、テストで使用するHTTP クライアントは、ブラウザをシミュレートしますので、ヘッドレスブラウザ(=CUIブラウザ)を使っているかのようにWebサイトをナビゲートすることができます。ホームページから特定のカンファレンスページをクリックするテストを新しく追加してください。

guestbook/tests/Controller/ConferenceControllerTest.php
   :
   :
   :
        $this->assertResponseIsSuccessful();
        $this->assertSelectorTextContains('h2', 'Give your feedback');

    }

+   public function testConferencePage()
+   {
+       $client = static::createClient();
+       $crawler = $client->request('GET', '/');
+
+       $this->assertCount(2, $crawler->filter('h4'));
+
+       $client->clickLink('View');
+
+       $this->assertPageTitleContains('Amsterdam');
+       $this->assertResponseIsSuccessful();
+       $this->assertSelectorTextContains('h2', 'Amsterdam 2019');
+       $this->assertSelectorExists('div:contains("There are 1 comments")');
+   }

このテストで何が行われたかを説明しましょう:
• 最初のテストのようにホームページを開きます
request() メソッドは、ページ内の要素(リンクやフォームなどCSS セレクターやXPath で探せるもの全て)を探すのに便利なCrawler インスタンスを返します。
CSS セレクターを使って、ホームページにカンファレンスが2つ表示されているのを確認することができます
• そして、"View" リンクをクリックします(同時に複数のリンクをクリックできないので、Symfony は最初に見つけたリンクを選択します)
• ページタイトル、レスポンス、ページの<h2> が正しいページのものであるかアサートします(ルートがマッチするかも確認することができます)
• 最後に、ページにコメントが1つあることをアサートします。div:contains() は、CSSセレクターとしては無効ですが、Symfony にはjQuery の機能から一部持ってきた便利な追加機能があります。

テキスト(すなわちView)をクリックしなくても、CSSセレクターを使ってリンクを選択することもできます
$client->click($crawler->filter('h4 + p a')->link());

新しいテストが通ることを確認してください

guestbook> php bin/phpunit tests/Controller/ConferenceControllerTest.php

  PHPUnit 7.5.20 by Sebastian Bergmann and contributors.

  Testing App\Tests\Controller\ConferenceControllerTest
  ..                                                                  2 / 2 (100%)

  Time: 15.48 seconds, Memory: 28.00 MB

  OK (2 tests, 10 assertions)

テストデータベースを使う

デフォルトでは、phpunit.xml.dist ファイルでSymfony 環境をtest として定義しており、test 環境でテストは実行されます

guestbook/phpunit.xml.dist
<phpunit>
    <php>
        <server name="APP_ENV" value="test" force="true" />
    </php>
</phpunit>

テストで別のデータベースを使用するには、.env.test ファイルにあるDATABASE_URL 環境変数をオーバーライドしてください

guestbook/.env.test
# define your env variables for the test env here
+ DATABASE_URL=mysql://root:rootpw@127.0.0.1:3306/guestbook_database_1
KERNEL_CLASS='App\Kernel'
APP_SECRET='$ecretf0rt3st'
SYMFONY_DEPRECATIONS_HELPER=999999
PANTHER_APP_ENV=panther

test環境のデータベースにフィクスチャをロードしてください:

(オプションの書き方にしてもいちいち本と違うな。とてもver5のために書かれたとは思えない...)
guestbook> php bin/console doctrine:fixtures:load --env=test
  Careful, database "guestbook_database_1" will be purged. Do you want to continue? (yes/no) [no]:
  > yes

    > purging database
    > loading App\DataFixtures\AppFixtures

このステップの残りの場所では、環境変数DATABAE_URL は再定義しません。テストするのにdev 環境と同じデータベースを使用するといくつかの利点があります。次のセクションで紹介します。
(注:.env.testも同じデータベースにしてるので結果的に同じ意味)

ファンクショナルテストでフォームを投稿する

フォームの投稿をシミュレートしてカンファレンスに写真付きのコメントを追加してみましょう。以下の必要なコードを見てください。今までに書いたものと同じように複雑ではありません

※ここにマジックナンバー注意!
本来コレは、スラッグ がうまく作れていないといけないが飛ばしているので(お手本も /conference/amsterdam-2019 みたいな指定の仕方になっているが仕方なく /conference/4 となっている。当然採番されるタイミングで数字が異なる)。さらにいうと、There are 2 comments の数字も変わる(コレに関しては次のセクションで説明)

guestbook/tests/Controller/ConferenceControllerTest.php
   :
   :
   :
        $this->assertSelectorExists('div:contains("There are 1 comments")');
    }
+
+   public function testCommentSubmission()
+   {
+       $client = static::createClient();
+       $client->request('GET', '/conference/4'); // ※ここにマジックナンバー注意!
+       $client->submitForm('Submit', [
+           'comment_form[author]' => 'Fabien',
+           'comment_form[text]' => 'Some feedback from an automated functional test',
+           'comment_form[email]' => 'me@automat.ed',
+           'comment_form[photo]' => dirname(__DIR__, 2).'/public/images/under-construction.gif',
+       ]);
+       $this->assertResponseRedirects();
+       $client->followRedirect();
+       $this->assertSelectorExists('div:contains("There are 2 comments")');
+   }
}

submitForm() でフォームをサブミットするのに、ブラウザの開発ツールもしくは、Symfonyのプロファイラパネルからinput(※img??) の名前を見つけてください。工事中のイメージが再利用されているのに気づきましたか?テストをもう一度実行し、全てパスすることを確認してください。

フィクスチャをリロードする

テストをもう一度走らせると、テストは失敗します。それは、データベースにコメントが追加されたからで、コメントの数を調べるアサーションが壊れてしまっているからです。テスト実行の前にフィクスチャをリロードして、テスト実行毎にデータベースの状態をリセットする必要があります。

guestbook> php bin/console doctrine:fixtures:load
guestbook> php bin/phpunit tests/Controller/ConferenceControllerTest.php

非同期にする

フォーム投稿時にスパムの判定をするのには多少問題があります。例えば、Akismet API に遅延の問題があったときに、私たちのWeb サイトも遅くなってしまいます。さらに、タイムアウトされてしまったり、Akismet API に問題があったときには、コメントを失ってしまうかもしれません。公開することなく投稿されたデータを保存して、レスポンスを早く返すことが理想とするところです。そのためにスパムのチェックとは独立して実行します。

  • コメントにフラグを付ける(掲載済みの state は文字のpublishedが入る)
    • Comment クラスにstate プロパティを追加
    • データベースマイグレーション
    • 既に登録されているコメント全てにデフォルトの値としてpublished を指定するように UPDATE
    • state のデフォルトの値をsubmitted としてセットされていることを確認
    • EasyAdmin の設定を変更して管理画面でコメントの状態(state)を見ることができるようにしましょう。
    • フィクスチャにstate をセットして、テストコードを修正しましょう
    • コントローラーのテストでは、バリデーションをシミュレートします
  • メッセンジャーを理解する
    • 非同期処理が必要な際に、メッセージをメッセージバスに送ってください。このバスはキューに格納され、処理を止めることなくすぐリターンされます。コンシューマーは、継続的にバックグラウンドで動いており、キ ューにある新しいメッセージを読み、そのメッセージに関連したロジックを実行します。コンシューマーは、Web アプリケーションと同じサーバーでも別のサーバーにあっても動作します。レスポンスがない以外は、HTTP リクエストを処理するときととても似ています。
  • メッセージハンドラーをコーディングする
    • メッセージはデータオブジェクトのクラスで、ロジックを持つべきではありません。シリアライズされ、キューに格納されます。“シンプルな”シリアライズ可能なデータのみを格納しましょう。
    • CommentMessage クラスを作成する
    • CommentMessage メッセージを処理するCommentMessageHandler クラスを作成してください
  • 表示されるコメントを制限する
    • CommentRepositoryの表示ロジックを変更して、公開されていないコメントをフロントエンドへ表示しないようにしましょう
  • 実際に非同期にする
    • デフォルトでは、ハンドラは、同期的に処理します。非同期にするために、config/packages/messenger.yaml の設定ファイルに、ハンドラがどのキューを使用するかを明示的に設定してください
  • Docker のスタックに RabbitMQ を追加する
  • Dockerサービスをリスタートする
  • メッセージを取得実行する

多分コレはAjaxではない、Symfonyの内部的な非同期処理(時間の掛かりそうな処理をキューに入れてレスポンスだけ返しましょう)を経験しましょうってやつなんやな。まぁ、Ajaxでやるんで結構です!みたいな感じやな。

(省略)ワークフローを使って判定する

workflowはタスク管理みたいなやつ。参考にしたいなら本買ってみて。僕は興味がない(ぉぃ

(省略)管理者へメールを送信する

Akismet APIspam と判断したときに、管理者へメールを送信し、承認フローを設けるというものだが、Akismet APIに依存したサンプル多すぎだろ...参考にしたいなら本買ってみて。僕は興味がない(ぉぃ

パフォーマンス向上のためにキャッシュする

パフォーマンスに関する問題はよく訪れます。典型的な例だと、データベースのインデックスの欠如や、ページあたりの大量のSQL発行などです。空のデータベースでは発生しませんが、トラフィックが増えデータが増加すると、発生する可能性があります。

1時間キャッシュを保持するとかそういう事が書いてあって、んー、その手のキャッシュはクエリストリングに更新日時をつけたりして回避するので結構です、みたいな。参考にしたいなら本買ってみて。僕は興味がない(ぉぃ

Webpackでユーザーインタフェースにスタイリングする

ここまで、まだユーザーインタフェースのデザインをあまりしてきていません。プロのようにスタイルをするのに、Webpack ベースのモダンなスタックを使ってみましょう。アプリケーションとの統合をやりやすくするためにSymfony touch を追加してください。Webpack Encore をインストールしましょう:

guestbook> composer require symfony/webpack-encore-bundle

完全なWebpack 環境は、既に作成されており、適切なデフォルトの設定でpackage.jsonwebpack.config.js が生成されています。Webpack を設定するのにEncore の設定をするwebpack.config.js を開いてください。package.json ファイルに、毎回使う便利なコマンドが定義されています。assets ディレクトリには、css/app.cssjs/app.js のようなプロジェクトのアセットのメインエントリーポイントが入っています。

Sass を使用する

生の CSS を使うのではなく、 Sass を使ってみましょう

Rename
guestbook> mv assets/css/app.css assets/css/app.scss
guestbook/assets/js/app.js
- import '../css/app.css';
+ import '../css/app.scss';

Sass ローダーをインストールしてください
※PowerShell のスクリプトが実行できない場合の対処方法

PowerShell_-ExecutionPolicy_RemoteSignedはpowershellスクリプト実行用の枕コマンド
guestbook> PowerShell -ExecutionPolicy RemoteSigned yarn add node-sass "sass-loader@^7.0.1" --dev

Webpack で Sass ローダーを有効化してください

guestbook/webpack.config.js
- //.enableSassLoader()
+ .enableSassLoader()

インストールするパッケージはどうしたら分かるのでしょうか。もしパッケージインストール無しでアセットをビルドしようとすると、Encore は .scss ファイルをロードするのに必要な依存パッケージをインストールする yarn add コマンドが必要であることをエラーメッセージで表示してくれます。

Bootstrap でレバレッジする

適切なデフォルト値でレスポンシブな Webサイトをビルドするには、 Bootstrap のような CSS フレームワークが良いでしょう。Bootstrap をパッケージとしてインストールしてください:

PowerShell_-ExecutionPolicy_RemoteSignedはpowershellスクリプト実行用の枕コマンド
guestbook> PowerShell -ExecutionPolicy RemoteSigned yarn add bootstrap jquery popper.js bs-custom-file-input --dev

CSSファイルで Bootstrap を require してください(ファイルをクリーンアップもしています)

guestbook/assets/css/app.scss
- body {
-     background-color: lightgray;
- }

+ @import '~bootstrap/scss/bootstrap';

JS ファイルにも同じようにしてください

guestbook/assets/js/app.js
// any CSS you import will output into a single css file (app.css in this case)
import '../css/app.scss';
+ import 'bootstrap';
+ import bsCustomFileInput from 'bs-custom-file-input';
- // Need jQuery? Install it with "yarn add jquery", then uncomment to import it.
- // import $ from 'jquery';
-
- console.log('Hello Webpack Encore! Edit me in assets/js/app.js');
+ bsCustomFileInput.init();

Symfony のフォームシステムは、特別なテーマでBootstrap をネイティブでサポートしていますので、有効にしてください

guestbook/config/packages/twig.yaml
twig:
    default_path: '%kernel.project_dir%/templates'
+   form_themes: ['bootstrap_4_layout.html.twig']

HTML をスタイリングする

アプリケーションをスタイリングする準備ができましたので、アーカイブをダウンロードし、プロジェクトのルートディレクトリに展開してください

zipのなかみが以下の図の感じってことは、要は素材上書きしろってことね(※上書きするとコントローラのpath指定とかが異なるけど、ひとつひとつエラーを見て修正していけるはずだ!)
image.png

Expand-ArchiveはPowerShell用のUnzipコマンドだよ
guestbook> php -r "copy('https://symfony.com/uploads/assets/guestbook.zip', 'guestbook.zip');"
guestbook> Expand-Archive -Path guestbook.zip -DestinationPath . -Force

テンプレートを見ると、少し Twig にトリックがあるのに気づくと思います。

アセットをビルドする

Webpack 使用することでの主な違いは、アプリケーションがCSSや JS ファイルを直接使うことができないことです。最初に “コンパイル”してあげる必要があります。開発環境では、 encore dev コマンドでアセットをコンパイルすることができます

guestbook> symfony run yarn encore dev

JS や CSS の変更時にコマンドを毎回実行するのではなく、JS やCSSの変更を検知して、バックグラウンドで実行させましょう

guestbook> symfony run -d yarn encore dev --watch

確認

少し時間をかけて視覚的な変更を発見してみてください。ブラウザで新しいデザインを見てみてください。

guestbook> php bin/console cache:clear
guestbook> php -S 127.0.0.1:8000 -t public

image.png
image.png

画像をリサイズする(p.271)

guestbook> 
guestbook> 

Cookie

DefaultController
    /**
     * @Route("/default", name="default")
     */
    public function index(GiftsService $gifts)
    {
        $users = $this->getDoctrine()->getRepository(User::class)->findAll();

        // これ!
        $cookie = new Cookie('my_cookie', 'cookie value', time() + 60); // now() + 60sec
        $res = new Response();
        $res->headers->setCookie($cookie);
        $res->send();

        return $this->render('default/index.html.twig', [
            'controller_name' => 'DefaultController',
            'users' => $users,
            'random_gift' => $gifts->gifts
        ]);
    }

確認

Console
my-project> php -S 127.0.0.1:8000 -t public

ははぁ、なるほどね。っていうかchromeでcookieも見れたんやな...
image.png

Cookieのクリア

Cookieを消すとき
        $res = new Response();
        $res->headers->clearCookie('my_cookie');
        $res->send();

セッション

「セッションIDの表示アンドSTOP」のところで処理が止まっちゃう(※exit)なので、手でコメントアウトして、コメントアウト外して、ってパチパチやるとセッションに入った値とIDが確認できる

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
    public function index(..., Request $request, SessionInterface $session)
    {
        // セッションIDの表示アンドSTOP
        exit($request->cookies->get('PHPSESSID'));

        // セッションに値を仕込んで表示
        $session->set('name', 'session value');
        if($session->has('name')){
            exit($session->get('name'));
        }

        // おまけ(セッション削除)
        $session->remove('name');
        // おまけ(セッション全消し)
        $session->clear();
    }

GET

    public function index(GiftsService $gifts, Request $request, SessionInterface $session)
    {
        // クエリストリングに page があれば page の数字を返しなければ 'default' を返す
        exit($request->query->get('page', 'default'));
    }

確認

Console
my-project> php -S 127.0.0.1:8000 -t public

ははぁ、なるほどね。
image.png
image.png

おまけ

        // アップロードされたファイル foo の取得
        $request->files->get('foo');

404

カスタムエラーページ

なんかエラーページをオーバーライドしてカスタマイズできるらしいけど教材がなんか変で ボブみたいになってる から質問中。

意図的な例外の発生

    public function index(GiftsService $gifts, Request $request)
    {
        // ユーザーがいなかったら?
        if(!$users){
            throw $this->createNotFoundException('The users do not exist');
        }
    }

確認

Console
my-project> php -S 127.0.0.1:8000 -t public

ははぁ、なるほどね(※これは意図的に if の否定を取ってエラーを起こしました)
image.png

サービスを作成する

MyService.phpを新規作成(最初はServiceというフォルダ自体がないと思います)。処理内容は、配列にランダム処理をして返却するとしましょう。

agg_project/src/Service/MyService.php(新規)
<?php
namespace App\Service;

class GiftsService {

    public $gifts = ['flowers', 'car'];

    public function __construct()
    {
        shuffle($this->gifts);
    }

}

サービスをコントローラーにバインドする

メソッドの引数で直接使う

GiftsService $gifts に注目

DefaultController.php
<?php

namespace App\Controller;

use App\Entity\User;
use App\Service\GiftsService;
use App\Service\MyService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\VarDumper\VarDumper;

/**
 * Class DefaultController
 *
 * @author "Yoshitaka Okada <yoshi@gmail.com>"
 */
class DefaultController extends AbstractController
{

    /**
     * @Route("/", name="default")
     * @return \Symfony\Component\HttpFoundation\Response
     */
    public function index(GiftsService $gifts)
    {

        return $this->render(
            'default/index.html.twig',
            [
                'controller_name' => 'DefaultController',
                'random_gift' => $gifts,
            ]
        );
    }
}

ダウンロード機能

my-project\config\services.yaml(parametersのセクションに追記)
# This file is the entry point to configure your own services.
# Files in the packages/ subdirectory configure your dependencies.

# Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices/configuration.html#application-related-configuration
parameters:
    download_directory: '../public/' #追記
DefaultController.php
    /**
     * @Route("/download")
     */
    public function download()
    {
        $path = $this->getParameter('download_directory');
        return $this->file($path.'file.pdf');
    }

publicフォルダにファイルを作って
image.png

※You cannot guess the mime type as the Mime component is not installed. Try running "composer require symfony/mime".

MIME読めねぇよって言ってきたら

my-project> composer require symfony/mime

確認

Console
my-project> php -S 127.0.0.1:8000 -t public

ははぁ、なるほどね
image.png
image.png

CRUD

DefaultController.php
    public function index(GiftsService $gifts, Request $request)
    {

        $entityManager = $this->getDoctrine()->getManager();

        // read
        $user = $this->getDoctrine()->getRepository(User::class)->findOneBy(['name'=>"yoshitaka"]);
        dump($user);

        // create
        $user = new User();
        $user->setName('yoshitaka'); // 列名のついたインテリセンスが出てくる
        $entityManager->persist($user);
        $entityManager->flush();

        // update
        $user->setName('yoshitaka2');
        $entityManager->flush();

        // delete
        $entityManager->remove($user);
        $entityManager->flush();

        // raw sql
        $conn = $entityManager->getConnection();
        $sql = 'SELECT * FROM user u WHERE u.id > :id';
        $stmt = $conn->prepare($sql);
        $stmt->execute(['id'=> 5]); // :id 置換を試すためにワザと
        dump($stmt->fetchAll());

    }

データベースマイグレーションしなおし

Console
# delete tables in database
my-project> php bin/console doctrine:schema:drop -n -q --force --full-database

# delete migration file
my-project> rm migrations/*.php

# migration
my-project> php bin/console make:migration
my-project> php bin/console doctrine:migrations:migrate -n -q
my-project> composer require web-profiler-bundle

TEST

なんか教材が英語な上に複合的になってまとめられなくなってきたからテストのところに移ろう。テストって面倒でついついやりたくない。

インストール

Console
my-project> composer require --dev symfony/phpunit-bridge

サービスの単体テスト

基本はこれ

Console
my-project> php bin/console make:unit-test
  The name of the unit test class (e.g. UtilTest):
    > CalculatorTest
  created: tests/CalculatorTest.php
  Success!
my-project\src\Services\Calculator.php
<?php
namespace App\Services;

class Calculator {
    public function add($a, $b)
    {
        return $a + $b;
    }
}
my-project\tests\CalculatorTest.php
<?php

namespace App\Tests;

+ use App\Services\Calculator;
use PHPUnit\Framework\TestCase;

class CalculatorTest extends TestCase
{
    public function testSomething()
    {
+        $calc = new Calculator();
+        $result = $calc->add(1, 9);
+        $this->assertEquals(10, $result);
    }
}
Console
my-project> php ./bin/phpunit
  PHPUnit 7.5.20 by Sebastian Bergmann and contributors.
  Testing Project Test Suite                                       
  1 / 1 (100%)
  Time: 790 ms, Memory: 2.00 MB
  OK (1 test, 1 assertion)

データプロバイダーのテスト

アノテーションでデータプロバイダーこと「provideUrls」を指定して、「provideUrls」関数を定義して、urlを複数回弾込めしてテストするってことか

<?php

namespace App\Tests;

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

class DefaultControllerTest extends WebTestCase
{
+    /**
+     * @dataProvider provideUrls
+     */
+    public function testSomething($url)
    {
        $client = static::createClient();
+        $crawler = $client->request('GET', $url);

+        $this->assertTrue($client->getResponse()->isSuccessful());
    }

+    public function provideUrls()
+    {
+        return [['/home'], ['/login']];
+    }
}

分離テスト(新)

Console
my-project> composer require --dev symfony/test-pack
Console
my-project> php bin/console make:functional-test AdminControllerCategoriesTest
  created: tests/AdminControllerCategoriesTest.php
  Success! 

Controllersフォルダに移す(整理)

Console
my-project> mkdir tests/Controllers
my-project> mv tests/AdminControllerCategoriesTest.php tests/Controllers/AdminControllerCategoriesTest.php

image.png

my-project\phpunit.xml.dist(.envファイルからデータベース接続情報を持ってくる)
<?xml version="1.0" encoding="UTF-8"?>

<!-- https://phpunit.readthedocs.io/en/latest/configuration.html -->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="bin/.phpunit/phpunit.xsd"
         backupGlobals="false"
         colors="true"
         bootstrap="tests/bootstrap.php"
>
    <php>
        <ini name="error_reporting" value="-1" />
        <server name="APP_ENV" value="test" force="true" />
        <server name="SHELL_VERBOSITY" value="-1" />
        <server name="SYMFONY_PHPUNIT_REMOVE" value="" />
        <server name="SYMFONY_PHPUNIT_VERSION" value="7.5" />
+        <server name="DATABASE_URL" value="mysql://root:pwxxxxx@127.0.0.1:3306/xxxxxdb?serverVersion=5.7" />
    </php>

    <testsuites>
        <testsuite name="Project Test Suite">
            <directory>tests</directory>
        </testsuite>
    </testsuites>

    <filter>
        <whitelist processUncoveredFilesFromWhitelist="true">
            <directory suffix=".php">src</directory>
        </whitelist>
    </filter>

    <listeners>
        <listener class="Symfony\Bridge\PhpUnit\SymfonyTestsListener" />
    </listeners>
</phpunit>

分離テスト

データベースのロールバックを毎回しながらテストする
※うまくいかないので講師に英語で問い合わせ中。データベースの削除テストとかはちゃんとしときたいやつやな...

Console
my-project> composer require --dev symfony/test-pack
my-project\phpunit.xml.dist
<?xml version="1.0" encoding="UTF-8"?>

<!-- https://phpunit.readthedocs.io/en/latest/configuration.html -->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="bin/.phpunit/phpunit.xsd"
         backupGlobals="false"
         colors="true"
         bootstrap="tests/bootstrap.php"
>
    <php>
        <ini name="error_reporting" value="-1" />
        <server name="APP_ENV" value="test" force="true" />
        <server name="SHELL_VERBOSITY" value="-1" />
        <server name="SYMFONY_PHPUNIT_REMOVE" value="" />
        <server name="SYMFONY_PHPUNIT_VERSION" value="7.5" />

+        <env name="DATABASE_URL" value="mysql://root:xxxxx@127.0.0.1/xxxxxdb" />

    </php>

    <testsuites>
        <testsuite name="Project Test Suite">
            <directory>tests</directory>
        </testsuite>
    </testsuites>

    <filter>
        <whitelist processUncoveredFilesFromWhitelist="true">
            <directory suffix=".php">src</directory>
        </whitelist>
    </filter>

    <listeners>
        <listener class="Symfony\Bridge\PhpUnit\SymfonyTestsListener" />
    </listeners>
</phpunit>
my-project\tests\DefaultControllerTest.php
<?php

namespace App\Tests;

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

class DefaultControllerTest extends WebTestCase
{

    private $entityManager;
    protected function setUp()
    {
        parent::setUp();
        $this->client->static::createClient();
    }

    /**
     * @dataProvider provideUrls
     */
    public function testSomething($url)
    {
        $client = static::createClient();
        $crawler = $client->request('GET', $url);

        $this->assertTrue($client->getResponse()->isSuccessful());
    }

    public function provideUrls()
    {
        return [['/home'], ['/login']];
    }
}

モックオブジェクトのテスト

テストしたい相手の内蔵の動きと結果を指定できて(getPromotionPercentage関数を実行して20を返す)、その前提で機能だけをテストできる。人工呼吸みたいなもんやな。「俺がこいつの肺になります!」

my-project\src\Services\PromotionCalculator.php(テスト対象)
<?php
namespace App\Services;

class PromotionCalculator {

    // Calculatorが、不特定配列を全加算するように進化した
    public function calculatePriceAfterPromotion(... $prices)
    {
        $start = 0;
        foreach($prices as $price)
        {
            $start += $price;
        }

        // ファイルの数字の分だけ割り引いて返す
        return $start - ($start * $this->getPromotionPercentage() / 100);
    }

    // ファイルの数字を読んで数字で返す
    public function getPromotionPercentage()
    {
        return (int) \file_get_contents('file.txt');
    }
}
my-project\file.txt(外部読み込み係数)
20
my-project\tests\CalculatorTest.php
<?php

namespace App\Tests;

use App\Services\Calculator;
use PHPUnit\Framework\TestCase;
use App\Services\PromotionCalculator;

class CalculatorTest extends TestCase
{
    public function testSomething()
    {
        // getPromotionPercentageというメソッドを持ったMockを定義
        $calc = $this->getMockBuilder(PromotionCalculator::class)
        ->setMethods(['getPromotionPercentage'])
        ->getMock();

        // メソッドを実行し、20を返すものとする、というモックの振る舞いを定義
        $calc->expects($this->any())
        ->method('getPromotionPercentage')
        ->willReturn(20);

        // 10 - (10*20%) = 8
        $result = $calc->calculatePriceAfterPromotion(1, 9);
        $this->assertEquals(8, $result);

        // 80 - (80*20%) = 64
        $result = $calc->calculatePriceAfterPromotion(10, 20, 50);
        $this->assertEquals(64, $result);

    }
}
my-project> php ./bin/phpunit tests/CalculatorTest.php  
  PHPUnit 7.5.20 by Sebastian Bergmann and contributors.
  Testing App\Tests\CalculatorTest
  1 / 1 (100%)
  Time: 159 ms, Memory: 2.00 MB
  OK (1 test, 1 assertion)

カバレッジレポート

カバレッジとは?
カバレッジ(網羅率)とは、ソフトウェアテストにおいて、全体のうちどのくらいまでテストを実施したのかを割合で表したものです。
カバレッジを計測することにより、テストのヌケ・モレを防ぐことができます。

参考:PHPをWindows 10上のVisual Studio Codeを使って「Webサーバなしで」デバッグする方法

Cmd(コマンドプロンプト)
C:\Users\yoshi> php.exe -i

長い出力結果が出てくるので、Xdebugのページに貼り付けるとダウンロードリンクが出てくる
https://xdebug.org/wizard
image.png
image.png

php.iniファイルの最後に以下の内容を追記する。

[XDebug]
xdebug.remote_enable = 1
xdebug.remote_autostart = 1
xdebug.remote_connect_back = 1
xdebug.remote_port = 9000
zend_extension = C:\php\ext\php_xdebug-2.9.6-7.4-vc15-x86_64.dll
Console
my-project> php ./bin/phpunit --coverage-text
  PHPUnit 7.5.20 by Sebastian Bergmann and contributors.
  Error:         No code coverage driver is available
  Testing Project Test Suite
  2 / 2 (100%)
  Time: 935 ms, Memory: 8.00 MB
  OK (2 tests, 3 assertions)
13
14
1

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
13
14