НЕкостыль: gRPC-клиент на PHP в продакшене
Привет! Я хочу показать, что завести gRPC на PHP — это нормальное боевое решение, которое пишется быстро, легко разворачивается и может быть для вас проще, чем сокеты.
Сначала все работало на REST и работало хорошо, но начался рост…
Skyeng постоянно взаимодействует с учениками: периодически нам надо позвонить — чтобы подтвердить запись на пробный урок или, например, уточнить, все ли хорошо, если человек пропустил занятие.
На заре школы работа со звонками велась вручную, но бизнесу быстро захотелось как-то автоматизировать и анализировать работу операторов. Это помог сделать Voximplant. Мы до сих пор пользуемся их технологиями — удобно.
Чтобы операторы не слушали гудки, не тратили время на прозвон недоступных номеров и так далее, у ребят есть PDS (predictive dialing system) — система автоматического дозвона. Она берет два пула — операторов и клиентов, и по ходу прозвона вычисляет контактность базы, задавая скорость дальнейшего набора номеров. Идея в том, чтобы операторы и клиенты как можно меньше ждали на линии.
Долгое время все работало по примерно такой схеме.
Например, мы загружаем PDS список из 1000 номеров — и знаем, что сейчас у нас 50 операторов. Начинаем прозванивать первые 100.
- Номер недоступен — обрабатываем сценарий на JS, отправляем на endpoint номер и его статус, пишем в базу факт неудачного звонка.
- Срабатывает автоответчик: все то же самое, но со статусом «автоответчик».
- Происходит дозвон до клиента — он берет трубку и говорит «Алло». В это время из пула операторов срочно подыскивается первый подходящий: мы знаем, что если держать человека «на проводе» больше двух секунд, он просто положит трубку.
При росте начались сложности. PDS вычисляет контактность базы — и набирает еще сколько-то номеров. А параллельно считает длительность текущих разговоров и обновляет статистику, чтобы балансировать время ожидания операторов и клиентов. Упор всегда делается на удобство клиента (те самые пара секунд), но время простоя оператора то не должно затягиваться. Нормально, если это 30–40 секунд. На практике случалось, что утилизация времени операторов достигала почти 30% — они долго сидели в ожидании, это было критично.
Также мы стали сталкиваться с кейсами двойного прозвона. С утра мы собирали большой колл-лист на тысячи номеров и загружали его в систему. Но параллельно через сайт приходили новые потенциальные ученики — опять же, опытным путем бизнес установил, что их надо прозвонить в приоритетном порядке в ближайшие часы после заявки. Опции «докинуть номеров» у PDS не было. Поэтому мы останавливали ее, обновляли лист и снова запускали его в работу. Но на момент остановки часть номеров могла уйти в набор —, а вот их статусы в базу еще не пришли. Они не помечались как прозвоненные и, бывало, что с человеком поговорили, а через две минуты ему набирает следующий оператор… Мы не могли пометить, что номер отдан в прозвон, на своей стороне: так как работали сразу с тысячами номеров, это вызывало большие тормоза на нашей базе.
Тогда ребята из Voximplant дали нам нам прототип своей новой PDS — более продвинутого решения, которое получило название PDS2. И нам надо было как-то подключиться к нему.
Почему выбрали gRPC? И почему не подошел клиент на Go
Ох, у gRPC много классных фич:
- Protobuf как инструмент описания сериализации типов данных — мы описываем протокол в протофайл, это быстро.
Вот типичный protobuf-файл — очень похож на JSON, всё достаточно просто:
syntax = "proto3" ;
message SearchRequest {
string query = 1;
int32 page_number = 2;
int32 result_per_page = 3
}
- Есть gRPC-плагин, который позволяет сгенерировать весь необходимый код из протофайла — сервис, типы, PDS-клиент, в нашем случае. Единственное, что он не генерит, это серверную часть на PHP.
- HTTP/2 в качестве транспорта — можно прекратить выполнение запроса на сервере, а еще переиспользовать один cокет для нескольких параллельных запросов.
- Вместо набора «domain.com/сервис/коллекция/ресурс/запрос? параметр=значение», как в REST, есть только сервис. Все остальное описывается через Protobuf в терминах нашей модели и ее событий.
Однако, выбрали не из-за фич: просто выбора не было — PDS2 общалась только по gRPC.
А вот с Go мы попробовали. Прототип клиента от разработчиков из Voximplant какое-то время крутился в проде, но его тяжело было поддерживать. PHP основной язык в Skyeng, на нем написано почти все. И мы поняли, что надо тащить в Go-клиент много кода, а затем поддерживать его и в клиенте, и PHP-части. Например, у нас были проблемы с таймзонами — и было решение, но на PHP. И это всё приходилось уносить на тот Go-клиент.
Долго обсуждали это с тимлидом, и в итоге решили, что проще затянуть все на PHP. Спустя месяцы эксплуатации понимаю, что это было верное решение.
Что делать, если PHP не завезли? Написать свое решение — это (почти) просто
Я следовал рекомендациям с gRPC.io для PHP. В принципе, там описано все, что нужно.
Был лишь один забавный нюанс. Пару дней искал решение, как сгенерировать код с неймспейсами в нашем протофайле. Все сгенерил, все нормально, только их не хватает. В итоге засел перечитывать всю документацию. Оказалось, оно называется packages.
Так что, если тоже зададитесь вопросом, всё достаточно просто: пишем в файле
package foo.bar;
message MyMessage {}
И это сгенерирует вот такой namespace.
Foo\Bar\MyMessage
Детали по ссылке.
Как это работает. При генерации мы задаем необходимые параметры подключения к Voximplant, стартуем, и у нас получается бесконечный цикл, который постоянно слушает наш стрим. Наш клиент — по факту, обычный демон.
Вот пример от Voximplant. Наш бандл показать не могу: он сильно разросся за счет сложной и специфичной для нас логики.
Что в итоге
Наш клиент вместе с supervisorD крутится на проде с января, он стабилен. В сочетании c супервизором это почти демон — если что, супервизор поднимет и запишет падение к себе.
Проблемы роста мы решили.
Благодаря демону мы отгружаем номера по запросу, динамически, маленькими порциями — по 50 за раз. И теперь, если у нас появляются какие-то «горячие» номера с морды сайта, он уже знает, что эти номера имеют самый высокий приоритет — когда от Voximplamt приходит новый запрос, отправляет их. У нас появилась гибкость.
А еще, время ожидания операторов сократилось примерно до 20 секунд, —, но это уже чисто за счет лучших алгоритмов самой PDS2, которую писали не мы.