Facing refucktoring // PHP

История одного рефакторинга, или о сказ о том как не надо разрабатывать на PHP…
Началось все как всегда, откуда-то появляется заказчик весь в слезах и трансе, орет не своим голосом: «Спасите, помогите, мой проект тормозит, от меня пользователи бегут. У вас неделя…»
Развернули проект на нашем сервере — н-да уж… Дальше был анализ сего творения фирмы «Qwerty123» (кто следит за мной, знает о какой украинской фирме речь), смех сквозь слезы, и истерика в течении недели. Ну для начала был взят XDEBUG и посмотрели профайлером что там и как (кликабельно):
Такую клевую штуку рисует Webgrind, удобно и наглядно. Другие тулзы для профайлера можно найти на их домашней странице, если же не хотите заморачиваться — то в PHPStorm есть встроенная тулза для просмотра :)
Т.е. у нас страница, которая выводит приветствие + выбор языка + выбор категории для просмотра (захардкоденных кстати) генерировалась за 4,6 секунды на сервере с Core2 Quad CPU Q6600@2.40GHz/16Gb (истерика и лучи ненависти конторе «Qwerty123»).
Куда более жутка картина ожидала нас на главной странице:
До применения Zend Framework’a нас отделяло еще несколько недель оптимизации г..кода…
FastTemplate такой «Fast»
Если присмотреться, то на предыдущем скрине видно, что очень дорого нам обходится некая функция parse_body()
:
Внутри нас ждет:
- вызов strtoupper — 56560 раз
- вызов str_replace — 56560 раз
Смотрим на код:
// $content - шаблон // $rec - переменные $content = preg_replace("/\\$([A-Z][A-Z0-9_]+)/", "@\\1@", $content); if (is_array($rec)) { foreach ($rec as $key => $value) { $name = strtoupper($key); $content = str_replace("@$name@", "$value", "$content"); } } return $content;
Т.е. в самом начале мы ищем в шаблонах что-то вроде $HTTP_PATH
, заменяем это на @HTTP_PATH@
, потом перебором пытаемся заменить все известные переменные. Есть одна проблема — шаблонов у нас много, в них используется совсем чуть-чуть из пары сотен переменных. Простая замена str_replace()
на preg_replace_callback()
дала прирост в пару секунд (т.е. около 10%).
Примеры кода
Слабонервным лучше пропустить данный абзац.
Классика плохого PHP кода, встречается у индусов и наших студентов:
function n() { global $db, $CONSTANTS, $user, ...; // много одним словом echo $CONSTANTS[HOMEPAGE_BANNER1_ID]; // думаете это константа? echo $CONSTANTS[HOMEPAGE_BANNER2_ID]; // а знаете, что внутри? echo $CONSTANTS[HOMEPAGE_BANNER3_ID]; // 1, 2, 3, которые меняются совсем не там где объявляются О_о }
Использование файловой системы вместо системы контроля версий:
tpl |-- index.tpl |-- index2.tpl |-- index3.tpl |-- index__.tpl `-- index.44.tpl
С базой данных тот же номер — таблицы categories_old, items_1 и т.д.
Если мальчик любит труд
тычет в книжку пальчик,
про такого пишут тут:
он хороший мальчик.
Это, как вы понимаете, не про наших «мальчиков», у наших 9 000 notices на главной. Да и manual’ами пользуются только слабаки:
// мы не читаем мануалов while ($row = mysql_fetch_array($res)) { if ($row) { foreach ($row AS $key => $field) { if (ereg("^[0-9]+", $key)) { unset($row[$key]); } } } $rows[] = $row; } // если чуть-чуть допилить // мелочь, конечно, но прирост ~0,1 сек т.к. имеет место 23162 вызовов while ($row = mysql_fetch_array($res, MYSQL_ASSOC)) { $rows[] = $row; }
Далее просто гениальное решение, тайный смысл этого творения я не осилил:
$sqls[] = $sql; if (is_array($sqls)) { foreach ($sqls AS $ssql) { if ($ssql) { $res = mysql_db_query($db['name'], $ssql, $db['id']); if (!$res) { return 0; } } } } else { return 0; }
Подсчет результатов поиска, что может быть проще:
// перед выполнением запроса можно подсчитать кол-во результатов // воспользовавшись функцией db_count (вызывается в 96 разных местах) function db_count($sql) { global $db; $res = mysql_db_query($db['name'], $sql, $db['id']); $result = mysql_num_rows($res); return $result; }
Постраничная навигация, и это тоже можем:
// пошел запрос к БД $res = mysql_db_query($db['name'], $sql, $db['id']); // подсчитали итого (хотя до этого уже был вызван db_count) $row_count = mysql_num_rows($res); // подсчитали сколько у нас страниц получается $page_count = floor($row_count / $pager['per_page'] + 1); // это offset $bi = ($pos - 1) * $pager['per_page']; // теперь выбираем только нужные записи for ($i = $bi; $i < $bi + $pager["per_page"]; $i++) { if ($i >= $row_count) { break; } if (!mysql_data_seek($res, $i)) { break; } if (!($row = mysql_fetch_assoc($res))) { break; } // складируем результат $new_rows[] = $row; }
Повторение строк — мы не ищем легких путей (str_repeat):
// в файле categories.sql.php // функция которая строит select для HTML $offset_string = ''; for ($i = 1; $i < $rec['level']; $i++) { $offset_string .= ' '; }
Если нам надо обрезать строку на 100 символов, и при этом не кромсать слова то вот оно решение:
$data['row']['description'] = substr($description, 0, 100); $i = 100; while (!($description[$i] == " " || $description[$i] == "_") && $i < strlen($description)): $data['row']['description'] .= $description[$i]; $i++; endwhile; if ($i < strlen($description)) { $data['row']['description'] .= "..."; }
У нас так много глобальных переменных, там есть конечно $db
, и она же передается во все функции которые работают с БД:
function db_query($db, $sql) {} function db_sql_query($db, $sql) {} function db_count($db, $sql) {} // и т.д. // но почему не так, ведь у нас одна БД function db_query($sql) { global $db; }
Пусть на море качка, но мы всегда прибережем обходные пути:
$sql = "SELECT id, name, pasw from users where name = '$_POST[username]'";
Про агрегирование в SQL мы не знаем:
// $rows - записи из БД foreach ($rows as $value) { $total += $value["price"]; }
Необходимо SEO URL? Не проблема:
switch ($params[1]): case "usageagreement": $page_id = 13; break; case "privacypolicy": $page_id = 14; break; case "termsandconditions": $page_id = 15; break; case "affiliates": $page_id = 22; break; case "aboutus": $page_id = 19; break; endswitch;
Ладно с PHP, но HTML то можно было подучить:
<!-- id такой id --> <div id="banner">...</div> <div id="banner">...</div> <div id="banner">...</div> <div id="banner">...</div> <!-- class это почти style --> <li class="padding-left:15px;">...</li> <!-- табличная верстка --> <!-- хотя не стоит 10 вложенных таблиц расписывать --> <!-- margin, что такое margin? -->
Применение Zend_Cache
Кеш спасет мир, подумали мы и прикрутили его для всех SQL запросов (благо кто-то догадался написать единую функцию db_sql_query()
) и всех вызовов parse_body()
. Для начала попробовали кешировать в файлы, на тестовом сервере это помогло, на живом — нет. Причина — у нас так много мелких шаблонов (~200 для главной), что операции с файловой системой свели на нет прирост кеширования.
Вторая попытка оказалась более удачной, решили применить memcache — прирост скорости ~180%. Какой клевый показатель, но верен лишь в 100% попадании в кеш, таким образом перед нами вырисовывалась перспектива полного рефакторинга системы.
Немного клиентской оптимизации
Ну что тут можно рассказать, простое добавление следующих правил в .htaccess
сильно облегчило навигацию пользователям, которые хоть раз заходили на сайт:
FileETag MTime Size AddOutputFilterByType DEFLATE text/html text/plain text/xml text/css text/javascript application/x-javascript <ifModule mod_expires.c> ExpiresActive On ExpiresDefault "access plus 1 seconds" ExpiresByType text/html "access plus 1 seconds" ExpiresByType image/x-icon "access plus 2592000 seconds" ExpiresByType image/gif "access plus 2592000 seconds" ExpiresByType image/jpeg "access plus 2592000 seconds" ExpiresByType image/png "access plus 2592000 seconds" ExpiresByType text/css "access plus 604800 seconds" ExpiresByType text/javascript "access plus 216000 seconds" ExpiresByType application/x-javascript "access plus 216000 seconds" </ifModule>
А далее все по порядку:
- Оптимизация изображений для web (есть такой пункт в Photoshop) — 40% на всех JPEG фалах
- Спрайты — кропотливая работа, десятки обращений к серверу можно свести к единицам
- Жмем JavaScript и CSS — ~50%
- И последним пунктом — nginx для всего этого добра
Zend Framework
А теперь расскажу о том, как проект медленно переезжает на Zend Framework. Начинается всё с простой проверки в index.php
(о да в нашей системе одна точка входа):
// список модулей, которые уже отрефакторили $modules = array ( '/search/', '/about/' ); $path = $_SERVER['REQUEST_URI']; // нас устроила такая простая проверка, // но запрос вида /search/?... уже не будет обрабатываться if(in_array($path, $modules)) { // подключаем ZF (внутри стандартный код из сгенерированного public/index.php) require 'loader.php'; exit(); } // а эта будет foreach ($modules as $module) { if (strpos($path, $module) === 0) { require 'loader.php'; exit(); } }
Если у нас не одна точка входа, то в каждом файле, которые были затронуты делаем простую вставку:
$_SERVER['REQUEST_URI'] = str_replace($_SERVER['PHP_SELF'], '/admin/', $_SERVER['REQUEST_URI']); require 'loader.php'; exit();
Что-бы забыть «глобальный» ужас, жизненно-необходимые переменный были закинуты в Zend_Registry
(а в дальнейшем закинуты в конфигурационный файл application.ini
, где им самое место).
Так же Zend_Translate
была скормлена таблица с переводами (см. адаптер array
)
Результат
Сложно судить о результате, проект находится в разработке, но вот несколько сравнительных замеров:
Time (old) | Time (re) | Time (ZF) | Size (old) | Size (ZF) | |
---|---|---|---|---|---|
Homepage | 4 663ms | 2 759ms | — | 699.5Kb | 288.0Kb |
Static pages | 3 115ms | 2 008ms | 295ms | 263.3Kb | 166.2Kb |
Item’s page | 3 082ms | 1 745ms | 180ms | 589.1Kb | 260.8Kb |
Еще наглядный скриншот среднего/максимального времени генерации страниц по датам: http://screencast.com/t/NDY1NGE5
P.S. Все имена вымышлены, совпадения случайны…