Дружим gRPC с долгоживущим проектом, PHP и фронтендом

Пару лет назад мы достаточно спокойно работали нашей небольшой командой и делали хостинг. Вышло так, что каждый сервис в системе обладал собственным уникальным и неповторимым API. Но потом это стало проблемой и было решено все переделать.

Мы расскажем о том, как объединить внешнее API с внутренним и что делать, если у вас много кода на PHP, но хочется воспользоваться преимуществами gRPC.

Сейчас очень много говорят про микросервисы и SOA в целом. Наша инфраструктура не исключение: ведь мы занимаемся хостингом и наши сервисы позволяют управлять почти тысячей серверов.

Со временем сервисов в нашей системе стало появляться все больше: стали регистратором доменов — выносим регистрацию в отдельный сервис; метрик с серверов стало очень много — пишем сервис, который делает выборки из ClickHouse / InfluxDB; Нужно сделать эмулятор запуска задач «как через Crontab»; для пользователей — пишем сервис. Наверное, это многим будет знакомо.

Входящих задач в разработке много. Количество различных сервисов растет плавно и, вроде бы, незаметно. Заранее учесть все будущие нюансы невозможно, поэтому на смену одним API приходили другие, более лучшие. Но настал день, когда стало очевидно, что у нас развелось слишком много протоколов:

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

Ах, да… еще ведь документация нужна. Иначе в чатиках происходят такие диалоги:
Ребят, как мне получить баланс пользователя из биллинга?
Сделай вызов в billing/getBalance(customerId)
А список услуг как получить?
Не помню, поищи нужный контроллер в

Короче говоря, зародилась мечта о волшебном едином стандарте и технологии для создания сетевых API, которые решат все проблемы и сэкономят нам вагон времени.

Формируем требования

Немного подумав, мы составили свой небольшой список требований:

  • Используемый способ описания API должен быть декларативным
  • Результат должен быть однозначным и человеко-понятным: нужно проводить code review
  • Нужна возможность описывать как успешный flow, так и ошибки. Причем это должно делаться явно для каждого метода
  • На основе описания нужно генерировать как можно больше скучного кода для клиента и сервера

В результате поисков, оценки и небольших тестов мы остановились на gRPC. Этот фреймворк не новый и о нем уже писали на Хабре.

Из коробки он удовлетворял почти всем нашим требованиям. Если в двух словах:

  • Декларативное описание методов и структур данных
  • Он очень читабельный и простой. По получившимся .proto-файлам легко проводить code review. Синтаксис IDL близок к популярным ЯП
  • Завезены генераторы под большинство популярных ЯП (но есть нюанс. О нем ниже)
  • gRPC — просто механизм RPC без каких-либо строгих требований к организации API. Это дает возможность разработать собственные принципы и гайдлайны с учетом накопленного опыта

Однако, идеальных технологий не существует. Для нас возникло несколько камней преткновения:

  • Мы активно используем PHP и он не умеет в сервер gRPC;
  • Наш фронтенд по-прежнему ожидает привычный HTTP. На текущий момент мы были вынуждены «проксировать» запросы фронтенда через отдельное приложение, формирующее правильные запросы к внутреннему API. В подавляющем большинстве случаев это лишняя скучная работа. Хотелось бы внутри нашей системы все отдавать через один протокол с автоматической конвертацией в HTTP для фронтенда.

К счастью, мы достаточно легко решили эти проблемы. Далее я буду предполагать, что читатель знаком с gRPC. Если нет — лучше сначала обратиться к упомянутой выше статье.

Но почему вы просто не стали использовать Swagger (OpenAPI)?!? Он же отлично подходит!

Да, действительно. OpenAPI и набор инструментов, предоставляемые Swagger’ом, выглядят заманчиво.

Сразу надо сказать, что сравнивать OpenAPI и gRPC не совсем корректно. gRPC — это прежде всего фреймворк, решающий техническую проблему RPC-взаимодействия. Он предлагает свой протокол, способ сериализации, язык описания сервисов и некоторый тулинг.

OpenAPI же в первую очередь является спецификацией, пытающаяся стать единым стандартом для описания интерфейсов. Пускай сейчас она сильно ориентирована в сторону REST, но есть предложения добавить поддержку RPC. Возможно, в будущем OpenAPI станет современным аналогом веб-служб и WSDL.

Тем не менее, у нашей команды нашлось несколько своих аргументов в пользу ипользования gRPC вместо OpenAPI:

  • Во-первых, OpenAPI как спецификация в данный момент ориентирован на интерфейсы, построенные по принципам REST. В нашей системе большая часть взаимодействий между сервисами — внутренние. Использование принципов REST зачастую может привести к неоправданному усложнению: далеко не всегда можно превратить какой-нибудь метод вида doSomethingVerySpecialShit в правильный ресурс, доступный по своему URL. Ну, вернее, конечно можно (сконвертировав глагол-метод в существительное), но выглядеть это будет весьма инородно. С gRPC таких проблем нет;
  • Во-вторых, будем честны: мало кто из разработчиков сможет корректно и правильно спроектировать такой API. На собеседованиях люди зачастую смысл аббревиатуры REST не могут объяснить =) Конечно, это слабый аргумент: при наличии должной экспертизы в компании всему можно обучить. Но мы посчитали, что сейчас лучше потратить время на другие задачи;
  • В-третьих, нам удобнее обмениваться описанием сервисов в виде простых и понятных файлов, которые можно читать достаточно быстро и комфортно. На наш взгляд, тут однозначно выигрывает Protobuf: описание методов и структур за счет собственного IDL близко к популярным ЯП. OpenAPI предлагает описывать их в yaml или json. Несмотря на то, что эти форматы также доступны для восприятия, они все равно так же остаются форматами сериализации данных, а не полноценным IDL;
  • В-четвертых, в gRPC есть однонаправленные и двунаправленные стримы. В OpenAPI похожие функции (насколько мы смогли разобраться), выполняют callbacks. Кажется, что это похожая вещь, но судя по предложениям, полноценных двусторонних стримов до сих пор не реализовано, а мы иногда их используем (например, для передачи больших блобов по частям).

Если подвести небольшой итог, то можно сказать, что для нас OpenAPI оказался слишком объемен и не всегда предлагал адекватные решения. Напомню, что наша конечная цель — перейти на единый технический и организационный стандарт для RPC. Внедрение принципов REST потребовало бы переосмысления и рефакторинга многих вещей, что было бы уже задачей из совсем другой весовой категории.

Также можно ознакомиться, например, с этой статьей-сравнением. В целом мы согласны с ней.

Все любят PHP

Как я говорил выше, у нас много бизнес-логики написано на PHP. Если обратиться к документации, то нас ожидает облом: из-за особенностей модели выполнения кода он не может выступать в роли сервера (разнообразные reactphp не в счет). Зато он неплохо работает в роли клиента и, если скормить генератору кода proto-файл с описанием сервиса, он честно сгенерирует классы для всех структур (request и response). А значит, проблема вполне решаема.

Все что мы нашли на тему работы PHP в роли сервера — обсуждение на эту тему в Google Groups. В этом обсуждении один из участников сообщил, что они работают над возможностью проксирования gRPC в FastCGI (его хочет видеть используемый нами PHP-FPM). Это именно то, что мы искали. К сожалению, связаться, узнать статус этого проекта и поучаствовать в нем нам не удалось.

В связи с этим было принято решение написать свою небольшую прокси, которая могла бы принимать запросы и конвертировать их в FactCGI. Так как gRPC работает поверх HTTP/2 и вызов метода в нем по-факту является обычным HTTP-запросом, задача не является сложной.

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

Схема работы следующая:

  1. Принимаем запрос;
  2. Вынимаем из него тело, сериализованное в Protobuf;
  3. Формируем заголовки для FastCGI-запроса;
  4. Отправляем FastCGI-запрос в PHP-FPM;
  5. В PHP обрабатываем запрос. Формируем ответ;
  6. Получаем ответ, конвертируем в gRPC, отправляем адресату;

Таким образом принцип обработки запроса в PHP очень простой:

  • Тело запроса будет содержаться в body (поток php://input);
  • Запрашиваемый сервис и его метод содержится в query-параметре r (мы используем Yii2 и его роутер хочет видеть роут в этом параметре)

Например, для такого сервиса

syntax = 'proto3';
package api.customer;
service CustomerService {
    rpc getSomeInfo(GetSomeInfoRequest) returns (GetSomeInfoResponse) {}
}
message GetSomeInfoRequest {
    string login = 1;
}
message GetSomeInfoResponse {
    string first_name = 2;
    string second_name = 3;
}

При запросе getSomeInfo в параметре r будет содержаться api.customer.customer-service/get-some-info.

Концентрированный пример обработки запроса в приложении:

example.php

<?php
// Полное имя метода, вызываемого через через gRPC вида:
// package.service-name/method-name
$route = $_GET['r'];
// Тело запроса, сериализованное в protobuf
$body = file_get_contents("php://input");
try {
    // Для демонстрации ошибки геренируем ее вероятностью 50%
    if (rand(0, 1)) {
        throw new \RuntimeException("Some error happened!");
    }
    // Далее просто десериализуем тело запроса в сгенерированную структуру, выполняем
    // бизнес-логику, заполняем ответную структуру, сериализуем ее и отправляем в кач-ве ответа
    $request = new GetSomeInfoRequest;
    $request->parse($body);
    $customer = findCustomer($request->getLogin());
    $response = (new GetSomeInfoResponse)
        ->setFirstName($customer->getFirstName());
    echo $response->serialize();
} catch (\Throwable $e) {
    // Валидный статус-код.
    // Доступные коды можно посмотреть, например тут:
    // https://github.com/grpc/grpc-go/blob/master/codes/codes.go
    $errorCode = 13;
    header("X-Grpc-Status: ERROR");
    header("X-Grpc-Error-Code: {$errorCode}");
    header("X-Grpc-Error-Description: {$e->getMessage()}");
}

Как можно заметить, все достаточно просто. Вы можете реализовать эту логику в любом популярном фреймворке и сделать удобный слой контроллеров, в которых вся сериализация/десериализация будет происходить автоматически.

У нас обработчики методов выглядят как-то так (мы допилили наш Yii2)

<?php
namespace app\api\controllers\customer\actions;
use app\api\base\Action;
// Используем сгенерированные из protobuf классы
use app\generated\api\customer\GetSomeInfoRequest;
use app\generated\api\customer\GetSomeInfoResponse;
class GetSomeInfoAction extends Action
{
    public function run(GetSomeInfoRequest $request): GetSomeInfoResponse
    {
        // испольузем информацию из запроса для поиска нужных данных
        $customer = findCustomer($request->getLogin());
        // Возвращаем ответ
        return (new GetSomeInfoResponse)
            ->setFirstName($customer->getFirstName())
            ->setSecondName($customer->getSecondName());
    }
}

Обработка исключений и конвертация их в соответствующие gRPC statuscodes реализована на уровене приложения и происходит автоматически.
Все, что требуется разработчику в итоге — это создать Action и в сигнатуре метода run указать ожидаемые типы для запроса и ответа.

Для генерации кода в PHP

  • Не так давно вышла официальная поддержка protobuf-плагина. Рекомендуется к использованию.
  • На данный момент мы сами еще используем неофициальный плагин и планируем миграцию.

Преимущества

  • Мы вписали PHP в общую систему мессаджинга на основе gRPC;
  • Используемая прокси сама по себе легковесная, не выполняет кодирование/декодирование запросов;
  • Прокси не требует для своей работы самих .proto-файлов. Можно «запустить и забыть» (в нашем случае прокси просто запускается в соседнем контейнере через docker-compose. Nginx для отдачи HTTP становится не нужен).

Недостатки

  • Несмотря на легковесность, добавилась еще одна потенциальная точка отказа;
  • Нужно один раз написать чуть-чуть кода для автоматизации генерации классов и адаптации роутера/контроллеров под ваш фреймворк;
  • Нет поддержки стримов со стороны сервера. Думаем, что при определенных ограничениях их реализация не составит труда, но пока что такой потребности не возникло;

Gateway для фронтенда

Если вопросы с внутренним мессаджингом мы более-менее решили, то еще остался фронтенд. В идеале, хочется стремиться к использованию единого стека технологий и для бекенда и для фронтенда: это проще и дешевле. Поэтому мы начали изучать этот вопрос.

Почти сразу нашли проект grpc-gateway, позволяющий генерировать прокси для конвертации gRPC/Protobuf в HTTP/JSON. Кажется, что это неплохое решение для отдачи API на фронтенд и тем клиетам, которые не хотят или не могут использовать gRPC (например, если нужно по-быстрому написать какой-нибудь одноразовый скрипт на bash’е).

Об этом проекте также есть статья на Хабре, поэтому буквально в двух словах: плагин к protoc на основе переданных .proto-файлов с описанием сервиса и специальной мета-информации об HTTP-роутах в них генериурет код для реверс-прокси. Далее руками пишется main-файл, в котором просто запускается сгеренрированный прокси-сервер (авторы grpc-gateway подробно описывают все действия в README.md).

Правда, из коробки для нас нашлась пара неудобств:

  • Хочется добавить несколько middleware перед проксированием входящего запроса (аутентификация через JWT, логирование итп.);
  • Каждый сервис хранит свое API в отдельном репозитории (что бы его легко мог подключить себе клиент в виде субмодуля). Необходимо максимально автоматизировать процесс обновления .proto-файлов и сборки актуальной версии gateway;
  • Помимо прочего, при вводе в строй нового сервиса надо вносить изменения в код main-файла;

Короче говоря, требуется вносить изменения часто и быстро. Придется немного поработать над удобством.

Сам grpc-gateway является плагином к protoc, который генерирует прокси и написан на Go. Исходя из этого решение напрашивается само-собой: написать генератор, который генерирует плагин, который генерирует прокси =) Ну и автоматизировать запуск и деплой всего этого в нашем Gitlab CI.

В результате получился генератор генератора, принимающий на вход простой конфиг:

- url: git.repository.com/api0/example-service        # Адрес репозитория с .proto файлами
  ref: c4d0504f690ee66349306f66578cb15787eefe72       # Ревизия
  target: grpc-external.example.service.consul:50051  # Куда проксируем запросы
- ...

После его изменения и старта сборки, в нашем CI запускается генератор, скачивающий все нужные репозитории на основе конфига, генерирует код для main-файла, оборачивает саму прокси различными middleware и на выходе получает готовый бинарник, который затем деплоится на продакшн.

Преимущества

  • Мы распространили большую часть наших принципов описания и работы с API на фронтенд;
  • Разработчик на бекенде оперирует лишь одним протоколом;
  • Деплой изменений достаточно простой: просто редактируется и пушится конфиг. Далее все собирается и обновляется в CI;
  • Все HTTP-роуты прописываются прямо в proto-файлах в описании каждого метода.

Недостатки

  • Да, еще одна прокси =) С другой стороны, нам все равно нужно место, в котором будет проводиться аутентификация;
  • Любители хайлоада могут заметить, что есть момент конвертации json в protobuf и он, конечно стоит некоторых ресурсов;
  • Нужно потратить пару часов, что бы понять, как проксируются HTTP-заголовки и как конвертируются различные типы из protobuf в JSON (https://developers.google.com/protocol-buffers/docs/proto3#json)

Результат и выводы

В результате всех этих технических пертурбаций мы смогли довольно быстро подготовить нашу инфраструктуру к переходу на единый протокол мессаджинга. Таким образом, мы смогли упростить и ускорить обмен информацией между разработчиками, добавили нашим интерфейсам строгости в вопросе типов, перешли к принципу проектирования Design First и предварительному Code Review на уровне межсервисных интерфейсов.

Получилось как-то так:

На текущий момент мы перевели большую часть внутреннего мессаджинга на gRPC и работаем над новым публичным API. Все это происходит в фоновом режиме по мере возможностей. Однако, этот процесс не настолько сложен, как казалось ранее. Взамен мы получили возможность быстро и единообразно проектировать наши API, обмениваться ими между разработчиками, проводить Code Review и генерировать клиенты. Для случаев, когда очень нужен HTTP (например, для внутренних веб-интерфейсов), мы просто добавляем несколько аннотаций, дописываем пару строк в конфиг grpc-gateway и получаем готовый endpoint.

Еще нам хотелось бы, что бы в будущем стало возможным использовать gRPC или аналогичный протокол прямо на фронтенде =)

Нельзя сказать, что обошлось без косяков и сложностей. Среди интересных проблем, характерных для gRPC, мы выделили следующие:

  • Отсутствие нормальной документации по конфигурационным параметрам клиента и сервера. Например, мы столкнулись с ограничением на длину response-сообщения. Оказалось, что за него отвечает параметр grpc.max_receive_message_length. Найти его удалось лишь, покопавшись в сишном исходном коде клиента;
  • Система пакетов и include path, использующиеся при генерации кода, достаточно долго вгоняют в уныние. Приходится вырабатывать правила интеграции proto-файлов и писать скрипты для правильной генерации кода через protoc. Правда сделать это нужно лишь один раз;
  • Не для всех ЯП генераторы написаны идеально. Например, для того же PHP пришлось достаточно активно голосовать за поддержку fluent-интерфейсов для сгенерированных структур. Также раньше возникали забавные проблемы в случае использования в protobuf зарезирвированных для разных ЯП слов (например, public или private). Сейчас это уже почти полностью исправили;
  • В текущей версии Protobuf у каждого типа (кроме пользовательских типов-структур), есть значение по-умолчанию. Работает примерно так же, как в Go. Если не передать значение для какого-нибудь uint32, то на сервере получим 0, а не null. Это может быть непривычно, но, с другой стороны это оказалось достаточно удобно;

Помимо технических моментов, описанных в этой статье, были и другие вещи, которым нужно уделить пристальное внимание при переходе на новое API:

  • Решить где и в каком виде вы будете хранить вашу документацию или, как в нашем случае, описания интерфейсов в файлах .proto;
  • Понять, как вы будете его версионировать;
  • Написать гайдлайны про принципы проектирования. В случае с gRPC, предоставляемая им свобода может сыграть злую шутку: ваше API Превратится в винегрет из различных способов именования, структур и прочих красивостей. Для этого мы разработали внутри команды небольшой набор правил и способов решения типичных задач.

Надеемся, что наш опыт будет полезен командам, работающими над долгоживущими проектами и готовыми бороться с тем, что обычно называют термином «исторически сложилось».