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

Комментарии (0)