Производительность pagination в CakePHP 1.2

Хотя использование разбивки на страницы (pagination) вполне заслуживает отдельного, обстоятельного поста, хочу остановиться лишь на паре не слишком очевидных моментов. Я предполагаю, что у читающих этот пост есть определенный навык использования CakePHP, моделей вообще и pagination в частности. Этот пост и так получается длинным.

Известно, что метод контроллера paginate() вызывает последовательно два метода модели — первый для подсчета общего числа записей, удовлетворяющих заданным условиям, и второй на выборку указанного числа записей начиная с заданной. Т.е. первый это фактически функция SQL COUNT(*), второй — SELECT … LIMIT n,m.

Именно из-за того, что вызовов методов больше одного, не срабаывает временная привязка/отвязка подчиненных моделей с помощью bindModel()/unbindModel(). Этим методам, для корректной работы с paginate() приходится передавать второй параметр, равный false. К счастью, еть ContainableBehavior, решающий эту проблему.

Эти запросы не всегда оптимальны и есть возможность улучшить производительность метода paginate() именно за счет оптимизации собственно самих запросов.

Для того, чтобы влиять на работу метода paginate(), в контроллере CakePHP предусмотрены вызовы двух специальных методов модели: Model::paginateCount() и Model::paginate(). Метод paginate() контроллера ищет у модели указанные методы и, если они определены, вызывает их. Если у модели методы не определены, вызываются стандартные Model::find(‘count’ …) и Model::find(‘all’,..), которым в качестве параметров передаются условия выборки, условия для Containable и все прочее.

Понятно, что определив эти методы в модели, все вместе или любой на выбор, можно творить все, что захочется. А сам метод Controller::paginate() (не путайте с нашим Model::paginate()!) тогда будет заниматься исключительно вспомогательной работой — опредять параметры для LIMIT и настраивать переменные вида (View). Самое главное, чтобы наш метод Model::paginateCount() возвращал (int) количество записей, а Model::paginate() — массив записей.

Все это предоставляет большое пространство для творчества и гибкость при каких-нибудь навернутых запросах. Но не только. Этими методами можно воспользоваться для оптимизации запросов, которые CakePHP мог бы и самостоятельно составлять.

Model::paginateCount

Первым займемся методом для подсчета количества записей. Этот метод я уже описывал чуть более года назад, но то описание во-первых устарело, потому, что в итоге в CakePHP сменился синтаксис вызова COUNT(*), а во-вторых здесь я постараюсь дать более развернутое описание.

При вызове метода Model::paginateCount(), контроллер передает ему три параметра: $conditions, $recursive и $extra.

  • (array) conditions — ассоциативный массив условий, такой же, как массивы условий для любых других методов модели. В нашем случае в него копируются условия из массива Controller::paginate[‘conditions’].
  • (int) recursive — значение параметра $recursive. Если это значение установлено в переменной Controller::raginate[‘recursive’], то оно. Если нет, то берется из значения свойства Model::recursive
  • (array) extra — ассоциативный массив всех остальных переменных, передаваемых в Controller::paginate, ‘contain’ (для ContainableBehavior), ‘fields’ и т.д. В общем, содержимое переменной Controller::paginate без первых двух параметров.

Хочу отметить, что никто не запрещает указывать в массиве Controller::paginate какие-то свои собственные ключи. Так что, если в методе нашего контроллера определить что-то типа:

то эта пара ключ-значение тоже попадет в параметр extra.

Чаще всего для разбивки на страницы используется модель, связанная с другими. Классический пример с моделью Article, которая принадлежит (belongsTo) модели Author. Для окончательного вывода нам нужны данные из обоих моделей, но для подсчета записей достаточно посчитать только записи в Article. Т.е. для подсчета строк достаточно выполнить COUNT(*) только для таблицы articles, без лишних JOIN’ов. MySQL работает гораздо веселее, когда удается обойтись без связанных таблиц. Для этого простого случая вполне можно вызывать $this->Article->unbindModel(array(‘belongsTo’=>array(‘Author’)))) и связанная модель Author не будет участвовать в подсчете, но будет в выборке результатов. Помните, что метод unbindModel() по умолчанию «отвязывает» модель только на один, следующий, запрос?

Но если связанных моделей много, что, надо помнить их все? Да и дергать unbindModel() перед Controller::paginate() что-то не улыбается. Особенно с точки зрения парадигмы «тонкий контроллер, толстая модель».

Иными словами, я считаю, что надо написать такой метод paginateCount, который будет подсчитывать записи в таблице без присоединения зависимых моделей. Сделать это можно жестко задавая параметр $recursive при вызове Model::find(‘count’…). Получится примерно такой код:

Теперь при вызове COUNT(*) не будет совсем никаких JOIN’ов, несмотря ни на какие отношения.

Я довольно долго пользовался этим вариантом метода и быль вполне им доволен. Но совсем недавно возникла необходимость все-таки в полном подсчете записей — из-за условия отбора по полю присоединенной модели. Совсем убирать этот метод мне не хотелось и я решил просто добавить возможность указания — надо ли использовать для подсчета запрос с присоединенными моделями или можно обойтись подсчетом записей только в одной. Если конкретнее, то для frontend метода index использую «быстрый» метод подсчета, а в админской части есть более сложные фильтры, требующие использования «медленного» метода. Чтобы указать необходимость использования «медленного» метода, с использованием присоединенных моделей, используется дополнительный ключ в массиве Controller::paginate. Что я, зря, чтоль столько букв про $extra написал? :-) Теперь если задан сложный фильтр по записям, кроме условий в Controller::paginate[‘conditions’], добавляю ключ Controller::paginate[‘count_recursive’]=1. А метод стал выглядить так:

Получилось, на мой взгляд вполне неплохо.

Model::paginate

После доработки подсчета я решил посмотреть, что можно сделать с самим запросом на выборку. По сути, find(‘all’, …) он и есть find(‘all’, …) чего там еще с ним сделаешь? Тем не менее, из метода контроллера Controller::paginate(), одноименный метод модели Model::paginate() вызывается с множеством параметров.

Для простых запросов тут не стоило бы и напрягаться. Зато для сложных запросов, которые CakePHP не может выполнить сам, или выполняет неоптимально, вызов этого метода — золотое дно. Ведь здесь можно манипулировать результирующими данными.

В качестве примера, простая ситуация с цепочкой из трех моделей. Таблица с продуктами, у которых есть много цен (розничная, опт1, и т.д.) в разных валютах (их всего три, так получилось). Для работы только с розничными ценами мы перепривязали с помощью unbindModel() и bindModel() модель с ценами как hasOne с условием Price.price_type=’retail’. Но цепочка из трех моделей осталась:

  • модель Product владеет ценой (hasOne) Price;
  • Price принадлежит валюте (belongsTo) Currency

Обычный Model::find(‘all’…) поступит просто. Выберет все записи Product-JOIN-Price, а потом для каждого элемента массива выберет Currency. С учетом того, что в самом худшем варианте, на странице будут цены в трех валютах, двадцать запросов ‘SELECT Price … ‘ все равно выглядят удручающе. В похожей ситуации я поступил так:

  • С помощью recursive при вызове Model::find(‘all’,…) ограничил выборку только связью Product-Price
  • Используя Set::classicExtract получил массив id нужных валют. Можно было и с помощью SQL запроса SELECT DISTINCT это сделать, но Set::classicExtract мне ближе ;-)
  • Выбрал нужные вылюты из модели Currency
  • Добавил в массив, полученный в первом пункте выбранные валюты в соответствии с Price.currency_id

Получилось всего три запроса, включая запрос на подсчет!

Возможность оптимизации менее сложных запросов при вызове Controller::paginate() тоже есть.

Когда я сделал свой метод Model::paginateCount() для общей и админской части, обратил внимание, что возможен вариант, когда в админской части заданы такие условия выборки, под которые не попадает ни одной записи. Например, в указанном разделе автор не опубликовал ни одной записи. В этом случае возвращается, как нетрудно догадаться, пустой массив. Поскольку меня уже было не остановить :-) , я задался вопросом — а если нет ни одной записи, и paginateCount() это уже определил, то зачем вообще выполнять запрос к БД на выборку? Можно же просто вернуть пустой массив! Так и поступил.

В модель добавил переменную, в которую Model::paginateCount() записывал полученное количество записей.

И написал маленький метод Model::paginate(). Мною собственноручно написана только одна строка, проверка переменной с результатами paginateCount(), которая была вызвана до того. Весь остальной код просто скопировал из соответствующего метода Controller::paginate().

Кстати, обратите внимание, на параметр type, это не я придумал, это в оригинале так было. Он позволяет вместо find(‘all’,…) использовать для выборки любой другой тип запроса. Даже самостоятельно определенный, например, find(‘recent’,…), если, конечно, поддержку этого метода кто-нибудь заранее напишет ;-)

Автор

Сергей Родовниченко

Родился, учился, работал и все такое. Занимаюсь поддержкой сайтов на Shop-Script, Joomla, Wordpress, Prestashop. А также на самописных движках на базе CakePHP.

One thought on “Производительность pagination в CakePHP 1.2”

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *