Как настроить gRPC на примере микросервисов на Ruby и Go
- суббота, 9 декабря 2023 г. в 00:00:17
 

В этой статье мы хотим поделиться личным опытом, как у нас получилось организовать взаимодействие микросервисов на Ruby и Go на основе gRPC. Мы расскажем:
о преимуществах gRPC;
об особенностях работы с протоколом;
о трудностях, с которыми может столкнуться начинающий разработчик.
gRPC - это система удалённого вызова процедур для обмена сообщениями между клиентом и сервером. Главная цель технологии - обеспечить высокую производительность в условиях, где это особенно критично, например, при интенсивном обмене информацией в режиме реального времени.
GRPC имеет ряд преимуществ:
Легковесность и высокая производительность
Независимость от конкретного языка программирования: шаблон для клиента и сервера генерируется на основе proto-файла (его генерация возможна при помощи protocol buffer компилятора)
Поддержка клиентских, серверных и двунаправленных потоковых вызовов
Наш пример использования gRPC - приложение для обслуживания магазина, которое позволяет получать информацию о товарах и добавлять новые. Сервер написан на языке Ruby, а в качестве клиента будет модуль, написанный на Go.
Для организации сервера на Ruby используется gruf - фреймворк, который предоставляет инструменты, помогающие быстро и эффективно масштабировать службы gRPC в Ruby.
Для создания клиента на языке Go были использованы такие библиотеки, как protoc-gen-go, плагин компилятора буфера протокола для генерации кода Go и protoc-gen-go-grpc.
Для работы с gRPC первым шагом будет создание самого proto-файла. В нём необходимо определить сервисы или службы, в которых будет храниться описание методов, а также типы их запросов и ответов. Proto-файлы для клиента и сервера должны выглядеть аналогичным образом, чтобы обеспечить их правильное взаимодействие. Создадим файл products.proto со следующим содержимым:
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, при котором клиент отправляет запрос на сервер с помощью заглушки и ждёт ответа, как при обычном вызове функции.
rpc GetProduct(GetProductReq) returns (GetProductResp) {}
2. RPC с потоковой передачей на стороне сервера, при котором клиент отправляет запрос на сервер и получает поток для обратного чтения последовательности сообщений. Клиент читает из возвращённого потока, пока не кончатся сообщения.
rpc GetProducts(GetProductsReq) returns (stream Product) {}
3. RPC с потоковой передачей на стороне клиента, при котором клиент записывает последовательность сообщений и отправляет их на сервер, снова используя предоставленный поток. Как только клиент закончит писать сообщения, он ждёт, пока сервер прочитает их все и вернёт свой ответ.
rpc CreateProducts(stream Product) returns (CreateProductsResp) {}
4. Двунаправленный потоковый RPC, при котором обе стороны отправляют последовательность сообщений, используя поток чтения-записи. Два потока работают независимо, поэтому клиент и сервер могут читать и писать в любом порядке:
сервер может дождаться получения всех клиентских сообщений прежде, чем писать свои ответы
сервер может поочерёдно читать сообщения, а затем отправлять их. Отметим, что порядок сообщений в каждом потоке сохраняется.
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 методами
На сервере для удобства мы добавили модель и 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
endEnumerator для обработки ответа:
# 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-файле, для клиента и сервера (для наглядности опустим некоторые проверки на наличие ошибок и другие дополнительные операции).
Метод для получения товара:
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}")
 endfunc 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
 }Метод для получения списка товаров:
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
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
}Метод для создания товара:
 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
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
}
Метод для создания товаров:
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
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
}
После реализации всех методов необходимо описать запуск клиента и сервера.
Запуск сервера:
# 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. Например, распространённая проблема заключается в том, что все поля не являются нулевыми — они всегда имеют значения по умолчанию. Всё, что не передаётся, приравнивается такому значению в целях оптимизации трансфера. Это затрудняет обработку опциональных полей.
Неудобства могут возникать при тестировании и мониторинге работы приложения, так как в отличие от XML и JSON, файлы Protobuf не читаются человеком, поскольку данные сжимаются до двоичного формата. Разработчики должны использовать дополнительные инструменты для оценки полезной нагрузки, устранения неполадок и создания ручных запросов.
Если вы ищете способ для настройки коммуникации между вашими сервисами, то gRPC отлично справится с этой задачей. Несмотря на то, что это относительно новая система с рядом своих особенностей, она может помочь вам обеспечить эффективный обмен сообщениями между различными частями приложения, избежать дублирования кода на сервере и клиенте за счёт совместного использования файла .proto, создать основу для долгосрочных потоков связи в режиме реального времени.