Всем привет!
Сегодня я хочу вам рассказать, как была реализована работа с кэшем в социальное игре тайм менеджере. Можете считать эту статью продолжением вот этой.
Напомню, что в проекте используется 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.*'.
Перейдем собственно к теме поста и поставим задачи, которые хотим решить:
- Уменьшить количество запросов в базу на обновление
- Уменьшить количество запросов в базу на выборку
Уменьшаем количество запросов в базу на обновление
Как вы помните по прошлой статье, у нас используется очередь для выполнения команд. И для какого-то конкретного пользователя может требоваться выполнение более 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