Facing refucktoring

История одного рефакторинга, или о сказ о том как не надо разрабатывать на PHP…

Началось все как всегда, откуда-то появляется заказчик весь в слезах и трансе, орет не своим голосом: «Спасите, помогите, мой проект тормозит, от меня пользователи бегут. У вас неделя…»

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

Такую клевую штуку рисует 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 .= '&nbsp;&nbsp;&nbsp;&nbsp;';
}

Если нам надо обрезать строку на 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? -->
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;

Применение 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. Все имена вымышлены, совпадения случайны…

33 thoughts on “Facing refucktoring”

    1. не заметил, да, главное в рефакторинге итоговый результат?
      можно было бы вообще не лезть в код и надеть на сайт WSS

      1. оно правда не очень отображает, т.к. по хорошему надо было бы сравнивать только оптимизированные страницы с неоптимизированными.

        + логгер был прикручен уже после добавления мемкешеда, а до того среднее время генерации страницы было ~30 секунд.

  1. А каков итог ускорения?
    Со скольких на сколько стало быстрее?
    зы: код пахнет индусами, им за количество строк платят

    1. Да, действительно, сейчас выложу сравнительную таблицу.
      P.S. Клепалось нашими…

    2. Помоему от кода за версту несет украинусами. И удивительного ничего нет,- пока что, всё ещё, именно так и выглядит 90% кода изготавливаемого вполне себе коммерчески успешными компании на территории ссср.

      Хотя почитать про сам процесс рефакторинга было очень даже интересно, спасибо.

    3. зы: код пахнет индусами, им за количество строк платят

      вот пример рекурсивного обхода директории в одну строку

      new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory), true);

      сколько это занимает со многими строками описывать не буду.

      так что, я получается меньше получу… бред какой-то…. А скорость, а опыт…

  2. Что-то скриншоты при ресайзе совсем нечитабельные стали.

    Я правильно понимаю что “Q” – это вполне себе такая коммерчески успешная контора?

    Хотя я бы не переживал. Чем больше таких контор на рынке, тем более оплачиваемой будет работа у хороших спецов :)

  3. Вам ребята молоко за вредность надо. Санитары говнокода

  4. Со слов “Zend Framework” я был убит. Лично для меня понятия ЗФ и производительность – несовместимые)

  5. Довольно странно слышать об «оптимизации изображений для web», когда скриншоты таблиц в этой же статье приводятся в формате JPEG. ;-)

  6. Я недавно тоже оптимизировал некий шаблонизатор, который тоже в цикле юзал str_replace:

    while() {
        $result .= str_replace($key, $val, $result);
    }

    после того как я переделал на нечто вида:

    while() {
        $keys[] = $key;
        $values[] = $val;
    }
    $result = str_replace($keys, $values, $result);

    Производительность улучшилась на 50% наверное, уже не помню… но больше 10%

  7. Индусский говнокод цветет и пахнет. Не завидую вам ребята, так как сам не так давно проводил рефакторинг кода одной знаменитой украинской компании, после которого производительность выросла на 57%.

  8. Антон, большое спасибо за статью!!!
    Я давно так не ржал! Это действительно, настоящий пятничный пост )))

  9. Грустно. Очень грустно.
    Но мне интересно – вы знаете что во многих компаниях нет code review?
    Вы знаете что в маленьких компаниях часто нет отделов QA?
    Вы знаете что в маленьких компаниях часто есть заказчики, которые не хотят много платить а хотят “проект и быстро!” – и поэтому берётся старый код, быстро-быстро натягивается новый дизайн и продаётся.
    Оптимизация? Когда мной была упомянута оптимизация, мой знакомый фрилансер с большим опытом выпучил глаза – а зачем ты её делаешь, тебе за неё не заплатят! Моё объяснение что “я её делаю потому что я не хочу чтобы мне было стыдно за мою работу” его не убедило, он всё-таки хотел покрутить пальцем у виска.
    Почитайте любых классиков программирования. Вот не помню сейчас где именно, по-моему в “Совершенном коде” у МакКонелла написано: “Программа не должна быть идеальной. Как бы вам не хотелось сделать её идеальной, это неразумно. Программа просто должна удовлетворять клиента/быть достаточной/выполнять те функции которые запрошены.”

    1. Несомненно Макконнелл прав, но в данном случае программа перестала удовлетворять клиента (если вообще когда-то удовлетворяла). Несомненно и Вы правы, что практически нигде нет code review и других активностей, направленных на повышение качества кода, и наличие такого рода проектов не удивительно… Но удивителен настолько некачественный код, настолько “своеобразные” решения, да и цель поста, по-моему мнению – показать, как можно хоть немного исправить это.

      Также есть очень хорошее видео от Дяди Боба из которого следует, что сознательно писать плохой код просто не профессионально.

  10. Дякую, дуже цінний досвід.
    З Webgrind можливо створити граф переходів по функціях?

  11. Антон, а имя название компании озвучьте) А то очень оригинальные решения применяют.
    Под конец рабочего валяюсь под столом)))
    Спасибо большое)

  12. маловастенький прирост, это всего в пару раз чтоли? кешируйте дальше, хотя бы до 0.5 главную догоните, а то несерьезно :)

    а дерьмокодеров да, хватает :(

  13. Весело конечно:) Когда читаю подобного рода творения, удивляет как они до такого додумывались? Некоторые вещи не подвластны нормальной логике и им точно не учат в статьях и книжках.

  14. margin, что такое margin?
           насчет ПХП помолчу, сам 3-й месяц вебдизайном занимаюсь, а вот эта фраза из неразрывных пробелов зацепила за душу – примерно так выглядел мой первый личный сайт :) Судя по посту, меня ждет великое вебдизайнерское будущее, осталось открыть фирму S и захватить мировой рынок :)

  15. HTML-“код” просто таки доставил (((: class-style…да они просто ниндзя!) Идея достойная – объединить и класс и стиль ))

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.