Как избежать зависания при импорте в Magento

Мы поддерживаем один магазин на Magento, в котором есть необходимость импорта большого количества товаров. К сожалению, наш хостер ограничивает время выполнения скриптов 30-ю секундами и стандартный импорт не успевает выполниться (Maximum execution time of 30 seconds exceeded). Я придумал решение, чтобы обойти это ограничение и предлагаю его здесь.

Как работает стандартный импорт в Magento

Для начала полезно знать, как работает стандартный импорт в Magento, чтобы в дальнейшем изменить его под свои нужды. Путем прочтения исходников, выяснилось следующее:

  1. Когда файл импорта отправляют на валидацию, строки, которые прошли проверку, вносятся в таблицу "importexport_importdata".

  2. При нажатии на кнопку "Импорт", которая появляется после успешной валидации, Magento обрабатывает данную вспомогательную таблицу и заносит необходимые данные в основные таблицы базы. Это занимает большую часть времени, из-за чего хостер прерывает работу скрипта.

Именно за счет существования промежуточного этапа с заполнением таблицы "importexport_importdata", возможно реализовать предложенный мной метод.

Первый этап стоит описать подробно: Magento считывает из файла импорта по одной строке, проверяет ее валидность и заносит в массив. При накоплении определенного количества строчек или при достижении массивом максимально допустимого размера, JSON-представление массива заносится в новую строку таблицы "importexport_importdata" (строка называется "bunch", то есть "связка").

Не всегда один товар оказывается в одной связке. Это может произойти, когда продукт занимает более одной строки во входном файле.

Как предлагаю модифицировать стандартный импорт в Magento 

Изучив работу стандартного импорта, становиться понятно, что можно обойти ограничение хостера в 30 секунд, если выполнять скрипт частями и в несколько подходов.

На формирование таблицы "importexport_importdata" уходит мало времени (свободно вписывается в 30 секунд). А второй этап импорта можно разбить на несколько подходов (запустим скрипт несколько раз). За один раз будем обрабатывать несколько связок и удалять их. Продолжать будем пока связки в таблице не закончатся.

Чтобы схема работала корректно, все строки одного продукта должны находиться в одной связке. В моем случае каждый продукт занимает 5 строчек, потому что есть 5 цен для разных групп пользователей.

В 303-ей строке файла:

app/code/core/Mage/ImportExport/Model/Import/Entity/Abstract.php

if (($productDataSize+$rowSize)>=$maxDataSize||$isBunchSizeExceeded) {

меняем на

if((($productDataSize+$rowSize)>=$maxDataSize||$isBunchSizeExceeded)
	&& $rowData['sku']!=NULL) {

Для второй и следующих строк одного товара sku=NULL. Такие строки будут добавляться к связке в любом случае.

Если строки очень длинные (или импортируется много данных для каждого продукта), то нужно уменьшить $maxDataSize, чтобы при вставке в базу данных ничего не обрезалось. В моем случае это не понадобилось.

Далее переходим к обработке связок. Нужно, чтобы за один заход обрабатывались не все, а только выбранное количество.

Для этого в файле:

app/code/core/Mage/ImportExport/Model/Resource/Import/Data.php

устанавливаем количество связок, которое будет импортироваться за раз:

protected $limit=50

Изменяем функцию, которая возвращает объект итератора по связкам:

public function getIterator(){
	$adapter = $this->_getWriteAdapter();
	$select = $adapter->select()
		->from($this->getMainTable(), array('data'))
		->order('id ASC')
		->limit($this->limit);
	$stmt = $adapter->query($select);
	$rows = $stmt->fetchAll();
	$iterator = new ArrayIterator($rows);
	return $iterator;
	}

Тут берется $limit связок и создается из них итератор, вместо того чтобы создать итератор по всем связкам.

Также нужно создать функцию, которая будет удалять эти $limit связок:

public function deleteSomeBunches(){
	$adapter = $this->_getWriteAdapter();
	$select = $adapter->select()
		->from($this->getMainTable(), array('id'))
		->order('id ASC')
		->limit($this->limit);
	$stmt = $adapter->query($select);
	$rows = $stmt->fetchAll();
	$ids=array();
	foreach($rows as $row){
		$ids[]=$row['id'];
		}
	$this->_getWriteAdapter()
		->delete($this->getMainTable(),
			'id in ('.implode(',',$ids).')');
	}

И, конечно же, нужно дописать вызов этой функции внутри функции _importData():

app/code/core/Mage/ImportExport/Model/Import/Entity/Product.php

сразу после

$this->_saveCustomOptions();
foreach ($this->_productTypeModels as $productType => $productTypeModel) {
	$productTypeModel->saveData();
	}

Надо добавить

$this->_dataSourceModel->deleteSomeBunches();

После таких действий, при импорте будет выдаваться сообщение, что все импортировано успешно. На самом деле, из всего файла будет импортировано всего-то $limit связок, а остальные проигнорированы.

Требуется организовать повторное выполнение второй части импорта, чтобы обработать оставшиеся связки. 

Для этого нужно изменить функцию startAction() контроллера:

app/code/core/Mage/ImportExport/controllers/Adminhtml/ImportController.php

Поэтому необходимо модифицировать $resultBlock, который ответственен за ответ от сервера. Было:

$resultBlock->addAction('hide', 
		array('edit_form', 'upload_button', 'messages'))
	->addSuccess($this->__('Import successfully done.'));

Стало:

$data=Mage::getResourceSingleton('importexport/import_data');
$count=Mage::getResourceSingleton('importexport/import_data')->getCount();
$resultBlock->addAction('hide', 
	array('edit_form', 'upload_button', 'messages'));
if($count==0){
	$resultBlock->addSuccess(
		$this->__('Import successfully done.'));
	}
else{
	$resultBlock->addSuccess(
		$count.' раз осталось нажать на кнопку',true);
	} 

После этих манипуляций появится кнопка - она будет повторно вызывать импорт. Контроллер обращается к модели ресурса, который работает с таблицей импорта, и узнает, сколько еще раз нужно нажимать на кнопку. Если 0 - выведется сообщение, что импорт закончен.

Конечно, нужно дописать функцию getCount(), которая будет возвращать число оставшихся кликов на кнопку. В файле:

app/code/core/Mage/ImportExport/Model/Resource/Import/Data.php

нужно создать функцию:

public function getCount(){
	$adapter = $this->_getWriteAdapter();
	$select = $adapter->select()
		->from($this->getMainTable(), array('count(*) as cnt'));
	$val=$adapter->query($select)->fetchAll();
	return ceil(reset($val)['cnt']/$this->limit);
	}

Последний штрих -  экспериментально подобрать значение $limit таким, чтобы скрипт успевал выполняться за 30 секунд, и чтобы  $limit был как можно большим.

После этого всего получается вариант интерфейса, в котором пользователь должен кликать по кнопке n раз, что не является примером хорошего дизайна. Думаю, стоит добавить немного javascript, который будет делать "грязную работу" вместо пользователя.

Но основная проблема импорта большого файла решена! Теперь можно импортировать большие файлы, даже не на выделенном хостинге.