Использование ActiveRecord от Yii в игре тайм менеджере

    Всем привет!

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

    Напомню, что в проекте используется php(Yii), mysql и memcached. В проекте достаточно много сущностей, для каждой из которой есть своя модель, которая наследуется от CActiveRecord.

    Хранятся файлы моделей следующим образом. В папке models создаем папку base. Когда генерируем модель через Gii, то указываем, что ее нужно положить в папку models/base и к имени класса добавляем Base. Затем создаем в models аналогичный класс без Base, который наследуется от базового класса и имеет в себе лишь метод model().

    Кстати заранее скажу, что базовые модели наследуем не от CActiveRecord, а от ExtActiveRecord — расширяем CActiveRecord под наши нужды. Но об этом позже. Пока что разницы никакой.

    Пример:
    models/base/BaseUser.php — стандартный класс, который генерируется через Gii
    models/User.php — класс, который наследуется от BaseUser и имеет в себе метод model()
    	/**
    	 * Returns the static model of the specified AR class.
    	 * @param string $className active record class name.
    	 * @return User the static model class
    	 */
    	public static function model($className=__CLASS__) {
    		return parent::model($className);
    	}
    	



    Данная схема используется для того, чтобы в случае повторной генерации файла модели не потерять свой код и просто не забивать пространство стандартным кодом от Yii.

    Не забываем добавить в конфиге 'application.models.base.*'.

    Перейдем собственно к теме поста и поставим задачи, которые хотим решить:
    1. Уменьшить количество запросов в базу на обновление
    2. Уменьшить количество запросов в базу на выборку




    Уменьшаем количество запросов в базу на обновление


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

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

    Мы сейчас сделаем так, что подгрузка пользователя произойдет при первом к нему обращении, а сохранение только после выполнения всех команд. Для этого мы сделаем реестр моделей.
    Это такая штука, к которой мы будем обращаться, чтобы получить модель User, вместо того, чтобы писать User::model()->findByPk().

    Реестр моделей будет являться компонентом и будет прописан в конфиге в components
    'components' => array(
    	// ...
    	'modelRegistry'=>array(
    		'class' => 'ModelRegistry'
    	)
    	// ...
    )	
    


    Сам класс выглядит следующим образом
    class ModelRegistry {
    	protected $registries = array();
    
    	public function init() {}
    
    	/**
    	 * Возвращает реестр выбранной модели
    	 * @param string $name
    	 * @param mixed $attr
    	 * @return ExtActiveRecord
    	 */
    	public function & registry($name, $attr = array()) {		
    		$key = $name . md5(serialize($attr));
    		if (!isset($this->registries[$key])) {
    			$model = ucfirst($name);
    			$obj = $model::model();
    
    			if (!is_array($attr)) $attr = array($attr);
    
    			$this->registries[$key] = call_user_func_array(array(&$obj, 'registry'), $attr);
    		}
    
    		// будет возвращена ссылка на объект в массиве благодаря & в имени функции
    		return $this->registries[$key];
    	}
    
    	/**
    	 * Сохранение изменений
    	 */
    	public function saveAll() {
    		foreach ($this->registries as $obj) {
    			$obj->save();
    		}
    	}
    }
    


    У каждой нашей модели, которую мы хотим получить через реестр моделей будет метод registry, который будет возвращать объект. User в данном случае выглядит следующим образом

    /**
     * @property integer $id
     * @property integer $exp
     * @property integer $money
     * @property integer $name
     */
    class User extends BaseUser
    {
    	/**
    	 * Returns the static model of the specified AR class.
    	 * @param string $className active record class name.
    	 * @return User the static model class
    	 */
    	public static function model($className=__CLASS__) {
    		return parent::model($className);
    	}
    
    	/**
    	 * Получает профиль игрока
    	 * @param int $userID
    	 * @return User|bool
    	 */
    	public function registry($userID) {
    		if ($obj = $this->findByPk($userID)) {
    			$res = $obj;
    		} else {
    			$res = false;
    		}
    
    		return $res;
    	}
    }
    


    К чему это все привело. К примеру у нас есть контроллер, который вызывает два метода, изменяющие пользователя.

    	/**
    	 * @var ModelRegistry
    	 */
    	protected $reg;
    
    	public function actionRun() {
    		$userID = 1;
    
    		$this->reg = &Yii::app()->modelRegistry;
    		$this->firstChange($userID);
    		$this->secondChange($userID);
    		$this->reg->saveAll();
    	}
    
    	public function firstChange($userID) {
    		// здесь первой обращение. Реестр создаст модель и подгрузит данные из базы
    		// & нужно, чтобы получить ссылку на объект из массива
    		$user = &$this->reg->registry('user', $userID);
    		$user->exp = 10;
    	}
    
    	public function secondChange($userID) {
    		// здесь обращение к тому, что уже подгружено в реест. В базу обращения нет
    		// & аналогично
    		$user = &$this->reg->registry('user', $userID);
    		$user->money = 20;
    	}
    


    Собственно будет одно обращение в базу на select и одно на update.
    Здесь мы сталкиваемся с дополнительной задачей. Если вызвать этот код второй раз, то поля пользователя остануться теми же, но сохранение все равно будет произведено. Нам нужно сделать так, чтобы наш ActiveRecord сохранялся только в том случае, когда объект притерпел изменения.

    Тут нам на помощь приходит наш ExtActiveRecord, который мы использовали для расширения CActiveRecord.

    class ExtActiveRecord extends CActiveRecord {
    
    	protected $_oldAttributes = array();
    
    	/**
    	 * Тот самый метод, который должен быть во всех моделях
    	 */
    	public function registry() {}
    
    	/**
    	 * Запомнить текущее состояние модели
    	 */
    	public function memoryAttributes() {
    		$this->_oldAttributes = $this->attributes;
    	}
    
    	/**
    	 * Поиск изменений в моделе. Возвращает список измененных полей
    	 * Если объект был только создан и его нужно будет сохранить полностью, то возвращает false
    	 * @return array|false
    	 */
    	protected function getChanges() {
    		$res = array();
    		if (empty($this->_oldAttributes)) {
    			$res = false;
    		} else {
    			foreach ($this->_oldAttributes as $key => $value) {
    				if ($this->$key != $value) {
    					$res[] = $key;
    				}
    			}
    		}
    
    		return $res;
    	}
    
    	/**
    	 * Сохраняем только изменения
    	 * @return bool
    	 */
    	public function save() {
    		if (($attr = $this->getChanges()) === false) {
    			$res = parent::save();
    		} elseif ($attr) {
    			$res = $this->update($attr);
    		} else {
    			$res = false;
    		}
    
    		return $res;
    	}
    }
    


    И обновляем метод registry в модели User

    public function registry($userID) {
    		if ($obj = $this->findByPk($userID)) {
    			$res = $obj;
    		} else {
    			$res = false;
    		}
    
    		if ($res) {
    			// запоминаем текущее состояние модели
    			$res->memoryAttributes();
    		}
    
    		return $res;
    	}
    


    Собственно теперь будет производиться только insert или update измененных полей модели.
    Оставляю вам пространство для творчества и позволяю самим разобраться в том, как создать нового пользователя и поместить его в реест моделей во время выполнения скрипта.

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

    class ModelList {
    
    	/**
    	 * @var array с данными ExtActiveRecord
    	 */
    	public $list = array();
    
    	/**
    	 * Создает новый список
    	 * @param array|bool $list массив с ExtActiveRecord
    	 * @return ModelList
    	 */
    	public static function make($list = array()) {
    		if (!is_array($list) && empty($list)) {
    			$list = array();
    		}
    		$obj = new ModelList();
    		$obj->list = $list;
    
    		return $obj;
    	}
    
    	/**
    	 * Добавить объект в список
    	 * @param ExtActiveRecord $obj
    	 */
    	public function pushObject($obj) {
    		$this->list[] = $obj;
    	}
    
    	/**
    	 * Вызвать у всех моделей метод
    	 * @param string $name
    	 */
    	public function callMethod($name) {
    		foreach ($this->list as &$obj) {
    			$obj->$name();
    		}
    	}
    
    	/**
    	 * Сохранение всех объектов
    	 */
    	public function save() {
    		$this->callMethod('save');
    	}
    }
    


    А вот так выглядит модель машины
    <?php
    
    /**
     * @property integer $id
     * @property integer $user_id
     * @property integer $car_id
     * @property integer $speed
     */
    class Car extends BaseCar
    {
    	/**
    	 * Returns the static model of the specified AR class.
    	 * @param string $className active record class name.
    	 * @return Car the static model class
    	 */
    	public static function model($className=__CLASS__) {
    		return parent::model($className);
    	}
    
    	/**
    	 * Получает реестр всех машин пользователя
    	 * @param int $userID
    	 * @return ModelList
    	 */
    	public function registry($userID) {
    		$list = $this->findAllByAttributes(array('user_id'=>$userID));
    		$res = ModelList::make($list);
    
    		// у всех машин сохраняем состояние
    		$res->callMethod('memoryAttributes');
    
    		return $res;
    	}
    
    	/**
    	 * Создаем, но не сохраняем. Используется, когда мы хотим положить в ModelList через pushObject
    	 * @param int $userID
    	 * @param int $carID
    	 * @return Car
    	 */
    	public static function make($userID, $carID) {
    		$obj = new Car();
    		$obj->user_id = $userID;
    		$obj->car_id = $dict->area_id;
    		$obj->speed = 10;
    		return $obj;
    	}
    }
    


    Собственно теперь, когда мы выполним следующий код,
    $carList = &Yii::app()->modelRegistry->registry('car', 1);
    

    то получим объект класса ModelList, который будет содержать в себе все машины игрока. Их так же можно изменять (не забывая обращаться по ссылке в $carList->list) и потом сохранять через реест моделей стандартным saveAll.

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

    Данный вариант работы с моделями удобно используется в нашем проекте, но возможно не подойдет вам.
    Все что я хотел, так это просто показать какие бывают задачи и как их можно решать.
    Alawar Entertainment
    53,00
    Компания
    Поделиться публикацией

    Комментарии 26

      +4
      Алексей, один вопрос, зачем & при работе с объектами?
        0
        Да, в самом деле :) Исправлюсь :)
        +2
        Ох ох, согласен с первым комментаром. Ведь объекты в PHP и так передаются по ссылке, а еще в Yii есть CTypedList и CAttributeCollection гляньте, вам понравится. Это конкретно для более грамотной реализации списков моделей.
          0
          Ага, спасибо за наводку.
            +1
            Они чуть больше памяти кушают.
              –1
              Зато мы чуть ближе к Java становимся.
                +1
                А зачем становится ближе к Java?
                  +5
                  чтобы потреблять больше памяти
            0
            И ещё один вопрос, как-то не понял из текста, почему спасает реестр моделей.
            К примеру у нас приходит пачка из 3х команд: увеличить опыт, купить здание и поменять имя игрока. Предположим, что опыт, деньги и имя хранится в одной таблице user.

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

            Мы сейчас сделаем так, что подгрузка пользователя произойдет при первом к нему обращении, а сохранение только после выполнения всех команд. Для этого мы сделаем реестр моделей.

            В воркер (а то и в разные воркеры) пришло 3 независимых команды, которые друг о друге ничего не знают. Кто первая, кто последняя — тоже не ясно. Каким образом добились, что модель сохранится только 1 раз?
              0
              Смотри. Три команды, которые как-то изменяют поля пользователя
              Обработчик запускает команды поочередно. Команды через реестр получают модель пользователя и изменяют поля.
              Сохранение происходит в обработчике после обработки всех команд. При этом сохраняются все модели, которые были получены командами.

                0
                Ага, ясно, а что значит «после обработки всех команд»? Каким образом определяется, что команды кончились?
                  0
                  Из очереди достается пачка с командами. Это массив. Вот как все они были выполнены — обработка завершена.
                  Про очередь писал в прошлом посте — habrahabr.ru/post/176497/
                  Там правда немного изменился уже принцип работы, но суть та же.
                    0
                    Вот так и знал, что часть поясняется в предыдущем посте :)
                    Спасибо.
              0
              Зачем вы переопределяете save? Чем вас beforeSave не устроил? Возвращаете false когда изменений не было и все. По поводу реестра моделей — интересная идея, но сам вижу немного другую реализацию.
                0
                Потому что beforeSave вызывается внутри методов update и insert у CActiveRecord, а у нас в переодпределенном save вызывается update метод, при этом указывается, какие поля изменились
                  0
                  Так же save от CAvtiveRecord выполняет валидации, которые нам не нужны
                    0
                    Согласен, но и это можно обойти. Я за события, так как с ними легче вынести логику в поведение.
                      0
                      У нас не событие. Мы конкретно меняем логику работы сохранения, по этому используется данное решение.
                  0
                  Довольно неплохо. Мне понравилась идея с реестром моделей.
                  PS:
                  1. memoryAttributes лучше положить в afterFind и afterSave родителя ExtActiveRecord
                  2. Yii::app()->modelRegistry->registry('car', 1); — теряется наглядность: по логике кажется что происходит запрос модели car с id = 1, но мы получим ModelList.
                  стоит завести модель UserCars который реализует интерфейс Iterator.
                  Yii::app()->modelRegistry->registry('userCars', 1); — вернет объект UserCars, наглядность вернулась :)
                  3. ошибка в методе Car::make, $dict->area_id — не существует
                    0
                    Я приводил примеры кода из текущего проекта. Пришлось часть убрать, которая не нужна. Ошибка с $dict->area_id все таки прошла :)

                    По первому пункту — вызывается в registry, так как у нас уже реализована работа с кэшем, а после получения данных из кэша не происходит afterFind
                    0
                    Еще у CActiveRecord есть хороший метод populateRecord а также populateRecords что позволяет хранить списки моделей в виде массивов и только при вызове нужного метода разворачивать их в объекты.
                      0
                      ага, интересно. Спасибо за наводку
                      +1
                      Зачем вы оставляете в классе User метод model, если не хотите оставлять в нем стандартный код? LSB позволяет вынести этот метод в родительский класс, если конечно у вас php > 5.3
                        0
                        Ага, в самом деле
                        Сделал так. В ExtActiveRecord добавил

                        /**
                        * Избавляет от model() в моделях
                        * return CActiveRecord
                        */
                        public static function model() {
                        return parent::model(get_called_class());
                        }

                        и убрал в моделях (кроме базовых) метод model.

                        Такой вариант предлагали?
                          +1
                          Да, именно такой.
                          Единственное, что можно добавить, это phpdoc к классу User

                          method static User model(string $class = __CLASS__)

                          чтобы IDE нормально отдавала методы для этой модели.
                            0
                            ага, спасибо за информацию

                      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                      Самое читаемое