How to builid a gRPC & protobuf based Ruby service
Microservice based architectures are gaining popularity for the right reasons. They help simplify complexity for large scale systems and help the teams manage the individual services independently. In our experience, we’ve encountered cases where we had to breakdown a Ruby on Rails monolith and transition to a microservice-based architecture as the monolith application evolved to incorporate large doses of business logic.
One of the approaches we have taken to carve out microservices from a monolith is to define language-neutral interfaces with tools such as protobuf and gRPC. In this article, I present a simple example of building a gRPC and protobuf based ruby service and extend it with potential needs to cover cases involving ActiveRecord, DelayedJob etc
Ruby microservice code layout
We will build a sample microservice that uses gRPC for communication to retrieve Student details such as name and age from the database. This commit illustrates the initial code setup.
Here is the directory tree structure I setup for implementing a gRPC based Ruby service.
Let's examine what each directory contains.
proto
This directory contains the proto files for your service. In our example, we define a simplistic StudentsDetailsService that implements a single RPC endpoint.
# proto/students_details.proto
syntax = 'proto3';
package students_details;
service StudentsDetailsService {
rpc Hello( HelloRequest ) returns ( HelloResponse ) {}
}
message HelloRequest {
string name = 1;
}
message HelloResponse {
string body = 1;
}
lib/protos
This directory contains the auto-generated protobuf files from the proto files.
lib/students_details_service.rb
In this file, I implement the RPC service. The endpoint implementations call a controller method with the request object as a parameter. This approach helps in decoupling the controllers from the gRPC implementation, thereby making the business logic easier to test.
# lib/students_details_service.rb
require 'rubygems'
require 'bundler/setup'
Bundler.require(:default)
require './lib/protos/students_details_services_pb'
require './app'
class StudentsDetailsService < StudentsDetails::StudentsDetailsService::Service
def hello(request, _unused_call)
HelloController.say_hello(request)
end
end
app/controllers
This directory has the controller related logic. For our example, the HelloController has the implementation for the ‘say_hello’ method that returns a protobuf based response.
# app/controllers/hello_controller.rb
class HelloController
def self.say_hello(request)
StudentsDetails::HelloResponse.new(body: "Hello #{request.name}")
end
end
app/controllers.rb
This file loads all the controllers in the ‘app/controllers’ directory.
Dir[File.expand_path './app/controllers/*.rb'].each do |file|
require file
end
app.rb
This file loads all the files in the app folder
Dir[File.expand_path 'app/*.rb'].each do |file|
require file
End
students_details_server.rb
This file loads the service implementation file and has the code to create the gRPC server.
# students_details_server.rb
require 'rubygems'
require 'bundler/setup'
require './lib/students_details_service'
require 'logging'
Bundler.require(:default)
module GRPC
extend Logging.globally
end
Logging.logger.root.appenders = Logging.appenders.stdout
Logging.logger.root.level = :info
class StudentsDetailsServer
class self
def start
start_grpc_server
end
private
def start_grpc_server
@server = GRPC::RpcServer.new
@server.add_http2_port('0.0.0.0:50052', :this_port_is_insecure)
@server.handle(StudentsDetailsService)
@server.run_till_terminated
end
end
end
StudentsDetailsServer.start
test/test_client.rb
The following test client code makes a request to the gRPC server and prints out the response.
# test/test_client.rb
require './lib/protos/students_details_services_pb'
require 'grpc'
stub = StudentsDetails::StudentsDetailsService::Stub.new(
'0.0.0.0:50052', :this_channel_is_insecure
)
request = StudentsDetails::HelloRequest.new(name: "Harry")
response = stub.hello(request)
puts response.body
Setting up Rake tasks
1. Create the directory “lib/tasks” that houses all the application related rake files.
2. Create a file “Rakefile” in the app root directory which loads all the gems and includes all the rake files in “lib/tasks”.
# Rakefile
require 'rubygems'
require 'bundler/setup'
Bundler.require(:default)
Dir[File.expand_path 'lib/tasks/*.rake'].each do |file|
import file
end
Reference commit – https://github.com/beautiful-code/grpc_with_ruby/commit/a50ba42e35d3410983c51f7943e9e3bdff5d4931
Setting up initializers
1. Create a directory `config/initializers` and place the various initializers.
2. Create a file “config/initializers.rb” which loads all the files in the “config/initializers” directory.
# config/initializers.rb
Dir[File.expand_path './config/initializers/*.rb'].each do |file|
require file
end
3. Require “config/initializers” in the “lib/students_details_service”
require 'rubygems'
require 'bundler/setup'
Bundler.require(:default)
require './lib/protos/students_details_services_pb'
require './config/initializers'
require './app'
Reference commit: https://github.com/beautiful-code/grpc_with_ruby/commit/6e5554c22417dff84d5fc3112ec8fa24198b8d36
Setting up Active Record
We chose to use ActiveRecord as our ORM as it has the widest support in the ruby community and most of the ruby developers are familiar with the DSL of active record.
1. Add “activerecord” and the required database adapter gem to your gemfile ( I will be using ‘mysql2’ ).
2. Create “app/models” directory to store all the model classes.
3. Create the file “app/models/application_record” that defines the base class for all ActiveRecord models.
# app/models/application_record.rb
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
end
4. Create the file “app/models.rb” which will load all the files in the “app/models” directory.
# app/models
Dir[File.expand_path './app/models/*.rb'].each do |file|
require file
end
5. Create the directory “db/migrate” that contains all the migration-related files.
6. Create the file “config/db_config.rb” that defines the configuration to connect to the database.
# config/db_config.rb
class DbConfig
def self.config
{
adapter: 'mysql2',
host: 'localhost',
username: 'root',
password: '********',
database: 'student_details_db',
pool: 5,
timeout: 5000,
reconnect: true
}
end
end
7. Copy the “lib/tasks/db.rake” file from the repo, it contains the following rake tasks
- db:create ( To create the database )
- db:drop ( To drop the database )
- db:update_schema ( To update the “db/schema.rb” file )
- db:migrate ( To run the migrations )
- db:rollback ( To undo the last migration )
- g:migration migration_class ( To create a new migration file in “db/migrate” directory
8. Make sure that the connection pool_size for the ActiveRecord is greater than or equal to the gRPC server thread pool_size. The reason being gRPC server uses the threads from the thread pool to serve every new request it receives. Once a request is served, gRPC server doesn’t terminate the thread but puts it into sleep state until it needs the thread again. Each thread uses a connection from the ActiveRecord connection pool but doesn’t release it back into the connection pool until the thread is terminated. By keeping the number of database connection pool greater than the number of gRPC thread pool we ensure that there is a ActiveRecord connection for ever gRPC thread.
# bookings_report_server.rb
def start_grpc_server
@server = GRPC::RpcServer.new( pool_size: 5)
@server.add_http2_port('0.0.0.0:50052', :this_port_is_insecure)
@server.handle(BookingsReportService)
@server.run_till_terminated
end
Setting up the Students Table
1. Generate a migration file.
rake g:migration create_students_details
2. Implement the migration file.
# db/migrate/20180131121156_create_students_details.rb
class CreateStudentsDetails < ActiveRecord::Migration[5.1]
def change
create_table(:students) do |t|
t.column :name, :string
t.column :age, :int
t.column :deleted_at, :datetime
t.timestamps
end
end
end
3. Run the migration
rake db:migrate
Setting up Delayed Job
Delayed job is used for running tasks in the background. Here are the steps to set up delayed job.
1. Add delayed_job_active_record & daemons gems to your Gemfile.
2. Copy the following migration to add the “jobs” table to the “db/migrate” folder which is required by the gem: https://www.google.com/url?q=https://github.com/beautiful-code/grpc_with_ruby/blob/master/db/migrate/20180201181632_setup_delayed_job.rb&sa=D&ust=1521517643272000&usg=AFQjCNFqBbXylfcHaYi6dppAUrdCz7bq2w
3. Copy “lib/tasks/jobs.rake”, which contains the following rake tasks:
- jobs:work to run the delayed job on foreground.
- jobs:clear to clear all the jobs in the delayed job queue.
Reference Commit: https://github.com/beautiful-code/grpc_with_ruby/commit/2ac8726fd9196d6295473783454ca7d9971222d6
Setting up RSpec
1. Add rspec gem to the Gemfile and run bundle install
2. Run “rspec –init” to create the spec folder structure and spec_helper file
3. In “spec/spec_helper.rb” require your service file at the top of the spec_helper file ( “require ‘./lib/students_details_service’” )
# spec/spec_helper.rb
require './lib/students_details_service'
Reference Commit: https://github.com/beautiful-code/grpc_with_ruby/commit/86c1527f128dc5ad52ef3bd386d4b2fb31cdf23c
Setting up paper_trail
Paper Trail gem is used to track changes to your models.
1. Add paper_trail gem to Gemfile and run bundle install
2. Copy the migration to you “db/migrate” folder to create versions table which is required by PaperTrail gem: https://github.com/beautiful-code/grpc_with_ruby/blob/master/db/migrate/20180202163412_add_versions_for_paper_trail.rb
3. Add “has_paper_trail” to the active record model that you want paper trail to track.
Reference commit: https://github.com/beautiful-code/grpc_with_ruby/commit/3927d508322d621ccd395c85f63e240c58e65e88
Setting up server side code
Now, I’m extending the service to include an endpoint that retrieves Student records from the database.
1. Defining the SearchStudents RPC method.
# proto/student_details.proto
service StudentsDetailsService {
rpc Hello( HelloRequest ) returns ( HelloResponse ) {}
rpc SearchStudents( SearchRequest ) returns ( Students ) {}
}
message SearchRequest {
string name = 1;
}
message Students {
repeated Student students = 1;
}
message Student {
string name = 1;
int64 age = 2;
}
2. Then we need to regenerate the protobuf files
grpc_tools_ruby_protoc -Iproto --ruby_out=lib/protos --grpc_out=lib/protos proto/students_details.proto
3. Now adding server side logic to support the RPC:
# lib/students_details_service.rb
class StudentsDetailsService < StudentsDetails::StudentsDetailsService::Service
def hello(request, _unused_call)
HelloController.say_hello(request)
end
def search_studetns(request, _unused_call)
StudentsController.search(request)
end
end
4. Creating a controller concern to convert ActiveRecord Object to gRPC objects:
# app/controllers/concerns/build_grpc_objects
module BuildGrpcObjects
class << self
def convert_students_to_grpc_obj(students)
students_grpc_obj = StudentsDetails::Students.new(students: [])
students.collect do |student|
students_grpc_obj.students
convert_student_to_grpc_obj(student)
end
students_grpc_obj
end
def convert_student_to_grpc_obj(student)
StudentsDetails::Student.new(
name: student.name,
age: student.age,
)
end
end
end
5. Adding controller logic to return the student:
# app/controllers/students_controller.rb
class StudentsController
class << self
def search(request)
students = Student.where("name like '%#{request.name}%'")
unless students.present?
raise GRPC::BadStatus.new(
GRPC::Core::StatusCodes::NOT_FOUND,
"Couldn't find Student with name: #{request.name}"
)
end
BuildGrpcObjects.convert_students_to_grpc_obj(students)
end
end
end
6. Sample client code to search for a student:
# test/test_client.rb
search_request = StudentsDetails::SearchRequest.new(name: "Harry")
begin
response = stub.search_request(search_request)
response.students.each do |student|
puts "Name: #{student.name}, Age: #{student.age}"
end
rescue GRPC::BadStatus =&amp;amp;amp;gt; e
if e.code == GRPC::Core::StatusCodes::NOT_FOUND
puts e.message
end
end
Reference Commit: https://github.com/beautiful-code/grpc_with_ruby/commit/9368606057b7e85281c8d645c1e7ec8d0a4bea68
So, there you go. This code structure should help you in coming up with gRPC based microservices should you want to break your monolith.