How to builid a gRPC & protobuf based Ruby service

Sai Prasanth NG
Sai Prasanth NG
Partner August 31, 2019
#backendengineering

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.

Code structure

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;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.