Многие знакомы с gRPC — открытым RPC-фреймворком от Google, который поддерживает 10 языков и активно используется внутри Google, Netflix, Kubernetes, Docker и многими другими. Если вы пишете микросервисы, gRPC предоставляет массу преимуществ перед традиционным подходом REST+JSON, но на существующих проектах часто переход не так просто осуществить из-за наличия уже использующихся REST-клиентов, которые невозможно обновить за раз. Нередко общаясь на тему gRPC можно услышать "да, мы у нас в компании тоже смотрим на gRPC, но всё никак не попробуем".
Что ж, этой проблеме есть хорошее решение под названием grpc-rest-gateway, которое занимается именно этим — автогенерацией REST-gRPC прокси с поддержкой всех основных преимуществ gRPC плюс поддержка Swagger. В этой статье я покажу на примере как это выглядит и работает, и, надеюсь, это поможет и вам перейти на gRPC, не теряя существующие REST-клиенты.
Но, для начала, давайте определимся о каких вообще ситуациях речь. Два самых частых варианта:
- бекенд (Go/Java/C++/node.js/whatever) и фронтенд (JS/iOS/Kotlin/Java/etc) общаются с помощью REST API
- микросервисы (на разных языках) общаются между собой также через REST-подобный API (протокол HTTP и JSON для сериализации)
Для маленьких проектов это абсолютно нормальный выбор, но по мере того, как проекты и количество людей на нём растут, проблемы REST API начинают очень явно давать о себе знать и отнимать львиную долю времени разработчиков.
Чем плох REST?
Безусловно, REST используется везде и повсюду в виду его простоты и даже размытого понимания, что такое REST. Вообще, REST начался как диссертация одного из создателей HTTP Роя Филдинга под названием "Архитектурные стили и дизайн сетевых программных архитектур". Собственно, REST это и есть лишь архитектурный стиль, а не какая-то чётко описанная спецификация.
Но это и является корнем некоторых весомых проблем. Нет единого соглашения, когда какой метод HTTP использовать, когда какой код возвращать, что передавать в URI, а что в теле запроса и т.д. Есть попытки прийти к общей договорённости, но они, к сожалению, не очень успешны.
Далее, при REST подходе, у вас есть чересчур много сущностей, которые несут смысл — метод HTTP (GET/POST/PUT/DELETE), URI запроса (/users
, /user/1
), тело запроса ({id: 1}
) плюс заголовки (X-User-ID: 1
). Всё это добавляет излишнюю сложность и возможность неверной интерпретации, что превращается в большую проблему по мере того, как API начинает использоваться между различными сервисами, которые пишут различные команды и синхронизация всех этих сущностей начинает занимать значительную часть времени команд.
Это приводит нас к следующей проблеме — сложности декларативного описания интерфейсов API и описания типов данных. OpenAPI Specification (известное как Swagger), RAML и API Blueprint частично решают эту проблему, но делают это ценой добавления другой сложности. Кто-то пишет YAML файлы ручками для каждого нового запроса, кто-то использует web-фрейморки с автогенерацией, раздувая код описаниями параметров и типов запроса, и поддержка swagger-спецификации в синхронизации с реальной реализацией API всё равно лежит на плечах ответственных разработчиков, что отнимает время от решения, собственно, задач, которые эти API должны решать.
Отдельная сложность заключается в API, которое развивается и меняется, и синхронизация клиентов и серверов может отнимать довольно много времени и ресурсов.
gRPC
gRPC решает эти проблемы кодогенерацией и декларативным языком описания типов и RPC-методов. По-умолчанию используется Google Protobuf 3 в качестве IDL, и HTTP/2 для транспорта. Кодогенераторы есть по 10 языков — Go, Java, C++, Python, Ruby, Node.js, C#, PHP, Android.Java, Objective-C. Есть также пока неофициальные реализации для Rust, Swift и прочих.
В gRPC у вас есть только одно место, где вы определяете, как будут именоваться поля, как называться запросы, что принимать и что возвращать. Это описывается в .proto файле. Например:
syntax = "proto3";
package library;
service LibraryService {
rpc AddBook(AddBookRequest) returns (AddBookResponse)
}
message AddBookRequest {
message Author {
string name = 1;
string surname = 2;
}
string isbn = 1;
repeated Author authors = 2;
}
message AddBookResponse {
int64 id = 1;
}
Из этого proto-файла, с помощью protoc
-компилятора генерируются код клиентов и серверов на всех поддерживаемых языках (ну, на тех, которые вы укажете компилятору). Дальше, если вы что-то изменили в типах или методах — перезапускаете генерацию кода и получаете обновлённый код и клиента, и сервера.
Если вы когда-либо разруливали конфликты в названиях полей вроде UserID
vs user_id
, вам понравится работать с gRPC.
Но я не буду сильно подробно останавливаться на принципах работы с gRPC, и перейду к вопросу, что же делать, если вы хотите использовать gRPC, но у вас есть клиенты, которые всё ещё должны работать через REST API, и их не просто будет перевести/переписать на gRPC. Это особенно актуально, учитывая, что официальной поддержки gRPC в браузере пока нет (JS только Node.js официально), и реализация для Swift также пока не в списке официальных.
GRPC REST Gateway
Проект grpc-gateway, как и почти всё в grpc-экосистеме, реализован в виде плагина для protoc
-компилятора. Он позволяет добавить аннотации к rpc-определениям в protobuf-файле, который будут описывать REST-аналог этого метода. Например:
import "google/api/annotations.proto";
...
service LibraryService {
rpc AddBook(AddBookRequest) returns (AddBookResponse) {
option (google.api.http) = {
post: "/v1/book"
body: "*"
};
}
}
После запуска protoc с указанным плагином, вы получите автосгенерированный код, который будет прозрачно перенаправлять POST HTTP запросы на указанный URI на реальный grpc-сервер и также прозрачно конвертировать и отправлять ответ.
Тоесть формально, это API Proxy, который запущен, как отдельный сервис и делает прозрачную конвертацию REST HTTP запросов в gRPC коммуникацию между сервисами.
Пример использования
Давайте, продолжим пример выше — скажем, наш сервис работы с книгами, должен уметь работать со старым iOS-фронтендом, который пока умеет работать только по REST HTTP. Другие сервисы вы уже перевели на gRPC и наслаждаетесь меньшим количеством головной боли при росте или изменениях ваших API. Добавив выше указанные аннотации, создаём новый сервис — например rest_proxy
и в нём автогенерируем код обратного прокси:
protoc -I/usr/local/include -I. -I$GOPATH/src -I$GOPATH/src/github.com/grpc-ecosystem/grpc-gateway/third_party/googleapis --grpc-gateway_out=logtostderr=true:. library.proto
Код самого сервиса может выглядеть вот как-нибудь так:
import (
"github.com/myuser/rest-proxy/library"
)
var main() {
gw := runtime.NewServeMux(muxOpt)
opts := []grpc.DialOption{grpc.WithInsecure()}
err := library.RegisterLibraryServiceHandlerFromEndpoint(ctx, gw, "library-service.dns.name", opts)
if err != nil {
log.Fatal(err)
}
mux := http.NewServeMux()
mux.Handle("/", gw)
log.Fatal(http.ListenAndServe(":80", mux))
}
Этот код запустит наш прокси на 80-м порту, и будет направлять все запросы на gRPC сервер, доступный по library-service.dns.name
. RegisterLibraryServiceHandlerFromEndpoint
это автоматически сгенерированный метод, который делает всю магию.
Очевидно, что этот прокси может служить входной точкой для всех остальных ваших сервисов на gRPC, которым нужен fallback в виде REST API — просто подключаете остальные автосгенерированные пакаджи и регистрируете их на тот же gw-объект:
err = users.RegisterUsersServiceHandlerFromEndpoint(ctx, gw, "users-service.dns.name", opts)
if err != nil {
log.Fatal(err)
}
и так далее.
Преимущества
Автосгенерированный прокси поддерживает автоматический реконнект к сервису, с экспоненциальной backoff-задержкой, как и в обычных grpc-сервисах. Аналогично, поддержка TLS есть из коробки, таймаутов и всё, что доступно в grpc-сервисах, доступно и в прокси.
Middlewares
Отдельно хочется написать про возможность использования т.н. middlewares — обработчиков запросов, которые автоматически должны срабатывать до или после запроса. Типичный пример — ваши HTTP запросы содержат специальный заголовок, который вы хотите передать дальше в grpc-сервисы.
Для примера, я возьму пример со стандартным JWT токеном, которые вы хотите расшифровывать и передавать значение поля UserID grpc-сервисам. Делается это также просто, как и обычные http-middlewares:
func checkJWT(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
bearer := r.Header.Get("Authorization")
...
// parse and extract value from token
...
ctx = context.WithValue(ctx, "UserID", claims.UserID)
h.ServeHTTP(w, r.WithContext(ctx))
})
}
и заворачиваем наш mux-объект в эту middleware-функцию:
mux.Handle("/", checkJWT(gw))
Теперь на стороне сервисов (все gRPC-методы в Go реализации принимают первым параметром context), вы просто достаёте это значение из контекста:
func (s *LIbrary) AddBook(ctx context.Context, req *library.AddBookRequest) (*library.AddBookResponse, error) {
userID := ctx.Value("UserID").(int64)
...
}
Дополнительный функционал
Разумеется, ничего не ограничивает ваш rest-proxy от реализации дополнительного функционала. Это обычный http-сервер, в конце-концов. Вы можете пробросить какие-то HTTP запросы на другой legacy REST сервис:
legacyProxy := httputil.NewSingleHostReverseProxy(legacyUrl)
mux.Handle("/v0/old_endpoint", legacyProxy)
Swagger UI
Отдельной вишенкой в подходе с grpc-gateway есть автоматическая генерация swagger.json
файла. Его можно затем использовать с онлайн UI, а можно и отдавать напрямую из нашего же сервиса.
С помощью небольших манипуляций со SwaggerUI и go-bindata, можно добавить ещё один endpoint
к нашему сервису, который будет отдавать красивый и, что самое важное, актуальный и автосгенерированный UI для REST API.
Генерируем swagger.json
protoc -I/usr/local/include -I$GOPATH/src -I$GOPATH/src/github.com/grpc-ecosystem/grpc-gateway/third_party/googleapis --swagger_out=logtostderr=true:swagger-ui/ path/to/library.proto
Создаем handler-ы, которые будут отдавать статику и генерировать index.html (в примере статика добавляется прямо в код с помощью go-bindata):
mux.HandleFunc("/swagger/index.html", SwaggerHandler)
mux.Handle("/swagger/", http.StripPrefix("/swagger/", http.FileServer(assetFS())))
...
// init indexTemplate at start
func SwaggerHandler(w http.ResponseWriter, r *http.Request) {
indexTemplate.Execute(w, nil)
}
и вы получаете Swagger UI, подобный этому, с актуальной информацией и возможностью тут же тестировать:
Проблемы
В целом мой опыт работы с grpc-gateway можно пока-что охарактеризовать одной фразой — "оно просто работает из коробки". Из проблем, с которыми приходилось сталкиваться, например могу отметить следующее.
В Go для сериализации в JSON используются так называемые "тэги структур" — мета информация для полей. В encoding/json
есть такой тэг omitempty
— он означает, что если значение равно нулю (нулевому значению для этого типа), то его не нужно добавлять в результирующий JSON. Плагин grpc-gateway для Go именно этот тег и добавляет к структурам, что приводит иногда к неверному поведению.
Например, у вас есть переменная типа bool
в структуре, и вы отдаёте эту структуру в ответе — оба значения true
и false
одинаково важны в ответе, и фронтенд ожидает это поле получить. Ответ же, сгенерированный grpc-gateway будет содержать это поле, только если значение равно true
, в противном случае оно просто будет пропущено (omitempty).
К счастью, это легко решается с помощью опций конфигурации:
customMarshaller := &runtime.JSONPb{
OrigName: true,
EmitDefaults: true, // disable 'omitempty'
}
muxOpt := runtime.WithMarshalerOption(runtime.MIMEWildcard, customMarshaller)
gw := runtime.NewServeMux(muxOpt)
Ещё одним моментом, которым хотелось бы поделиться, можно назвать неочевидная семантика работы с самим protoc
-компилятором. Команды вызова очень длинные, трудночитаемые, и, что самое важное, логика того, откуда берется protobuf и куда генерируется вывод (+какие директории создаются) — очень неочевидна. Например, вы хотите использовать proto-файл из другого проекта и сгенерировать каким-нибудь плагином код, положив его в текущий проект в папку swagger-ui/
. Мне пришлось минут 15 перепробовать массу вариантов вызова protoc, прежде чем стало понятно, как заставить генератор работать именно так. Но, снова же, ничего нерешаемого.
Заключение
gRPC может ускорить продуктивность и эффективность работы с микросервис архитектурой в разы, но часто помехой становится требование обратной совместимости и поддержки REST API. grpc-gateway
предоставляет простой и эффективный способ решения этой проблемы, автоматически генерируя обратный прокси сервер, транслирующий REST/JSON запросы в gRPC вызовы. Проект очень активно развивается и используется в продакшене во многих компаниях.
Ссылки
Комментарии (14)
svistunov
12.09.2017 15:52+2В grpc-gateway есть фатальный недостаток — он очень медленный. Цепочка выглядит:
Umarshal JSON -> Marshal protobuf -> Call gRPC -> Marshal protobuf on server -> Unmarshal protobuf on gateway -> Marshal JSON.
В Go на стороне сервиса довольно легко написать HTTP handler, который будет делать Unmarshal JSON'а напрямую в gRPC Request структуры (уже есть теги json) и вызывать реализацию gRPC метода.mayorovp
12.09.2017 15:59+2Я бы не назвал это очень медленным: просто не самая оптимальная реализация, которая сойдет на время переходного периода.
divan0 Автор
12.09.2017 16:27-1Это правда, grpc-gateway это ещё одно звено в цепи, со своей сериализацией/десереализацией. Но «очень медленный» это относительная фраза — речь всё таки о десятках миллисекунд, что для многих случаев более чем позволительно.
Подход с десереализацией напрямую может работать только в случае, если это один сервис. Если же вы, к примеру, разбиваете монолит на gRPC-сервисы, и при этом хотите сохранить legacy REST API (хотя бы на время), то уже так не получится. А так да, вариант.
farcaller
12.09.2017 18:01Как раз тыкаю эту связку для нового проекта. Вы с аплоадом файлов в такой схеме не сталкивались?
divan0 Автор
12.09.2017 18:35К счастью, не сталкивался.
Вот такую issue в grpc-gateway обнаружил: github.com/grpc-ecosystem/grpc-gateway/issues/410farcaller
12.09.2017 18:36Я ее тоже нашел :-) как-то коряво, конечно.
divan0 Автор
12.09.2017 18:40Ну, корявость в задаче, а не в инструменте :)
Если подумать — grpc гоняет туда-сюда только протобафы + стримы. HTTP MultiPart это такой древний пережиток, что его втиснуть тут только каким-то своим методом можно.
Тоесть, либо самому делать handler, который будет вычитывать и на ходу писать в grpc-стрим (или все читать в память/диск и передавать одним большим объектом), или надеятся, что это сделают за нас в grpc-gateway :)
SirEdvin
Спасибо за интересную статью! Подскажите, а есть ли неплохие русскоязычные статьи про grpc или в целом доки хватает?
Kolyuchkin
Чтобы начать и заинтересоваться, достаточно (взято из ссылок автора поста)