Как настроить gRPC на примере микросервисов на Ruby и Go
В этой статье мы хотим поделиться личным опытом, как у нас получилось организовать взаимодействие микросервисов на Ruby и Go на основе gRPC. Мы расскажем:
о преимуществах gRPC;
об особенностях работы с протоколом;
о трудностях, с которыми может столкнуться начинающий разработчик.
Содержание
Что же такое gRPC?
gRPC на практике
— Библиотеки gRPC для Ruby и Go
— Настройка proto-файла
— Реализация методов
— Запуск клиента и сервера
Особенности 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, создать основу для долгосрочных потоков связи в режиме реального времени.