گام 13: مدیریت چرخه‌حیات اشیاء Doctrine

5.0 version
Maintained

مدیریت چرخه‌حیات اشیاء Doctrine

زمانی که یک نظر جدید ایجاد می‌شود، اگر مقدار createdAt به صورت خودکار به زمان و تاریخ فعلی تنظیم گردد، عالی می‌شود.

Doctrine راه‌های متفاوتی برای دستکاری اشیاء و ویژگی‌هایشان در طول چرخه‌حیات اشیاء ارائه می‌دهد (قبل از اینکه ردیف در پایگاه‌داده ایجاد شود، بعد از به‌روزرسانی ردیف و غیره).

تعریف فراخوانی‌های چرخه‌حیات

زمانی که رفتار مورد نظر، به هیچ سرویسی احتیاج ندارد و باید تنها به یک نوع موجودیت (entity) اعمال شود، یک فراخوانی در کلاس موجودیت تعریف کنید:

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 استفاده می‌شود.

افزودن Slug به کنفرانس‌ها

URLهایِ کنفرانس‌ها معنادار نیستند: /conference/1. مهمتر از آن، به جزئیات پیاده‌سازی وابسته‌اند (کلید اصلی پایگاه‌داده لو می‌رود).

به عنوان جایگزین، URLهایی همچون /conference/paris-2020 چطور هستند؟ این بسیار بهتر به نظر می‌رسد. paris-2020 چیزی است که ما به آن slug کنفرانس می‌گوییم.

یک ویژگی جدید با نام slug به کنفرانس‌ها اضافه کنید (یک رشته‌ی ناتهی از ۲۵۵ حرف):

1
$ symfony console make:entity Conference

یک فایل جدید migration برای افزودن ستون جدید ایجاد کنید:

1
$ symfony console make:migration

و این migration جدید را اجرا کنید:

1
$ symfony console doctrine:migrations:migrate

Got an error? This is expected. Why? Because we asked for the slug to be not null but existing entries in the conference database will get a null value when the migration is ran. Let's fix that by tweaking the migration:

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 باشد، سپس slug را بر روی یک مقدار غیر null تنظیم می‌کنیم، و در نهایت ستون slug را تغییر می‌دهیم تا نتواند مقدار null بگیرد.

توجه

برای یک پروژه‌ی واقعی، استفاده از CONCAT(LOWER(city), '-', year) ممکن است کافی نباشد. در این صورت نیاز داریم که از یک Slugger «واقعی» استفاده کنیم.

حالا migration باید به درستی اجرا شود:

1
$ symfony console doctrine:migrations:migrate

چون اپلیکیشن به زودی از slugها برای پیداکردن هر کنفرانس استفاده می‌کند، بیایید موجودیت Conference را اصلاح کنیم تا اطمینان یابیم که مقادیر slug در پایگاه‌داده منحصربه‌فرد است:

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
@@ -5,9 +5,11 @@ namespace App\Entity;
 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
 {
@@ -39,7 +41,7 @@ class Conference
     private $comments;

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

احتمالاً حدس زده‌اید که نیاز داریم تا رقص migration را به اجرا بگذاریم:

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

تولید Slug‌ها

تولید یک slug که به خوبی در URL خوانده شود (هر چیزی به جز حروف ASCII باید انکود شود)، به خصوص برای زبان‌های غیر انگلیسی، وظیفه‌ی چالش‌برانگیزی است. برای نمونه چگونه é را به e تبدیل کنیم؟

به جای اختراع مجدد چرخ، بیایید از کامپوننت String سیمفونی استفاده کنیم که دستکاری رشته‌ها را آسان کرده و یک slugger فراهم می‌کند:

1
$ symfony composer req string

یک متد computeSlug() به کلاس Conference اضافه کنید که بر اساس داده‌های کنفرانس، slug را محاسبه می‌کند:

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
@@ -6,6 +6,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)
@@ -60,6 +61,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 الزامی است. بنابراین به یک مقدار ناخالی احتیاج داریم تا به اپلیکیشن بگوید که ما می‌خواهیم 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())، slug به‌روز می‌شود.

پیکربندی یک سرویس درون کانتینر

تا اینجا هنوز در مورد یک کامپوننت کلیدی سیمفونی که کانتینر تزریق وابستگی‌ها (dependency injection container) است، صحبت نکرده‌ایم. این کانتینر مسئول مدیریت سرویس‌ها (services) است: ایجاد سرویس‌ها و تزریق آن‌ها در هر زمان که مورد نیاز هستند.

سرویس (service) یک شیء «جهانی» است (مثل یک mailer، یک logger، یک slugger و ...) که بر خلاف اشیاء داده‌ای (data objects) (مثلاً نمونه‌های موجودیت Doctrine)، یک قابلیت را فراهم می‌آورد.

شما به ندرت به صورت مستقیم با یک کانتینر تعامل می‌کنید، زیرا کانتینر، اشیاء سرویس را هر زمان که به آن‌ها احتیج داشته باشید، به صورت خودکار ازریق می‌کند: مثلاً زمانی که آرگمان کنترلر را type-hint می‌کنید، کانتینر آن شیءِ مورد تعیین‌شده را تزریق می‌کند.

اگر از اینکه چگونه در گام قبلی شنونده ثبت شد متعجب هستید، حالا پاسخ آن را دارید: کانتینر. زمانی که یک کلاس، رابط‌های (interfaces) خاصی را پیاده (implement) می‌کند، کانتینر می‌فهمد که این کلاس نیاز دارد تا به نحوه معینی ثبت گردد.

متأسفانه، خودکارسازی برای تمام چیزها فراهم نشده است، به خصوص برای بسته‌های شخص ثالث (third-party). موجودیت شنونده که ما در آن مثال نوشتیم، نمی‌تواند به صورت خودکار توسط سرویس کانتینر سیمفونی مدیریت شود. زیرا که نه هیچ رابطی را پیاده می‌کند و نه هیچ «کلاس شناخته‌شده‌ای (well-know class)» را بسط می‌دهد.

نیاز داریم تا شنونده را در کانتینر به صورت جزئی اعلام کنیم. سیم‌کشی وابستگی‌ها الزامی نیست زیرا که هنوز کانتینر می‌تواند آن‌را حدس بزند، اما باید تعدادی تگ (tag) بیافزاییم تا شنونده در اعزام‌کننده‌ی رویدادِ Doctrine ثبت شود:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
--- a/config/services.yaml
+++ b/config/services.yaml
@@ -25,3 +25,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 را با شنونده‌های رویداد سیمفونی اشتباه نگیرید. هر چند که بسیار شبیه هم هستند، در بخش‌های درونی خود، از زیرساخت یکسان استفاده نمی‌کنند.

استفاده از Slugها در اپلیکیشن

کنفرانس‌های بیشتری را به پشت صحنه اضافه کنید و شهر و سال کنفرانس‌های فعلی را تغییر دهید؛ slug به‌روز نمی‌شود مگر اینکه از مقدار ویژه‌ی - استفاده کنید.

آخرین تغییر، به‌روزرسانی کنترلرها و قالب‌ها است تا برای راه‌ها (routes)، به جای id کنفرانس، از slug کنفرانس استفاده کنند:

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
@@ -31,7 +31,7 @@ class ConferenceController extends AbstractController
     }

     /**
-     * @Route("/conference/{id}", name="conference")
+     * @Route("/conference/{slug}", name="conference")
      */
     public function show(Request $request, Conference $conference, CommentRepository $commentRepository)
     {
--- a/templates/base.html.twig
+++ b/templates/base.html.twig
@@ -10,7 +10,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/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>
--- 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 %}

حالا دسترسی به صفحه‌ی کنفرانس، از طریق slug کنفرانس انجام می‌شود:


  • « Previous گام 12: گوش‌دادن به رویدادها
  • Next » گام 14: پذیرش بازخوردها از طریق فرم

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