Особливості методу xPDOObject::save() + транзакції

Зовсім недавно Сергій Прохоров ака proxyfabio написав статтю Валідація об'єктів + транзакції. Трохи ця тема обговорювалася тут. Від себе хочу додати, що ця тема дуже важлива, і на сьогодні це одна з найголовніших проблем в розробці великих проектів на MODX Revolution.

Тут відразу попрошу не починати нічого на кшталт «Якщо робите великі проекти, не треба їх робити на MODX, візьміть бла-бла-бла». Ми робили великі проекти, і не тільки на MODX. На MODX цілком можна робити великі проекти, і на сьогодні є всього лише кілька слабких місць, які ми відправляємо на індивідуальних проектах, в іншому ж MODX на 98% придатний для розробки великих проектів.

Отже, одна з серйозних проблем пов'язана саме з методом xPDOObject::save() (викликається при збереженні xPDO-об'єктів). Суть цієї проблеми в тому, що всередині нього спрацьовує метод збереження пов'язаних об'єктів xPDOObject::_saveRelatedObjects() двічі. Раз і два. Робиться це для того, щоб виставити первинні і вторинні ключі для цих зв'язаних об'єктів (див. довідковий матеріал від Іллі Уткіна). Поясню детальніше на прикладі. Ось код:
<?php
$user_data = array(
"username" => "test",
);

$profile_data = array();

$user = $modx->newObject('modUser', $user_data);
$user->Profile = $modx->newObject('modUserProfile', $profile_data);
$user->save();

print '<pre>';
print_r($user->toArray());
print_r($user->Profile->toArray());


В цілому напевно суть цього коду зрозуміла багатьом, але давайте зосередимося на деталях. Коли ми створили два нових об'єкти ($user і $user->Profile), у них ще немає айдишников, поки їх не зберегли. Але зберігши тільки об'єкт $user, ми на виході отримуємо і збережений об'єкт $user->Profile. Це як би теж зрозуміло чому, Ілля у своїй статті все це описує. Але питання, яке не зовсім на увазі бовтається — це «як xPDO „знає“ який id об'єкту $user, щоб призначити id як $modx->Profile->internalKey?». Для цього давайте знову-таки пробіжимося по коду методу xPDO::save();

Ось у нас перший виклик методу $user->_saveRelatedObjects(). У цей момент об'єкт $user ще не збережений (не записаний у базу), id-шника у нього ще немає. $user->Profile теж не зберігається і не має ні id, ні internalKey. Переходячи до виклику методу $user->_saveRelatedObjects(), ми бачимо, що йде перебір пов'язаних об'єктів, їх збереження (метод xPDO::_saveRelatedObject()). Тут я ще раз уточню, що ми зберігаємо об'єкт $user, для якого об'єкт $user->Profile є пов'язаною. І ось тут-то і виходить, що фактично об'єкт $user->Profile збережеться раніше, ніж об'єкт $user. Чому? Тому що у виклику $user->_saveRelatedObject($user->Profile) буде викликаний метод $user->Profile->save(), а так як в поточний момент для $user->Profile немає пов'язаних об'єктів, він буде записаний у базу даних. І що тут у нас виходить? $user->Profile вже збережений і у нього є свій id, id але ні в об'єкта $user (тому що він ще не був збережений). З цієї причини і вторинний ключ $user->Profile->internalKey все ще порожній.

ГАРАЗД, з цим розібралися, їдемо далі. А далі у нас йде збереження вже самого об'єкта $user із записом його в БД і присвоєнням йому id. Все, запис зроблено. Ось тепер у нас в обох об'єктів є ці id-шники, але все ще немає значення $user->Profile->internalKey. От саме для цього і викликається метод $user->_saveRelatedObjects() ще раз. Тепер, коли буде зберігатися зв'язаний об'єкт $user->Profile, він зможе отримати значення $user->id і привласнити його як $user->Profile->internalKey і зберегтися.

Так, я згоден, що все це дуже заплутано (а пояснюю це ще заплутанішими), але логіка у цьому є. І, власне, саме з цієї причини я бачу таке завзяте використання MyIsam замість innoDB. Чому? Та тому що на innoDB це просто не зможе повноцінно працювати. І ось зараз ми розберемо наявну проблему, а не сам принцип роботи. Відразу скажу, що для повного розуміння всього цього потрібно хороше розуміння MySQL, а саме розуміння транзакцій, primary і foreign key і т. п.

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

1. Переведемо таблиці на движок innoDB.




2. В таблиці modx_users поле id int(10)unsigned, а в modx_users_attributes поле internalKey int(10) (не unsigned). З-за цього ми просто не зможемо налаштувати вторинний ключ, бо типи даних в колонках таблиць зобов'язані повністю збігатися.
Міняємо на unsigned


3 Створюємо вторинний ключ




Якщо при збереженні вторинного ключа ви не отримали ніяких помилок, то чудово! Але є кілька помилок, які ви можете отримати. Найпоширеніші з них:
1. Типи даних не збігаються.
2. Для вторинної запису не існує первинної (тобто, приміром, у вас є запис у modx_user_attributes з internalKey = 5, а записи в modx_users з id = 5 нету).

А тепер давайте подивимося суть проблеми на прикладі. Для цього виконаємо консолі наступний код:

<?php

$user_data = array(
"username" => "test_". rand(1,100000),
);

$profile_data = array(
"email" => "test@local.host",
);

$user = $modx->newObject('modUser', $user_data);
$user->Profile = $modx->newObject('modUserProfile', $profile_data);

$user->save();

print '<pre>';
print_r($user->toArray());
print_r($user->Profile->toArray());


Зараз ми ніякої проблеми не побачили, все збереглося без зауважень. Приблизний висновок при успішному виконанні

Array
(
[id] => 59
[username] => test_65309
[password] => 
[cachepwd] => 
[class_key] => modUser
[active] => 1
[remote_key] => 
[remote_data] => 
[hash_class] => hashing.modPBKDF2
[salt] => 
[primary_group] => 0
[session_stale] => 
[sudo] => 
)
Array
(
[id] => 54
[internalKey] => 59
[fullname] => 
[email] => test@local.host
[phone] => 
[mobilephone] => 
[blocked] => 
[blockeduntil] => 0
[blockedafter] => 0
[logincount] => 0
[lastlogin] => 0
[thislogin] => 0
[failedlogincount] => 0
[sessionid] => 
[dob] => 0
[gender] => 0
[address] => 
[country] => 
[центр] => 
[state] => 
[zip] => 
[fax] => 
[photo] => 
[comment] => 
[сайт] => 
[extended] => 
)



А тепер трохи змінимо наш код:

<?php

$user_data = array(
"username" => "test_". rand(1,100000),
);

$profile_data = array(
"email" => "test@local.host",
);

$user = $modx->newObject('modUser', $user_data);
$user->Profile = $modx->newObject('modUserProfile', $profile_data);

// Заздалегідь встановимо id первинного об'єкта. Тут слід вказати свій якийсь id, переконавшись, що в БД він не зайнятий.
$user->id = 40;

$user->save();

print '<pre>';
print_r($user->toArray());
print_r($user->Profile->toArray());


Що ми тепер отримаємо при виконанні цього коду?

1. Повідомлення про SQL-помилку

Array
(
[0] => 23000
[1] => 1452
[2] => Cannot add or update a child row: a foreign key constraint fails (`shopmodxbox_test2`.`modx_user_attributes`, CONSTRAINT `modx_user_attributes_ibfk_1` FOREIGN KEY (`internalKey`) REFERENCES `modx_users` (`id`))
)


2. Обидва наші об'єкта все-таки збереглися і мають коректні id і internalKey.

Чому так відбувається? При збереженні вторинного об'єкта xPDO перевіряє чи є значення первинного ключа, і тільки якщо він є, тоді вже встановлює його значення в якості вторинного ключа і зберігає цей об'єкт. У нашому випадку ми вручну вказали первинний ключ id і вторинний об'єкт зумів отримати його значення і спробував записатися в базу даних, але так як фактично первинної записи там немає, ми і отримуємо SQL-повідомлення про неможливість записати вторинну запис без первинного об'єкта. Але збереження первинного об'єкта на цьому не переривається. Після цього первинний об'єкт $user успішно записується в базу, а при повторній спробі збереження пов'язаного об'єкта $user->Profile вже нормально все зберігається, так як первинна запис є.

З усього цього випливає два висновки.

1. При збереженні пов'язаних об'єктів неможливо відстежити помилки збереження вторинних об'єктів і як на них зреагувати. Тобто ніколи не можна з упевненістю сказати, з якої причини не був збережений вторинний об'єкт (то немає поки первинного об'єкта, і він зможе пізніше записатися при повторному виклику методу xPDOObject::_saveRelatedObjects(), то там якийсь унікальний ключ сконфликтовал і запис в принципі не може бути записана, то там валідація на рівні мапи не пройшла і т. д. і т. п.).

2. З цієї причини неможливо використовувати повноцінно транзакції.

Можливий шлях вирішення цієї проблеми.

Ми бачимо вирішення цієї проблеми в тому, щоб розмежувати перший і другий виклик методу xPDOObject::_saveRelatedObjects() за типами пов'язаних об'єктів, а саме перший дзвінок — для первинних об'єктів, а другий виклик — для вторинних. У такому разі точно не буде плутанини з ключами, і якщо об'єкт з якоїсь причини не зберігся, то це точно буде означати помилку і можна буде виконувати переривання процесу збереження (в тому числі і відкат транзакцій).

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.