Производительность 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 какие-то свои собственные ключи. Так что, если в методе нашего контроллера определить что-то типа:
$this->paginate['my-cool-param']='YES!';
то эта пара ключ-значение тоже попадет в параметр 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’…). Получится примерно такой код:
function paginateCount($conditions, $recursive, $extra) { return $this->find('count', array( 'conditions'=>$conditions, 'recursive'=>-1 ) ); }
Теперь при вызове COUNT(*) не будет совсем никаких JOIN’ов, несмотря ни на какие отношения.
Я довольно долго пользовался этим вариантом метода и быль вполне им доволен. Но совсем недавно возникла необходимость все-таки в полном подсчете записей — из-за условия отбора по полю присоединенной модели. Совсем убирать этот метод мне не хотелось и я решил просто добавить возможность указания — надо ли использовать для подсчета запрос с присоединенными моделями или можно обойтись подсчетом записей только в одной. Если конкретнее, то для frontend метода index использую «быстрый» метод подсчета, а в админской части есть более сложные фильтры, требующие использования «медленного» метода. Чтобы указать необходимость использования «медленного» метода, с использованием присоединенных моделей, используется дополнительный ключ в массиве Controller::paginate. Что я, зря, чтоль столько букв про $extra написал? :-) Теперь если задан сложный фильтр по записям, кроме условий в Controller::paginate['conditions'], добавляю ключ Controller::paginate['count_recursive']=1. А метод стал выглядить так:
function paginateCount($conditions, $recursive, $extra) { if (@$extra['count_recursive']) { unset($extra['count_recursive']); } else { $recursive=-1; } return $this->find('count', array_merge( array( 'conditions'=>$conditions, 'recursive'=>$recursive), $extra ) ); }
Получилось, на мой взгляд вполне неплохо.
Model::paginate
После доработки подсчета я решил посмотреть, что можно сделать с самим запросом на выборку. По сути, find(‘all’, …) он и есть find(‘all’, …) чего там еще с ним сделаешь? Тем не менее, из метода контроллера Controller::paginate(), одноименный метод модели Model::paginate() вызывается с множеством параметров.
function paginate($conditions, $fields, $order, $limit, $page = 1, $recursive = null, $extra = array())
Для простых запросов тут не стоило бы и напрягаться. Зато для сложных запросов, которые 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() записывал полученное количество записей.
private $_lastPaginationCountResult = 0; function paginateCount($conditions, $recursive, $extra) { if (@$extra['count_recursive']) { unset($extra['count_recursive']); } else { $recursive=-1; } $this->_lastPaginationCountResult = $this->find('count', array_merge( array( 'conditions'=>$conditions, 'recursive'=>$recursive), $extra ) ); return $this->_lastPaginationCountResult; }
И написал маленький метод Model::paginate(). Мною собственноручно написана только одна строка, проверка переменной с результатами paginateCount(), которая была вызвана до того. Весь остальной код просто скопировал из соответствующего метода Controller::paginate().
function paginate($conditions, $fields, $order, $limit, $page = 1, $recursive = null, $extra = array()) { if ($this->_lastPaginationCountResult === 0) return array(); $parameters = compact('conditions', 'fields', 'order', 'limit', 'page'); if ($recursive != $this->recursive) { $parameters['recursive'] = $recursive; } if (array_key_exists('type', $extra)) { $type = $extra['type']; unset($extra['type']); } else $type='all'; return $this->find($type, array_merge($parameters, $extra)); }
Кстати, обратите внимание, на параметр type, это не я придумал, это в оригинале так было. Он позволяет вместо find(‘all’,…) использовать для выборки любой другой тип запроса. Даже самостоятельно определенный, например, find(‘recent’,…), если, конечно, поддержку этого метода кто-нибудь заранее напишет ;-)



