Управління життєвим циклом об'єктів Doctrine
Створюючи новий коментар, було б чудово, якби дата createdAt
була встановлена автоматично, з використанням значень поточної дати й часу.
В Doctrine є різні способи маніпулювання об'єктами та їх властивостями протягом їх життєвого циклу (до створення запису в базі даних, після оновлення, ...).
Визначення зворотних викликів життєвого циклу
Якщо поведінка не потребує доступу до сервісу й застосовується тільки до одного типу сутності, визначте зворотний виклик в класі сутності:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
--- a/src/Entity/Comment.php
+++ b/src/Entity/Comment.php
@@ -6,6 +6,7 @@ use App\Repository\CommentRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: CommentRepository::class)]
+#[ORM\HasLifecycleCallbacks]
class Comment
{
#[ORM\Id]
@@ -90,6 +91,12 @@ class Comment
return $this;
}
+ #[ORM\PrePersist]
+ public function setCreatedAtValue()
+ {
+ $this->createdAt = new \DateTimeImmutable();
+ }
+
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
. Виправімо це, змінивши міграцію:
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 Version00000000000000 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
.
Note
Для реального проекту, використання CONCAT(LOWER(city), '-', year)
може виявитися недостатнім. У цьому випадку нам потрібно було б використовувати "справжній" Slugger.
Тепер міграція має виконатися нормально:
1
$ symfony console doctrine:migrations:migrate
Оскільки застосунок незабаром використовуватиме "slugs" для пошуку кожної конференції, налаштуймо сутність конференції, щоб мати впевненість, що значення у базі даних будуть унікальними:
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,8 +6,10 @@ 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
{
#[ORM\Id]
@@ -27,7 +29,7 @@ class Conference
#[ORM\OneToMany(mappedBy: 'conference', targetEntity: Comment::class, orphanRemoval: true)]
private $comments;
- #[ORM\Column(type: 'string', length: 255)]
+ #[ORM\Column(type: 'string', length: 255, unique: true)]
private $slug;
public function __construct()
Як ви могли здогадатися, нам потрібно виконати ще один танок з міграціями:
1
$ symfony console make:migration
1
$ symfony console doctrine:migrations:migrate
Генерування "Slugs"
Генерування "slugs", що добре читаються в URL-адресах (де все, крім символів ASCII, має бути закодовано), є складним завданням, особливо для мов, відмінних від англійської. Наприклад, як конвертувати é
у e
?
Замість того щоб винаходити колесо, використовуймо компонент Symfony String
, який полегшує маніпуляції з рядками та забезпечує slugger.
Додайте метод computeSlug()
до класу Conference
, який обчислюватиме значення на основі даних конференції:
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)]
#[UniqueEntity('slug')]
@@ -47,6 +48,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:
Зверніть увагу, що значення оновлюється при створенні нової конференції (prePersist()
) і кожного разу, коли вона оновлюється (preUpdate()
).
Налаштування сервісу в контейнері
Досі ми не говорили про один із головних компонентів Symfony — контейнер впровадження залежностей. Він керує сервісами: створює їх і впроваджує, коли це необхідно.
Сервіс — це "глобальний" об'єкт, який надає певні функції (як-от mailer, logger, slugger і т.д.) на відміну від об'єктів даних (як-от екземплярів сутностей Doctrine).
Ви рідко будете працювати з контейнером безпосередньо, оскільки процес впровадження сервісів проходить автоматично всякий раз, коли це необхідно: контейнер впроваджує об'єкти, коли ви вказуєте тип сервісів у якості аргументів контролера.
Якщо ви були здивовані тим, як був зареєстрований слухач подій, на попередньому кроці, тепер у вас є відповідь — завдяки контейнеру. Коли клас реалізує певні інтерфейси, контейнер знає, що клас має бути зареєстровано певним чином.
На жаль, автоматизація передбачена не для усього, особливо це стосується сторонніх пакетів. Слухач сутності, про який ми щойно писали, є одним із таких прикладів; він не може бути налаштований автоматично, за допомогою контейнеру сервісів Symfony, оскільки він не реалізує жодного інтерфейсу й не розширює "добре відомий клас".
Нам потрібно частково оголосити слухача в контейнері. Зв'язування залежностей можна упустити, оскільки зв'язки все ще можуть бути вгадані контейнером, але нам потрібно вручну додати кілька тегів, щоб зареєструвати слухача в диспетчері подій Doctrine:
1 2 3 4 5 6 7 8 9 10
--- a/config/services.yaml
+++ b/config/services.yaml
@@ -22,3 +22,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'}
Note
Не плутайте слухачів Doctrine і слухачів Symfony. Навіть якщо вони виглядають дуже схожими, вони використовують різну інфраструктуру.
Використання "Slugs" у застосунку
Спробуйте додати більше конференцій у панелі керування і змінити місто чи рік наявної; синонім не буде оновлюватися, за винятком тих випадків, коли ви використовуєте спеціальне значення -
.
Остання зміна полягає в оновленні контролерів і шаблонів для використання slug
замість id
конференції для маршрутів:
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
@@ -18,7 +18,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":
Йдемо далі
- Система подій Doctrine (зворотні виклики і слухачі життєвого циклу, слухачі сутностей і підписники життєвого циклу);
- Документація по компоненту String;
- Контейнер сервісів;
- Шпаргалка по сервісах Symfony.