Как перейти на gRPC, сохранив REST

  • Tutorial

Многие знакомы с 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
  • 0

    Спасибо за интересную статью! Подскажите, а есть ли неплохие русскоязычные статьи про grpc или в целом доки хватает?

  • +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 метода.
    • +2

      Я бы не назвал это очень медленным: просто не самая оптимальная реализация, которая сойдет на время переходного периода.

      • –1
        Это правда, grpc-gateway это ещё одно звено в цепи, со своей сериализацией/десереализацией. Но «очень медленный» это относительная фраза — речь всё таки о десятках миллисекунд, что для многих случаев более чем позволительно.

        Подход с десереализацией напрямую может работать только в случае, если это один сервис. Если же вы, к примеру, разбиваете монолит на gRPC-сервисы, и при этом хотите сохранить legacy REST API (хотя бы на время), то уже так не получится. А так да, вариант.
      • –1
        Чем GraphQL не устраивает?
      • 0

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

        • 0
          К счастью, не сталкивался.
          Вот такую issue в grpc-gateway обнаружил: github.com/grpc-ecosystem/grpc-gateway/issues/410
          • 0

            Я ее тоже нашел :-) как-то коряво, конечно.

            • 0
              Ну, корявость в задаче, а не в инструменте :)
              Если подумать — grpc гоняет туда-сюда только протобафы + стримы. HTTP MultiPart это такой древний пережиток, что его втиснуть тут только каким-то своим методом можно.

              Тоесть, либо самому делать handler, который будет вычитывать и на ходу писать в grpc-стрим (или все читать в память/диск и передавать одним большим объектом), или надеятся, что это сделают за нас в grpc-gateway :)
        • 0
          Хотел выбрать его, но как понял он не может работать чисто по tcp, только по http? верно?
          • 0

            gRPC работает поверх HTTP2

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