Как я переписывал поисковик авиабилетов с PHP на NodeJS -31


Привет. Меня зовут Андрей, я студент-магистрант в одном из технических ВУЗов Москвы и по совместительству очень скромный начинающий предприниматель и разработчик. В этой статье я решил поделиться своим опытом перехода от PHP (который когда-то мне нравился из-за своей простоты, но со временем стал ненавидим мною — под катом объясняю почему) к NodeJS. Здесь могут приводиться очень банальные и кажущиеся элементарными задачи, которые, тем не менее, лично мне было любопытно решать в ходе моего знакомства с NodeJS и особенностями серверной разработки на JavaScript. Я попытаюсь объяснить и наглядно доказать, что PHP уже окончательно ушёл в закат и уступил своё место NodeJS. Возможно, кому-то даже будет полезно узнать некоторые особенности рендеринга HTML-страниц в Node, который изначально не приспособлен к этому от слова совсем.


Введение


Во время написания движка я использовал простейшие техники. Никаких менеджеров пакетов, никакого роутинга. Только хардкор — папки, чьё название соответствовало запрашиваемому маршруту, да index.php в каждой из них, настроенный PHP-FPM для поддержания пула процессов. Позднее возникла необходимость использовать Composer и Laravel, что и стало последней каплей для меня. Перед тем, как перейти к рассказу о том, зачем же я вообще взялся переписывать всё с PHP на NodeJS, расскажу немного о предыстории.


Менеджер пакетов


В конце 2018 года довелось работать с одним проектом, написанным на Laravel. Нужно было исправить несколько багов, внести правки в существующий функционал, добавить несколько новых кнопок в интерфейсе. Начался процесс с установки менеджера пакетов и зависимостей. В PHP для этого используется Composer. Тогда заказчик предоставил сервер с 1 ядром и 512 мегабайтами оперативной памяти и это был мой первый опыт работы с Composer. При установке зависимостей на виртуальном приватном сервере с 512 мегабайтами памяти процесс аварийно завершился из-за нехватки памяти.


Wut?


Для меня, как человека знакомого с Linux и имеющего опыт работы с Debian и Ubuntu решение такой проблемы было очевидно — установка SWAP-файла (файл подкачки — для тех, кто не знаком с администрированием в Linux). Начинающий неопытный разработчик, установивший свой первый Laravel-дистрибутив на Digital Ocean, например, просто пойдёт в панель управления и будет поднимать тариф до тех пор, пока установка зависимостей не перестанет завершаться с ошибкой сегментации памяти. А как там обстоят дела с NodeJS?
А в NodeJS есть свой собственный менеджер пакетов — npm. Он значительно проще в использовании, компактнее, может работать даже в среде с минимальным количеством оперативной памяти. В целом, ругать Composer на фоне NPM особо не за что, однако в случае возникновения каких-то ошибок при установке пакетов Composer вылетит с ошибкой как обычное PHP-приложение и вы никогда не узнаете, какая часть пакета успела установиться и установилась ли она в конце-концов. И вообще, для администратора Linux вылетевшая установка = флэшбеки в Rescue Mode и dpkg --configure -a. К тому времени, как меня настигли такие "сюрпризы", я уже недолюбливал PHP, но это уже были последние гвозди в крышку гроба моей некогда великой любви к PHP.


Long-term support и проблема версионирования


Помните, какой ажиотаж и изумление вызвал PHP7, когда разработчики впервые презентовали его? Увеличение производительности более чем в 2 раза, а в некоторых компонентах до 5 раз! А помните, когда появился на свет PHP седьмой версии? А как быстро-то заработал WordPress! Это был декабрь 2015 года. А знали ли вы, что PHP 7.0 уже сейчас считается устаревшей версией PHP и крайне рекомендуется обновить его… Нет, не до версии 7.1, а до версии 7.2. По утверждениям разработчиков, версия 7.1 уже лишена активной поддержки и получает лишь обновления безопасности. А через 8 месяцев прекратится и это. Прекратится, вместе с активной поддержкой и версии 7.2. Выходит, что к концу текущего года у PHP останется лишь одна актуальная версия — 7.3.


Используемые в текущее время версии PHP


На самом деле, это не было бы придиркой и я бы не отнёс это к причинам моего ухода от PHP, если бы написанные мной проекты на PHP 7.0.* уже сейчас не вызывали deprecation warning при открытии. Вернёмся к тому проекту, где вылетала установка зависимостей. Это был проект, написанный в 2015 году на Laravel 4 с PHP 5.6. Казалось, прошло же всего 4 года, ан-нет — куча deprecation-предупреждений, устаревшие модули, невозможность нормально обновиться до Laravel 5 из-за кучи корневых обновлений движка.


И это касается не только самого Laravel. Попробуйте взять любое приложение на PHP, написанное во времена активной поддержки первых версий PHP 7.0 и будьте готовы потратить свой вечер на поиск решений проблем, возникших в устаревших модулях PHP. Напоследок интересный факт: поддержка PHP 7.0 была прекращена раньше, чем поддержка PHP 5.6. На секундочку.


А как дела с этим обстоят у NodeJS? Я бы не сказал, что здесь всё значительно лучше и что сроки поддержки у NodeJS кардинально отличаются от PHP. Нет, здесь всё примерно так же — каждая LTS-версия поддерживается в течение 3 лет. Вот только у NodeJS немного больше этих самых актуальных версий.


Используемые в текущее время версии NodeJS


Если у вас возникнет необходимость развернуть приложение, написанное в 2016 году, то будьте уверены, что у вас не будет с этим совершенно никаких проблем. Кстати говоря, версия 6.* перестанет поддерживаться только в апреле этого года. А впереди ещё 8, 10, 11 и готовящаяся 12.


О трудностях и сюрпризах при переходе на NodeJS


Начну, пожалуй, с самого волнующего меня вопроса о том, как же всё-таки рендерить HTML-страницы в NodeJS. Но давайте для начала вспомним, как это делается в PHP:


  1. Встраивать HTML напрямую в PHP-код. Так делают все новички, кто ещё не добрался до MVC. И так сделано в WordPress, что категорически ужасающе.
  2. Использовать MVC, что как бы должно упростить взаимодействие разработчика и обеспечить какое-то разбиение проекта на части, но на деле этот подход лишь усложняет всё в разы.
  3. Использовать шаблонизатор. Наиболее удобный вариант, но не в PHP. Просто посмотрите синтаксис, предлагаемый в Twig или Blade с фигурными скобками и процентами.

Я являюсь ярым противником совмещения или слияния нескольких технологий воедино. HTML должен существовать отдельно, стили для него — отдельно, JavaScript-отдельно (в React это вообще выглядит чудовищно — HTML и JavaScript вперемешку). Именно поэтому идеальный вариант для разработчиков с предпочтениями как у меня — шаблонизатор. Искать его для веб-приложения на NodeJS долго не пришлось и я сделал выбор в пользу Jade (PugJS). Просто оцените простоту его синтаксиса:


    div.row.links
        div.col-lg-3.col-md-3.col-sm-4
            h4.footer-heading Флот.ру
            div.copyright
                div.copy-text 2017 - #{current_year}  Флот.ру
                div.contact-link
                    span Контакты:
                        a(href='mailto:hello@flaut.ru') hello@flaut.ru

Здесь всё достаточно просто: написал шаблон, загрузил его в приложение, скомпилировал один раз и далее используешь в любом удобном месте в любое удобное время. По моим ощущениям, производительность PugJS примерно в 2 раза лучше, чем рендеринг с помощью внедрения HTML в PHP-код. Если раньше в PHP статическая страница генерировалась сервером примерно за 200-250 миллисекунд, то теперь это время составляет примерно 90-120 миллисекунд (речь идёт не о самом рендеринге в PugJS, а о времени, затраченном от запроса страницы до ответа сервера клиенту с готовым HTML). Так выглядит загрузка и компиляция шаблонов и их компонентов на стадии запуска приложения:


const pugs = {}

fs.readdirSync(__dirname + '/templates/').forEach(file => {
    if(file.endsWith('.pug')) {
        try {
            var filepath = __dirname + '/templates/' + file
            pugs[file.split('.pug')[0]] = pug.compile(fs.readFileSync(filepath, 'utf-8'), { filename: filepath })
        } catch(e) {
            console.error(e)
        }
    }
})

// и далее рендеринг уже скомпилированного шаблона
return pugs.tickets({  ...config })

Выглядит невероятно просто, но с Jade возникла небольшая сложность на этапе работы с уже скомпилированным HTML. Дело в том, что для внедрения скриптов на странице используется асинхронная функция, которая забирает все .js файлы из директории и добавляет к каждому из них дату их последнего изменения. Функция имеет следующий вид:


for(let i = 0; i < files.length; i++) {
    let period = files[i].lastIndexOf('.') // get last dot in filename
    let filename = files[i].substring(0, period)
    let extension = files[i].substring(period + 1)

    if(extension === 'js') {
        let fullFilename = filename + '.' + extension
        if(env === 'production') {
            scripts.push({ path: paths.production.web + fullFilename, mtime: await getMtime(paths.production.code + fullFilename)})
        } else {
            if(files[i].startsWith('common') || files[i].startsWith('search')) {
                scripts.push({ path: paths.developer.scripts.web + fullFilename, mtime: await getMtime(paths.developer.scripts.code + fullFilename)})
            } else {
                scripts.push({ path: paths.developer.vendor.web + fullFilename, mtime: await getMtime(paths.developer.vendor.code + fullFilename)})
            }
        }
    }
}

На выходе получаем массив объектов с двумя свойствами — путь к файлу и время его последнего редактирования в timestamp (для обновления клиентского кэша). Проблема в том, что ещё на этапе сбора файлов скриптов из директории они все загружаются в память строго по алфавиту (так они расположены в самой директории, а сбор файлов в ней осуществляется сверху вниз — от первого до последнего). Это приводило к тому, что сначала загружался файл app.js, а уже после него шёл файл core.min.js с полифиллами, и в самом конце — vendor.min.js. Решилась эта проблема достаточно просто — очень банальной сортировкой:


scripts.sort((a, b) => {
    if(a.path.includes('core.min.js')) {
        return -1
    } else if(a.path.includes('vendor.min.js')) {
        return 0
    }
    return 1
})

В PHP же это всё имело монструозный вид в виде заранее записанных в строку путей к JS-файлам. Просто, но непрактично.


NodeJS держит своё приложение в оперативной памяти


Это огромный плюс. У меня всё устроено так, что на сервере параллельно и независимо друг от друга существуют два отдельных сайта — версия для разработчика и production-версия. Представим, что я сделал какие-то изменения в PHP-файлы на сайте для разработки и мне нужно выкатить эти изменения на production. Для этого нужно останавливать сервер или ставить заглушку "извините, тех. работы" и в это время копировать файлы по отдельности из папки developer в папку production. Это вызывает какой-никакой простой и может вылиться в потерю конверсий. Преимущество in-memory application в NodeJS заключается для меня в том, что все изменения в файлы движка будут внесены только после его перезагрузки. Это очень удобно, поскольку можно скопировать все необходимые файлы с изменениями и только после этого перезагрузить сервер. Процесс занимает не более 1-2 секунд и не вызовет даунтайма.
Такой же подход используется в nginx, например. Вы сначала редактируете конфигурацию, проверяете её при помощи nginx -t и только потом вносите изменения с service nginx reload


Кластеризация NodeJS-приложения


В NodeJS есть очень удобный инструмент — менеджер процессов pm2. Как мы обычно запускаем приложения в Node? Заходим в консоль и пишем node index.js. Как только мы закрываем консоль — приложение закрывается. По крайней мере, так происходит на сервере с Ubuntu. Чтобы избежать этого и держать приложение запущенным всегда, достаточно добавить его в pm2 простой командой pm2 start index.js --name production. Но это ещё не всё. Инструмент позволяет проводить мониторинг (pm2 monit) и кластеризацию приложения.


Давайте вспомним о том, как организованы процессы в PHP. Допустим, у нас есть nginx, обслуживающий http-запросы и нам нужно передать запрос на PHP. Можно либо сделать это напрямую и тогда при каждом запросе будет спавниться новый процесс PHP, а при его завершении — убиваться. А можно использовать fastcgi-сервер. Думаю, все знают что это такое и нет необходимости вдаваться в подробности, но на всякий случай уточню, что в качестве fastcgi чаще всего используется PHP-FPM и его задача — заспавнить множество процессов PHP, которые в любой момент готовы принять и обработать новый запрос. В чём недостаток такого подхода?


Во-первых, в том, что вы никогда не знаете, сколько памяти будет потреблять ваше приложение. Во-вторых, вы всегда будете ограничены в максимальном количестве процессов, а соответственно, при резком скачке трафика ваше PHP-приложение либо использует всю доступную память и упадёт, либо упрётся в допустимый предел процессов и начнёт убивать старые. Это можно предотвратить, установив не-помню-какой-параметр в конфигурационном файле PHP-FPM в dynamic и тогда будет спавниться столько процессов, сколько необходимо в данное время. Но снова-таки, элементарная DDoS-атака выест всю оперативную память и положит ваш сервер. Или, например, багнутый скрипт съест всю оперативную память и сервер зависнет на какое-то время (были прецеденты в процессе разработки).


Кардинальное отличие в NodeJS заключается в том, что приложение не может потребить более чем 1,5 гигабайта оперативной памяти. Нет никаких ограничений по процессам, есть только ограничение по памяти. Это стимулирует писать максимально легковесные программы. К тому же очень просто рассчитать количество кластеров, которые мы можем себе позволить в зависимости от имеющегося ресурса CPU. Рекомендуется на каждое ядро "вешать" не более одного кластера (ровно как и в nginx — не более одного воркера на одно ядро CPU).


Кластеризация в PM2


Преимущество такого подхода ещё и в том, что PM2 перезагружает все кластеры по очереди. Возвращаясь к предыдущему абзацу, в котором говорилось о 1-2 секундном даунтайме при перезагрузке. В Cluster-Mode при перезагрузке сервера ваше приложение не испытает ни миллисекунды даунтайма.


NodeJS — это хороший швейцарский нож


Ныне сложилась такая ситуация, когда PHP выступает в качестве языка для написания сайтов, а Python выступает в качестве инструмента для краулинга этих самых сайтов. NodeJS — это 2 в 1, с одной стороны вилка, с другой — ложка. Вы можете писать быстрые и производительные приложения и веб-краулеры на одном сервере в пределах одного приложения. Звучит заманчиво. Но как это может быть реализовано, спросите вы? Сама компания Google выкатила официальное API с Chromium — Puppeteer. Вы можете запускать Headless Chrome (браузер без интерфейса пользователя — "безголовый" Chrome) и получить максимально широкий доступ к API-браузера для обхода страниц. Максимально просто и доступно о работе Puppeteer.


Например, в нашей группе ВКонтакте происходит регулярный постинг скидок и специальных предложений на различные направления из городов СНГ. Мы генерируем изображения для постов в автоматическом режиме, а чтобы они были ещё и красивыми — нам нужны красивые картинки. Я не любитель подвязываться на различные API и заводить на десятках сайтов аккаунты, поэтому я написал простое приложение, имитирующее обычного пользователя с браузером Google Chrome, который ходит по сайту со стоковыми картинками и забирает случайным образом изображение, найденное по ключевому слову. Раньше для этого я использовал Python и BeautifulSoup, но теперь в этом отпала необходимость. А самая главная особенность и преимущество Puppeteer заключается в том, что вы можете с лёгкостью краулить даже SPA-сайты, ведь вы имеете в своём распоряжении полноценный браузер, понимающий и исполняющий JavaScript-код на сайтах. Это до боли просто:


const browser = await puppeteer.launch({headless: true, args:['--no-sandbox']})
const page = (await browser.pages())[0]
await page.goto(`https://pixabay.com/photos/search/${imageKeyword}/?cat=buildings&orientation=horizontal`, { waitUntil: 'networkidle0' })

Вот так в 3 строчки кода мы запустили браузер и открыли страницу сайта со стоковыми изображением. Теперь мы можем выбрать случайный блок с изображением на странице и добавить к нему класс, по которому в дальнейшем сможем точно так же обратиться и уже перейти на страницу непосредственно с самим изображением для его дальнейшей загрузки:


var imagesLength = await page.evaluate(() => {
    var photos = document.querySelectorAll('.search_results > .item')

    if(photos.length > 0) {
        photos[Math.floor(Math.random() * photos.length)].className += ' --anomaly_selected'
    }

    return photos.length
})

Вспомните, сколько бы потребовалось кода, чтобы написать подобное на PhantomJS (который, кстати, закрылся и вступил в тесное сотрудничество с командой разработки Puppeteer). Разве наличие такого чудесного инструмента может остановить кого-нибудь от перехода к NodeJS?


В NodeJS заложена асинхронность на фундаментальном уровне


Это можно считать огромным преимуществом NodeJS и JavaScript, особенно с приходом async/await в ES2017. В отличии от PHP, где любой вызов выполняется синхронно. Приведу простой пример. Раньше у нас в поисковике страницы генерировались на сервере, но кое-что нужно было выводить на страницу уже в клиенте с помощью JavaScript, а на тот момент Яндекс ещё не умел в JavaScript на сайтах и специально для него пришлось реализовывать механизм снапшотов (снимков страниц) при помощи Prerender. Снапшоты хранились у нас на сервере и выдавались роботу при запросе. Дилемма заключалась в том, что эти снимки генерировались в течение 3-5 секунд, что совершенно недопустимо и может повлиять на ранжирование сайта в поисковой выдаче. Для решения этой задачи был придуман простой алгоритм: когда робот запрашивает какую-то страницу, снимок которой у нас уже есть, то мы просто отдаём ему уже имеющийся у нас снимок, после чего "в фоне" выполняем операцию по созданию нового снимка и заменяем им уже имеющийся. Как это было делано в PHP:


exec('/usr/bin/php ' . __DIR__ . '/snapshot.php -a ' . $affiliation_type . ' -l ' . urlencode($full_uri) . ' > /dev/null 2>/dev/null &');

Никогда так не делайте.
В NodeJS этого можно добиться вызовом асинхронной функции:


async function saveSnapshot() {
    getSnapshot().then((res) => {
        db.saveSnapshot().then((status) => {
            if(status.err) console.error(err)
        })
    })
}

/**
 * И вызываем функцию без await
 * Т.е. не будем дожидаться resolve() от промиса
 */

saveSnapshot()

Вкратце, вы не пытаетесь обойти синхронность, а сами определяете, когда использовать синхронное выполнение кода, а когда асинхронное. И это действительно удобно. Особенно когда вы узнаете о возможностях Promise.all()


Сам движок поисковика авиабилетов устроен таким образом, что он отсылает запрос на второй сервер, собирающий и агрегирующий данные, и затем обращается к нему за уже готовыми к выдаче данными. Для привлечения трафика из органики используются страницы направлений.


Например, по запросу "Авиабилеты Москва Санкт-Петербург" будет выдана страница с адресом /tickets/moscow/saint-petersburg/, а на ней нужны данные:


  1. Цены авиабилетов по этому направлению на текущий месяц
  2. Цены авиабилетов по этому направлению на год вперёд (средняя цена по каждому месяцу для следующих 12 месяцев)
  3. Расписание авиарейсов по этому направлению
  4. Популярные направления из города отправки — из Москвы (для перелинковки)
  5. Популярные направления из города прибытия — из Санкт-Петербурга (для перелинковки)

В PHP все эти запросы выполнялись синхронно — один за другим. Среднее время ответа API по одному запросу — 150-200 миллисекунд. Умножаем 200 на 5 и получаем в среднем секунду только на выполнение запросов к серверу с данными. В NodeJS есть замечательная функция Promise.all, которая выполняет все запросы параллельно, но записывает результат поочерёдно. Например, так выглядел бы код выполнения всех пяти выше описанных запросов:


var [montlyPrices, yearlyPrices, flightsSchedule, originPopulars, destPopulars] = await Promise.all([
    getMontlyPrices(),
    getYearlyPrices(),
    getFlightSchedule(),
    getOriginPopulars(),
    getDestPopulars()
])

И получаем все данные за 200-300 миллисекунд, сократив время генерации данных для страницы с 1-1,5 секунд до ~500 миллисекунд.


Заключение


Переход с PHP на NodeJS помог мне ближе познакомиться с асинхронным JavaScript, научиться работе с промисами и async/await. После того, как движок был переписан, скорость загрузки страниц была оптимизирована и отличалась разительно от результатов, которые показывал движок на PHP. В этой статье можно было бы ещё рассказать о том, насколько просто используются модули для работы с кэшем (Redis) и pg-promise (PostgreSQL) в NodeJS и устроить сравнение их с Memcached и php-pgsql, но эта статья итак получилась достаточно объёмной. А зная мой "талант" к писательству, она получилась ещё и плохо структурированной. Цель написания этой статьи — привлечь внимание разработчиков, которые до сих пор работают с PHP и не знают о прелестях NodeJS и разработки веб-ориентированных приложений на нём на примере реально существующего проекта, который когда-то был написан на PHP, но из-за предпочтений своего владельца ушёл на другую платформу.


Надеюсь, что мне удалось донести мои мысли и более-менее структурировано изложить их в этом материале. По крайней мере, я старался :)


Пишите любые комментарии — доброжелательные или гневные. Буду отвечать на любой конструктив.




К сожалению, не доступен сервер mySQL