Getting started with gRPC in Rust
gRPC is a modern communication framework that can run in any environment and helps connect services efficiently. This post explores what gRPC is and how to get started by building a user management service using gRPC, MongoDB and Rust.
Demola Malomo
May 23 2023
8 min read
Microservices architecture is one of the preferred methods of building scalable and robust applications. It involves breaking large applications into smaller components that are well-defined, performs a specific task and uses sets of application programming interface (API) for their communication.
Communication is an essential part of microservices; it plays an important role in letting services talk to each other within the larger application context. Some examples of protocol microservices use to communicate with each other includes HTTP, gRPC, message brokers, etc.
In this article, we will explore what gRPC is and how to get started by building a user management service using gRPC, MongoDB and Rust.
What is gRPC?
gRPC is a modern communication framework that can run in any environment and helps connect services efficiently. It was introduced in 2015 and governed by the Cloud Native Computing Platform (CNCF). Beyond efficiently connecting services across a distributed system, mobile applications, frontend to backend, etc., it supports health checking, load balancing, tracing, and authentication.
gRPC offers a fresh perspective to developers building medium to complex applications as it can generate client and server bindings for multiple languages. The following are some of its benefits:
Service definition
gRPC uses Protocol Buffers as its interface description language, similar to JSON and provides features like authentication, cancellation, timeouts, etc.
Lightweight and performant
gRPC definitions are 30 percent smaller than JSON definitions and are 5 to 7 times faster than a traditional REST API.
Multiple platform support
gRPC is language agnostic and has automated code generation for client and server-supported languages.
Scalable
From the developer’s environment to production, gRPC is designed to scale millions ler seconds requests.
Getting started
Now that we understand gRPC's importance in building scalable applications, let’s build a user management service with gRPC, MongoDB, and Rust. The project source code can be found here.
Prerequisites
To fully grasp the concepts presented in this tutorial, the following are required:
- Basic understanding of Rust
- Basic understanding of Protocol Buffer
- Protocol Buffer compiler installed
- A MongoDB account to host the database. Signup is completely free.
- Postman or any gRPC testing application
Project and Dependencies setup
To get started, we need to navigate to the desired directory and run the command below in our terminal:
1cargo new grpc_rust && cd grpc_rust
This command creates a Rust project called grpc_rust
and navigates into the project directory.
Next, we install the required dependencies by modifying the [dependencies]
section of the Cargo.toml
file as shown below:
1//other code section goes here 2 3[dependencies] 4tokio = {version = "1", features = ["macros", "rt-multi-thread"]} 5serde = {versiom = "1", features = ["derive"]} 6dotenv = "0.15.0" 7tonic = "0.9.2" 8prost = "0.11.9" 9futures = "0.3" 10 11[dependencies.mongodb] 12version = "2.2.0" 13 14[build-dependencies] 15tonic-build = "0.9.2"
tokio = {version = "1", features = ["macros", "rt-multi-thread"]}
is a runtime that enables asynchronous programming in Rust.
serde = {versiom = "1", features = ["derive"]}
is a framework for serializing and deserializing Rust data structures.
dotenv = "0.15.0"
is a library for managing environment variables.
tonic = "0.9.2"
is a Rust implementation of gRPC.
prost = "0.11.9"
is a Protocol Buffers implementation in Rust and generates simple, idiomatic Rust code from proto2
and proto3
files.
futures = "0.3"
is a library for doing asynchronous programming with MongoDB driver
[dependencies.mongodb]
is a driver for connecting to MongoDB. It also specifies the required version and the feature type(Asynchronous API).
[build-dependencies]
specifies tonic-build = "0.9.2"
as a dependency. It compiles .proto
files into Rust code.
Defining the user management Protocol Buffer and compilation
To get started, we need to define a Protocol Buffer to represent all the operations and responses involved in the user management service. To do this, first, we need to create a proto
folder in the root directory, and in this folder, create a user.proto
file and add the snippet below:
1syntax = "proto3"; 2package user; 3 4service UserService { 5 rpc GetUser (UserRequest) returns (UserResponse); 6 rpc CreateUser (CreateUserRequest) returns (CreateUserResponse); 7 rpc UpdateUser (UpdateUserRequest) returns (UpdateUserResponse); 8 rpc DeleteUser (DeleteUserRequest) returns (DeleteUserResponse); 9 rpc GetAllUsers (Empty) returns (GetAllUsersResponse); 10} 11 12message UserRequest { 13 string id = 1; 14} 15 16message UserResponse { 17 string id = 1; 18 string name = 2; 19 string location = 3; 20 string title = 4; 21} 22 23message CreateUserRequest { 24 string name = 2; 25 string location = 3; 26 string title = 4; 27} 28 29message CreateUserResponse { 30 string data = 1; 31} 32 33message UpdateUserRequest { 34 string _id = 1; 35 string name = 2; 36 string location = 3; 37 string title = 4; 38} 39 40message UpdateUserResponse { 41 string data = 1; 42} 43 44message DeleteUserRequest { 45 string id = 1; 46} 47 48message DeleteUserResponse { 49 string data = 1; 50} 51 52message Empty {} 53 54message GetAllUsersResponse { 55 repeated UserResponse users = 1; 56}
The snippet above does the following:
- Specifies the use of
proto3
syntax - Declares
user
as the package name - Creates a
service
to Create, Read, Edit, and Delete (CRUD) a user and their corresponding responses asmessage
s.
Secondly, we need to create a build file that instructs tonic-build = "0.9.2"
dependency to compile our user.proto
file into a Rust code. To do this, we need to create a build.rs
file in the root directory and add the snippet below:
1fn main() -> Result<(), Box<dyn std::error::Error>> { 2 tonic_build::compile_protos("proto/user.proto")?; 3 Ok(()) 4}
Lastly, we need to compile the user.proto
file using the build.rs
instruction we specified earlier by running the command below in our terminal:
1cargo build
Using the generated code from gRPC in our application
With the build process done, we can start using the generated code in our application.
Database setup and integration
First, we need to set up a database and a collection on MongoDB as shown below:
We also need to get our database connection string by clicking on the Connect button and changing the Driver to Rust
.
Secondly, we must modify the copied connection string with the user's password we created earlier and change the database name. To do this, we need to create a .env
file in the root directory and add the snippet copied:
1MONGOURI=mongodb+srv://<YOUR USERNAME HERE>:<YOUR PASSWORD HERE>@cluster0.e5akf.mongodb.net/<DATABASE NAME>?retryWrites=true&w=majority
Sample of a properly filled connection string below:
1MONGOURI=mongodb+srv://malomz:malomzPassword@cluster0.e5akf.mongodb.net/rustDB?retryWrites=true&w=majority
Lastly, we need to navigate to the src
folder, create a mongo_connection.rs
file to implement our database logic and add the snippet below:
1use std::{env, io::Error}; 2 3use dotenv::dotenv; 4use futures::TryStreamExt; 5use mongodb::bson::doc; 6use mongodb::bson::oid::ObjectId; 7use mongodb::results::{DeleteResult, InsertOneResult, UpdateResult}; 8use mongodb::{Client, Collection}; 9use serde::{Deserialize, Serialize}; 10 11#[derive(Debug, Serialize, Deserialize)] 12pub struct User { 13 #[serde(rename = "_id", skip_serializing_if = "Option::is_none")] 14 pub id: Option<ObjectId>, 15 pub name: String, 16 pub location: String, 17 pub title: String, 18} 19 20pub struct DBMongo { 21 col: Collection<User>, 22} 23 24impl DBMongo { 25 pub async fn init() -> Self { 26 dotenv().ok(); 27 let uri = match env::var("MONGOURI") { 28 Ok(v) => v.to_string(), 29 Err(_) => format!("Error loading env variable"), 30 }; 31 let client = Client::with_uri_str(uri) 32 .await 33 .expect("error connecting to database"); 34 let col = client.database("rustDB").collection("User"); 35 DBMongo { col } 36 } 37 38 pub async fn create_user(new_user: User) -> Result<InsertOneResult, Error> { 39 let db = DBMongo::init().await; 40 let new_doc = User { 41 id: None, 42 name: new_user.name, 43 location: new_user.location, 44 title: new_user.title, 45 }; 46 let user = db 47 .col 48 .insert_one(new_doc, None) 49 .await 50 .ok() 51 .expect("Error creating user"); 52 Ok(user) 53 } 54 55 pub async fn get_user(id: String) -> Result<User, Error> { 56 let db = DBMongo::init().await; 57 let obj_id = ObjectId::parse_str(id).unwrap(); 58 let filter = doc! {"_id": obj_id}; 59 let user_detail = db 60 .col 61 .find_one(filter, None) 62 .await 63 .ok() 64 .expect("Error getting user's detail"); 65 Ok(user_detail.unwrap()) 66 } 67 68 pub async fn update_user(id: String, new_user: User) -> Result<UpdateResult, Error> { 69 let db = DBMongo::init().await; 70 let obj_id = ObjectId::parse_str(id).unwrap(); 71 let filter = doc! {"_id": obj_id}; 72 let new_doc = doc! { 73 "$set": 74 { 75 "id": new_user.id, 76 "name": new_user.name, 77 "location": new_user.location, 78 "title": new_user.title 79 }, 80 }; 81 let updated_doc = db 82 .col 83 .update_one(filter, new_doc, None) 84 .await 85 .ok() 86 .expect("Error updating user"); 87 Ok(updated_doc) 88 } 89 90 pub async fn delete_user(id: String) -> Result<DeleteResult, Error> { 91 let db = DBMongo::init().await; 92 let obj_id = ObjectId::parse_str(id).unwrap(); 93 let filter = doc! {"_id": obj_id}; 94 let user_detail = db 95 .col 96 .delete_one(filter, None) 97 .await 98 .ok() 99 .expect("Error deleting user"); 100 Ok(user_detail) 101 } 102 103 pub async fn get_all_users() -> Result<Vec<User>, Error> { 104 let db = DBMongo::init().await; 105 let mut cursors = db 106 .col 107 .find(None, None) 108 .await 109 .ok() 110 .expect("Error getting list of users"); 111 let mut users: Vec<User> = Vec::new(); 112 while let Some(user) = cursors 113 .try_next() 114 .await 115 .ok() 116 .expect("Error mapping through cursor") 117 { 118 users.push(user) 119 } 120 Ok(users) 121 } 122}
The snippet above does the following:
- Line: 1 - 9: Imports the required dependencies
- Line: 11 - 18: Creates a
User
struct with required properties. We also added field attributes to theid
property to rename and ignore the field if it is empty. - Line: 20 - 22: Creates a
DBMongo
struct with acol
field to access MongoDB collection - Line: 24 - 122: Creates an implementation block that adds methods to the
MongoRepo
struct to initialize the database with its corresponding CRUD operation.
Integrating the database logic with gRPC-generated code
With our database logic setup, we can use the methods to create our application handlers. To do this, we need to create a service.rs
file inside the same src
folder and add the snippet below:
1use mongodb::bson::oid::ObjectId; 2use tonic::{Request, Response, Status}; 3use user::{ 4 user_service_server::UserService, CreateUserRequest, CreateUserResponse, DeleteUserRequest, 5 DeleteUserResponse, Empty, GetAllUsersResponse, UpdateUserRequest, UpdateUserResponse, 6}; 7use crate::mongo_connection::{self, DBMongo}; 8use self::user::{UserRequest, UserResponse}; 9 10pub mod user { 11 tonic::include_proto!("user"); 12} 13 14#[derive(Debug, Default)] 15pub struct User {} 16 17#[tonic::async_trait] 18impl UserService for User { 19 async fn create_user( 20 &self, 21 request: Request<CreateUserRequest>, 22 ) -> Result<Response<CreateUserResponse>, Status> { 23 let req = request.into_inner(); 24 let new_user = mongo_connection::User { 25 id: None, 26 name: req.name, 27 location: req.location, 28 title: req.title, 29 }; 30 let db = DBMongo::create_user(new_user).await; 31 match db { 32 Ok(resp) => { 33 let user = CreateUserResponse { 34 data: resp.inserted_id.to_string(), 35 }; 36 Ok(Response::new(user)) 37 } 38 Err(error) => Err(Status::aborted(format!("{}", error))), 39 } 40 } 41 42 async fn get_user( 43 &self, 44 request: Request<UserRequest>, 45 ) -> Result<Response<UserResponse>, Status> { 46 let req = request.into_inner(); 47 let db = DBMongo::get_user(req.id).await; 48 match db { 49 Ok(resp) => { 50 let user = UserResponse { 51 id: resp.id.unwrap().to_string(), 52 name: resp.name, 53 location: resp.location, 54 title: resp.title, 55 }; 56 Ok(Response::new(user)) 57 } 58 Err(error) => Err(Status::aborted(format!("{}", error))), 59 } 60 } 61 62 async fn update_user( 63 &self, 64 request: Request<UpdateUserRequest>, 65 ) -> Result<Response<UpdateUserResponse>, Status> { 66 let req = request.into_inner(); 67 let new_user = mongo_connection::User { 68 id: Some(ObjectId::parse_str(req.id.clone()).unwrap()), 69 name: req.name, 70 location: req.location, 71 title: req.title, 72 }; 73 let db = DBMongo::update_user(req.id.clone(), new_user).await; 74 match db { 75 Ok(_) => { 76 let user = UpdateUserResponse { 77 data: String::from("User details updated successfully!"), 78 }; 79 Ok(Response::new(user)) 80 } 81 Err(error) => Err(Status::aborted(format!("{}", error))), 82 } 83 } 84 85 async fn delete_user( 86 &self, 87 request: Request<DeleteUserRequest>, 88 ) -> Result<Response<DeleteUserResponse>, Status> { 89 let req = request.into_inner(); 90 let db = DBMongo::delete_user(req.id).await; 91 match db { 92 Ok(_) => { 93 let user = DeleteUserResponse { 94 data: String::from("User details deleted successfully!"), 95 }; 96 Ok(Response::new(user)) 97 } 98 Err(error) => Err(Status::aborted(format!("{}", error))), 99 } 100 } 101 102 async fn get_all_users( 103 &self, 104 _: Request<Empty>, 105 ) -> Result<Response<GetAllUsersResponse>, Status> { 106 let db = DBMongo::get_all_users().await; 107 match db { 108 Ok(resp) => { 109 let mut user_list: Vec<UserResponse> = Vec::new(); 110 for data in resp { 111 let mapped_user = UserResponse { 112 id: data.id.unwrap().to_string(), 113 name: data.name, 114 location: data.location, 115 title: data.title, 116 }; 117 user_list.push(mapped_user); 118 } 119 let user = GetAllUsersResponse { users: user_list }; 120 Ok(Response::new(user)) 121 } 122 Err(error) => Err(Status::aborted(format!("{}", error))), 123 } 124 } 125}
The snippet above does the following:
- Line: 1 - 8: Imports the required dependencies (including the gRPC-generated)
- Line: 10 - 12: Declares
user
struct to bring into scope our gRPC-generated code usingtonic::include_proto!("user")
- Line: 14 - 15: Creates a
User
struct to represent our application model - Line: 17 - 125: Implements the
UserService
traits from the gRPC-generated code for theUser
struct by creating required methods and returning appropriate responses as generated by gRPC
Creating the server
With that done, we can create the application gRPC server by modifying the main.rs
file as shown below:
1use std::net::SocketAddr; 2 3use service::{user::user_service_server::UserServiceServer, User}; 4use tonic::transport::Server; 5 6mod mongo_connection; 7mod service; 8 9#[tokio::main] 10async fn main() -> Result<(), Box<dyn std::error::Error>> { 11 let address: SocketAddr = "[::1]:8080".parse().unwrap(); 12 let user = User::default(); 13 14 Server::builder() 15 .add_service(UserServiceServer::new(user)) 16 .serve(address) 17 .await?; 18 Ok(()) 19}
The snippet above does the following:
- Imports the required dependencies and adds the
mongo_connection
andservice
as a module - Creates a server using
Server::builder()
method and adds theUserServiceServer
as a service.
With that done, we can test our application by running the command below in our terminal.
1cargo run
Testing with Postman
With our server up and running, we can test our application by creating a new gRPC Request.
Input grpc://[::1]:8080
as the URL, select the Import a .proto file option and upload the user.proto
file we created earlier.
With that done, the corresponding method will be populated and we can test them accordingly.
We can also validate that our gRPC server works by checking our MongoDB collection
Conclusion
This post discussed what gRPC is, its role in building scalable applications and how to get started by building a user management service with Rust and MongoDB. Beyond what was discussed above, gRPC offers robust techniques around authentication, error handling, performance, etc.
These resources might be helpful: