Достаточно много вопросов поступило после прошлых статей относительно моей непосредственной роли в жизни проекта – все сводилось к желанию узнать технические подробности, не составляющие базовую логику мира, а непосредственно поддерживающие существование всего задуманного. На чьи-то вопросы уже были даны ответы, но некоторые моменты остались за кадром. Я долго пытался сообразить, что же такого хорошего я могу поведать про систему, что не являлось бы «банальными решениями», но было бы действительно необычным. Таковых, действительно архиважных и необычных, на мой взгляд, моментов так и не нашлось.
Конечно, в исходниках содержатся некоторые интересные места, но они специфичны конкретно для нашего проекта и подойдут далеко не всем. Об этих слегка выделяющихся из общей массы функционала я и желаю рассказать, но не следует ждать уровня «монстров» индустрии – все решения глубоко интегрированы в логику самого проекта и являются её выводами из поставленных задач.
Как и в прошлый раз, рассказ будет вестись с точки зрения одного из членов команды, занимающихся разработкой проекта с самого его зарождения – программиста, то есть на сей раз — меня.
Труд разработчика
Сколько бы я не смотрел на блок от «хантим» на страницах «хабра», сколько бы не встречал предложений долевого участия в проектах – я всегда скептически относился к ним. Обычно есть «полная команда», но не хватает только разработчика, а предложение заключается в крошечной доле и огромном объеме работ.
До сих пор я не могу ответить на вопрос — что же мной двигало, когда я соглашался на участие в нашем проекте? Ясно было сразу — работа затянется не на один год, а реальной физически ощутимой прибыли может и не последовать вовсе. Видимо, так сложились звезды, что я оказался неожиданно лоялен к команде, а команда приняла именно меня.
Дальние знакомые позвали близких и дальних знакомых, в итоге сложилась команда, в итоге близкие знакомые ушли из команды, в итоге дальние знакомые стали близкими друзьями.
Благодаря этому проекту объем получаемого опыта был всегда стабилен вне зависимости от моей основной работы, где обычно поток новых знаний заканчивался через пару месяцев, и начиналась рутина. По крайней мере, я надеялся, что этот проект станет дополнительным преимуществом для меня при устройстве на новую работу, если это потребуется. Однако позднее оказалось, что работодателям такое не интересно – их всегда интересовали только предыдущие места работы, за которые я получал прямое вознаграждение.
Единственное, что оставалось делать – это вести свой проект с надеждой на то, что когда-нибудь он станет основным, и я буду работать бок о бок с товарищами по духу. Что ж, опыт – значит опыт.
Не топчите, я только что подмел
Самое первое, о чем я хочу сказать про разработку проекта – не важно какого он масштаба и сложности — держите по возможности его в чистоте. Если не для других разработчиков, которые придут после вас, то для себя любимых. Сколько раз мне попадались в доработку чужие проекты, столько же раз я удивлялся тому, как люди могут жить в таком бардаке.
Непосредственно на старте у нас существовал лишь один модуль – «панель администрирования», остальное (пользовательская часть) потихоньку «пилилось» в группе контроллеров вне модуля. Но этому настал предел, когда количество контроллеров перевалило за десяток, а модуль панели администрирования своим набором директорий перестал помещаться в экран при просмотре дерева в IDE. Категоричным решением было полностью переработать структуру проекта и расфасовать всё по различным модулям, включая вложенные модули для панели администрирования. Это привело в проект некий «дзен» и легкую доступность каждого раздела. Благодаря быстрой «замене по строкам» привели в соответствие и базу, хотя впоследствии все равно пришлось дочищать руками.
Подобные глобальные манипуляции гораздо легче переносятся, когда объем кода только-только подходит к критической отметке, и проект еще не находится в боевых условиях – это спасет от дальнейшего сложного рефакторинга, и позволит дальше поддерживать крепкую структуру проекта.
При объеме базы данных в 135 таблиц также нельзя устраивать кучу в папке с моделями – с самых зачатков проекта «модельки» раскладываются по директориям с тройной вложенностью, чтобы не создавать хаос (даже сортировка по алфавиту не спасает от поиска в 30 файлах, начинающихся с Bird~). Кто-то может подумать, что 2-4 клика при поиске нужного файла для редактирования – много, но для меня и для координатора проекта данный порядок оказался гораздо лучше, чем попытки скроллинга колесиком мыши в поисках одного файла из полутора сотен.
Любая строгая структура проекта, дополняющая основную архитектуру, всегда позволит легче ориентироваться в нем, и код автоматически становится на отметку выше по качеству. Даже при наличии сомнительных решений на скорую руку я все еще не считаю проект плохо написанным и верю, что когда настанет пора принять в команду дополнительных программистов, то они с легкостью будут ориентироваться в коде.
При этом мне изначально было понятно, что свою роль в написании кода проекта сыграет и наш координатор – он в прошлом делал свои сайты, насколько мог и насколько хватало его знаний, но заменить полноценного разработчика он все равно не может. Однако при этом он запросто может избавить меня от задач по точечному изменению коэффициентов или исправления мелких недочетов верстки, и это была одна из причин дополнительных улучшений структуры кода.
Все формулы, в которых присутствуют численные коэффициенты, и которые участвуют в критически важных расчетах, полностью вынесены в отдельный класс Formula. Во-первых, мы избавились от наличия констант в коде – мы просто пишем числа в формулах, и в этом нет ничего плохого, так как класс и его методы именно для этого и предназначены. Во-вторых, мы получили единую точку системы, которая позволяет править баланс игры, не бегая по остальному проекту.
Кроме того, в Yii есть готовые механизмы для поддержки мультиязычности – в нашем проекте пока не предполагается нескольких языков, однако этот механизм помог нам отделить литературные тексты описания ошибок от «механических», написанных мной в процессе создания функционала. И опять же – все тексты ошибок или удачных действий собраны в нескольких файлах, и нет необходимости выискивать тексты по всему проекту.
По ту сторону кода
Поскольку весь наш проект построен на времени ожидания пользователем «свершения какого-либо действия» (окончания работы, завершения таймера, обновления списка чего-либо и так далее), то, несомненно, большую часть такой работы должен выполнять сервер вне зависимости от присутствия пользователя онлайн.
Некоторые предполагали, что для работы «серверной» части проекта нужны дополнительные приложения, написанные на более «правильном» языке программирования, обязательны новомодные технологии и тому подобное. Однако, в условиях ограниченного багажа знаний и, уж точно, не безграничных нервов и терпения, мы не могли себе позволить внедрять неизученные нами до конца технологии, и основывались на том, что есть. Соответственно, раз «морда» сайта у нас базируется на PHP с фреймворком Yii, то и «зад» проекта работает на тех же «колесах».
При этом совершенно не обязательно, что мы почувствуем просадку по производительности достаточно скоро – мы можем себе позволить некоторые хитрости, которые временно нейтрализуют недостаток технологий. А уж если у нас действительно возникнет такая проблема – это будет означать популярность проекта и наличие средств на развитие.
На текущий момент проект работает на базе сорока пяти записей в списке фоновых задач (с некоторым повторением, но не суть) – много это или мало, решит для себя каждый сам, но это явно не предел. Вот так выглядит наш список:
Самые важные задачи – расчет результатов отложенных действий пользователя – помещены в единый поток задач, названный tick, который запущен 100% времени работы сервера. Разумеется, демон на PHP – это не то, что можно заставить работать нормально при стандартном
К слову о «тике» — его работа должна быть быстрой, и она не терпит никаких задержек даже на доли секунды, так как на другой стороне системы может ждать пользователь, у которого страница перезагрузится автоматически по истечении таймера действия, соответственно, точность времени расчета очень важна в данной ситуации. Рассчитывать заранее и начислять внутриигровую валюту сразу после начала задания мы не можем по определению – от задачи можно отказаться, в момент выполнения работы на игрока может напасть другой игрок и отобрать часть средств, имеющихся на тот момент. В данном случае мы прибегаем к небольшой хитрости и рассчитываем результаты в фоновой задаче за пару секунд до истечения срока работы – за эти секунды пользователь банально не успеет понять, что произошло, если вдруг откроет страницу раньше назначенного срока и увидит письмо о начисленном вознаграждении. На эти две секунды в момент пиковой нагрузки сервер сможет создать себе очередь игроков, которые ждут завершения задач, и успеть выполнить их до наступления времени «х».
Чтобы не грузить сервер постоянными выборками из базы с целью поиска новых пользователей, которые вот-вот должны получить результат работы, в задаче «тик» предусмотрен механизм ожидания, который позволяет динамически вычислять время бездействия. Если говорить более простым языком, то сервер не делает лишних выборок во время работы «тика», если нет пользователей, которые в скором времени ожидают выполнения работы.
Чтобы иметь структуру списка фоновых задач и не потерять ничего при деплое, список составляется автоматически из отдельного конфига, где в многомерном массиве хранятся данные о каждой задаче. Список команд генерируется в читаемом виде при помощи отдельной консольной команды “php yiic.php crontab”, которая фактически деплоится вручную одной командой через “php yiic.php crontab | crontab –u production –“, переписывая список задач сервера для нужного окружения.
Помнить все
Кто бы знал, что одной из самых жестоких вещей на проекте окажется кэш – с момента появления его зачатков в коде мы постоянно натыкались на нестандартные ситуации, когда кэш мешал нормальному функционированию. Постепенно набираясь опыта, приходилось решать вопросы кэширования чуть ли не радикальными методами.
Во-первых, на кэше держится примерно половина проекта – невозможно адекватно быстро работать системе, где на главной странице персонажа показано столь много информации, что для этого задействуется половина базы данных. Часть данных обновляет сервер в фоновом режиме посредством периодических расчетов и принудительной перезаписи кэшированных данных (например, части статистики). Часть данных просто теряет свою актуальность и загружается по мере востребования пользователем (например, количество людей онлайн). И еще кусочек данных обновляется по мере наступления определенных событий – в частности «поступление нового письма» обновляет список последних писем в «сайдбаре».
Во-вторых, на кэше держится огромный пласт выборки из базы. На проекте существует информация, которая не меняется по велению пользователя, а только по строгому указу администрации – характеристики предметов (за исключением голосования при изобретении оных), методы добычи валюты (тексты к ним, настройки для них), характеристики построек, погода, награды, подарки – всего около 35 наименований таблиц. Правильным, на мой взгляд, решением было умять их в перманентный кэш до тех пор, пока не придет команда «свыше» на изменение данных. Модели будут сопротивляться, ставить палки в колеса, но прирост скорости того стоит.
Для работы перманентного кэша избранных моделей была дописана крохотная прослойка для СActiveRecord, которая при попытке обращения к CDbConnection определяла необходимость указания на использование кэша – и при удобном случае подставляла стандартными методами соответствующую директиву. Фактически я создал себе достаточное количество головной боли, на тему того — каким образом устанавливается кэширование в прослойке. Загляните в исходники Yii и посмотрите, что происходит при установке «Model::model()->cache()». Для ленивых скажу, что в этом случае поступает указание «кешируй следующий запрос в базу данных», и нет никакой гарантии, что «следующий запрос» будет именно из источника указания.
Дополнительной проблемой такой схемы работы кэша стала точка входа в кэш – вызов CDbConnection происходил как для основной искомой модели, так и для всех моделей, которые идут в relation-связке (если такие указаны в запросе), причем вызов от related-моделей происходит позже, чем от основной, соответственно, все запросы с join неизбежно попадали в перманентный кэш.
Понаставив несколько костылей в виде «я абсолютно уверен, не надо кэшировать этот чудесный запрос с join’ом статической модели», мы получили работоспособное решение, но только при одном условии – все выборки по «статичным» моделям должны производиться строго без join’ов там, где это возможно. То есть если на странице отображается таблица со списком предметов персонажа – это означает, что был сделан один запрос в таблицу «bird_item» за связкой bird_id <-> item_id, и тьма отдельных запросов на получение единичных данных по каждому предмету, которые волшебным образом уже лежат в кэше. Для справки – до «прогрева» кэша на главной странице персонажа в базу данных отправляется более 230 запросов. После «прогрева» — всего 19.
/**
* Permanent cache for models
*
* Component "dbParam" is storring active parameters in database
* Config parameter "staticModelList" consists of model names should be in permanent cache
*
*/
class ActiveRecord extends CActiveRecord
{
/**
* Flag are cache setings already setted
*/
private $_cacheUpdated = false;
/**
* Setting cache for static data
*
* @return CDbConnection
*/
public function getDbConnection()
{
if (!empty($this->_cacheUpdated)) {
return parent::getDbConnection();
}
$className = get_class($this);
if (in_array($className, Yii::app()->params->staticModelList) !== false) {
$dependency = new CExpressionDependency('Yii::app()->dbParam->staticDataLastUpdateTime . "_' . $className . '_" . Yii::app()->dbParam->schemaLastUpdate');
return parent::getDbConnection()->cache(Yii::app()->db->schemaCachingDuration, $dependency);
}
return parent::getDbConnection()->cache(0);
}
/**
* Setting flag as updated cache
*
* @param int $duration
* @param null $dependency
* @param int $queryCount
* @return CActiveRecord
*/
public function cache($duration, $dependency = null, $queryCount = 1)
{
$className = get_class($this);
if (in_array($className, Yii::app()->params->staticModelList) === false) {
$this->_cacheUpdated = true;
}
return parent::cache($duration, $dependency, $queryCount);
}
/**
* Save model and drop static-model cache condition
*
* @param bool $runValidation
* @param null $attributes
* @return bool
*/
public function save($runValidation = true, $attributes = null)
{
if (in_array(get_class($this), Yii::app()->params->staticModelList) !== false) {
Yii::app()->dbParam->staticDataLastUpdateTime = time();
}
return parent::save($runValidation, $attributes);
}
/**
* Drop model and drop static-model cache condition
*
* @return bool
*/
public function delete()
{
if (in_array(get_class($this), Yii::app()->params->staticModelList) !== false) {
Yii::app()->dbParam->staticDataLastUpdateTime = time();
}
return parent::delete();
}
}
Принцип подобного кэша вводился далеко не с самых «пеленок» проекта, а примерно за несколько месяцев до альфа-тестирования. Найденные «баги» закравшихся join’ов «статичной» модели правились уже на рабочей версии проекта. Поэтому оперировать такими штуками приходится с особой осторожностью.
Шаблонный код
Несмотря на то, что у меня за плечами есть некоторый опыт работы с крупными проектами, мне никогда не приходилось возиться с паттернами проектирования (ну разве что стандартные MVC и singleton, но эти не в счет). Я даже изучал эту тему неоднократно, но она просто не откладывалась у меня в голове, и при необходимости внедрения какой-то архитектурной особенности, никогда не всплывала в памяти.
Однако, как оказалось несколько позднее, если «переизобрести» паттерн самостоятельно, то он постоянно будет кочевать из проекта в проект, но далеко не факт, что когда-то придет понимание, что это на самом деле «он» — это просто будет «хорошее решение», которое станет привычкой.
Таким паттерном у меня получился «strategy», с помощью которого в моих проектах реализованы так называемые «переключаемые поведения». В Yii есть замечательная вещь – behavior – которая позволяет организовывать «горизонтальное» наследование классов (грубо говоря, trait, но реализованный на уровне PHP-кода внутри фреймворка с давнего времени). Именно на таких классах-поведениях основываются некоторые элементы системы, требующие различных результатов работы в зависимости от состояния объекта.
Приведу пример. На проекте реализованы погодные условия – туман, солнечно, дождь, облачно, шторм и еще несколько других вариантов – которые несут в себе различные эффекты, влияющие на поведение игры. Например, при облачной погоде игроку может быть выдан бонус «дополнительного шанса» найти игровую валюту, либо при наступлении солнечной погоды после шторма есть шанс рассылки всем игрокам «шторм-пайка», включающего в себя небольшое денежное пособие «на восстановление после непогоды».
Таким образом, каждый тип погоды может содержать один или несколько эффектов, которые должны иметь под собой железную основу в виде кода и обычных параметров в базе данных. Полотном из условных операторов в данной ситуации отделаться не удастся. В нашем случае все решилось через «переключаемые поведения».
Во-первых, необходим базовый класс-поведение, который по умолчанию всегда будет подключен к оперируемой модели (можно и не к модели, но для текущей задачи – именно к ней), который должен содержать в себе методы для выбора нужного конечного поведения и методы-заглушки для возврата значений по умолчанию, если конечное поведение не используется. Во-вторых, нужен набор классов-поведений, которые фактически выполняют необходимые функции.
Для погодных условий был выделен список методов, которые фактически и составляют проверки на эффекты – это «получение коэффициента» (соответственно, в нужных местах проекта существует вызов и использование дополнительного коэффициента для расчетов), «активна ли постройка» (с соответствующими проверками в формулах), «активно ли умение», «выполнить при активации» (метод для выполнения при наступлении нового эффекта погоды). В базовом классе представлены все эти методы с проверкой «подключено ли конечное поведение» и возвратом значения по умолчанию, а в конечных классах-поведениях же содержится реальный код (которого достаточно мало и в котором легко ориентироваться, который легко править). При проверке (попытке в первый раз выполнить одну из функций), происходит отключение текущего базового класса от оперируемой модели и подключение конечного класса-поведения.
Фактически работает некий тумблер-переключатель, подключающий нужный класс в зависимости от состояния объекта.
/**
* This class is appended as behavior to WeatherHistoryEffect and provides
* real effect actions for current effect
*
* Class WeatherEffectBehavior
*/
class WeatherEffectBehavior extends CBehavior
{
/**
* Is weather enabled in admin settings
*
* @return bool
*/
private function isWeatherEnabled()
{
$isWeatherDisabledByEvent = GlobalEvent::model()->getValue(GlobalEvent::ACTION_WEATHER);
return empty($isWeatherDisabledByEvent) && (!empty(Yii::app()->par->weatherEnabled));
}
/**
* Activate weather effect
*
* @return bool
*/
public function activate()
{
$owner = $this->getOwner();
if ($this->isWeatherEnabled() && $this->checkEnabledBehavior()) {
return $owner->activate();
}
return false;
}
/**
* Checking if building is disabled during weather effect
*
* @param int $building
* @return bool
*/
public function isBuildingDisabled($building)
{
$owner = $this->getOwner();
if ($this->isWeatherEnabled() && $this->checkEnabledBehavior()) {
/**
* @var WeatherEffectBehavior $owner
*/
return $owner->isBuildingDisabled($building);
}
return false;
}
/**
* Checking if action is disabled during weather effect
*
* @param int $action
* @return bool
*/
public function isActionDisabled($action)
{
$owner = $this->getOwner();
if ($this->isWeatherEnabled() && $this->checkEnabledBehavior()) {
/**
* @var WeatherEffectBehavior $owner
*/
return $owner->isActionDisabled($action);
}
return false;
}
/**
* Checking if building is disabled during weather effect
*
* @param $type
* @return bool
*/
public function getCoefficient($type)
{
$owner = $this->getOwner();
if ($this->isWeatherEnabled() && $this->checkEnabledBehavior()) {
/**
* @var WeatherEffectBehavior $owner
*/
return $owner->getCoefficient($type);
}
return 1;
}
/**
* Generating text value of effect
*
* @return null|string
*/
public function getTextValue()
{
$owner = $this->getOwner();
if ($this->isWeatherEnabled() && $this->checkEnabledBehavior()) {
/**
* @var WeatherEffectBehavior $owner
*/
return $owner->getTextValue();
}
return null;
}
/**
* Generated weather effect description
*
* @return null|string
*/
public function getPublicText()
{
$owner = $this->getOwner();
if ($this->isWeatherEnabled() && $this->checkEnabledBehavior()) {
/**
* @var WeatherEffectBehavior $owner
*/
return $owner->getPublicText();
}
return null;
}
/**
* Switching behaviors for effect model
*
* @return bool
*/
public function checkEnabledBehavior()
{
$behavior = $this->getOwner()->asa('weatherFinalEffectBehavior');
if (empty($behavior)){
if (!empty($this->getOwner()->effect)) {
$behaviorName = false;
switch ($this->getOwner()->effect->effect_id) {
case WeatherEffect::TYPE_CONES_SEARCH_MAX_CHANCE_COEFFICIENT:
$behaviorName = 'ConesSearchMaxChanceCoefficientBehavior'; break;
case WeatherEffect::TYPE_CONES_SEARCH_TIME_COEFFICIENT:
$behaviorName = 'ConesSearchTimeCoefficientBehavior'; break;
case WeatherEffect::TYPE_COINS_HUNT_TIME_COEFFICIENT:
$behaviorName = 'CoinsHuntTimeCoefficientBehavior'; break;
case WeatherEffect::TYPE_COINS_HUNT_BONUS_COEFFICIENT:
$behaviorName = 'CoinsHuntBonusCoefficientBehavior'; break;
case WeatherEffect::TYPE_DEFENCE_IDOLS_DO_NOT_WORK:
$behaviorName = 'DefenceIdolsDoNotWorkBehavior'; break;
case WeatherEffect::TYPE_ATTACK_IDOLS_DO_NOT_WORK:
$behaviorName = 'AttackIdolsDoNotWorkBehavior'; break;
case WeatherEffect::TYPE_ATTACK_TIMER_COEFFICIENT:
$behaviorName = 'AttackTimerCoefficientBehavior'; break;
case WeatherEffect::TYPE_BATTLE_WIN_COEFFICIENT:
$behaviorName = 'BattleWinCoefficientBehavior'; break;
case WeatherEffect::TYPE_CAN_NOT_SELL_ON_FLEAMARKET:
$behaviorName = 'CanNotSellOnFleamarketBehavior'; break;
case WeatherEffect::TYPE_CAN_NOT_COINS_HUNT:
$behaviorName = 'CanNotCoinsHuntBehavior'; break;
case WeatherEffect::TYPE_CAN_NOT_CONES_SEARCH:
$behaviorName = 'CanNotConesSearchBehavior'; break;
case WeatherEffect::TYPE_NO_DELAY_BETWEEN_ATTACK:
$behaviorName = 'NoDelayBetweenAttackBehavior'; break;
case WeatherEffect::TYPE_BATTLE_STATS_BONUS_COEFFICIENT:
$behaviorName = 'BattleStatsBonusCoefficientBehavior'; break;
case WeatherEffect::TYPE_BATTLE_FEATHERING_CHANCE_COEFFICIENT:
$behaviorName = 'BattleFeatheringChanceCoefficientBehavior'; break;
case WeatherEffect::TYPE_STAT_COST_COEFFICIENT:
$behaviorName = 'StatCostCoefficientBehavior'; break;
case WeatherEffect::TYPE_SHOP_PRICE_COEFFICIENT:
$behaviorName = 'ShopPriceCoefficientBehavior'; break;
case WeatherEffect::TYPE_WORK_TIMER_COEFFICIENT:
$behaviorName = 'WorkTimerCoefficientBehavior'; break;
case WeatherEffect::TYPE_BATTLE_CONES_WIN_MAX_CHANCE_COEFFICIENT:
$behaviorName = 'BattleConesWinMaxChanceCoefficientBehavior'; break;
case WeatherEffect::TYPE_STORM_RATION:
$behaviorName = 'StormRationBehavior'; break;
case WeatherEffect::TYPE_COINS_LEVEL_REWARD:
$behaviorName = 'CoinsLevelRewardBehavior'; break;
case WeatherEffect::TYPE_COINS_PERCENT_REWARD:
$behaviorName = 'CoinsPercentRewardBehavior'; break;
case WeatherEffect::TYPE_IDOLS_DO_NOT_WORK:
$behaviorName = 'IdolsDoNotWorkBehavior'; break;
case WeatherEffect::TYPE_FOLIAGE_DO_NOT_WORK:
$behaviorName = 'FoliageDoNotWorkBehavior'; break;
case WeatherEffect::TYPE_POLE_DO_NOT_WORK:
$behaviorName = 'PoleDoNotWorkBehavior'; break;
case WeatherEffect::TYPE_NECKLACE_CONES_UPGRADE_COST_COEFFICIENT:
$behaviorName = 'NecklaceConesUpgradeCostCoefficientBehavior'; break;
case WeatherEffect::TYPE_RARE_ITEM_FOUND_CHANCE_COEFFICIENT:
$behaviorName = 'RareItemFoundChanceCoefficientBehavior'; break;
}
if (!empty($behaviorName)) {
$this->getOwner()->attachBehavior('weatherFinalEffectBehavior', $behaviorName);
$this->getOwner()->detachBehavior('weatherEffect');
return true;
}
}
}
return false;
}
}
class CoinsHuntTimeCoefficientBehavior extends WeatherEffectBehavior
{
public function getCoefficient($type)
{
if ($type == WeatherEffect::TYPE_COINS_HUNT_TIME_COEFFICIENT) {
return $this->getOwner()->value;
}
return 1;
}
public function getTextValue()
{
return ($this->getOwner()->value * 100) . '%';
}
public function getPublicText()
{
return CHtml::tag('b', array(), 'Время') .
', затрачиваемое на выполнение заданий в ' .
CHtml::tag('b', array('class' => 'g18_icons i_hunt', 'title' => 'бюро расследований'), '') .
CHtml::link('бюро расследований', '/location/coinshunt') . ', ' .
CHtml::tag('b', array(), ($this->getOwner()->value < 1 ? 'меньше' : 'больше')) .
Yii::app()->formatter->percentCoefficient($this->getOwner()->value);
}
}
Да, это усложняет структуру модели, добавляет дополнительные проверки, подключения и отключения классов (и, конечно же, это дает больше нагрузки на интерпретатор), но это очень и очень сильно упрощает оперирование однотипным кодом, различающимся технически и повторяющимся структурно.
Коллапс системы
Я уже рассказывал ранее о том, что достаточную часть времени работы над проектом была потрачена на размышления на тему возможных критических ситуаций, возникающих в системе. Будь то намеренная атака пользователя или закравшаяся ошибка кода – это каким-то образом обязательно должно быть обработано системой.
Есть особенно важные участки системы, без которых нормальное функционирование игры практически невозможно – например, работа команды «tick», от которой зависит расчет всех результатов работы пользователей. Если возникает ошибка в работе такой операцииметодафункции — не важно – система должна реагировать столь же критически.
В нашем случае был добавлен режим «комендантский час», который подразумевает полное бездействие всех игроков, закрытие всего функционала игры от пользователей и полное прекращение выполнения любых фоновых задач проекта. Казалось бы, несложное в реализации решение, однако оно полностью помогает предотвратить появление «поврежденных» данных (например, неверных многократных начислений средств, которые произошли из-за сбоя в середине функции начисления). В процессе отладки на альфа-тестировании подобная защита не раз срабатывала ночью, когда никто из администрации не мониторил состояние системы – лучше получить недоступную игру на пару-тройку часов, чем перекос в данных.
Этот же режим работы в дальнейшем нам пригодился для создания системы деплоя проекта. Мы реализовали горячую функцию «обновить код», которая позволяет в автоматическом режиме запустить таймер времени до закрытия игры, затем включить режим «комендантского часа», обновить код из репозитория, запустить таймер до открытия игры, и по истечении указанного времени впустить в игру ожидающих пользователей. В угоду безопасности, обновлять код проекта от имени пользователя, оперирующего веб-сервером, нельзя, поэтому специально обученный отдельный пользователь системы делает это за остальных, проверяя наличие такого указания с некоторой периодичностью.
Такой подход к обновлениям позволяет вводить безболезненно небольшие итерации нового кода в проект, не мешая администраторам предварительно проверить целостность обновления, и уведомить пользователей о событии.
Кроме всего вышесказанного, к возможной защите от возникновения проблем настроек проекта, которые хранятся в «статичных» моделях, можно отнести наш «костыль» синхронизации данных между версиями проекта – например, данные с тестового сервера, в случае их изменения, каждую ночь копируются в репозиторий, откуда сразу попадают на девелопмент-сервер, чтобы иметь актуальную среду для предварительного тестирования нового кода. То есть, если данные были изменены, то помимо сброса их перманентного кэша, ставится «активным» задание на их сохранение в репозиторий. И только в том случае, если сервер проекта помечен как «источник» (например, тестовый сервер является таковым, а dev-сервер нет) – так мы можем восстановить старые значения или перенести значения на другие версии проекта.
Хранение данных в репозитории осуществляется без каких-либо хитростей – это обычные файлы с serialize()-содержимым моделей, где на один файл приходится одна запись таблицы (объем данных на текущий момент такой, что одного файла недостаточно для всех данных целиком, чтобы не возникало проблем с нехваткой памяти при сериализации-десериализации).
Разделить, чтобы восстановить
До тех пор, пока в команде не было системного администратора, приходилось рассчитывать только на свои силы и знания в вопросе создания, хранения и восстановления бэкапов на случай возникновения непредвиденных ситуаций.
Сейчас же у нас реализован бэкап базы данных в обход mysqldump и блокирования базы, что позволяет восстановить архив гораздо быстрее, чем это было бы при помощи пачки запросов из дампа. Но также восстановление возможно только целиком, разом все имеющиеся базы на сервере. В скором времени нам обещают другой принцип бэкапа-восстановления – уже при помощи особых утилит от Percona, но о них я пока сказать ничего не могу.
Для собственного успокоения на старте проекта была придумана система разделения базы данных проекта на две части – одну полноценную самодостаточную часть (включающую весь набор таблиц), и вторую, включающую только те данные, которые можно ненадолго потерять, не нарушив при этом работоспособность проекта целиком. Иными словами, вторая часть – «логовая» база данных – содержала в себе таблицы с письмами, логами системы, историей проведенных боев и прочие аналогичные данные, которые накапливаются в очень большом количестве, но не имеют в себе реальной ценности для работоспособности системы в целом. По умолчанию все «логовые» данные записываются именно в отдельную базу данных при наличии доступа в нее, а если по каким-то причинам доступ во вторую базу данных пропадает – система переключается на основную базу (где по умолчанию в искомых таблицах данных нет, но в них можно временно записать новые данные, которые позднее перенесутся в дополнительную базу).
Таким образом, если у нас вдруг сгорит сервер, и не будет «горячей» замены, а только дампы двух баз данных, то основную базу данных (с важной информацией, поддерживающей всю игру) мы восстановим за 5-20 минут и запустим проект дальше, предупредив игроков, что произошел коллапс, и остальные данные подтянутся позднее. А через несколько часов, когда дамп дополнительной базы загрузится на новое место, можно будет переключить систему на работу с двумя базами.
По предварительным подсчетам соотношение размеров двух баз у нас составило 1 к 70 – то есть истории сражений, писем и прочей массивной «шелухи» на проекте во много раз больше, чем критически важных данных.
Реализация такой задумки оказалась не сильно сложной – всего лишь необходимо переопределить метод поиска CDbConnection в модели, который в зависимости от типа модели пробует подключиться к нужной базе данных. Но и при этом есть свои минусы (или, может, даже и не минусы) – любые «ручные» написания SQL-запросов обязаны быть написаны с использованием метода tableName() от используемой модели таблицы, где в название таблицы автоматически подставилось правильное название базы данных.
<?php
class ActiveRecord extends CActiveRecord
{
static private $_tableCheckedPrefix = array();
/**
* Table names with DB name prefix
*
* @param $tableName
* @return string
*/
public function tableNameWithPrefix($tableName)
{
if (empty(self::$_tableCheckedPrefix[$tableName])) {
$matches = array();
preg_match("/dbname=([^;]*)/", $this->getDbConnection()->connectionString, $matches);
return $this->getCheckedTableName($matches[1], $tableName);
}
return self::$_tableCheckedPrefix[$tableName] . '.' . $tableName;
}
/**
* Creating static var cache of table names
*
* @param $tableName
* @param $dbName
*/
public function setCheckedTablePrefix($tableName, $dbName)
{
self::$_tableCheckedPrefix[$tableName] = $dbName;
}
/**
* Creating static var cache of table names
*
* @param $dbName
* @param $tableName
* @return string
*/
public function getCheckedTableName($dbName, $tableName)
{
$this->setCheckedTablePrefix($tableName, $dbName);
return $dbName . '.' . $tableName;
}
}
class LogActiveRecord extends ActiveRecord
{
static private $_dbLog;
static private $_dbEnabled = null;
/**
* Checking DB available or resetting to main DB
*
* @return CDbConnection
*/
public function getDbConnection()
{
if (self::$_dbEnabled === false) {
return Yii::app()->db;
}
if (self::$_dbLog !== null) {
return self::$_dbLog;
} else {
try {
self::$_dbLog = Yii::app()->dbLog;
if (self::$_dbLog instanceof CDbConnection) {
return self::$_dbLog;
} else {
throw new CDbException(Yii::t('yii','Active Record requires a "db" CDbConnection application component.'));
}
} catch (Exception $e) {
self::$_dbEnabled = false;
return Yii::app()->db;
}
}
}
/**
* Table name with DB name prefix
*
* @param $dbName
* @param $tableName
* @return string
*/
public function getCheckedTableName($dbName, $tableName)
{
if ($this->getDbConnection()->getSchema()->getTable($tableName) === null) {
self::$_dbEnabled = false;
self::$_dbLog = Yii::app()->db;
return parent::tableNameWithPrefix($tableName);
}
self::$_dbEnabled = true;
$this->setCheckedTablePrefix($tableName, $dbName);
return $dbName . '.' . $tableName;
}
}
Велосипед или чужие тапки
Обязательно во время разработки возникает вопрос – использовать чужие наработки какой-то части системы или сделать самому. Даже при наличии большого каталога расширений для фреймворка, данный вопрос становится все равно непростым в виду сомнительного качества кода чужих расширений.
Из того, что реально используется на проекте, не доделывался никоим образом только новомодный дебаг-тулбар, заботливо перенесенный участником сообщества фремворка из новой версии Yii2 на код версии Yii1.1. Все остальное либо было дописано поверхностно, для избавления от возникающих «нотисов» в процессе работы расширений (как, например, swiftMailer, где периодически не инициализировались переменные или не проверялись наличия элементов массива), либо практически полностью переписывалось (как в случае XDbParam, EMutex, где в принципе код не устраивал по содержимому и качеству, но хотя бы позволял на нем основываться в плане общих идей).
Большим примером мук выбора может послужить введение в проект форума, и в этом вопросе мы выбрали создание своего собственного, а не установку одного из множества готовых вариантов. Во-первых, у меня недостаточно опыта и усидчивости, чтобы легко создать тему оформления для форума, если бы мы выбрали готовый вариант. Во-вторых, сторонний форум все равно пришлось бы доделывать, чтобы внедрить свой функционал, связывающий игрока и форум (аватарки, имена, рейтинги, ссылки на игроков и так далее) — да, вполне возможно, существуют «мосты» для связи между фреймворком и форумом, но сколько бы времени мы потратили на доведение его до ума.
Создание своего собственного форума позволило нам сделать минимально необходимый функционал на базе готового проекта, при этом время, потраченное на разработку базовой основы, оказалось настолько незначительным, что им можно пренебречь. Более того, код форума полностью попал в репозиторий проекта, в миграции к базам данных, в разделение на две базы данных и автоматическую архивацию «статичных» данных. Выгода от собственной разработки перекрыла для нас «возможность сразу иметь готовый функционал в большом объеме». Постепенно форум дописывается (вводятся новые разрешающие BB-коды, новые выборки по сообщениям и прочие улучшения), но при этом проект не остановился из-за возможных возникающих сложностей от создания верстки темы оформления.
Аналогичный подход к выбору проходят и все остальные элементы системы. Чьи-то чужие наработки можно взять за основу, что-то приходится делать самому, но мы имеем набор факторов, которые помогают нам определить путь с наименьшим сопротивлением для быстрой разработки.
Власть – админам
Примерно полгода назад к нам в проект «практически присоединился» мой коллега и по совместительству «практически одноклассник», который взялся помогать нам с администрированием серверов. Данный человек не указан «в титрах» проекта, поскольку я периодически ощущаю, что он не сильно заинтересован в полном погружении в проект, и пытаюсь оплачивать его работу из нашего бюджета.
Для альфа-тестирования нам были настроены две виртуальные машины в Hetzner – vq7 и vq12, между которыми протянулась репликация баз данных и memcache типа «мастер-мастер». С основной машины на вторую дополнительно синхронизировались файлы проекта, не хранящиеся в репозитории. Таким образом мы смогли протестировать в «околобоевых» условиях возможность наличия «горячей замены» нашей системы – обе «виртуалки» работали без сбоев на всем протяжении тестирования. При этом обе «виртуалки» использовались по полной программе – в DNS-записях домена были указаны оба ip-адреса, и пользователей раскидывало по двум серверам.
К старту проекта был арендован «железный» сервер EX6S в том же Hetzner. В качестве веб-сервера работает nginx с модулем php-fpm (изначально версия PHP была 5.4, чуть позднее перешли на 5.5), база данных – Percona Server (хотя предполагалось использование просто MySQL, так как непосредственного опыта использования «фишек» «перконы» у меня до сих пор нет), кэширование на Memcache. Для кэширования исполняемого кода ранее использовался APC-кэш, но с переходом на PHP 5.5 под самый новый год пришлось использовать совершенно незнакомый нам OPcache – правда особой разницы в производительности мы не заметили, разве что совсем незначительный прирост скорости.
Мониторинг проекта осуществляется при помощи Pinba и Zabbix. Лично мне хватает «Пинбы» для быстрого просмотра моментальных отчетов по загрузке, а Zabbix полностью остался на попечение системному администратору.
На графике времени запросов пики более 2.5 секунд – это открытие страниц логов в админке, пики в 1 секунду – регистрации пользователей (при ней генерируется достаточно большое количество данных), запросы в районе 500 мс – тяжелые страницы с большими расчетами (типа расчетов боев между игроками).
Еще один момент, связанный с работой админа, — это расширение системы при возрастающей нагрузке. Для этого в нашем арсенале есть надстройка для CActiveRecord, позволяющая поддерживать репликацию типа «master-slave» — дополнительный выбор базы данных для чтения и записи. И кроме этого мы уже тестировали возможность использования двусторонней репликации, что позволит нам в итоге использовать несколько серверов для обеспечения распределения нагрузки.
Перехода на nosql базы данных пока не планируется – слишком мало опыта в этой сфере для столь глобальных изменений и полное отсутствие времени для столь масштабной корректировки курса. Но в случае успеха проекта, данный шаг вполне может помочь в снижении нагрузки.
Периодически наш админ подкидывает все новые способы мониторинга работы и качества системы – например, в последнее время включено логирование «некачественных» sql-запросов в базу, чтобы заранее при небольшой нагрузке избавиться от недочетов.
Жизнь в коде
Из всего выше описанного можно сделать вывод, что ничего особо сложного в проекте не наблюдается. Главное – уметь делать логические выводы из потребностей системы и держать крепкую структуру проекта, не превращая его в свалку.
Быть частью такого проекта (и даже не просто частью, а фактическим совладельцем проекта) – это иметь огромные возможности и таких же размеров ответственность. Фактически каждый день сейчас мы наблюдаем развитие проекта и его приближение к полноценному запуску, до которого осталось менее месяца. Несмотря на все попытки на начальных этапах выполнять только свою часть работы, каждый участник команды на текущий момент принял на себя смежные обязательства других товарищей – у нас сложился не просто совет основателей, а дружный целостный костяк выросших на проекте разношерстных специалистов, готовых вести начатое дело до конца.
Объем текущих наработок уже настолько велик, что этапы наращивания функционала ускоряются с каждым днем — на сегодняшний день количество коммитов репозитория перевалило за отметку в 9500 штук, что на 2 тысячи больше, чем отметка два месяца назад. И каждый день приходится останавливать себя при попытках провести какой-нибудь глобальный рефакторинг, позволяющий сделать проект еще красивее, при наличии более важных задач и игроков, ожидающих очередное обновление.
Закончить проект не сложно – главное видеть в нем перспективы и иметь мотивирующий фактор на стороне. Моим мотивирующим фактором является координатор проекта, тянущий на себе всю команду вперед, и образовавшееся за последние два месяца небольшое сообщество игроков нашего проекта.
Автор: brntsrs