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