Всем привет! Я Ирина Матевосян, системный аналитик в направлении продуктового и системного анализа в отделе Tinkoff Mobile Core. Мы разрабатываем общие библиотеки, которые используют все мобильные приложения экосистемы Тинькофф.
Расскажу о протоколе gRPC. На Хабре много статей о тонкостях реализации, рассчитанных на разработчиков, я же хочу познакомить с ним своих коллег. Разберем, как работает протокол и как написать контракт так, чтобы вас поняли, но не будем погружаться в тонкости программной реализации, а скорее расширим кругозор. Возможно, для кого-то gRPC станет крутым решением в работе.
Что такое gRPC
RPC — remote procedure call, удаленный вызов процедур. Клиент отправляет запрос процессу на сервере, который постоянно прослушивает удаленные вызовы. В запросе есть вызываемая серверная функция и все передаваемые параметры. Процесс ловит запрос и выполняет его. Взаимодействие между клиентом и сервером происходит так, как если бы клиентский API-запрос был локальной операцией или запрос — внутренним кодом сервера.
gRPC — это фреймворк RPC от Google. gRPC и REST представляют собой два способа разработки API — механизма, который позволяет двум программным компонентам взаимодействовать друг с другом, используя набор определений и протоколов. Клиенты посылают на сервер информационные запросы — сервер предоставляет ответы. Главное отличие gRPC от REST:
В gRPC один компонент, клиент, вызывает определенные функции в другом программном компоненте — сервере. При этом программная реализация клиента и сервера не имеет особого значения благодаря кроссплатформенности протокола gRPC.
В REST вместо вызова функций клиент запрашивает или обновляет данные на сервере.
Существует четыре вида использования RPC в целом и gRPC в частности.
Унарный — Unary RPC, 1—1. Синхронный запрос клиента, который блокируется, пока не будет получен ответ от сервера. Клиент ничего не может сделать до получения ответа или пока запрос не упадет по таймауту.
Клиентский стрим — Client streaming RPC, N—1. При подключении сервера клиент начинает стримить сообщения на него. Клиент делает запрос на сервер в виде последовательности N сообщений и получает ответ в виде одного сообщения от сервера.
Серверный стрим — Server streaming RPC, 1—N. При подключении клиента сервер открывает стрим и начинает отправлять сообщения. Клиент делает запрос на сервер в виде одного сообщения и получает ответ в виде последовательности N сообщений от сервера.
Двунаправленный стрим — Bidirectional streaming, N—N. Клиент инициализирует соединение, создаются два стрима. В общем случае клиент делает запрос на сервер в виде последовательности N сообщений и получает ответ в виде последовательности N сообщений от сервера. Сервер может отправить изначальные данные при подключении или отвечать на каждый запрос клиента по типу пинг-понга. Два потока работают независимо, поэтому клиенты и серверы могут читать и писать в любом порядке. Например, сервер может дождаться получения всех клиентских сообщений, прежде чем записывать свои ответы, или он может поочередно читать сообщения, а затем сразу писать на них ответы. Возможна и какая-то другая комбинация чтения и записи. Порядок сообщений в каждом потоке сохраняется.
Посмотрим, как отличить виды использования друг от друга в proto-файле:
service Greeter{
rpc SayHello (HelloRequest) returns (HelloReply) {} // Унарный
rpc GladToSeeMe(HelloRequest) returns (stream HelloReply){} // Серверный стрим
rpc GladToSeeYou(stream HelloRequest) returns (HelloReply){} // Клиентский стрим
rpc BothGladToSee(stream HelloRequest) returns (stream HelloReply){} // Двунаправленный стрим
}
В вебе для общения фронта и бэка чаще используют унарную реализацию. Относительно недавно на фронте появилась поддержка стримов, так что стало возможным использовать и остальные. В мобильных приложениях есть поддержка для Kotlin из коробки и Swift в разработке. Для общения back-to-back ограничений нет.
Написание контракта, на мой субъективный взгляд, выглядит изящно и дружелюбно. Читать прото-файл куда проще и приятнее рестового сваггера.
Контракт — это набор методов, объединенных в сервисы. Описание метода состоит из названия, сообщения запроса и сообщения ответа. В запросе и ответе можно как указать стандартные типы данных, так и составить свой объект с необходимым наполнением. Во втором случае потребуется придумать ему название и описать с ключевым словом message.
Правила, по которым строится запрос:
Метод должен принимать что-то на вход и возвращать что-то на выходе — HelloRequest и HelloResponse. Если не нужно получать или отправлять какие-то данные, их можно заменить пустым значением google.protobuf.Empty. Тогда в ответ на запрос или в запросе не будут отправляться никакие данные, но придет код ответа. Ответ 2хх, если все успешно, или 4хх/5хх, если есть проблемы. Это позволяет снизить нагрузку на систему и повысить безопасность, передавая только самое необходимое.
В методе должны быть указаны типы данных, которыми он оперирует. В примере выше это string для name и message, а также HelloRequest и HelloResponse для самого запроса. Если тип данных заранее неизвестен, можно использовать google.protobuf.Any, который заменяет любой тип данных.
У поля в сообщении должен быть неповторяющийся порядковый номер. Если какое-то поле было использовано ранее и удалено, этот номер повторно использовать нельзя. Такие поля можно резервировать ключевым словом reserved или оставляя комментарии.
Для описания контракта используются ключевые слова:
‘syntax’ — текущая версия синтаксиса. Сейчас, как правило, новые сервисы пишутся на proto3.
‘import’ — для импорта стандартных пакетов. Например, “google/protobuf/timestamp.proto” загрузит тип данных timestamp.
‘service’ — для объявления сервиса. В сервис объединяют GRPC-методы
‘rpc’ — для объявления метода: его названия и request-сообщения.
‘returns’ — для объявления ответа метода, response-сообщения.
‘message’ — для объявления объекта.
‘enum’ — для объявления перечисления.
‘repeated’ — для объявления повторяющегося поля.
‘reserved’ — для резервирования поля.
‘optional’ или ‘required’; — для объявления необязательного или обязательного поля. В proto3 этот функционал убран.
‘oneof’ — для объявления сложного поля, в котором можно получить одно из нескольких значений. Это довольно нагруженная и сложно обрабатываемая конструкция, которая может много весить и тратить кучу ресурсов, поэтому ее лучше не использовать, а заменить на что-нибудь другое. Например, на строку, в которой придет на самом деле json.
Целый набор стандартных типов данных вроде bool, string, int64 и других.
Как читать proto-контракты разобрались, теперь посмотрим пример сервиса:
syntax = "proto3";
import "google/protobuf/any.proto";
import "google/protobuf/empty.proto";
import "google/protobuf/timestamp.proto";
service ProductService{
// метод добавления книги в каталог
rpc AddProduct(AddProductRequest) returns (google.protobuf.Empty) {}
// метод получения книги по ID
rpc GetProductById(GetProductByIdRequest) returns (GetProductByIdResponse) {}
// метод получения всех книг
rpc GetProductsList(GetProductsListRequest) returns (GetProductsListResponse) {}
}
message AddProductRequest{
BookInfo add_book_info = 1;
}
message BookInfo{
// эти поля нельзя будет использовать
reserved 6, 15, 9 to 11;
// данные об авторе не определены однозначно, и может прийти любой тип (строка или массив, например)
google.protobuf.Any author = 1;
string name = 2;
int32 price = 3;
Type type = 4;
bool in_store = 5;
// в типе bytes можно передавать файлы, но лучше заменить на ссылку в s3
bytes book_cover = 7;
// здесь мы будем использовать только одно из перечисленных полей, для пользователя это выглядит как, например, динамичная форма ввода
oneof additional_fields{
// используем с TYPE_UNDEFINED — он пригодится, когда будут добавляться новые значения в enum Type: созданные объекты BookInfo примут этот тип по умолчанию
AdditionalFieldsUndefined additional_fields_undefined = 8;
AdditionalFieldsDetective additional_fields_detective = 12;
}
}
enum Type{
TYPE_UNDEFINED = 0;
TYPE_DETECTIVE = 1;
}
message AdditionalFieldsUndefined{
string description = 1;
}
message AdditionalFieldsDetective{
string description = 1;
string period = 2;
}
message GetProductByIdRequest{
// protobuf еще не знает, что такое uuid, поэтому его можно передать типами string, bytes или кастомным типом
string id = 1;
}
message GetProductByIdResponse{
string id = 1;
BookInfo get_book_info = 2;
google.protobuf.Timestamp created_at = 3;
}
// limit и offset нужны для пагинации на бэке. Если у нас бесконечная лента, можно вместо объекта GetProductsListRequest передать google.protobuf.Empty
message GetProductsListRequest{
int32 limit = 1;
int32 offset = 2;
}
message GetProductsListResponse{
repeated BookInfo get_book_list_info = 1;
}
В примере описания библиотеки я постаралась собрать самые часто встречающиеся (хорошие и не очень) паттерны реализации контрактов. В коде реализованы методы добавления книги в каталог, получения конкретной книги по ID и полного списка книг. Версия синтаксиса — proto3, импорт типов данных — any, empty, timestamp и сервис ProductService из трех методов: AddProduct, GetProductById, GetProductsList.
Важная особенность gRPC — строгая типизация. Когда gRPC формирует файл, для каждого поля выделяется определенный объем байтов и позиция в будущем base64. Это значит, что для данных поля с типом bool и порядковым номером 1 будет выделен кусочек в начале файла, а данные поля с типом string и номером 2 будут расположены сразу за ним. Для этих полей будет выделено столько памяти, сколько требуется для bool и string, и их положение в файле будет четко зафиксировано. Даже если поля будут удалены, место останется зарезервированным и эти байты будут заполняться пустыми значениями. Поэтому, если поменять тип или номер поля, эта особенность типизации приведет к ошибкам при компиляции контракта. Чтобы не раздувать итоговый файл до огромных размеров, важно изначально четко понимать, какие данные в объекте нужно передавать.
Пример подстановки данных в контракт и строка в base64:
Контракт:
syntax = "proto3";
message exampleMessage{
string exampleString = 1;
bytes exampleBytes = 2;
uint32 exampleInt = 3;
repeated uint32 repeatedInt = 4;
}
Подставленные данные:
{
"exampleString": "test",
"exampleBytes": [255, 15],
"exampleInt": 2,
"repeatedInt": [2, 4]
}
Закодированные данные:
Hex: 0a04746573741202ff0f180222020204
Base64: CgR0ZXN0EgL/DxgCIgICBA==
Характеристики REST API и SOAP API
Неинтересно рассказывать про сферического коня в вакууме, поэтому предлагаю сравнить характеристики REST API, SOAP API и gRPC API.
Характеристики SOAP API
Основа: HTTP1.1.
Подход: сервисно-ориентированный дизайн. Клиент запрашивает у сервера услугу или функцию, которая может затрагивать ресурсы сервера.
Контракт: обязательны WSDL-схемы.
Формат передаваемых данных:
Запрос: XML.
Ответ: XML.
Как передает данные: использует только HTTP-POST-запросы.
Число конечных точек (точек входа в приложение): 1.
Работа в вебе: без допусилий.
Документирование: WSDL-схемы сложно писать и поддерживать.
Для какой архитектуры: сложная архитектура, выходящая за рамки CRUD. SOAP используют многие банки.
Вес: XML весит больше аналогичных JSON и base64 и в основном применяется в legacy-системах, которые были разработаны в конце 1990-х — начале 2000-х.
Достоинства:
Не зависит от языка.
Встроенная обработка ошибок.
Встроенный протокол безопасности.
Самодокументируемый.
Недостатки:
Тяжелый XML.
Сложный набор правил для описания контракта.
Долгое обновление сообщений — особенности схемы.
Постоянная необходимость в кодировании данных на сервере до передачи по каналам связи и их последующем декодировании на клиенте. Физический уровень протоколов обмена понимает только последовательности двоичных данных, это приводит к увеличению времени передачи, сложности кадрирования информации и рискам потери отдельных пакетов данных.
Зачем использовать: финтех и другие долгие массивные проекты со сложной архитектурой, легаси с 90-00 хх гг. или исторически выбранный SOAP, от которого не отказаться. Для нового проекта, возможно, стоит присмотреться к альтернативам.
Характеристики REST API
Основа: HTTP1.1.
Связь с сервером: 1—1.
Подход: объектно-ориентированный дизайн. Клиент запрашивает у сервера создание, совместное использование или модификацию ресурсов.
Контракт: необязательно OpenAPI, эндпоинты могут быть не задокументированы.
Формат передаваемых данных:
Запрос: преимущественно JSON.
Ответ: json со всеми данными, найденными на сервере по этой конечной точке.
Как передает данные: использует HTTP в качестве транспортного протокола, создает разовое соединение между двумя точками: создал соединение, отправил и закрыл. Клиент шлет в API сообщения и сразу получает ответ или ждет формирования ответа. Клиенту и серверу не нужно знать о внутренних данных. Использует четыре основных метода HTTP: GET, POST, PUT, DELETE.
Число конечных точек: может быть много — как одна, так и гораздо больше, без ограничений.
Работа в вебе: без допусилий.
Документирование: в JSON нужно задокументировать содержащиеся в нем поля и их типы. Часто информация может быть неточной, неполной или устаревшей.
Для какой архитектуры: преимущественно CRUD. Самая популярная архитектура API для веб-сервисов и микросервисных архитектур.
Вес: JSON меньше XML, но больше protobuf.
Достоинства:
Клиент отделен от сервера.
Нет длительного соединения с отслеживанием состояния → экономия ресурсов.
Масштабируемость.
Простой в использовании и понимании, большое комьюнити.
Есть стандартный список кодов ошибок, но все пользуются им по-своему.
Можно внедрять в самых разных форматах без стандартного программного обеспечения.
Кэширование на уровне HTTP без дополнительных модулей.
Недостатки:
Избыточная нагрузка на сеть.
Избыточная или недостаточная выборка данных.
Нет документирования и стандартизации.
Нет стандарта использования кодов ответов, поэтому часто в успешных кодах могут передаваться ошибки.
Постоянная необходимость в кодировании данных на сервере перед передачей по каналам связи и последующем их декодировании на клиенте. Физический уровень протоколов обмена понимает только последовательности двоичных данных. Это приводит к увеличению времени передачи, сложности кадрирования информации и повышению вероятности потери отдельных пакетов данных.
Зачем использовать: благодаря простой реализации и отображению структуры данных, удобству чтения с ним легко работать начинающим программистам.
Примеры использования REST API:
Веб-архитектуры.
Общедоступные интерфейсы API для легкого понимания внешними пользователями.
Простой обмен данными.
Характеристики gRPC API
RPC и REST — два разных подхода к проектированию. REST был запущен как альтернатива RPC для решения основной проблемы, которая у того имелась, — сложности интеграции из-за зависимости от языка разработки и риска раскрытия внутренних особенностей системы. REST получился уже не таким легковесным, как RPC, и создавал большое количество метаданных в своих сообщениях. Вероятнее всего, это и привело к второму рождению RPC в лице GraphQL от Facebook и gRPC от Google.
Google разработала свой фреймворк для внутренних нужд работы с микросервисами, но в итоге открыла его исходный код для широкого применения. Сейчас gRPC все еще достаточно новый протокол и не у всех на слуху. Но им уже пользуются компании с высоконагруженными системами, такие как Google, IBM, Netflix, Twitter и другие. Ниже — его характеристики.
Основа: HTTP2 (работает в двух направлениях и за счет этого быстрее HTTP1.1).
Связь с сервером: 1—1, 1—N, N—N.
Подход: сервисно-ориентированный дизайн. Клиент запрашивает у сервера услугу или функцию, которая может затрагивать ресурсы сервера.
Контракт: обязательно пишется по стандарту Protocol Buffers, компилируется внутренним компилятором protoc, который генерирует необходимый исходный код классов из определений в proto-файле.
Формат передаваемых данных:
Запрос: бинарный файл — protobuf.
Ответ: бинарный файл — protobuf.
Как передает данные: создает постоянное соединение — сокет — между двумя точками, по которому передает бинарный файл и вызывает удаленно функцию, передавая в нее параметры. Шлет сообщения в обе стороны: gRPC обеспечивает двунаправленную потоковую передачу данных — и клиент, и сервер могут одновременно посылать и получать несколько запросов и ответов в рамках одного соединения. REST так не умеет. При этом и клиенту, и серверу нужен один и тот же файл Protocol Buffer, определяющий формат данных. Использует только HTTP-POST-запросы.
Число конечных точек: 1.
Работа в вебе с дополнительными усилиями:
gRPC работает на HTTP2 и передает бинарный файл, а JS в браузере работает на HTTP1 и взаимодействует только с текстовыми файлами. Поэтому существует gRPC-WEB, который может положить base64 в тело текстового сообщения, а затем JS отдельной библиотекой переводит base64 в JSON. gRPC-WEB — отдельный от gRPC протокол, существует только в браузере и действует как уровень перевода между gRPC и приложением в браузере.
Фронтовый кодген не знает, где поле обязательное, а где нет. Все не базовые типы генерируются как optional.
Стримы для фронта стали доступны не так давно. Раньше для отправки файла писали rest-точку.
Документирование: четко определенная и самодокументируемая схема. API на Protobuf генерирует код, код не будет рассинхронизирован с документацией. При генерации кода из Protobuf проходит базовая проверка — сгенерированный код не принимает поля неправильного типа.
Для какой архитектуры: преимущественно CRUD.
Вес: меньше JSON.
Достоинства:
Высокая производительность и низкая нагрузка на сеть.
Держит соединение, не надо тратить время на подключение.
Можно поставить таймаут клиента и тем самым экономить ресурсы.
Строгая спецификация типов данных. Под каждое поле выделяет набор битов по порядку.
Стандартизация кодов ошибок, зашитая в protobuf.
Сам генерирует исходный код по proto.
Самодокументируемый.
Не зависит от языка, контракт везде одинаковый.
Можно использовать для управления контейнерами в k8s и системами хранения данных.
Недостатки:
Не работает без gRPC-WEB в браузере.
Человек не прочитает сообщение без декодера.
Для работы нужно программное обеспечение gRPC как со стороны клиента, так и со стороны сервера.
Зачем использовать: разработан для того, чтобы дать разработчикам возможность создавать высокопроизводительные API для микросервисных архитектур в распределенных центрах обработки данных. В том числе для микросервисных архитектур на нескольких языках программирования, для которых API вряд ли будет меняться со временем. Также он хорошо подходит для внутренних систем, требующих потоковой передачи данных в реальном времени и загрузки больших объемов информации.
gRPC API лучше использовать, когда:
Создаются высокопроизводительные системы. Например, в высоконагруженных системах, где нужна высокая пропускная способность и производительность при низких требованиях к сети, а также аппаратным ресурсам сервера и клиента — платформы интернета вещей. А еще в распределенных вычислениях или тестировании, когда выполнение ресурсоемких задач распределяется между несколькими серверами или при проверке работоспособности тестов на различных платформах.
Загружаются большие данные.
Разрабатываются приложения реального времени или потоковых приложений.
Нужно удаленное администрирование — управление конфигурационными файлами с единого узла.
Необходимо туннелирование — выход за границы маршрутизируемой сети.
В gRPC нельзя:
Переиспользовать номера полей. Это особенности кодгена, все ломается.
Менять тип поля. Удали и добавь новое с новым номером. Особенности кодгена, все ломается.
Забывать резервировать удаленные поля. Их может кто-то переиспользовать. И что? Правильно, все сломается. Касается и названий, и номеров полей.
Добавлять обязательные поля. Убрать обязательность сложно. Если нужно сделать поле обязательным, сделай его таким в коде и напиши коммент к контракту.
Добавлять много полей в объект. Он будет много весить и в некоторых случаях даже не скомпилируется. В Java, например, существует жесткое ограничение на размер метода.
Забывать про UNDEFINED с номером 0 в перечислении. Во-первых, для совместимости proto2 и proto3 это единственный вариант, когда ничего не сломается при добавлении нового значения в enum. Во-вторых, прямо прописав значение 0, мы избавляем себя от логических ошибок. В примере с книжным сайтом, если не будет значения UNDEFINED, всем книгам автоматически будет присваиваться тип «детектив». Но, если будет такое значение, пользователю можно будет отобразить обязательное поле с выпадающим списком с единственным выбором и он будет вынужден его заполнить.
Изобретать велосипед. Практически все типы данных либо импортированы по-умолчанию (string), либо подключаются (timestamp). Посмотри документацию, прежде чем делать кастомный тип, чтобы сэкономить ресурсы.
Использовать константы и ключевые слова языка в enum. Все сломается.
Менять значение по умолчанию. Сломается обратная совместимость, поэтому в proto3 этот функционал вообще убрали.
Не рекомендуется убирать repeated, если он был. Теряем все сообщение или конкретное поле в зависимости от версии. В любом случае плохо.
Мы с коллегами — разработчиками и аналитиками — выделили в использовании gRPC ряд достоинств и недостатков. Если вы знаете еще какие-то, добавляйте в комментариях.
Достоинства:
Общение back-to-back. Быстрое и стабильное.
Proto сильно облегчает жизнь разработчикам сервиса, для которого пишется контракт. Если меняется ответ, сервис узнает об этом сразу, а не когда обновят документацию (если обновят). Сваггер всем писать всегда лень.
Быстрый, base64 меньше весит по сравнению с JSON. Подходит для микросервисной архитектуры.
Безопасный.
Контракт удобен для чтения человеком.
Типизация.
Недостатки:
Не работает полноценно с фронтом, поэтому при неправильной настройке может работать некорректно. И обязательно нужен gRPC-web.
Фронтовый кодген не знает, где поле обязательное, а где нет. Все не базовые типы генерируются как optional.
Из первого и второго пункта вытекает, что не особо хорош в вебе. Но используют.
Кодген не дает менять тип поля — только удалять и добавлять новое, с новым номером (особенности типизации, о которых забывают).
На прощание
Выбор технологии для проекта — вопрос сложный и должен решаться совместно архитекторами и разработчиками. Системный аналитик больше теоретик, чем практический технический специалист, но хорошо разбираться в технологиях и иметь совещательный голос должен. Как правило, мы не пишем код и не знаем тонкостей применения той или иной технологии и конечная реализация лежит не на наших плечах. Но мы можем поделиться своим опытом других проектов. И если сегодняшнее знакомство в gRPC вдохновило вас на его дальнейшее изучение и презентацию своей команде, это прекрасно.
Для самых любопытных: что такое g в gRPC? В каждой версии gRPC значение g меняется — в актуальной на момент написания 1.61 он означает grand. Посмотреть значения в предыдущих версиях можно на GitHub.
Krouler7
Честно, не понял пункта с сравнением SOAP и REST в моменте передачи данных.
Якобы в SOAP стандартизированные данные, а в REST нет.
В контексте REST указан тип данных JSON, который тоже имеет некую согласованную форму представления. Чем же это является тогда, если не стандартом?
UPD
SOAP также может использоваться в REST архитектуре, но не используется лишь из-за объема передаваемой информации.
Itiliniel Автор
Речь о том, какие данные используются. У SOAP есть спецификация, в которой описаны требования к передаваемым сообщениям. А JSON у REST не является стандартом хотя бы потому, что его использование не является обязательным. Хотя де-факто во многих компаниях это стандарт.