Как стать автором
Обновить

Использование Symfony / PHP (II)

Уровень сложностиСредний
Время на прочтение7 мин
Количество просмотров6.3K

Привет! Я, Андрей, Symfony разработчик - мы делаем сайты. Каждый день мы тратим много ресурсов на администрирование и базовые настройки проектов. В этой статье я продолжаю делиться опытом, как можно адаптировать фреймворк Symfony под свои нужды. Сегодня я расскажу как мы работаем с базой данных и Doctrine. Поехали

Часть I

Обработка запросов и изменение сущностей

Мы используем Doctrine ORM для работы с базой данных, правда, как и всё, мы немного изменили подход работы под свои нужды. Например, мы почти не используем setter/getter у сущностей. Для получения данных применяем наши View, а изменение данных мы реализуем в самих сущностях (Entity), которые теперь содержат бизнес логику.

Используя пример с настройками пользователя из предыдущей статьи, обновление в классе User может выглядеть следующим образом:

<?php
/** . */

class User implements UserInterface
{
   private string|null $firstName;
   private string|null $lastName;
   private \DateTimeImmutable $updatedDatetime;
  
   public function updateSettings(UserSettingsDto $data): void
   {
       $this->firstName = $data->firstName;
       $this->lastName = $data->lastName;
       $this->updatedDatetime = new \DateTimeImmutable();
   }
}

Этот вариант простой, ниже более сложный пример по списанию средств с баланса пользователя:

<?php
/** . */

class User implements UserInterface
{
    public function withdraw(int $amount, TransactionCategory $category, array $data = []): UserTransaction
    {
       if ($amount < 0) {
           throw new \TypeError(\sprintf('Passed amount should be greater than zero, %d passed.', $amount));
       }
       
       if ($this->balance - $amount < 0) {
            throw BillingException::createNotEnoughBalanceException($this, $amount, $category);
       }
       
       $this->transactions->add($transaction = new UserTransaction($this, -$amount, $category, $data));
       $this->balance -= $amount;
       
       return $transaction;
    }
}

При вынесении бизнес логики в сущности, значительно упрощается описание кода, рефакторинг и тестирование.

Entity / переиспользуемые трейты

Сущности у нас не сильно отличаются от описания в документации, за исключением, что часть используемых полей мы вынесли в трейты. Это несильно, но экономит время при разработке.

<?php
/** . */

use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\IdGenerator\UuidGenerator;
use Symfony\Component\Uid\Uuid;

trait GeneratedIdTrait
{
    #[ORM\Id]
    #[ORM\GeneratedValue(strategy: 'CUSTOM')]
    #[ORM\CustomIdGenerator(class: UuidGenerator::class)]
    #[ORM\Column(type: UuidType::NAME, unique: true, nullable: false)]
    protected Uuid|null $id;

    public function getId(): Uuid
    {
        return $this->id;
    }
}

Как видно из этого трейта, мы не используем AUTO INCREMENT на уровне базы данных, вместо этого мы используем UUID.

Ниже пример ещё одного трейта, который мы часто используем.

<?php
/** . */

use Doctrine\ORM\Mapping as ORM;

trait TimestampCreateTrait
{
    #[ORM\Column(type: 'datetime_immutable', nullable: false)]
    protected \DateTimeImmutable $createdDatetime;

    public function getCreatedDatetime(): \DateTimeImmutable
    {
        return $this->createdDatetime;
    }
}

В зависимости от использования, набор трейтов может быть разным. В нашем случае, часто используемые трейты это:

Entity / ChangeTrackingPolicy

Для оптимизации ресурсов при работе с сущностями и избежания случайных обновлений мы используем изменение настроек отслеживания ChangeTrackingPolicy.

https://www.doctrine-project.org/projects/doctrine-orm/en/2.16/reference/change-tracking-policies.html

Правда, при использовании такого подхода для обновления сущности ресурсами Doctrine, становится обязательным помечать её на обновление Doctrine\ORM\EntityManager::persist()

<?php
/** . */

#[ORM\ChangeTrackingPolicy(value: ‘DEFERRED_EXPLICIT')]
class User {
  /** . */
}
<?php
/** In any service */

   private function update(User $user, UserSettingsDto $data): void
   {
      $user->updateSettings($data);
      $this->em->persist($user); # it’s obligated
      $this->em->flush();
   }

Entity / readonly

Также, мы часто используем readonly атрибуты при описании сущностей, которые не обновляются, например статистические данные или данные, которые можно только добавить или удалить. В этом случае Doctrine не следит за такими обновлениями совсем, что позволяет нам сэкономить немного ресурсов.

<?php
/** . */

#[ORM\Entity(repositoryClass: PushMessageSendRepository::class, readOnly: true)]
#[ORM\ChangeTrackingPolicy(value: 'DEFERRED_EXPLICIT')]
class PushMessageSend {
  /** . */
}

Entity / ServiceEntityRepository

Все связанные с базой запросы мы переносим в репозиторий, отвечающий за класс. Мы почти не используем базовые shortcutfindBy, это упрощает тестирование и рефакторинг.

В качестве базового репозитория мы используем свой ServiceEntityRepository на основе \Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository. Мы дополнили его отдельными методами для поддержки постраничной навигации. Правда, в этом случае, нужно переопределить настройку отвечающую за репозиторий: doctrine.orm.default_repository_class: Your\Class\Name

https://symfony.com/doc/current/doctrine.html#querying-for-objects-the-repository

Doctrine / базовые кэши

Для оптимизации Doctrine мы используем различные кэши.
Метаданные классов (metadata cache) и результат парсинга запросов от QueryBuilder (query cache), как и рекомендуется, мы храним в APCu для быстрого доступа.

Кэширование результатов запросов (result cache) мы не используем по причинам описанным в следующей секции, поэтому этого блока в нашей конфигурации нет.

Так как наши продукты редко обновляются после релиза, все таймеры имеют долгий срок жизни в зависимости от проекта. Также, при каждом релизе мы обновляем все кэши. Ниже есть пример, который мы используем по-умолчанию:

framework:
    cache:
        pools:
            doctrine.system_cache_pool:
                adapter: cache.adapter.apcu
                default_lifetime: 86400
doctrine:
    orm:
        metadata_cache_driver:
            type: pool
            pool: doctrine.system_cache_pool
        query_cache_driver:
            type: pool
            pool: doctrine.system_cache_pool

Doctrine / кэш второго уровня (SLC)

Кроме базовых кэшей, мы используем кэш второго уровня — Second Level Cache, который значительно снижает количество запросов к базе данных. То есть, сначала выполняется поиск данных в кэше, и при их отсутствии, будет выполнен запрос к базе данных, а результат помещён в кэш.

https://www.doctrine-project.org/projects/doctrine-orm/en/2.16/reference/second-level-cache.html

Такая имплементация позволяет снизить время как на сам запрос, так и на гидратацию данных (заполнение объектов данными из базы по результату выполнения запроса).

SLC работает как для самих сущностей, так и для коллекций. Это накладывает своего рода ограничения и может вызывать различного рода артефакты, поэтому нужно внимательно следить за успешным обновлением коллекции. 

Кэш второго уровня достаточно подключить в основной конфигурации. Сразу же можно переопределять область хранения кэша, которая используется по умолчанию. Мы, например, используем ту же область, что мы используем для основных данных приложения cache.app, в нашем случае это Redis.

Функциональность такого кэша до сих пор помечена как экспериментальная, поэтому стоит это учитывать. Мы не полагаемся на SLC при обработке критических данных, но за годы использования так и не выявили его недостатков.

Ниже, я оставил пример настройки по сроку хранения данных:

doctrine:
    orm:
        second_level_cache:
            enabled: true # enables SLC
            region_cache_driver: # overrides the default cache region
                type: pool
                pool: doctrine.result_cache_pool
            region_lock_lifetime: 10 # in seconds
            region_lifetime: 7200 # in seconds
            log_enabled: true # for prod log_enabled=false

В примере мы переопределили область хранения данных в кэше. Области хранения кэша могут отличатся в зависимости от требований. Их можно настраивать также и в конфигурации:

doctrine:
    orm:
        second_level_cache:
            regions:
                user_region:
                    lifetime: 3600
                    cache_driver:
                        type: service
                        id: doctrine.result_cache_provider
                messages_region:
                    lifetime: 600
                    cache_driver:
                        type: service
                        id: doctrine.result_cache_provider

Режимы SLC

У SLC есть несколько режимов работы:

  • READ_ONLY
    Используется для необновляемых сущностей и коллекций. Это те же сущности, которые мы помечаем как readonly.

  • NONSTRICTED_READ_WRITE 
    Режим для сущностей, которые обновляются не часто и не имеют конкурентной записи. К примеру, такой режим можно использовать для настроек пользователя.

  • READ_WRITE 
    Режим для часто обновляемых сущностей с конкурентной записью. Например, read/write можно использовать для обновления игровых ставок пользователя.

Первые два способа просты в реализации. Помимо базовых настроек следует добавить нужный атрибут у сущности. Пример:

<?php
/** . */

#[ORM\Entity(repositoryClass: PushMessageSendRepository::class, readOnly: true)]
#[ORM\Cache(usage: 'READ_ONLY', region: 'messages_region')]
#[ORM\ChangeTrackingPolicy(value: 'DEFERRED_EXPLICIT')]
class PushMessageSend {
    /** . */
}

В таком режиме работы, настройки для коллекций выглядят следующим образом:

<?php
/** . */

class User {
    /** . */
    #[ORM\OneToMany(mappedBy: "user", targetEntity: Video::class)]
    #[ORM\Cache(usage: 'NONSTRICT_READ_WRITE')]
    #[ORM\OrderBy(['createdDatetime' => 'ASC'])]
    private iterable $videos = [];
}

Для обработки конкурентной записи READ_WRITE Doctrine по умолчанию использует свой адаптер доступа и обновления данных в кэше Doctrine\ORM\Cache\Region\FileLockRegion. Для использования этого режима, нужно сконфигурировать регион хранения данных:

doctrine:
    orm:
        second_level_cache:
            regions:
                fast:
                    cache_driver:
                        type: service
                        id: doctrine.result_cache_provider
                    lock_path: ‘%kernel.cache_dir%/doctrine/orm/slc/fast.filelock' // path to store files for locking
                    lock_lifetime: 10
                    type: filelock

Так как мы добавили свой регион хранения кэша, то его нужно указать в атрибуте:

<?php
/** . */

class User {
    /** . */
    #[ORM\OneToMany(mappedBy: "user", targetEntity: Video::class)]
    #[ORM\Cache(usage: 'READ_WRITE', region: 'fast')]
    #[ORM\OrderBy(['createdDatetime' => 'ASC'])]
    private iterable $videos = [];
}

С помощью SLC ощутимо возрастает быстродействие, особенно, когда при запросе поднимается большое количество сущностей с зависимостями, например списочные данные. Но, как я упоминал, эта функциональность всё ещё помечена как экспериментальная и требует внимания при разработке. В нашем случае, мы изначально ведём разработку с включенным SLC в dev среде.


Doctrine - это мощный инструмент, и быстрый при правильном использовании. В статье многое осталось неописанным - транзакционность, локирование, удаление, описание DQL, и т.д., так как, я считаю, это сильно зависит от требований и реализации.


Рекомендации:

Часть I

Теги:
Хабы:
Всего голосов 9: ↑8 и ↓1+11
Комментарии20

Публикации

Истории

Работа

PHP программист
102 вакансии

Ближайшие события

15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань