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

в 9:05, , рубрики: api, Go, grpc, rest, swagger, Программирование, Разработка систем связи

Многие знакомы с gRPC — открытым RPC-фреймворком от Google, который поддерживает 10 языков и активно используется внутри Google, Netflix, Kubernetes, Docker и многими другими. Если вы пишете микросервисы, gRPC предоставляет массу преимуществ перед традиционным подходом REST+JSON, но на существующих проектах часто переход не так просто осуществить из-за наличия уже использующихся REST-клиентов, которые невозможно обновить за раз. Нередко общаясь на тему gRPC можно услышать "да, мы у нас в компании тоже смотрим на gRPC, но всё никак не попробуем".

Что ж, этой проблеме есть хорошее решение под названием grpc-rest-gateway, которое занимается именно этим — автогенерацией REST-gRPC прокси с поддержкой всех основных преимуществ gRPC плюс поддержка Swagger. В этой статье я покажу на примере как это выглядит и работает, и, надеюсь, это поможет и вам перейти на gRPC, не теряя существующие REST-клиенты.

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

Но, для начала, давайте определимся о каких вообще ситуациях речь. Два самых частых варианта:

  • бекенд (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, сохранив REST - 2

Проблемы

В целом мой опыт работы с 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 вызовы. Проект очень активно развивается и используется в продакшене во многих компаниях.

Ссылки

Автор: divan0

Источник

Поделиться

* - обязательные к заполнению поля