Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Привет! Меня зовут Павел и я занимаюсь бэкенд разработкой. Сегодня мы рассмотрим коллекции в Magento 2 (далее — M2). Несмотря на кажущуюся простоту реализации и интуитивно понятное назначение, эта сущность таит в себе несколько неочевидных подводных камней, которые влияют на производительность, а иногда и на саму возможность работы кода.
Сегодня рассмотрим принципы работы коллекций, полезные фишки и лайфхаки их использования, а также оценим влияние разных нюансов на производительность.
Погнали!
Как работают коллекции в M2
По общему определению, справедливому для большинства фреймворков, коллекции в М2 — это набор объектов-моделей, объединенных какими либо общими признаками, а также инструменты для работы с ними: фильтрация, сортировка, пагинация и пр.
При объявлении коллекции указывается модель и ресурс-модель объектов, с набором которых работает коллекция:
<?php
declare(strict_types=1);
namespace RSHB\WhiteRabbit\Model\ResourceModel\WhiteRabbit;
use Magento\Framework\Model\ResourceModel\Db\Collection\AbstractCollection;
use RSHB\WhiteRabbit\Model\ResourceModel\WhiteRabbit as WhiteRabbitResource;
use RSHB\WhiteRabbit\Model\WhiteRabbit as WhiteRabbitModel;
/**
* Class Collection
* @package RSHB\WhiteRabbit\Model\ResourceModel\WhiteRabbit
*/
class Collection extends AbstractCollection
{
/**
* {@inheritDoc}
*/
protected function _construct()
{
$this->_init(
WhiteRabbitModel::class,
WhiteRabbitResource::class
);
}
}
Как мы можем увидеть, в базовом варианте достаточно указать модель и ресурс-модель, т. е. коллекция теперь знает, что брать и откуда.
Давайте создадим новую коллекцию, воспользовавшись фабрикой:
$collection = $this->whiteRabbitCollectionFactory->create();
и укажем произвольный фильтр для нашей коллекции:
$collection->addFieldToFilter('name', $name);
Затем поставим точку остановки после задания фильтров и посмотрим на объект коллекции в xdebug. Мы видим, что поле _items
в коллекции пустое, однако в базе явно присутствуют элементы, отвечающие нашим фильтрам. Почему?
Здесь я хотел бы заострить внимание на первом аспекте коллекций, которые являются не очевидными для начинающих M2-разработчиков. Сразу после создания и задания фильтров коллекция является хранилищем sql запроса. Фактически, фильтры, которые мы задавали, — это надстройка над ORM, которая формирует элементы запроса. Давайте посмотрим, какой запрос сейчас содержится в нашей коллекции, для чего достанем из коллекции наш запрос и воспользуемся магическим методом для извлечения из него текста запроса:
$collection->getSelect()->__toString()
И посмотрим на сам запрос:
SELECT main_table. FROM rshb_white_rabbit AS main_table WHERE (name = 'White Rabbit') AND (value = 1)
Как мы видим, оба наших фильтра сформировали условия запроса в части WHERE
. А вот основная часть запроса SELECT
была сформирована при инстанцировании объекта коллекции при помощи метода _initSelect()
(о нем более подробно ниже).
Таким образом, в настоящий момент коллекция не содержит никаких реальных данных из базы, но при этом имеет сформированный запрос, чтобы эти данные достать.
Вывод: коллекция имеет два состояния: загруженная и незагруженная. До загрузки данных коллекция служит хранилищем SQL запроса, который формируется и модифицируется инструментами коллекции.
Загрузка коллекции
Итак, самый очевидный способ загрузить данные в коллекцию из БД — вызвать метод load()
. Вызовем данный метод от нашей коллекции, после чего посмотрим на нее через xdebug:
Теперь сделаем кое что интересное. Уберем метод load()
, а вместо этого добавим нашу коллекцию в цикл foreach
(мы можем делать это без дополнительных манипулций, поскольку коллекция реализует интерфейс ArrayAccess
):
$collection = $this->whiteRabbitCollectionFactory->create();
$name = 'White Rabbit';
$collection->addFieldToFilter('name', $name);
foreach ($this->collection as $rabbit) {
//stop point
}
Остановимся внутри цикла на первой итерации и посмотрим в объект. Данные загружены!
Как так получилось, ведь мы не вызывали метод load()
? Дело в том, что загрузка происходит автоматически при попытке обращения к элементам коллекции. Таким образом, мы можем не вызывать явным образом метод load()
и начинать работать с коллекцией так, будто данные уже загружены.
Из всего сказанного выше следует один важный момент: после загрузки модификация фильтров и прочих элементов запроса коллекции не будет оказывать влияния на уже загруженные данные.
Проиллюстрируем простым примером. Немного модифицируем наш код и коллекцию,
После чего загрузим ее:
$collection = $this->whiteRabbitCollectionFactory->create();
$name = '%Rabbit';
$collection->addFieldToFilter('name', [‘like’ => $name]);
$collection->load();
//stop point 1
SELECT main_table.* FROM rshb_white_rabbit AS main_table WHERE (name LIKE '%Rabbit')
добавим в нее новый фильтр и поставим точку остановки до второй загрузки:
$ids = [1, 2];
$collection->addFieldToFilter('id', [‘in’ $ids]);
//stop point 2
$collection->clear();
$collection->load();
Удостоверимся, что хранящийся в коллекции запрос изменился:
SELECT `main_table`.* FROM `rshb_white_rabbit` AS `main_table` WHERE (`name` LIKE '%Rabbit') AND (`id` IN (1, 2))
После чего посмотрим, какие элементы сейчас фактически загружены:
Как мы видим, фактически загруженные элементы не изменились, наш фильтр на них не подействовал. Теперь вызовем у коллекции метод clear()
, что инициирует очистку коллекции и ее возврат в незагруженное состояние, а затем метод load()
и посмотрим еще раз:
И здесь есть тонкость. Метод load()
принудительно инициирует загрузку данных в незагруженной коллекции, но при обращении к элементам уже загруженной коллекции этого не произойдет, повторная загрузка произведена не будет. Уберем методы clear()
и load()
и вызовем коллекцию в цикле foreach
:
Повторная загрузка данных с использованием нового запроса не произошла. Однако, если коллекцию очистить при помощи clear()
и затем обратиться к ее элементам, загрузка будет осуществлена в соответствии с измененным нами запросом.
Считаем элементы коллекции
Регулярно встречаю в коде посторонних модулей такие конструкции:
if (count($collection)) {
//do something…
}
И, на первый взгляд, здесь нет ошибки, count вернет нам количество элементов коллекции (помним про интерфейсы ArrayAccess
и Countable
, которые реализует коллекция). Но только на первый взгляд.
Самые внимательные уже догадались, в чем подвох. При выполнении count
произойдет автоматическая загрузка коллекции, т.е. Загрузятся все данные в соответствии с запросом. Если нам нужно просто проверить, что в базе есть какое-то количество элементов, соответствующих нашему запросу, вместо count
нужно использовать $collection->getSize()
. Данный метод использует тот же запрос с одним отличием:
SELECT COUNT(*) FROM `rshb_white_rabbit` AS `main_table` WHERE (`name` LIKE '%Rabbit') AND (`id` IN (1, 2))
Что экономнее и быстрее при том же эффекте.
Таким образом, при операциях с коллекцией следует избегать действий, которые будут инициировать ненужную загрузку всей коллекции.
Вместе с тем, если в вашем случае не имеет значения, есть ли в базе нужные элементы или нет, то подобную проверку вообще стоит опустить, тем самым исключив один COUNT
запрос:
If ($collection->getSize()) {
foreach ($collection as $item) {
//Do something
}
}
foreach ($collection as $item) {
//do nothing if collection is empty
}
Запрос одного элемента при помощи коллекции
Что ж, теперь давайте рассмотрим ситуацию, когда коллекции используются для получения одного элемента. Нецелевое, на первый взгляд, использование может быть оправдано, если репозиторий не содержит нужных методов для нестандартных запросов, к примеру, запроса по какому-то произвольному полю.
Рассмотрим следующий код:
$collection = $this->collectionFactory->create();
$name = '%Rabbit';
$collection->addFieldToFilter('name', [‘like’ => $name]);
$item = $collection->getFirstItem();
Данная конструкция применяется обычно в двух случаях:
Запрос гарантированно вернет один элемент;
Нам нужен первый элемент коллекции (это бывает полезно, когда коллекция отсортирована по какому либо полю).
Во втором случае произойдет не совсем то, что мы ожидали: загрузятся все элементы которые удовлетворяют условиям, и лишь потом из загруженных элементов будет извлечен первый, о чем свидетельствует запрос, содержащийся в коллекции:
SELECT main_table.* FROM rshb_white_rabbit AS main_table WHERE (name LIKE '%Rabbit')
Если нам нужен только один элемент, мы можем заметно ускорить выборку, задав ограничение на количество элементов:
$collection = $this->collectionFactory->create();
$name = '%Rabbit';
$collection->addFieldToFilter('name', [‘like’ => $name]);
$collection->addOrder(‘name’, ASC);
$collection->setPageSize(1);
$item = $collection->getFirstItem();
SELECT `main_table`.* FROM `rshb_white_rabbit` AS `main_table` WHERE (`name` LIKE '%Rabbit') ORDER BY name ASC LIMIT 1
AND и OR condition
Создадим коллекцию и добавим в нее несколько фильтров:
$collection = $this->whiteRabbitCollectionFactory->create();
$name = '%Rabbit';
$collection->addFieldToFilter('name', [‘like’ => $name]);
$ids = [1, 2];
$collection->addFieldToFilter('id', [‘in’ $ids]);
Посмотрим SQL-запрос:
SELECT `main_table`.* FROM `rshb_white_rabbit` AS `main_table` WHERE (`name` LIKE '%Rabbit') AND (`id` IN (1, 2))
Видим, что фильтры объединяются при помощи оператора AND
.
Чтобы добавить фильтры с оператором OR
, нужно применять несколько иную конструкцию:
$collection = $this->whiteRabbitCollectionFactory->create();
$name = '%Rabbit';
$ids = [1, 2];
$collection->addFieldToFilter(
['name', 'id'],
[
['like' => $name],
['in' => $ids]
]
);
SQL-запрос:
SELECT `main_table`.* FROM `rshb_white_rabbit` AS `main_table` WHERE ((`name` LIKE '%Rabbit') OR (`id` IN (1, 2)))
Мы можем комбинировать оба подхода, располагая фильтры в нужном порядке.
Работа с очень большими коллекциями
В некоторых случаях, когда коллекция содержит десятки миллионов элементов, мы можем столкнуться с проблемой нехватки памяти при попытке итеративного прохода по всей коллекции:
PHP Fatal error: Allowed memory size of XXXX bytes exhausted (tried to allocate XXXX bytes) in vendor/magento/zendframework1/library/Zend/Db/Statement/Pdo.php on line 228
Есть несколько подходов для решения данной проблемы.
Загрузка элементов порциями, пока не будет обработан весь объем:
$collection = $this->collectionFactory->create();
$name = '%Rabbit';
$lastId = 500;
$collection->addFieldToFilter('name', [‘like’ => $name]);
$collection->addFieldToFilter('id', [‘gt’ => $lastId]);
$collection->addOrder(‘id’);
$collection->setPageSize(500);
В этом случае необходимо реализовать логику хранения последнего обработанного элемента.
Использование метода
walk()
итератораMagento\Framework\Model\ResourceModel\Iterator
:
public function doAnything()
{
...
$this->iterator->walk(
$collection->getSelect(),
[[$this, 'callback']]
);
}
public function callback($args)
{
//do something
}
Стоит применять данные подходы, когда существует угроза нехватки памяти (также полезно применять при работе с любыми потенциально большими коллекциями).
Кастомизация коллекции
Как я уже упоминал ранее, базовый запрос формируется сразу при инстанцировании коллекции при помощи метода _initSelect()
. Мы можем использовать этот метод для модификации базового запроса под наши нужды. Наиболее часто такая кастомизация используется для join`а другой таблицы:
<?php
declare(strict_types=1);
namespace RSHB\WhiteRabbit\Model\ResourceModel\WhiteRabbit;
use Magento\Framework\Model\ResourceModel\Db\Collection\AbstractCollection;
use RSHB\WhiteRabbit\Model\ResourceModel\WhiteRabbit as WhiteRabbitResource;
use RSHB\WhiteRabbit\Model\WhiteRabbit as WhiteRabbitModel;
/**
* Class Collection
* @package RSHB\WhiteRabbit\Model\ResourceModel\WhiteRabbit
*/
class Collection extends AbstractCollection
{
/**
* {@inheritDoc}
*/
protected function _construct()
{
$this->_init(
WhiteRabbitModel::class,
WhiteRabbitResource::class
);
}
/**
* @return Collection|void
*/
protected function _initSelect()
{
parent::_initSelect();
$this->getSelect()->join(
['another_white_rabbit' => 'rshb_another_white_rabbit'],
'main_table.id = another_white_rabbit.white_rabbit_id',
['another_name' => 'another_white_rabbit.name']
);
return $this;
}
}
Вы также можете использовать данный подход в любых других случаях, когда нужно изменить базовый запрос коллекции под ваши нужды.
Вопросы производительности
Кратко обозначим моменты, влияющие на производительность коллекций.
Используйте
getSize()
вместоcount()
(или не используйте вообще, если можно без этого обойтись).Используйте ограничение на количество элементов (
setPageSize()
) в случаях, когда это необходимо.Если вам не нужны все поля элементов, загружайте только нужные:
//Плохая идея:
$collection->addFieldToSelect('*');
//Указываем нужные нам поля:
$collection->addFieldToSelect(['first', 'second', 'third']);
//или, если нужно получить значения только одного поля:
$collection->getFieldValues('somefield');
Старайтесь не инициировать загрузку элементов, которые вам не нужны.
В некоторых случаях в целях повышения производительности бывает полезно сформировать запрос вручную, особенно это актуально при работе с большими объемами данных или формировании очень сложных запросов. В этом случае стоит воспользоваться
Connection
:
/** @var \Magento\Framework\App\ResourceConnection $connection **/
$connection = $this->connection->getConnection();
$tableName = $this->connection->getTableName('rshbwhite_rabbit’);
$sql = "SELECT * FROM $tableName";
$result = $connection->fetchAll($sql);
Однако злоупотреблять данным подходом не стоит, применение коллекций — наиболее правильный путь в подавляющем большинстве случаев.
Заключение
Мы рассмотрели ряд интересных особенностей коллекций в М2. Я уверен, что читатели могут дополнить данную статью своими наблюдениями, в связи с чем предлагаю делиться знаниями в комментариях.
На этом все. Пишите код с удовольствием. До встречи!