Заглянемо під капот Doctrine 2

Doctrine 2

Мабуть, я не збрешу, якщо скажу, що в екосистемі Symfony найбільш часто використовуваною ORM є Doctrine. Тому вивчення цієї бібліотеки вкрай важливе для Symfony-розробника. У цій статті пропоную зазирнути "під капот" цього Мустанга у світі ORM-ок і з'ясувати, на базі яких абстракцій і патернів побудована ця бібліотека.

Коли я вперше почув про Doctrine, це була ще 1-ша версія. Доповідач, якого я слухав, скаржився, яка це "бяка" і скільки головного болю вона завдавала команді на проєкті. Але відтоді спливло багато води, і на момент написання статті активно ведеться розробка вже 3-ої версії бібліотеки, яка істотно відрізняється від того, що вона являла собою спочатку. Самі посудіть, над бібліотекою працюють майже 600 контриб'юторів, які зафіксували вже майже 12 000 комітів... однозначно, Doctrine варта вашої уваги.

Про ORM загалом

Перед тим як заглибитися в Doctrine 2, я б хотів поговорити, як не дивно, про проблему як про причину. Річ у тім, що програмні продукти не з'являються просто так, це завжди наслідок певної проблеми, з якою ми зіткнулися і яка істотно сповільнює розробку проєкту (а в низці випадків навіть здорожує завдяки ускладненню коду і подальшій його підтримці).

Класичний приклад веб-додатка в плані компонентів — це UI, через який користувач взаємодіє з додатком, backend, що містить бізнес-логіку, і persistence-рівень, в якому зберігаються дані і в якості якого виступає реляційна база даних (БД).

З огляду на те, що знайомі нам реляційна БД і класичні класи в ООП передбачають різні підходи до зберігання даних, а також механізми управління цими даними, виникає завдання синхронізації змін між цими рівнями.

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

ORM (Object Relational Mapper) — це якраз той інструмент, який бере вирішення всіх цих завдань на себе. Крім цього, він надає низку додаткових корисних можливостей, як-от Events, QueryBuilder, DQL для швидкої побудови запитів тощо.

ORM

Архітектура Doctrine 2

В основі Doctrine лежать патерни й абстракції, розуміння яких допоможе краще розібратися в принципах роботи цієї ORM. Почнемо, мабуть, з найголовнішого — Data mapper, тому що Doctrine у загальному вигляді і є реалізація цього патерну.

Data Mapper pattern

Як я писав раніше, з тієї причини, що об'єктна і реляційна схеми неідентичні, виникає проблема обміну даними між ними. У розробника без ORM просто немає іншого виходу, як писати однотипні, повторювані SQL-запити для всіх об'єктів застосунку. Це вкрай незручно і складно в підтримці, а отже, дорого.

Data mapper розв'язує це завдання завдяки ізоляції об'єктів і БД відносно один одного і як основну, концептуальну абстракцію використовує Entity.

За фактом Entity — це звичайний PHP клас, де властивості зіставлені з полями таблиці з бази даних, для якої і створювалася Entity (далі "сутність"). Усю ж роботу з маппінгу полів, обчислення змін тощо бере на себе mapper.

Доктрина Data mapper pattern

EntityManager

EntityManager (EM) — це класична реалізація патерну Facade. Через його легкий API ми працюємо з низкою підсистем: Unit Of Work, Query language і Repository API. Управління сутностями виконується виключно через API EntityManager-а.

Entity states

Сутність завжди перебуває в одному з 4-х станів: New, Managed, Removed або Detached.

Коли ви тільки-но створили об'єкт сутності та виконали persist($entity), початковий стан сутності буде New, який після відпрацювання логіки persist (диспатч події, генерація ідентифікатора) EM перемикає в Managed. Це означає, що тепер усі зміни в об'єкті цієї сутності будуть відстежуватися EM і після виклику flush зафіксовані в БД. Також стан Managed отримують сутності, які були отримані з БД.

Стан Detach використовується не так часто, але бувають ситуації, коли є необхідність звернутися до однієї й тієї самої сутності в контексті різних EM - тоді він і може стати в пригоді. Сутність у стані Detach не відстежується EM. Підкреслю, це не означає, що сутність видаляється, просто всі зміни в об'єкті після виклику detach($entity) не будуть ніяк відображені в базі даних, навіть після виклику flush. Також важливо пам'ятати, що Doctrine дозволяє каскадно застосувати Detach для всіх пов'язаних сутностей (асоціацій).

І останній стан, Removed. Очевидно, що перемикання сутності в цей стан має сенс тільки в разі, якщо сутність перебуває вже в Managed. Важливо розуміти, що після перемикання сутності в Removed стан, EM ще відстежує зміни, але після виконання flush сутність буде видалена з БД. За аналогією з Detach, також є можливість керувати каскадним видаленням асоціацій.

Identity Map Pattern

Уявіть ситуацію, коли в застосунку вам з якоїсь причини необхідно двічі запитати один і той самий об'єкт із БД. Чи варто в цьому випадку повторно звертатися до БД? Очевидно, що це недоцільно, принаймні в більшості ситуацій.

Логічнішим виглядає використання підходу, який дозволяв би задіяти результати першої вибірки. Паттерн Identity Map (карта відповідностей / присутності) якраз вирішує це завдання.

Давайте розглянемо приклад з офіційної документації:

public function testIdentityMap()
{
    $objectA = $this->entityManager->find('EntityName', 1);
    $objectB = $this->entityManager->find('EntityName', 1);
 
    $this->assertSame($objectA, $objectB)
}

Другий виклик find не призведе до повторного звернення до БД. Навпаки, ORM-ка в карті відповідностей виявить об'єкт з ID = 1 і поверне вже його.

Інша справа, коли у вибірці задіяні критерії:

public function testIdentityMapRepositoryFindBy()
{
    $repository = $this->entityManager->getRepository('Person');
    $objectA = $repository->findOneBy(array('name' => 'Benjamin'));
    $objectB = $repository->findOneBy(array('name' => 'Benjamin'));
 
    $this->assertSame($objectA, $objectB);
}

У цьому випадку буде виконано два запити до бази даних. Це пов'язано з тим, що всередині себе Doctrine зберігає карту відповідностей, згруповану тільки за ID сутності.

Варто згадати, що все ж таки певна оптимізація і в цьому випадку присутня: після виконання другого запиту до БД новий об'єкт сутності створено не буде, буде задіяно об'єкт, який уже збережено в карті.

Lazy Loading Pattern

Коли ми хочемо отримати інформацію про сутність, у якій є асоціації, Doctrine дає змогу керувати тим, чи потрібно конструювати об'єкти також для них.

Коли це корисно? Уявіть: ви працюєте з системою типу "форум", де користувачі можуть залишати пости в топіках. У вас виникла потреба запросити сутність топіка з БД, і оскільки коментарі є пов'язаними сутностями, можна припустити, що Doctrine запитає дані щодо них, зокрема. Очевидно, що це зайвий, невиправданий overhead на передачу даних, зайві запити, крім цього, застосунку знадобиться більше пам'яті для зберігання об'єктів коментарів.

Цю проблему Doctrine вирішує за допомогою механізму ледачого завантаження (Lazy Loading). За замовчуванням, до речі, цей механізм активовано для всіх асоціацій, загалом же доступно три варіанти:

  • LAZY (за замовчуванням) — у пам'ять буде завантажено тільки керований об'єкт, а асоціації будуть довантажені тільки при першому зверненні до них;
  • EAGER — у пам'ять буде завантажено як керований об'єкт, так і всі асоціації;
  • EXTRA LAZY — у деяких випадках, підвантаження всіх пов'язаних об'єктів недоцільне, навіть якщо це відбувається тільки при першому зверненні. Скажімо, ви хочете отримати тільки кількість пов'язаних об'єктів Collection#count(). Для оптимального вирішення цього завдання можна задіяти EXTRA_LAZY опцію. У цьому разі під час виконання будь-якого з наведених нижче методів Doctrine не буде довантажувати всю колекцію в пам'ять:
Collection#contains($entity)
Collection#containsKey($key) (доступно начиная с Doctrine 2.5)
Collection#count()
Collection#get($key) (доступно начиная с Doctrine 2.4)
Collection#slice($offset, $length = null)

Proxy pattern

Для реалізації lazy-механізму, а також вирішення partial object problematic, Doctrine на нижчих рівнях оперує, насправді, проксі-об'єктами.

Proxy-об'єкт — це об'єкт, який використовується за місцем або замість реального об'єкта. Коли я кажу "за місцем", мається на увазі, що ми можемо запросити в EM не оригінальний об'єкт сутності, а його проксі-варіант, і використовувати аналогічно оригіналу. Які переваги це нам дає?

Розглянемо приклад із документації. Припустимо, ми знаємо ідентифікатор $item і нам хотілося б додати його в колекцію, бажано без завантаження цього елемента з БД, скажімо, як якихось оптимізаційних заходів. Це можна зробити досить просто:

$item = $em->getReference('MyProject\Model\Item', $itemId);
$cart->addItem($item);

Якщо ж спробуємо викликати будь-який метод з $item, його стан буде повністю ініціалізовано з БД. У цьому прикладі $item є екземпляром proxy-класу, який був згенерований для Item сутності. Причому зверніть увагу: нам не потрібно писати додаткову логіку для проксі або реального об'єкта, Doctrine це робить прозоро для нас.

Partial object problematic

Partial object — це об'єкт, стан якого повністю не ініціалізовано.

З коробки подібна ситуація неможлива, оскільки Doctrine ініціалізує сутність повністю (за винятком асоціацій). Але у вас цілком може виникнути бажання запитувати як оптимізацію не всі поля сутності. Це можливо для DQL за допомогою ключового слова partial:

<?php $q = $em->createQuery("select partial u.{id,name} from MyApp\Domain\User u");

Або безпосередньо через EM:

$reference = $em->getPartialReference('MyApp\Domain\User', 1);

Незважаючи на те що в документації даються рекомендації не використовувати partial objects, я не бачу в цьому проблеми у випадках, коли потрібно просто віддати конкретні поля клієнту через REST API, і ми точно знаємо, що об'єкт більше ніяк не використовуватиметься.

Transactional write-behind

Уявіть, якби щоразу зміни будь-якої властивості сутності призводили б до надсилання запиту в БД. Очевидно, що в більшості випадків це небажана поведінка.

Transactional write-behind підхід дає змогу розв'язати цю проблему за рахунок затримки між тим, коли змінилися дані і коли вони реально оновлюються в БД. Ми можемо вносити безліч змін, щось додавати, видаляти або оновлювати, але тільки після виклику flush усі зміни підуть у БД.

Причому Doctrine турбується про те, щоб це відбувалося оптимальним способом. Наприклад, досить часто виникає ситуація, коли є необхідність масового вставляння, оновлення або видалення записів у таблиці. Це так званий Batch processing. Завдяки механізму Transactional write-behind, Doctrine справляється з цим завданням максимально ефективно.

Unit of Work pattern

Говорячи про Unit of Work, необхідно згадати поняття бізнес-транзакції (або бізнес-дії). У контексті PHP це час від початку запуску runtime до його завершення. Завдання Unit of Work стежити за всіма діями додатка, які можуть змінити БД у рамках однієї бізнес-транзакції. Коли бізнес-транзакція завершується, Unit of Work виявляє всі зміни і вносить їх у БД, паралельно оптимізуючи цей процес.

Підіб'ємо підсумок

Doctrine — це складна бібліотека, і це мала частка того, що можна було б розповісти. Але я сподіваюся, що побіжний огляд базових концептів дасть вам чудовий старт і мотивує на вивчення цієї бібліотеки. Або просто систематизує досвід, який у вас уже був.

Дякую, що читали, буду радий фідбеку і пропозиціям. Всіх благ!

Як Doctrine 2 допомагає підвищити продуктивність проєкту на Symfony, читайте тут.

Stfalcon.com має солідну експертизу в розробці проєктів а Symfony, і ми завжди готові поділитися досвідом. Пишіть нам — будемо раді співпраці!