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

в 9:44, , рубрики: activerecord, php, yii, Блог компании «Alawar Entertainment», метки: , ,

Всем привет!

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

Напомню, что в проекте используется 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.

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

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

Автор: anonimizer_me

Источник


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js