Крок 13: Управління життєвим циклом об’єктів Doctrine

5.2 version
Maintained

Управління життєвим циклом об’єктів Doctrine

Створюючи новий коментар, було б чудово, якби дата createdAt була встановлена автоматично, з використанням значень поточної дати й часу.

В Doctrine є різні способи маніпулювання об’єктами та їх властивостями протягом їх життєвого циклу (до створення запису в базі даних, після оновлення, …).

Визначення зворотних викликів життєвого циклу

Якщо поведінка не потребує доступу до сервісу й застосовується тільки до одного типу сутності, визначте зворотний виклик в класі сутності:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
--- a/src/Entity/Comment.php
+++ b/src/Entity/Comment.php
@@ -7,6 +7,7 @@ use Doctrine\ORM\Mapping as ORM;

 /**
  * @ORM\Entity(repositoryClass=CommentRepository::class)
+ * @ORM\HasLifecycleCallbacks()
  */
 class Comment
 {
@@ -106,6 +107,14 @@ class Comment
         return $this;
     }

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

Подія @ORM\PrePersist оголошується тоді, коли об’єкт вперше зберігається у базі даних. Коли це трапляється, викликається метод setCreatedAtValue() який використовує поточні дату й час як значення для властивості createdAt.

Додавання «Slugs» для конференцій

URL-адреси для конференцій не несуть в собі смислового навантаження: /conference/1. Що ще важливіше, вони розкривають деталі реалізації (витік значення первинного ключа в базі даних).

А як щодо використання URL-адрес на кшталт /conference/paris-2020? Це виглядало б набагато краще. paris-2020 — це те, що ми називаємо slug конференції.

Додайте нову властивість slug для конференцій (рядок довжиною 255 символів, що не може містити значення null):

1
$ symfony console make:entity Conference

Створіть файл міграції, щоб додати новий стовпчик:

1
$ symfony console make:migration

А потім виконайте нову міграцію:

1
$ symfony console doctrine:migrations:migrate

Помилка? Це очікувано. Чому? Хоча ми вказали, що значення «slug» не може містити значення null, при виконанні міграції наявні записи в базі даних конференцій отримають значення null. Виправімо це, змінивши міграцію:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
--- a/migrations/Version00000000000000.php
+++ b/migrations/Version00000000000000.php
@@ -20,7 +20,9 @@ final class Version20200714152808 extends AbstractMigration
     public function up(Schema $schema) : void
     {
         // this up() migration is auto-generated, please modify it to your needs
-        $this->addSql('ALTER TABLE conference ADD slug VARCHAR(255) NOT NULL');
+        $this->addSql('ALTER TABLE conference ADD slug VARCHAR(255)');
+        $this->addSql("UPDATE conference SET slug=CONCAT(LOWER(city), '-', year)");
+        $this->addSql('ALTER TABLE conference ALTER COLUMN slug SET NOT NULL');
     }

     public function down(Schema $schema) : void

Хитрість полягає в тому, щоб додати стовпчик і дозволити йому мати значення null, потім встановити значення, що відмінні від null, і, нарешті, змінити стовпчик «slug» так, щоб не допустити можливості встановлення значення null.

Примітка

Для реального проекту, використання CONCAT(LOWER(city), '-', year) може виявитися недостатнім. У цьому випадку нам потрібно було б використовувати «справжній» Slugger.

Тепер міграція має виконатися нормально:

1
$ symfony console doctrine:migrations:migrate

Оскільки застосунок незабаром використовуватиме «slugs» для пошуку кожної конференції, налаштуймо сутність конференції, щоб мати впевненість, що значення у базі даних будуть унікальними:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
--- a/src/Entity/Conference.php
+++ b/src/Entity/Conference.php
@@ -6,9 +6,11 @@ use App\Repository\ConferenceRepository;
 use Doctrine\Common\Collections\ArrayCollection;
 use Doctrine\Common\Collections\Collection;
 use Doctrine\ORM\Mapping as ORM;
+use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;

 /**
  * @ORM\Entity(repositoryClass=ConferenceRepository::class)
+ * @UniqueEntity("slug")
  */
 class Conference
 {
@@ -40,7 +42,7 @@ class Conference
     private $comments;

     /**
-     * @ORM\Column(type="string", length=255)
+     * @ORM\Column(type="string", length=255, unique=true)
      */
     private $slug;

Оскільки ми використовуємо валідатор для забезпечення унікальності «slugs», нам потрібно додати компонент Symfony Validator:

1
$ symfony composer req validator

Як ви могли здогадатися, нам потрібно виконати ще один танок з міграціями:

1
$ symfony console make:migration
1
$ symfony console doctrine:migrations:migrate

Генерування «Slugs»

Генерування «slugs», що добре читаються в URL-адресах (де все, крім символів ASCII, має бути закодовано), є складним завданням, особливо для мов, відмінних від англійської. Наприклад, як конвертувати é у e?

Замість того, щоб винаходити колесо, використовуймо компонент Symfony String, який полегшує маніпуляції з рядками та забезпечує slugger:

1
$ symfony composer req string

Додайте метод computeSlug() до класу Conference, який обчислюватиме значення на основі даних конференції:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
--- a/src/Entity/Conference.php
+++ b/src/Entity/Conference.php
@@ -7,6 +7,7 @@ use Doctrine\Common\Collections\ArrayCollection;
 use Doctrine\Common\Collections\Collection;
 use Doctrine\ORM\Mapping as ORM;
 use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
+use Symfony\Component\String\Slugger\SluggerInterface;

 /**
  * @ORM\Entity(repositoryClass=ConferenceRepository::class)
@@ -61,6 +62,13 @@ class Conference
         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() обчислює «slug» лише тоді, коли поточне значення порожнє або встановлено спеціальне значення -. Навіщо нам потрібне спеціальне значення -? Тому що при додаванні конференції у панелі керування потрібен «slug». Отже, нам потрібне непорожнє значення, яке повідомляє застосунку про те, що ми хочемо, щоб значення було згенеровано автоматично.

Визначення складних зворотних викликів життєвого циклу

Так само як і для властивості createdAt, значення slug слід встановлювати автоматично кожного разу, коли конференція оновлюється, викликаючи метод computeSlug().

Але оскільки цей метод залежить від реалізації SluggerInterface, ми не можемо додати подію prePersist, як і раніше (у нас немає способу для впровадження сервісу slugger).

Замість цього створіть слухача сутності Doctrine:

src/EntityListener/ConferenceEntityListener.php
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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);
    }
}

Зверніть увагу, що значення оновлюється при створенні нової конференції (prePersist()) і кожного разу, коли вона оновлюється (preUpdate()).

Налаштування сервісу в контейнері

Досі ми не говорили про один із головних компонентів Symfony — контейнер впровадження залежностей. Він керує сервісами: створює їх і впроваджує, коли це необхідно.

Сервіс — це «глобальний» об’єкт, який надає певні функції (наприклад, mailer, logger, slugger і т.д.) на відміну від об’єктів даних (наприклад, екземплярів сутностей Doctrine).

Ви рідко будете працювати з контейнером безпосередньо, оскільки процес впровадження сервісів проходить автоматично всякий раз, коли це необхідно: контейнер впроваджує об’єкти, коли ви вказуєте тип сервісів у якості аргументів контролера.

Якщо ви були здивовані тим, як був зареєстрований слухач подій, на попередньому кроці, тепер у вас є відповідь — завдяки контейнеру. Коли клас реалізує певні інтерфейси, контейнер знає, що клас має бути зареєстровано певним чином.

На жаль, автоматизація передбачена не для усього, особливо це стосується сторонніх пакетів. Слухач сутності, про який ми щойно писали, є одним із таких прикладів; він не може бути налаштований автоматично, за допомогою контейнеру сервісів Symfony, оскільки він не реалізує жодного інтерфейсу й не розширює «добре відомий клас».

Нам потрібно частково оголосити слухача в контейнері. Зв’язування залежностей можна упустити, оскільки зв’язки все ще можуть бути вгадані контейнером, але нам потрібно вручну додати кілька тегів, щоб зареєструвати слухача в диспетчері подій Doctrine:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
--- a/config/services.yaml
+++ b/config/services.yaml
@@ -29,3 +29,7 @@ services:

     # add more service definitions when explicit configuration is needed
     # please note that last definitions always *replace* previous ones
+    App\EntityListener\ConferenceEntityListener:
+        tags:
+            - { name: 'doctrine.orm.entity_listener', event: 'prePersist', entity: 'App\Entity\Conference'}
+            - { name: 'doctrine.orm.entity_listener', event: 'preUpdate', entity: 'App\Entity\Conference'}

Примітка

Не плутайте слухачів Doctrine і слухачів Symfony. Навіть якщо вони виглядають дуже схожими, вони використовують різну інфраструктуру.

Використання «Slugs» у застосунку

Спробуйте додати більше конференцій у панелі керування і змінити місто чи рік наявної; синонім не буде оновлюватися, за винятком тих випадків, коли ви використовуєте спеціальне значення -.

Остання зміна полягає в оновленні контролерів і шаблонів для використання slug замість id конференції для маршрутів:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -28,7 +28,7 @@ class ConferenceController extends AbstractController
         ]));
     }

-    #[Route('/conference/{id}', name: 'conference')]
+    #[Route('/conference/{slug}', name: 'conference')]
     public function show(Request $request, Conference $conference, CommentRepository $commentRepository): Response
     {
         $offset = max(0, $request->query->getInt('offset', 0));
--- a/templates/base.html.twig
+++ b/templates/base.html.twig
@@ -12,7 +12,7 @@
             <h1><a href="{{ path('homepage') }}">Guestbook</a></h1>
             <ul>
             {% for conference in conferences %}
-                <li><a href="{{ path('conference', { id: conference.id }) }}">{{ conference }}</a></li>
+                <li><a href="{{ path('conference', { slug: conference.slug }) }}">{{ conference }}</a></li>
             {% endfor %}
             </ul>
             <hr />
--- a/templates/conference/index.html.twig
+++ b/templates/conference/index.html.twig
@@ -8,7 +8,7 @@
     {% for conference in conferences %}
         <h4>{{ conference }}</h4>
         <p>
-            <a href="{{ path('conference', { id: conference.id }) }}">View</a>
+            <a href="{{ path('conference', { slug: conference.slug }) }}">View</a>
         </p>
     {% endfor %}
 {% endblock %}
--- a/templates/conference/show.html.twig
+++ b/templates/conference/show.html.twig
@@ -22,10 +22,10 @@
         {% endfor %}

         {% if previous >= 0 %}
-            <a href="{{ path('conference', { id: conference.id, offset: previous }) }}">Previous</a>
+            <a href="{{ path('conference', { slug: conference.slug, offset: previous }) }}">Previous</a>
         {% endif %}
         {% if next < comments|length %}
-            <a href="{{ path('conference', { id: conference.id, offset: next }) }}">Next</a>
+            <a href="{{ path('conference', { slug: conference.slug, offset: next }) }}">Next</a>
         {% endif %}
     {% else %}
         <div>No comments have been posted yet for this conference.</div>

Доступ до сторінок конференції тепер має здійснюватися через її «slug»:


  • « Previous Крок 12: Прослуховування подій
  • Next » Крок 14: Отримання відгуків за допомогою форм

This work, including the code samples, is licensed under a Creative Commons BY-NC-SA 4.0 license.