Історія одного рефакторінгу, або оповідь про те, як не треба розробляти на PHP…

Почалося все як завжди, звідкись з’являється замовник весь у сльозах і трансі, кричить не своїм голосом: «Рятуйте, допоможіть, мій проект гальмує, від мене користувачі біжать. У вас тиждень…»

Розгорнули проект на нашому сервері — н-да уж… Далі був аналіз цього творіння фірми «Qwerty123» (хто стежить за мною, знає про яке ” українській фірмі мова), сміх крізь сльози, істерика протягом тижня. Ну для початку був узятий XDEBUG і подивилися профайлером що там і як (кликабельно):

Таку кльову штуку малює Webgrind, зручно і наочно. Інші тулзы для профайлера можна знайти на домашній сторінці, якщо ж не хочете морочитися в PHPStorm є вбудована програма для перегляду 🙂

Тобто у нас сторінка, яка виводить привітання + вибір мови + вибір категорії для перегляду (захардкоденных до речі) генерувалася за 4,6 секунди на сервері з Core2 Quad CPU [email protected]/16Gb (істерика і промені ненависті конторі «Qwerty123»).

Куди більш моторошна картина чекала нас на головній сторінці:

До застосування Zend Framework’a нас відділяло ще кілька тижнів оптимізації р..коду…

FastTemplate такий «Fast»

Якщо придивитися, то на попередньому скріні видно, що дуже дорого нам обходиться якась функція parse_body():

Всередині нас чекає:

  • виклик strtoupper — 56560 разів
  • виклик str_replace — 56560 разів

Дивимося на код:

// $content – шаблон
// $rec – змінні
$content = preg_replace(“/\\$([A-Z][A-Z0-9_]+)/”, “@\\[email protected]”, $content);
if (is_array($rec)) {
foreach ($rec as $key => $value) {
$name = strtoupper($key);
$content = str_replace(“@[email protected]”, “$value”, “$content”);
}
}
return $content;

Тобто в самому початку ми шукаємо в шаблонах щось подібне $HTTP_PATH, замінюємо це на @[email protected], потім перебором намагаємося замінити всі відомі змінні. Є одна проблема — шаблонів у нас багато, в них використовується зовсім трохи з пари сотень змінних. Проста заміна 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 = $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 можна було підучити:




  • Застосування 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
    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”

    А далі все по порядку:

    • Оптимізація зображень для 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

    Static pages

    Item’s page

    4 663ms 2 759ms 699.5 Kb 288.0 Kb
    3 115ms 2 008ms 295ms 263.3 Kb 166.2 Kb
    3 082ms 1 745ms 180ms 589.1 Kb 260.8 Kb

    Ще наочний скріншот середнього/максимального часу генерації сторінок по датах: http://screencast.com/t/NDY1NGE5

    P. S. Всі імена вигадані, збіги випадкові…