Как настроить gRPC на примере микросервисов на Ruby и Go

от автора

В этой статье мы хотим поделиться личным опытом, как у нас получилось организовать взаимодействие микросервисов на Ruby и Go на основе gRPC. Мы расскажем:

  • о преимуществах gRPC;

  • об особенностях работы с протоколом;

  • о трудностях, с которыми может столкнуться начинающий разработчик.

Содержание

Что же такое gRPC?

gRPC на практике

Особенности gRPC

Подведем итоги

Что же такое gRPC?

gRPC — это система удалённого вызова процедур для обмена сообщениями между клиентом и сервером. Главная цель технологии — обеспечить высокую производительность в условиях, где это особенно критично, например, при интенсивном обмене информацией в режиме реального времени. 

GRPC имеет ряд преимуществ:

  • Легковесность и высокая производительность

  • Независимость от конкретного языка программирования: шаблон для клиента и сервера генерируется на основе proto-файла (его генерация возможна при помощи protocol buffer компилятора)

  • Поддержка клиентских, серверных и двунаправленных потоковых вызовов

gRPC на практике

Наш пример использования gRPC — приложение для обслуживания магазина, которое позволяет получать информацию о товарах и добавлять новые. Сервер написан на языке Ruby, а в качестве клиента будет модуль, написанный на Go.

1. Библиотеки gRPC для Ruby и Go

Для организации сервера на Ruby используется gruf — фреймворк, который предоставляет инструменты, помогающие быстро и эффективно масштабировать службы gRPC в Ruby.

Для создания клиента на языке Go были использованы такие библиотеки, как protoc-gen-go,  плагин компилятора буфера протокола для генерации кода Go и protoc-gen-go-grpc.

2. Настройка proto-файла

Для работы с gRPC первым шагом будет создание самого proto-файла.  В нём  необходимо определить сервисы или службы, в которых будет храниться описание методов, а также типы их запросов и ответов.  Proto-файлы для клиента и сервера должны выглядеть аналогичным образом, чтобы обеспечить  их правильное взаимодействие. Создадим файл products.proto со следующим содержимым:

развернуть код (protobuf)
syntax = "proto3";  package rpc; option go_package = "./products"; // настройка для Go  // Определение сервисов для обработки товаров service Products {     // Метод для получения товара     rpc GetProduct(GetProductReq) returns (GetProductResp) {}     // Метод для получения списка товаров     rpc GetProducts(GetProductsReq) returns (stream Product) {}     // Метод для создания товаров     rpc CreateProducts(stream Product) returns (CreateProductsResp) {}     // Метод для создания товаров     rpc CreateProductsInStream(stream Product) returns (stream Product) {} }  // Описание типов запросов и ответов для методов сервиса обработки товаров message Product {     uint32 id = 1;     string name = 2;     float price = 3; }  message GetProductReq {     uint32 id = 1; }  message GetProductResp {     Product product = 1; }  message GetProductsReq {     string search = 1;     uint32 limit = 2; }  message CreateProductsResp {     repeated Product products = 1; }  

Есть 4 типа методов в gRPC:

1. Простой RPC, при котором клиент отправляет запрос на сервер с помощью заглушки и ждёт ответа, как при обычном вызове функции.

развернуть код (protobuf)
rpc GetProduct(GetProductReq) returns (GetProductResp) {}

2. RPC с потоковой передачей на стороне сервера, при котором клиент отправляет запрос на сервер и получает поток для обратного чтения последовательности сообщений. Клиент читает из возвращённого потока, пока не кончатся сообщения.

развернуть код (protobuf)
rpc GetProducts(GetProductsReq) returns (stream Product) {}

3. RPC с потоковой передачей на стороне клиента, при котором клиент записывает последовательность сообщений и отправляет их на сервер, снова используя предоставленный поток. Как только клиент закончит писать сообщения, он ждёт, пока сервер прочитает их все и вернёт свой ответ.

развернуть код (protobuf)
rpc CreateProducts(stream Product) returns (CreateProductsResp) {}

4. Двунаправленный потоковый RPC, при котором обе стороны отправляют последовательность сообщений, используя поток чтения-записи. Два потока работают независимо, поэтому клиент и сервер могут читать и писать в любом порядке:

  • сервер может дождаться получения всех клиентских сообщений прежде, чем писать свои ответы

  • сервер может поочерёдно читать сообщения, а затем отправлять их. Отметим, что порядок сообщений в каждом потоке сохраняется.

развернуть код (protobuf)
rpc CreateProductsInStream(stream Product) returns (stream Product) {}

После того, как proto-файл определён, необходимо ввести в консоли следующую команду:

protoc -I ./ —go_out=. —go_opt=paths=source_relative \

—go-grpc_out=. —go-grpc_opt=paths=source_relative \

*.proto

Выполнение этой команды генерирует два файла в созданном ранее каталоге proto-файла:

Сгенерированные файлы содержат:

  • весь protocol buffer код для заполнения, сериализации и извлечения типов сообщений запроса и ответа

  • типы интерфейса или заглушки для вызовов клиентов с помощью  определённых в сервисе Products методов

  • типы интерфейса, реализуемые серверами с определёнными в сервисе Products методами

3. Реализация методов

На сервере для удобства мы добавили модель и Enumerator-ы, в которых была вынесена генерация запросов и ответов клиента.

Модель Product выглядит следующим образом:

развернуть код
# frozen_string_literal: true  class Product < ApplicationRecord   validates_presence_of :name    scope :with_name, ->(name) { where(name: name) }    ##   # @return [Rpc::Product]   #   def to_proto Rpc::Product.new(   id: id.to_i,   name: name.to_s,   price: price.to_f )   end end  

Enumerator для обработки запроса:

развернуть код
# frozen_string_literal: true  module Rpc   class ProductRequestEnumerator def initialize(products, delay = 0.5)     @products = products     @delay = delay.to_f end  def each_item     return enum_for(:each_item) unless block_given?      @products.each do |product|         sleep @delay         puts "Next product to send is #{product.inspect}"         yield product     end end   end end

Enumerator для обработки ответа:

развернуть код
# frozen_string_literal: true  module Rpc   class ProductResponseEnumerator def initialize(products, created_products, delay = 0.5)     @products = products     @created_products = created_products     @delay = delay.to_f end  def each_item     Rails.logger.info 'got to ProductResponseEnumerator.each_item'     return enum_for(:each_item) unless block_given?    begin       @products.each do |req|          earlier_requests = @created_products[req.name]          @created_products[req.name] << req          Rails.logger.info "Got request: #{req.inspect}"           earlier_requests.each do |r|         product = Product.new(name: r.name, price: r.price).to_proto         sleep @delay         Rails.logger.info "Sending back to client: #{product.inspect}"         yield product          end       end   rescue StandardError => e       raise e # signal completion via an error   end     end   end end

Опишем методы, указанные в proto-файле, для клиента и сервера (для наглядности опустим некоторые проверки на наличие ошибок и другие дополнительные операции). 

Метод для получения товара:

Ruby-сервер
def get_product    product = ::Product.find(request.message.id.to_i)     Rpc::GetProductResp.new(      product: Rpc::Product.new(        id: product.id.to_i,        name: product.name.to_s,        price: product.price.to_f      )    )  rescue ::ActiveRecord::RecordNotFound => _e    fail!(:not_found, :product_not_found, "Failed to find Product with ID: #{request.message.id}")  rescue StandardError => e    set_debug_info(e.message, e.backtrace[0..4])    fail!(:internal, :internal, "ERROR: #{e.message}")  end

Go-клиент
func GetProduct(client products.ProductsClient, id uint32) (*products.GetProductResp, error) {     product, err := client.GetProduct(context.Background(), &products.GetProductReq{Id: id})     if err != nil {      log.Printf("failed to get product: %v", err)      return product, err      }     return product, nil  }

Метод для получения списка товаров:

Ruby-сервер
def get_products    return enum_for(:get_products) unless block_given?     q = ::Product    q = q.where('name LIKE ?', "%#{request.message.search}%") if request.message.search.present?    limit = request.message.limit.to_i.positive? ? request.message.limit : 100    q.limit(limit).each do |product|      yield product.to_proto    end  rescue StandardError => e    set_debug_info(e.message, e.backtrace[0..4])    fail!(:internal, :internal, "ERROR: #{e.message}")  end 

Go-клиент
func GetProducts(client products.ProductsClient, search string, limit uint32) ([]*products.Product, error) {       productList, err := client.GetProducts(context.Background(), &products.GetProductsReq{Search: search, Limit: limit})     if err != nil {      log.Printf("failed to get book list: %v", err)      return nil, err     }   prods := make([]*products.Product, 0)     for {     product, err := productList.Recv()     if err == io.EOF {     break     }     if err != nil {     log.Printf("failed to get product: %v", err)     return nil, err     }     prods = append(prods, product)     }       return prods, nil }

Метод для создания товара:

Ruby-сервер
 def create_products    products = []    request.messages do |message|      products << Product.create(name: message.name, price: message.price).to_proto    end    Rpc::CreateProductsResp.new(products: products)  rescue StandardError => e    set_debug_info(e.message, e.backtrace[0..4])    fail!(:internal, :internal, "ERROR: #{e.message}")  end  

Go-клиент
func CreateProducts(client products.ProductsClient, product_list []*products.Product) (*products.CreateProductsResp, error) {     stream, err := client.CreateProducts(context.Background())     if err != nil {      log.Printf("%v.CreateProducts(_) = _, %v", client, err)         return nil, err     }      for _, product := range product_list {     if err := stream.Send(product); err != nil {     log.Printf("%v.Send(%v) = %v", stream, product_list, err)      return nil, err     }     }      productList, err := stream.CloseAndRecv()     if err != nil {      log.Printf("failed to create product list: %v", err)     return productList, err     }   return productList, nil }  

Метод для создания товаров:

Ruby-сервер
def create_products_in_stream    return enum_for(:create_products_in_stream) unless block_given?     request.messages.each do |r|      sleep(rand(0.01..0.3))      yield Product.new(name: r.name, price: r.price).to_proto    rescue StandardError => e      set_debug_info(e.message, e.backtrace[0..4])      fail!(:internal, :internal, "ERROR: #{e.message}")    end  end 

Go-клиент
func CreateProductsInStream(client products.ProductsClient, product_list []*products.Product) ([]*products.Product, error) {     stream, err := client.CreateProducts(context.Background())     if err != nil {     log.Printf("%v.CreateProducts(_) = _, %v", client, err)     return nil, err     }     prods := make([]*products.Product, 0)     for {     prductsList, err := stream.Recv()     if err == io.EOF {       return nil, err     }     if err != nil {       return nil, err     }      for _, product := range prductsList {       if err := stream.Send(product); err != nil {     return nil, err       }       prods = append(prods, product)     }         }      return prods, nil  } 

4. Запуск клиента и сервера

После реализации всех методов необходимо описать запуск клиента и сервера. 

Запуск сервера:

развернуть код
# frozen_string_literal: true require_relative 'config/application'   cli = Gruf::Cli::Executor.new cli.run

Запуск клиента:

развернуть код
func main() {    err := godotenv.Load()    if err != nil {        log.Fatalf("err loading: %v", err)    }    conn, err := grpc.Dial(os.Getenv("SERVER_HOST"), grpc.WithTransportCredentials(insecure.NewCredentials()))    if err != nil {        log.Fatalf("failed to connect: %v", err)    }    defer conn.Close()    // Вызов и обработка описанных методов

Коммуникация между нашими микросервисами выглядит следующим образом:

Вот и всё, gRPC успешно настроен!

Особенности gRPC

Есть ряд нюансов, которые следует учесть при работе с gRPC. Например, распространённая проблема заключается в том, что все поля не являются нулевыми — они всегда имеют значения по умолчанию. Всё, что не передаётся, приравнивается такому значению в целях оптимизации трансфера. Это затрудняет обработку опциональных полей. 

Неудобства могут возникать при тестировании и мониторинге работы приложения, так как в отличие от XML и JSON, файлы Protobuf не читаются человеком, поскольку данные сжимаются до двоичного формата. Разработчики должны использовать дополнительные инструменты для оценки полезной нагрузки, устранения неполадок и создания ручных запросов.

Подведём итоги

Если вы ищете способ для настройки коммуникации между вашими сервисами, то gRPC отлично справится с этой задачей. Несмотря на то, что это относительно новая система с рядом своих особенностей, она может помочь вам обеспечить эффективный обмен сообщениями между различными частями приложения, избежать дублирования кода на сервере и клиенте за счёт совместного использования файла .proto, создать основу для долгосрочных потоков связи в режиме реального времени.


ссылка на оригинал статьи https://habr.com/ru/articles/779272/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *