gRPC
С нашим сервером до сих пор разговаривали браузеры. Но у ProfileService есть и другие собеседники: UserDirectory, GeoDirectory, биллинг. Сервисы между собой давно общаются не по REST, а по gRPC: строгие контракты, стриминг в обе стороны, дедлайны и коды ошибок из коробки.
Обычно под gRPC поднимают отдельный сервер на отдельном порту. Спросите себя: а зачем, собственно? gRPC — это протокол поверх HTTP/2 и HTTP/3. Поверх того, что у нас уже слушает. Серверу достаточно отличать такие запросы по content-type application/grpc и отдавать их в отдельный обработчик. Он именно так и делает.
Контракт сначала
Разговор в gRPC начинается не с кода. Он начинается с контракта, и это, пожалуй, главная культурная разница с REST. Опишем сервис профилей в profile.proto:
syntax = "proto3";
package profile;
service ProfileService {
rpc GetProfile (GetProfileRequest) returns (Profile);
}
message GetProfileRequest { int64 user_id = 1; }
message Profile {
int64 id = 1;
string name = 2;
string region = 3;
}Компилятор protobuf генерирует из этого PHP-классы, рантайм ставится композером:
$ composer require google/protobuf
$ protoc --php_out=src/Generated profile.protoТеперь в проекте есть Profile\GetProfileRequest и Profile\Profile: типизированные геттеры, сеттеры, сериализация. И ровно такие же классы protoc сгенерирует для клиента на Go, Java или Python. В этом весь смысл: контракт один, языки любые.
Сообщения вместо тел
Чем gRPC-обработчик отличается от HTTP-обработчика? Единицей общения. Там были «тело запроса» и «тело ответа», по одному на каждого. Здесь — поток сообщений в каждую сторону: readMessage() возвращает байты следующего входящего или null в конце, writeMessage() отправляет исходящее. Сервер отвечает за grpc-фрейминг, длины и флаги. Сгенерированные классы — за содержимое:
use Profile\GetProfileRequest;
use Profile\Profile;
$server->addGrpcHandler(function (HttpRequest $req, HttpResponse $res) {
// путь запроса называет метод контракта
if ($req->getPath() !== '/profile.ProfileService/GetProfile') {
$res->setTrailer('grpc-status', '12'); // UNIMPLEMENTED
return;
}
$getProfile = new GetProfileRequest();
$getProfile->mergeFromString($req->readMessage()); // байты -> объект
$profile = (new Profile())
->setId($getProfile->getUserId())
->setName(fetchName($getProfile->getUserId()))
->setRegion(fetchRegion($getProfile->getUserId()));
$res->writeMessage($profile->serializeToString()); // объект -> байты
});Роутинг здесь — просто путь: пакет, сервис, метод. Клиент на любом языке, вызывая GetProfile, шлёт POST на /profile.ProfileService/GetProfile. А сам обработчик живёт рядом с REST-роутами и видит тот же прогретый пул и контекст запроса.
Все четыре формы gRPC получаются из этого же API и различаются только числом вызовов:
// Unary: одно сообщение туда, одно обратно
$getProfile->mergeFromString($req->readMessage());
$res->writeMessage($reply->serializeToString());
// Server streaming: одно туда, много обратно
$getHistory->mergeFromString($req->readMessage());
foreach (loadHistory($getHistory->getUserId()) as $event) {
$res->writeMessage($event->serializeToString());
}
// Полный дуплекс: читаем и отвечаем вперемешку
while (($bytes = $req->readMessage()) !== null) {
$msg = new ChatMessage();
$msg->mergeFromString($bytes);
$res->writeMessage(process($msg)->serializeToString());
}readMessage() усыпляет корутину до следующего сообщения, writeMessage() отвечает сразу, не дожидаясь конца входящего потока. Тот же дуплекс, что у WebSocket, только с контрактом и по стандарту.
Передача ошибок
У gRPC своя система кодов ошибок, и живёт она в месте, которое поначалу сбивает с толку. HTTP-статус ответа всегда 200. Всегда. Настоящий результат едет в трейлерах — заголовках, которые HTTP/2 отправляет после тела. Мы мельком видели их в главе про стриминг, и вот их главный потребитель:
$server->addGrpcHandler(function (HttpRequest $req, HttpResponse $res) {
$data = $req->readMessage();
if (!authorized($req)) {
$res->setTrailer('grpc-status', '7'); // PERMISSION_DENIED
$res->setTrailer('grpc-message', 'access denied');
return;
}
$res->writeMessage(process($data));
// чистый return: сервер сам допишет grpc-status: 0 (OK)
});Об авариях тоже подумано: исключение, вылетевшее из обработчика, превращается в grpc-status: 13 (INTERNAL), а не в оборванное соединение.
Дедлайны
Помните, сколько глав первой серии мы потратили на идею «любое ожидание должно иметь предел»? Так вот, в gRPC эта идея возведена в стандарт протокола. Клиент передаёт свой дедлайн прямо в запросе, заголовком grpc-timeout, а сервер отдаёт его обработчику:
$deadline = $req->getGrpcTimeout(); // миллисекунды или null
$result = $group->all()->await(timeout($deadline ?? 5000));Вдумайтесь, насколько это правильная механика. У клиента осталось двести миллисекунд терпения? Тогда бессмысленно ходить в GeoDirectory с таймаутом в две секунды. Дедлайн продевается сквозь все внутренние ожидания, через все сервисы в цепочке, и вся система уважает терпение самого первого звонившего.
Конец второй серии
Вот и весь маршрут. Оглянемся разок.
Пятнадцать глав первой серии собирали словарь: корутины, отмена, Future, каналы, scope, группы, пулы, потоки, контекст. Честно говоря, местами могло казаться, что словарь избыточен. А потом пришла вторая серия, и оказалось, что сервер — это просто предложение, составленное из тех же слов. Каждый запрос — корутина. Базу бережёт пул. За запросом прибирает scope. Перегрузку держит обратное давление. Ядра занимают потоки. А поверх — статика, SSE, WebSocket и gRPC на одном порту.
Заметьте и то, чего в обеих сериях не было: колбэков, цепочек .then(), ключевых слов async и await на каждой второй строчке, ручного управления циклом событий. Весь код — обычный последовательный PHP. Он просто перестал ждать впустую.
Дальше сами. Возьмите сервис, который давно просился на переделку, и начните с одного обработчика. Справочник по классам — в документации сервера, внутренности — в архитектуре. А если что-то поведёт себя не так, как обещали эти главы, вы теперь знаете достаточно, чтобы завести хороший баг-репорт.