Итератор для провайдеров данных

Начиная с версии фреймворка 1.1.13 RC доступен класс итератора [CDataProviderIterator] для провайдеров данных
[CActiveDataProvider], [CArrayDataProvider] и [CSqlDataProvider]. Итераторы следует использовать тогда, когда
надо обработать большие объёмы данных, но при этом загрузить все данные сразу в память не представляется
возможным.

[CDataProviderIterator] производит процесс итерации по данным провайдера: с первой страницы и до последней.
Данный рецепт демонстрирует процесс использования итератора провайдеров данных на реальной задаче,
которая может возникнуть у пользователя фреймворка.

Задача и начальный код

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

Предположим, что нам была поставлена задача подсчитать средний рейтинг всех пользователей ресурса, имя
пользователя которых начинается на букву A, на букву B, С, D и так далее вплоть до Z. То есть результатом
решения задачи будет массив с ключами-буквами латинского алфавита, содержащий 26 вещественных чисел.

Было бы неправильно пытаться загрузить объекты сотен тысяч пользователей в память и затем осуществлять
вычисление агрегатного значения, основанного на пользовательских данных, т.к. во-первых это очень
неэкономно, а во-вторых оперативной памяти может просто не хватить. Итераторы решают данную задачу тем
образом, что они не загружают абсолютно все данные сразу в память, а делают это небольшими порциями
заданного размера.

Предположим, что модель пользователя сайта выглядит следующим образом:

/**
 * @property integer $id
 * @property string $username
 * @property integer $rating
 */
class User extends CActiveRecord{
	/**
	 * @param string $className
	 * @return User
	 */
	public static function model($className=__CLASS__){
		return parent::model($className);
	}

	public function tableName(){
		return '{{user}}';
	}
}

DDL (MySQL) для данной модели выглядит так:

DROP TABLE IF EXISTS tbl_user;
CREATE TABLE tbl_user (
    id INT(11) NOT NULL AUTO_INCREMENT,
    username VARCHAR(100) NOT NULL,
    rating INT(11) NOT NULL,
    PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

Решение задачи и описание

Пересчитывать средний рейтинг пользователей сайта приемлемо постоянно через определённый период
времени посредством cron. Результаты можно хранить как в таблице базы данных, так и в обычном
PHP-файле на диске.

Консольная команда, решающая описанную задачу выглядит следующим образом:

<?php

/**
 * Консольная команда для работы с пользователями сайта.
 */
class UserCommand extends CConsoleCommand{
	/**
	 * Количество пользователей, получаемых итератором одним блоком.
	 */
	const RATING_USERS_PER_ITERATION=5000;

	/**
	 * Экшн для пересчёта среднего рейтинга пользователей. Результаты формируются
	 * для 26 групп пользователей, каждая группа соответствует пользователям, имена
	 * которых начинаются на одну латинскую букву.
	 */
	public function actionRating(){
		Yii::import('application.models.User',true);
		// раскомментируйте строчку ниже если вы используете версию 1.1.13-dev
		// Yii::import('system.web.CDataProviderIterator',true);

		$letters='abcdefghijklmnopqrstuvwxyz';
		$results=array();

		for($i=0; $i<strlen($letters); $i++){
			$letter=$letters[$i];
			echo "Working on '{$letter}' letter...\n";

			// провайдер данных и итератор
			$dataProvider=new CActiveDataProvider('User',array(
				'criteria'=>array(
					'select'=>'t.rating',
					'order'=>'t.username',
					'condition'=>'t.username LIKE :username',
					'params'=>array(':username'=>$letter.'%'),
				),
			));
			$iterator=new CDataProviderIterator($dataProvider,
			    self::RATING_USERS_PER_ITERATION);

			// обходим данные для каждой буквы
			$result=0;
			foreach($iterator as $user)
				$result+=$user->rating/($iterator->totalItemCount)*100000;
			$results[$letter]=$result/100000;

			// умножение на 100000 каждого значения, а потом деление на 100000 общего
			// результата сделано для того, чтобы погрешность, вносимая особенностями
			// хранения вещественных чисел в современных ПК была минимальна

			echo "Letter '{$letter}' is done!\n";
		}

		// сохраняем результаты вычислений в файле
		file_put_contents(Yii::getPathOfAlias('application.data').'/userRating.php',
			"<?php\n\nreturn ".var_export($results,true).";\n",LOCK_EX);
	}
}

Резюме

Всё просто. Создаём провайдер данных для работы с AR-моделью пользователей. Затем создаём итератор
и начинаем обработку пользователей. Количество потребляемой памяти будет постоянным и при этом
мы можем осуществлять агрегации по большому объёму данных.

Консольная команда приведённая выше всего лишь наглядный пример и её можно улучшить следующим образом:

  • Провайдер данных [CActiveDataProvider] заменить на [CSqlDataProvider], если нет необходимости
    в использовании логики предметной области, которая уже реализована в обрабатываемых моделях или
    экономия памяти является важным критерием при функционировании приложения.
  • Добавить использование мьютеков (mutex) для того, чтобы предотвратить повторный случайный
    запуск команды из cron. Для этого существует специальное расширение
    mutex.