Разработка приложения велась в следующем окружении:
- PHP 5.5.9-1ubuntu4.9
- Apache/2.4.7
- memcached 1.4.14 (Ubuntu)
- mysql Ver 14.14 Distrib 5.5.41, for debian-linux-gnu (x86_64)
Для корректной работы приложения так же необходимы модули apache:
- mod_rewrite
- mod_headers
- mod_expires
При выполнении задания главной целью было: максимально быстрая разработка с полным соответствием заданию.
ВСЕ ТЕСТЫ, УКАЗАННЫЕ В ДАННОМ ДОКУМЕНТЕ ПРОВОДИЛИСЬ БЕЗ ИСПОЛЬЗОВАНИЯ memcache, ЕСЛИ ПОД РЕЗУЛЬТАТМИ ТЕСТА НЕ СКАЗАНО ОБРАТНОЕ
Реализовать простую систему просмотра списка товаров.
Товар описывается несколькими полями: id, название, описание, цена, url картинки.
Требуется:
- интерфейс создания/редактирования/удаления товара;
- страница просмотра списка товаров.
Товары можно просмотривать отсортированные по цене или по id.
Поддерживать количество товаров в списке – до 1000000.
Устойчивость к нагрузке – 1000 запросов к списку товаров в минуту.
Время открытия страницы списка товаров < 500 мс.
Техника:
PHP (без ООП), mysql, memcached.
Фронтэнд - на ваше усмотрение.
Проект должен быть на гитхабе и отражать процесс разработки.
В результате — ссылка на гитхаб и развёрнутое демо.
Соответствует такой схеме БД:
CREATE TABLE products( id INTEGER UNSIGNED AUTO_INCREMENT PRIMARY KEY, title VARCHAR(30), description TEXT, image VARCHAR(255), price DECIMAL(12, 2));
ALTER TABLE products ADD KEY price_id_idx (price, id);
Для ускорения загрузки страницы веб-сервер настроен на gzip-сжатие css и js-файлов. В моем текущем окружении, я использовал mod_deflate со следующей конфигурацией:
# Serve gzip compressed CSS files if they exist
# and the client accepts gzip.
RewriteCond "%{HTTP:Accept-encoding}" "gzip"
RewriteCond "%{REQUEST_FILENAME}\.gz" -s
RewriteRule "^(.*)\.css" "$1\.css\.gz" [QSA]
# Serve gzip compressed JS files if they exist
# and the client accepts gzip.
RewriteCond "%{HTTP:Accept-encoding}" "gzip"
RewriteCond "%{REQUEST_FILENAME}\.gz" -s
RewriteRule "^(.*)\.js" "$1\.js\.gz" [QSA]
# Serve correct content types, and prevent mod_deflate double gzip.
RewriteRule "\.css\.gz$" "-" [T=text/css,E=no-gzip:1]
RewriteRule "\.js\.gz$" "-" [T=text/javascript,E=no-gzip:1]
<FilesMatch "(\.js\.gz|\.css\.gz)$">
# Serve correct encoding type.
Header append Content-Encoding gzip
# Force proxies to cache gzipped &
# non-gzipped css/js files separately.
Header append Vary Accept-Encoding
</FilesMatch>
Так же включена возможность кеширования браузером и применены стандартные методы оптимизации скорости загрузки:
- оптимизация импортов
- использование мимимизированных исходников
- загрузка скриптов в голове страницы
Тестирование проводилось с использованием БД, содержащей 1 191 572 записей
Тестирование проводилось с помощью npm-пакета loader с настройкой rps(request per second) = 20, что в свою очередб равно 1200 запросам в минуту. Результаты представлены ниже
Время | Общее кол-во отправленных запросов | RPS | Среднее время отклика |
---|---|---|---|
[Fri Jul 03 2015 00:08:35 GMT+0700 (ICT)] | 0 | 0 | 0ms |
[Fri Jul 03 2015 00:08:40 GMT+0700 (ICT)] | 95 | 19 | 10ms |
[Fri Jul 03 2015 00:08:45 GMT+0700 (ICT)] | 195 | 20 | 10ms |
[Fri Jul 03 2015 00:08:50 GMT+0700 (ICT)] | 295 | 20 | 10ms |
[Fri Jul 03 2015 00:08:55 GMT+0700 (ICT)] | 395 | 20 | 10ms |
[Fri Jul 03 2015 00:09:00 GMT+0700 (ICT)] | 495 | 20 | 0ms |
[Fri Jul 03 2015 00:09:05 GMT+0700 (ICT)] | 595 | 20 | 0ms |
[Fri Jul 03 2015 00:09:10 GMT+0700 (ICT)] | 695 | 20 | 10ms |
[Fri Jul 03 2015 00:09:15 GMT+0700 (ICT)] | 795 | 20 | 10ms |
[Fri Jul 03 2015 00:09:20 GMT+0700 (ICT)] | 895 | 20 | 0ms |
[Fri Jul 03 2015 00:09:25 GMT+0700 (ICT)] | 995 | 20 | 0ms |
[Fri Jul 03 2015 00:09:30 GMT+0700 (ICT)] | 1095 | 20 | 10ms |
Как видно из таблицы, никаких трудностей приложение при подобных нагрузках не испытало.
Здесь все не так просто, как может покахаться на первый взгляд. Дело в том, что, если я загружу статические ассеты на собственный сервер, на результат будет влиять то, как долго до него ходят запросы от конкретного интернет-провайдера. Поэтому то, что можно загрузить через cdn приложение загружает через него, из-за чего, время открытия страницы так же не может полностью зависеть от меня. При этом я использовал и настроил кеширование изображений, *.js и *.css файлов, но, опять таки, если кеширование отключено в браузере это так же не поможет. Во время тестирования на локальной машине(не могу провести тест открытия страницы на удаленном сервере из-за ужасного интернет-соединения) время полной загрузки страницы составляло 400ms, что укладывается в рамки, обозначенные в ТЗ. На всякий случай я так же проверил время, за которое полностью генерируется ответ сервера - ** 60ms **
Если считать за отображение страницы полную генерацию ее HTML-кода, то можно измерить это время с помощью простой функции wget, на локальном окружении получилось так:
wget -p http://vk-test.local/list/1
--2015-07-03 08:08:11-- http://vk-test.local/list/1
Resolving vk-test.local (vk-test.local)... 127.0.0.1
Connecting to vk-test.local (vk-test.local)|127.0.0.1|:80... connected.
HTTP request sent, awaiting response... 200 OK
Length: unspecified [text/html]
Saving to: ‘vk-test.local/list/1’
[ <=> ] 32,190 --.-K/s in 0s
2015-07-03 08:08:11 (67.1 MB/s) - ‘vk-test.local/list/1’ saved [32190]
FINISHED --2015-07-03 08:08:11--
Total wall clock time: 0.008s
Downloaded: 1 files, 31K in 0s (67.1 MB/s)
Сортировка по | Порядок | Страница номер | Время |
---|---|---|---|
ID | asc | 1 | 0.008s |
ID | asc | 500 | 0.2s |
ID | desc | 1 | 0.01s |
ID | desc | 500 | 0.08s |
Цена | asc | 1 | 0.008s |
Цена | asc | 500 | 0.008s |
Цена | desc | 1 | 0.08s |
Цена | desc | 500 | 0.08s |
Отмечу, что выборка в данной таблице не является репрезенативной, поскольку было сделано всего по 1 запросу каждого вида, это просто помогает приблизительно оценить скорость загрузки.
Вообще, я уже немного отвык делать приложения без использования ООП и до начала выполнения задания провел около часа в поисках "лучших практик". Однако, как оказалось, публичный мир PHP старается максимально отойти от процедурного подхода и любые запросы с тегами "procedural" или "without OOP" на первых 3х страницах поисковой выдачи выдают в основном трактаты о том, что procedural - плохо, а OOP - хорошо. Поэтому, пришлось больше полагаться на интуицию, а некоторые концепции просто перенести из уже ставшего привычным MVC-подхода, но с огромным упрощением, а иногда и коверканьем идей.
Конфигурация приложения описывается в файле config.php
С помощью этого файла можно настроить соединение с БД, название приложения и число товаров выводимых на странице
Роутер разбирает запрос пользователя и отправляет его нужному обработчику(dispatcher
)
Сам разбор запроса сейчас очень сильно упрощен просто ввиду ненадобности более сложного, однако, несложно будет его переписать или дописать так, что бы он
соответствовал новым требованиям, если таковые появятся. В данный момент я ограничил возможные запросы на:
- односегментные =>
/
- двухсегментные =>
/list
- трехсегментные =>
/view/1
- четырехсегментные =>
/list/price/1
Конечно, можно было бы озадачится и сделать унифицированный роутер, который разбирал бы любые запросы, но данное ТЗ никаких намеков на это не дает, а мне, в процессе реализыции понадобились только эти, так что я просто решил не терять времени зря.
Для работы роутера, ему необходима конфигурация существующих маршрутов, которая описана в файле routes.php
В этом файле определяется глобальный ассоциативный массив $routes
, ключ которого - шаблон запроса, а значение - обработчик.
Примечательно, что в качестве обработчика можно указать как анонимную функцию, так и строку с названием функции.
Анонимные функции довольно удобны, если нужно быстро протестировать функционал.
$routes = [
'/view/{id}' => 'view_record', //Название функции - обработчика
'/list/{page}' => 'product_list',
'/' => function(){ //Анониманя функция-обработчик
echo "Dummy home page";
},
];
Как можно заметить из примера выше, некоторые из шаблонов запроса имеют сегменты с текстом, заключенном в фигурные скобки
Это параметры зарпоса. Если запрос совпадет с шаблоном, то параметры будут переданы в обработчик в качесве аргументов,
например, для того что бы посмотреть запись с идентификатором 1 пользователь перейдет по ссылке /view/1
, что соотвтетствует
шаблону /view/{id}
. В свою очередь, роутер вызовет обработчик - в данном случае это функция view_record
.
function view_record($id)
{
//find product and render it's page here
}
При этом параметр id будет передан автоматически. ВАЖНО: поскольку роутинг не является целью этого ТЗ, я не делал автоматический биндинг параметров по имени, как это обычно делают
Представления отвечают за отображение данных. Ничего сверхъестественного
Файл database.php создает новое соединение с базой данных, а так же определяет основные функции для работы с ней. Сначала была идея выделить работу с конкретными таблицами в отдельные файлы, но потом я подумал, что для данного ТЗ это будет overkill и оставил это для возможного улучшения в будущем.
Все запросы в приложении отправляются ТОЛЬКО в index.php, что позволяет с легкостью управлять приложением из одной точки
Поскольку поле цена может быть одинаковым для большого числа товаров, но при этом по нему необходимо сортировать
список товаров, был добавлен индекс price_id_idx (price, id)
для ускорения выборки с сортировкой по цене, при формировании SQL-запроса
добавилось: FORCE INDEX(price_id_idx)
. В этом вопросе пришлось больше руководствоваться слухами и домыслами, нежели какими-то подтвержденными фактами, или личным опытом
Дело в том, что на форумах пишут, что FORCE INDEX()
у многих, на самом деле замедляет, а не ускоряет ее.
Однако, немного поразбиравшись в теме, я выяснил, что на маленьком наборе данных это действительно может замедлить работу, особенно, если не
используется LIMIT
. Благо, у меня он есть везде, где нужно и набор данных довольно большой.
Ввиду неоптимизированной "из коробки" mysql команды LIMIT x, y(LIMIT x OFFSET y)
пришлось пойти на известную хитрость:
- Если мы отображаем первую страницу, мы просто устанавливаем LIMIT
- Если мы отображаем не первую страницу списка, то: Проверяем, передан ли в запросе параметр идентификатора последего товара на предыдущей странице и, если да, то вместо
LIMIT x,y
мы запрашиваемWHERE id > %last_page_id% LIMIT x
Скорее всего, я многое пропустил и чего-то недопонял, сильно строго прошу не судить, сделал за ночь сколько успел, довольно трудно выкроить больше времени на выполнение ТЗ.