Привет! Я, Андрей, Symfony разработчик - мы делаем сайты. Каждый день мы тратим много ресурсов на администрирование и базовые настройки проектов. В этой статье я продолжаю делиться опытом, как можно адаптировать фреймворк Symfony под свои нужды. Сегодня я расскажу как мы работаем с базой данных и Doctrine. Поехали
Обработка запросов и изменение сущностей
Мы используем 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;
}
}
В зависимости от использования, набор трейтов может быть разным. В нашем случае, часто используемые трейты это:
GeneratedIdTrait
TimestampCreateTrait
TimestampUpdateTrait
MetaTrait
- используется для описания базовых настроек страницы - title, keywords, description, robots behaviour, seo description.SlugTrait
- используется для описания связки Название страницы и ЧПУ.VersionTrait
- используется для реализации оптимистичного локирования Doctrine.
https://www.doctrine-project.org/projects/doctrine-orm/en/2.17/reference/transactions-and-concurrency.html#optimistic-locking
Entity / ChangeTrackingPolicy
Для оптимизации ресурсов при работе с сущностями и избежания случайных обновлений мы используем изменение настроек отслеживания ChangeTrackingPolicy
.
Правда, при использовании такого подхода для обновления сущности ресурсами 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, и т.д., так как, я считаю, это сильно зависит от требований и реализации.
Рекомендации:
Комментарии (18)
warhamster
09.01.2024 10:49+1При вынесении бизнес логики в сущности, значительно упрощается описание кода, рефакторинг и тестирование.
При вынесении бизнес-логики в entities получается какой-то Active record, который только ленивый не пинал за проблемы с читаемостью кода и тестируемостью. Что-то тут не так.
rusk
09.01.2024 10:49+2Поведение модели должно описываться в модели. Есть такое понятие как Anemic Domain Model, что является антипаттерном - https://martinfowler.com/bliki/AnemicDomainModel.html
michael_v89
09.01.2024 10:49+1Нет, не должно. Вернее может и должно, но бизнес-логика это не поведение модели. Бизнес-логика подразумевает поведение многих моделей, часть из которых представляют сторонние системы, например клиент для отправки email, и поэтому в одной модели находиться не должна. Другой пример это перевод денег с аккаунта на аккаунт.
Практически любая бизнес-логика требует какие-то зависимости, которые делают нужные действия. В модели их нет, поэтому и помещать эти действия в нее неправильно.
Фаулер неправ, так бывает. Возможно он представлял что-то свое.
rusk
09.01.2024 10:49+1Я не говорю, что вся бизнес логика должна быть запихана в модель. Все инфраструктурные моменты и связь с другими доменами должна быть убрана из модели. Другое дело, что инварианты, какие-то вычисления, связанные непосредственно с моделью, лучше делать внутри модели.
И тут просто вопрос в терминах - что такое бизнес логика? Я считаю, что поведение модели тоже входит в это понятие.michael_v89
09.01.2024 10:49Вычисления, связанные непосредственно с моделью, это только вычисления с ее полями. Например, можно сделать вычисляемое свойство fullName по firstName+lastName.
Поведение модели это лишь часть бизнес-логики. Бизнес-логика это реализация того, что обсуждается в бизнес-требованиях. "Отправить email после создания заказа" это бизнес-требование, но отправки email не должно быть в сущности, там должен быть только код, работающий с ее полями. Значит отправка email должна быть где-то еще. Это и есть сервис.
Когда отправку email пытаются запихнуть в сущность, получаются запутанные и сложные в поддержке решения - публичное поле sentEmails в сущности и какой-то универсальный механизм его обработки после коммита транзакции, или аргумент EmailSender в методе сущности, который отправляет до коммита транзакции, или транзакция внутри сущности, или отправка события с обработчиком неизвестно где. В сервисе это получается просто и понятно.
DExploN
09.01.2024 10:49+2"Отправить email после создания заказа" - мало похоже на бизнес логику, хоть и является требованием. Да это часть процесса, но это скорее инфраструктура. Зачастую ошибка тех, кто против этого подхода, что они думают, что ВСЯ кодовая база отправляется в сущности: отправка емейлов, очереди, регистрация, авторизация. Это не так. В сущности отправляется логика, которая может нарушить целостность системы и истинные инварианты, чтобы этого сделать было нельзя.
Является ли критической проблемой, что емейл будет не отослан? Наверное нет.
А является ли критической проблемой, что баланс юзера установился неправильно, из-за того, что человеком малопосвященным всем ньюансам системы, забыл перед этим вызвать какой то калькулятор или чекер, и просто засетил рандомный баланс? Видимо да. И решением этого будет инкапсуляция логики изменения баланса в сущность юзера $user->addMoney($money, $moneyChecker, $banking). И никто уже не забудет в флоу добавления баланса, проверить там что то и какие то операции вызвать.
Продублирую (давал в другом ответе уже) старый доклад на эту тему https://ocramius.github.io/doctrine-best-practices/#/32michael_v89
09.01.2024 10:49+2мало похоже на бизнес логику
Нет, в том и дело, это именно бизнес-логика, это обсуждается на уровне бизнеса. "Если заказ было создан успешно, то надо отправить письмо". "Если сумма заказа больше N, то уведомить менеджера". А как вы значения в поля модели записываете, как раз не обсуждается, бизнесу это неинтересно.
они думают, что ВСЯ кодовая база отправляется в сущности
Вот я как раз встречал сторонников логики в сущности, которые так думают.
В сущности отправляется логика, которая может нарушить целостность системы и истинные инварианты
А вот нет таких инвариантов. В одном сценарии поле required, в другом нет. Раньше было not required, теперь стало required. Это зависит исключительно от желания бизнеса.
Является ли критической проблемой, что емейл будет не отослан? Наверное нет.
Понятие бизнес-логики не связано с критичностью.
Вообще, ничего не является критической проблемой, все можно сделать вручную, сделать заказ по телефону, поправить через админку.Наверное нет.
Видимо да.Ага, хорошие критерии определения того, что помещать в сущность.
И решением этого будет инкапсуляция логики изменения баланса в сущность юзера
Почему не в сервис изменения баланса? Как вообще юзер может сам менять свой баланс? Это совершенно неправильная модель.
И никто уже не забудет в флоу добавления баланса, проверить там что то и какие то операции вызвать
Этим свойством обладает любое выделение в метод, он необязательно должен быть в сущности.
Ну как это не забудет, в сущности 1000 методов (потому что есть 1000 сценариев, а менять поля вне сущности мы не хотим), он пропустил что уже есть такой метод, и создал новый, который ставит как ему надо без всяких проверок.
DExploN
09.01.2024 10:49+2А как вы значения в поля модели записываете, как раз не обсуждается, бизнесу это неинтересно.
Так мы и не обсуждаем как записывать поле, мы обсуждаем инкпасулированный процесс, который влияет на 1 или несколько полей и состояние данных после этого процесса.
А вот нет таких инвариантов
У вас нет, у других есть. Например не может быть баланс меньше кредитного лимита или 0, ну не может и всё. А если может, значит нет такого инварианта действительно
Вообще, ничего не является критической проблемой, все можно сделать вручную, сделать заказ по телефону, поправить через админку
Какие то ошибки вам будут стоить ничего, а какие то уроном по бизнес репутации и финансовыми потерями. Все же есть разница, когда пользователь заказал товаров на не свой миллион и получил, и когда он не получил емейл
Ага, хорошие критерии определения того, что помещать в сущность.
К сожалению да, все зависит от той или иной ситуации и конкретного бизнес процесса. Нет серебрянной пули. Есть проблемы и подходы которые их решают или не решают
Почему не в сервис изменения баланса? Как вообще юзер может сам менять свой баланс? Это совершенно неправильная модель.
Как вы можете видеть в примере, я как раз передал пару сервисов, которые помогут юзера. Да, если вы работаете этим сервисом с БД напрямую - это другой вопрос, вообще без сущности и это мы не обсуждаем. Но если внутри этого сервиса, вы потом берете юзера и сетите ему какое то число денег - это опасная ситуация, что кто то просто без этого сервиса засетит баланс, просто не обладая должной компетенцией по проекту.Ну как это не забудет, в сущности 1000 методов (потому что есть 1000 сценариев, а менять поля вне сущности мы не хотим),
Если у вас 1000 сценариев на изменение 1 сущности, скорее всего что то идет не так. Но в целом такая проблема в подходе есть, когда сущность слишком разбухает, но это как раз из-за неверной проектировки больших сущностей. Опять же, был и такой опыт, я все равно вижу больше профита в такой сущности, потому что заходя даже через год на поддержке проекта в какой то код, я не могу сломать логику, потому что я что то забыл, и не вызывал какой то сервис или ивент, перед тем как что то засетить напрямую. Контракты взаимодействия с сущностью мне просто этого не дают.
Опять же DDD и агрегаты. Они в целом не могут существовать на анемиках. Если только не двойной маппинг, где есть отдельно сущности ОРМ и отдельно бизнес сущности.
Получается Фаулер не прав, DDD и Эванс не прав, Окрамиус не прав, Инкапсуляция не права, tell don't ask не право. Не верю )
Ну а вообще это вечный спор. Делайте как вам удобно, линки и причины использования того, а не иного я дал. Дальше каждый сам себе буратино :)michael_v89
09.01.2024 10:49мы обсуждаем инкпасулированный процесс, который влияет на 1 или несколько полей и состояние данных после этого процесса.
Нет, мы обсуждаем, где размещать бизнес-логику. Отправка email это часть бизнес-логики.
Например не может быть баланс меньше кредитного лимита или 0, ну не может и всё
Ага, это хороший пример. А потом приходит бизнес и говорит "А сделайте нам такую штуку, которая называется овердрафт, но не меньше вот этого значения в конфиге в БД", и начинаются глобальные рефакторинги.
а какие то уроном по бизнес репутации и финансовыми потерями
Да, если я не получил свой email с подтверждением заказа, это урон по бизнес репутации компании, и в следующий раз я пойду в другую компанию.
К сожалению да, все зависит от той или иной ситуации и конкретного бизнес процесса.
Нет, правило "Делайте сервис для бизнес-логики" работает нормально для всех бизнес-процессов.
Как вы можете видеть в примере, я как раз передал пару сервисов, которые помогут юзера
Ну и что, что передали? А он захотел и не вызвал. Пользователь не может менять сам свой баланс.
Да, если вы работаете этим сервисом с БД напрямую
Я не предлагал работать с БД напрямую.
Если у вас 1000 сценариев на изменение 1 сущности, скорее всего что то идет не так.
Это требования бизнеса, вы не можете их менять. У заказа например может быть несколько десятков значений поля "статус", и соответственно свой сценарий для каждого, и это только одно поле.
Но в целом такая проблема в подходе есть, когда сущность слишком разбухает, но это как раз из-за неверной проектировки больших сущностей.
Нет, это из-за подхода "Все изменяющие методы должны быть в сущности". Если их много, значит и сущность большая.
что кто то просто без этого сервиса засетит баланс
Вы говорите о чем-то другом. Я говорю не про сервис moneyChecker, а про сервис AccountService, в котором находится метод addMoney. Он точно так же всегда вызывает moneyChecker перед тем, как засетить баланс.
я не могу сломать логику, потому что я что то забыл, и не вызывал какой то сервис или ивент, перед тем как что то засетить напрямую
И с сервисом всё точно так же. Вы вызываете метод addMoney, он делает всю работу. Вы просто всегда работаете с сервисом, а не с сущностью. С сущностью вы работаете только если делаете новый сервис.
Когда вы делаете новый метод в сущности для новой функциональности, вы точно так же можете сломать логику, если забудете сделать вызов moneyChecker в новом коде.
DExploN
09.01.2024 10:49+1Active Record про маппинг на таблицу и работу с таблицей через модель и это совсем про другое, нежели бизнес логика. При занесении логики в сущность как раз улучшается инкапсулция и тестируемость. Никто не сможет занести в сущность невалдиные для бизнес процессов данные. Вот старый доклад на эту тему https://ocramius.github.io/doctrine-best-practices/#/32
Перенеся логику в сущность, вы без рефликсии и прямой работой с бд, не сможете занести в нее невалидные данные, забыв что то там вызвать ранее, потому что это было написано год зад и забыто.
$data = $actionHandler->action($dto)
$user->setData($data); // Взяли да забыли вызвать хэндлер и просто составили невалидный $data
vs
$user->action($actionDto)
Ну или если есть зависимости или сложная для одной сущности логика, то можно какой то сервис и передать.
$user->action($actionDto, $actionServiceHelper)
На нейминг не смотреть)
Saty
09.01.2024 10:49+3Ни разу не видел удачных реализаций ричмоделей. И первые примеры вызывают сомнения:
Раскидывать свойства энтити по трейтам, кмк, усложняет проект, а не упрощает, нет единого представления сущности. + Есть красивые пакеты для created_at и updated_at.
С транзакциями пример совсем плохой - баланс хранить как итог вместо суммы транзакций плохая практика. Да, можно кэшировать промежуточный итог, но тут явно имелось в виду не это - валидация нужна при фиксации транзакции, а не при кэшировании текущего баланса.
DExploN
09.01.2024 10:49+2С транзакциями пример совсем плохой - баланс хранить как итог вместо суммы транзакций плохая практика. Да, можно кэшировать промежуточный итог
По сути там так и есть - есть коллекция транзакций, и конечный итог суммы - баланс, чтобы каждый раз не поднимать всю коллекцию для перерасчета. А в случае какого то сбоя, можно баланс приравнять к сумме транзакций или банально перепроверить вручную
NiPh
>При вынесении бизнес логики в сущности, значительно упрощается описание кода, рефакторинг и тестирование.
Вы не могли бы привести примеры из вашей практики к этому тезису? Отдельно тестировать бизнес-логику, не тестируя сущности зачастую гораздо проще. Хотя-бы просто потому, что тестируется только изолированная часть логики.
DmitriyGordinskiy
Для тестирования бизнес-логики размещенной в сущности необходимо:
Экземпляра сущности.
Для тестирования бизнес-логики размещенной в сервисе необходимо:
Экземпляр сервиса.
Зависимости сервиса, либо их моки.
Экземпляра сущности или её мок.
И в чем тут простота?
NiPh
Простота с моей точки зрения состоит в том, что мы тестируем легковесный сервис, которому к тому же проще подготовить данные для теста. Кроме того, проще и быстрее чистить состояние перед и после теста (а в большинстве случаев это делать и вовсе не придется).
В первом же случае мы фактически пишем не юнит-тест, а интеграционный тест на сущность, который к тому же гораздо более требователен к ресурсам при запуске.
DmitriyGordinskiy
Видимо у нас разные понятия о сущностях или тестах.
В моем понимании сущность это Data Mapper + Rich Domain Model в которую помещается вся бизнес-логика для реализации которой не труебуется взаимодействие с другими сервисами. Таким образом ни для её создания ни для вызова её методов никакие другие сервисы не нужны, и тестировать её можно простым Unit-тестом.
michael_v89
Моки зависимостей нужны в обоих случаях, потому что они нужны для логики, а логика одна и та же. Либо в сущности меньше логики, чем в сервисе, тогда сравнение некорректное.