26 August 2015

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

MODX
Совсем недавно Сергей Прохоров ака 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] => 
    [city] => 
    [state] => 
    [zip] => 
    [fax] => 
    [photo] => 
    [comment] => 
    [website] => 
    [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() по типам связанных объектов, а именно первый вызов — для первичных объектов, а второй вызов — для вторичных. В таком случае точно не будет путаницы с ключами, и если объект по какой-то причине не сохранился, то это точно будет означать ошибку и можно будет выполнять прерывание процесса сохранения (в том числе и откат транзакций).
Tags:modxmodx revolutionxPDO
Hubs: MODX
+6
3.7k 12
Leave a comment
Top of the last 24 hours