Недавно мне пришлось работать с проектом, который был написан на core php без фреймворков, более того, как минимум половина была вообще на функциях без ООП. Разработчик объяснял такое решение тем, что данный небольшой проект получает более миллиона уников в сутки. Но меня не устроил такой типичный случай преждевременной оптимизации, я вооружился бенчмарком, гуглом, и решил провести исследование.
Для начала более подробно о проекте и ограничениях. Действительно, данный проект предоставляет ссылку, которая может размещаться на других сайтах, и количество уников может исчисляться миллионами. Внутри по ссылке выполняется не сложный алгоритм рассчета над данными, которые берутся из БД, но жестко и надого кешируются в Memcached. Так же каждый переход по ссылке сохраняется в Redis для статистики. Я решил в любом случае переписать проект на ООП, который состоял из двух файлов, в одном из которых были собраны все функции и классы.
Функции использовались для уменьшения потребления памяти объектами. Но так ли это существенно?
Объединение всего в один файл преследовало цель сократить включение файлов, т.е. уменьшить обращения к файловой системе. Но действительно ли это такая большая проблема?
Среди моих первых же догадок, породивших сомнения в целесообразности преждевременной оптимизации в ущерб качеству и поддерживаемости кода, было то, что на сервере php 5.6 и включен opCache, памяти много и она дешевая и речь идет о малых величинах. Так же скорость выполнения скрипта после переписывания должна быть все еще достаточно быстра, чтобы выделенные ресурсы успевали освобождаться.
В итоге все мои догадки подтвердились, более того в процессе переписывания я обнаружил баг с кешем и после его исправления скорость отдачи не то чтобы уменьшилас, она увеличилась в 3 раза по сравнению с оригиналом и в 4 чараза по сравнению с ООП версией. Смог бы я обнаружить этот баг в рамках старого плохого кода? И так, по порядку.
Объединение в один файл
Объединение в один файл это старый добрый олдскульный способ разработки. В очень старых проектах, на которых не работали нормальные разработчики, еще можно встретить файлы вида common.php, tools.php, functions.php и др., в которых содержится более 5000 строк кода с функциями на все случаи жизни. По проекту раскиданы инклюды этих файлов. В итоге проект становится настоящим адом для сопровождения, т.к. при данном подходе невозможно контроллировать сложность и какие именно ф-ии включать в данный момент. В лучшем случае может быть единая точка входа, где включены все общие файлы в начале и они доступны всегда на весь проект.
Есть еще одна техника. Проект можно разрабатывать как обычно с отдельными файлами, но потом использовать утилиту для объединения файлов в один, и уже подключать получившийся файл.
Снова таки мотивом для такого подхода выступает оптимизация.
Лично я попытался реализовать данный подход. Существуют готовые библиотеки, которые реализуют объединение и используют для этого в основе своей php parser nikic/PHP-Parser.
Первая это ClassPreloader/ClassPreloader. Установил я ее версии 2.0, это было недавнее обновление и оно оказалось не рабочим. Я нашел баг и сразу же запушил реквест с исправлением, который был принят. Но в итоге воспользоваться библиотекой мне так и не удалось. Я выполнил все следуя инструкциям, но объединенный файл генерировался пустым.
Вторая это /JuggleCode. Очень сырое решение с, как оказалось, ограничениями. В issue автор пишет, что не поддерживается включение по автозагрузке, т.е. я должен писать весь проект на require, чтобы воспользоваться этим чудом.
Рабочего решения я так и не нашел.
Плюсы:
- Можно быстро писать код, например реализуя прототип или малый проект (но этот код придется переписать при росте проекта).
- Кажется, что если больше кода положить в один файл – будет меньше обращений в файловую систему и меньше нагрузка (но это не верно, как показано далее).
Минусы:
- Нет готовых утилит.
- Сложность поддержки при росте проекта.
Автозагрузка
На данный момент самые популярные виды автозагрузки это стандарты PSR-0/PSR-4 и более старая версия – PEAR автозагрузка через “_” в имени классов. По сути весь смысл автозагрузки это принятие стандарта именования классов через namespace или через имя класса (в случае с PEAR) так, чтобы можно было провести соответствие от имени класса к расположению файла в файловой системе и выполнить его подключение. В стандартном случае у нас имеется некий алгоритм соответствующий выбранному стандарту, который установлен в функцию spl_autoload_register() и когда в коде встречается не знакомый класс, то вызывается заданная в spl_autoload_register() ф-я автозагрузки, которая по сути реализует аналог ф-ии __autoload(), и нужный файл с классом подключается.
Плюсы автозагрузки:
- Файлы с классами подключатся только тогда, когда они нужны, следовательно может требоваться меньше памяти, чем в случае с одним файлом.
- Более удобная разработка, следование стандартам приводит к лучшей поддерживаемости и расширяемости.
- Есть возможности для оптимизации.
Минусы:
- Частое обращение в файловую систему (так ли это – будет видно дальше).
- Алгоритм поиска файла по имени может занимать процессорное время (но это можно оптимизировать).
Оптимизация автозагрузки
opCache
На сцену выходит opCache. В последних версиях php он включен по умолчанию и создан он для того, чтобы движок не парсил каждый раз php файл в opcode для выполнения. Ранее были уже подобные решения такие как xCache, apc и прочее. Но теперь мы иммем кеш опкодов по умочанию и это значительно улучшает скорость работы php. С включенным opCache проблема частого обращения к файловой системе становится уже не такой существенной, т.к. парсинг файлов не выполнятся каждый раз, а берется из кеша. Так же, следуя документации, при использовании opCache файлы не подключаются повторно, если они закешированы, и нет обращений в файловую систему.
Classmap
Как можно уменьшить процессорное время затрачиваемое на парсинг имени файла для получения его расположения в файловой системе? Очень просто. Давно существует такая техника, как Classmap. В самом простом и самом распространенном случае имеется в виду php массив, ключами которого являются имена классов, а значениями – путь к файлу на диске. Такой файл подготавливается заранее и он используется в реализации ф-ии автозагрузки, установленной в spl_autoload_register(). Работает такой поиск очень быстро, процессорное время почти не тратится. Возрастает только потребление памяти, т.к. массив Classmap может быть достаточно большим и он загружается в память при подключении.
Одной из старейших утилит для генерации Classmap является утилита phpab. Ставится она через PEAR еще с версии php 4.3.0.
1 2 3 4 5 6 |
pear install theseer/DirectoryScanner pear install pear.netpiretes.net/Autoload phpab -v |
И вызов генерации
1 2 3 |
phpab -o custom_folder/classmap.php src |
Существуют более новые решения, а Zend Framework 2 поддерживает реализацию автозагрузки через Classmap и свой геренератор.
1 2 3 |
php classmap_generator.php Some/Directory/ -w |
Далее в коде или в index.php
1 2 3 4 5 6 7 8 9 |
$loader = new Zend\Loader\ClassMapAutoloader(); // Register the class map: $loader->registerAutoloadMap('Some/Directory/autoload_classmap.php'); // Register with spl_autoload: $loader->register(); |
Composer
Какой квалифицированный php программист сейчас не использует Composer? Если вы его не используете – вам стоит пересмотреть оценку своей квалификации и набор используемых инструментов. Если фреймворк не использует Composer – он давно морально устарел. Так вот, кроме того, что Composer реализует автозагрузку по стандартам PSR-0/PSR-4, он так же позволяет оптимизировать автозагрузку через Classmap, которую можно сгенерировать одной командой для всех подключенных пакетов
1 2 3 |
composer dump-autoload --optimize-autoloader |
или -o сокращенно.
Так же следуя вот этой статье можно сделать вывод, подтвержденный бенчмарком, что если использовать более специфичные имена namespace при определении автозагрузки пакетов, можно добиться улучшения производительности, т.к. сокращается область поиска файла в пакете. Бенчмарк из статьи:
Basic autoloader 18.7 ms Non optimized autoloader, but with specific namespaces 14.6 ms (22% faster) Optimized autoloader ( php composer.phar dumpautoload -o) 11.8 ms (37% faster)
Хватит теории, покажите мне бенчмарки!
Вот одна из статей, в которой приводится сравнение производительности при использовании PSR-0, require и classmap стратегий и, так же, с разными видами путей к файлам. Результаты и вывод:
Tests were run 10 times each, here are the average runtimes:
Relative paths Relative paths with extended include_path Absolute paths Absolute paths with extended include_path skip 0.04 0.042 0.04 0.42 PSR-0 2.643 3.676 2.595 2.608 class map 2.575 3.623 2.558 2.616 require_once 2.409 3.411 2.36 2.404 require 2.266 3.284 2.267 2.261 Conclusion:
- Autoloading does not significantly degrade performance. Include_path lookup, reading and parsing PHP scripts from disk takes much longer time than that bare autoloading logic costs.
Я думаю можно было бы и не переводить, и так видно, что на 5000 классах автозагрузка выигрывает. Так же стоит учесть упомянутую выше особенность, что учитывая путь выполнения кода – не все файлы будут подключены.
За следующим бенчмарком я обращусь в блог известного в php сообществе matthew weier o’phinney и в его блог. В посте приводится описание стратегий автозагрузки PEAR, PSR-0 и Classmap. Тесты выполнялись на большом количестве файлов, а именно 16^3 (дада, в степени 3) и всего были выполнены тесты на 6-ти вариациях указанных выше стратегий. Так же результаты разделены с учетом включен opCache или нет.
No Opcode Cache
Strategy Average Time (s) Baseline 0.0067 ZF1 autoloader (inc path) 1.2153 PSR-0 (no include_path) 1.0758 Class Map (include_path) 0.9796 Class Map ( __DIR__) 0.9800 SPL closure 0.9520 With Opcode Cache
Strategy Average over all (s) Unaccel Shortest Ave. Accelerated Baseline 0.0061 0.0053 0.0052 0.0062 ZF1 autoloader (inc_path) 0.4855 1.4444 0.3653 0.3789 PSR-0 (no include_path) 0.4021 1.5477 0.2437 0.2748 Class Map (include_path) 0.3022 1.2755 0.1724 0.1941 Class Map ( __DIR__) 0.2568 1.2253 0.1362 0.1492 SPL closure 0.2630 1.2971 0.1341 0.1481
Во первых мы можем видеть, что opCache ускоряет все стратегии в 3-4 раза. Если же сранивать стратегии между собой, то они все достаточно производительны при включенном opCache, но Classmap несомненный лидер.
За третим примером бенчмарка я обращусь к блогу фреймворка PHPixie и конкретно к посту, тема которого совпадает с данной статьей. В этом посте конкретно сравнивается автозагрузка через один файл или путем какой-то стратегии автозагрузки, с оптимизацией и без. Так же, как и в предыдущих тестах, были сгенерированы классы, в количестве 500 штук, при чем некоторые классы наследуются от других. В качестве кеша используется XCache. Вот результаты теста без кеша:
И тот же тест с включенным XCache.
По полученным данным мы можем сделать вывод, что без включенного кеша выигрыш от комбинированиия классов в один файл не значителен и исчисляется тысячными секунды. Более того, при включенном кеше опкодов результаты практически уравновешиваются. Но автор поста решил пойти дальше и доказать, что объединение классов может быть даже менее производительно, чем автозагрузка. Для этого было загружено только 10% от всех классов, что близко к реальным приложениям.
При таком тесте автозагрузка в пух и прах разносит методику комбинирования. Стоит признать, что комбинирование в один файл не только не эффективно, по сравнению со всеми минусами, но и морально устарело.
Мои результаты
Я хотел бы вернуться к примеру проекта, о котором я упоминал в начале. Как я уже говорил выше, я вооружился классом Benchmark, который изначально был помещен в тот же общий common.php и, выполняя замеры, начал последовательно проводить рефакторинг кода. Результаты являются средним от нескольких запусков и такие они были не старте:
1 2 3 4 5 6 |
* max * 249.8515625 Kbytes Time: 0.0038 Seconds * min common.php * 63.5625 Kbytes Time: 0.0038 Seconds |
Данные результаты стоит рассматривать в диапазонах по времени +- 5 десятысячных секунды и +-200кб памяти, т.к. по алгоритму еще загружалась картинка, и ее размер влиял на результат.
Далее, уже вынеся все классы по файлам и распределив функции по классам, а так же немного усложнив логику и добавив класс контроллер, а еще решив новую задачу, что привело к появлению новых классов стратегий, я получил следующие результаты:
1 2 3 4 |
* min autoload * 83.9296875 Kbytes Time: 0.0038 Seconds |
Следующие данные приведены уже с учетом оптимизации автозагрузки PSR-4:
1 2 3 4 5 6 |
* max * 435.8515625 Kbytes Time: 0.0035 Seconds * min * 34.578125 Kbytes Time: 0.0038 Seconds |
Почему потребление памяти в max случае так выросло? Потому, что в память начал загружаться весь Classmap, а я подключил некоторый пакет, который тянет весь Zend Framework 2 в vendor как зависимость (упс ^_^). Но мы видим, что это никак не повлияло на скорость, и даже видим ускорение. При учете сегодняшней стоимости памяти и ее объемах, данная цифра по прежнему не значительна.
Теперь я напомню о том баге с Memcached, который я исправил по ходу переработки кода. По сути Memcached не работал. И вот финальные результаты с исправленным кешем БД.
1 2 3 |
* 435.8515625 Kbytes Time: 0.0011 Seconds |
На этих приятных цифрах я пожалуй закончу.