Defining RPC methods in .proto files is how you specify the communication contract for your services using Protocol Buffers.
Let’s see this in action. Imagine you’re building a simple user service.
syntax = "proto3";
package userservice;
// Define a message for user creation requests
message CreateUserRequest {
string username = 1;
string email = 2;
}
// Define a message for user creation responses
message CreateUserResponse {
string user_id = 1;
string message = 2;
}
// Define the RPC service
service UserService {
// Method to create a new user
rpc CreateUser (CreateUserRequest) returns (CreateUserResponse);
}
This .proto file is the blueprint. The service keyword introduces a collection of RPC methods. Each rpc keyword defines a single method. The syntax rpc MethodName (RequestType) returns (ResponseType); specifies the method’s name, the message type it expects as input, and the message type it will return. The package declaration helps avoid naming conflicts.
The fundamental problem this solves is enabling structured, language-agnostic communication between different services. Instead of agreeing on raw JSON formats or custom binary protocols, you define your data structures (messages) and the operations (RPC methods) that can be performed on them. The Protocol Buffers compiler then generates code for various languages that handles serialization, deserialization, and the boilerplate for making and receiving these remote calls. This means your client code can call userService.CreateUser(...) as if it were a local function, and the generated code takes care of sending the CreateUserRequest message over the network and parsing the CreateUserResponse message.
The RequestType and ResponseType must be defined as message types within the same .proto file or imported from another. These messages are the payloads of your RPC calls. For the CreateUser method, the client will send a CreateUserRequest containing username and email, and the server will respond with a CreateUserResponse containing a user_id and a message.
When you compile this .proto file using the protoc compiler (e.g., protoc --go_out=. --go_opt=paths=source_relative user_service.proto), it generates code for your chosen language (Go in this example). This generated code includes:
- Message structs/classes: For
CreateUserRequestandCreateUserResponse. - Service interfaces/abstract classes: Defining the
UserServicewith theCreateUsermethod signature. - Client stubs: Implementations that handle sending requests and receiving responses.
- Server skeletons: Base implementations that you fill in with your business logic.
The server skeleton for UserService would look something like this in Go:
// This is a compile-time assertion to ensure that a server implements the
// appropriate interface.
var _ UserServiceServer = (*UnimplementedUserServiceServer)(nil)
// UnimplementedUserServiceServer can be embedded to have forward compatible implementations.
type UnimplementedUserServiceServer struct {
}
func (UnimplementedUserServiceServer) CreateUser(context.Context, *CreateUserRequest) (*CreateUserResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method CreateUser not implemented")
}
Your actual server implementation would embed UnimplementedUserServiceServer and override the CreateUser method to add your logic.
The types of RPCs you can define go beyond simple request-response. Protocol Buffers support:
- Unary RPC: The most common type, as shown with
CreateUser. A single request message is sent, and a single response message is returned. - Server streaming RPC: The client sends a single request, and the server returns a sequence of response messages. This is declared as
rpc MethodName (RequestType) returns (stream ResponseType);. - Client streaming RPC: The client sends a sequence of request messages, and the server returns a single response message. This is declared as
rpc MethodName (stream RequestType) returns (ResponseType);. - Bidirectional streaming RPC: Both client and server send sequences of messages. This is declared as
rpc MethodName (stream RequestType) returns (stream ResponseType);.
The stream keyword is crucial for indicating that multiple messages will be sent or received over a single RPC connection.
A subtle but powerful aspect of Protobuf services is that the generated code provides strong typing for your network communication. This eliminates entire classes of runtime errors that can occur with less structured formats like JSON, where a missing field or a type mismatch might only be discovered after the data has been sent and processed. Protobuf’s compiler enforces these contracts at compile time, catching many issues before your code even runs.
Understanding the different streaming types is key to optimizing your service design for various use cases, from fetching large datasets efficiently to real-time, interactive communication.
The next concept you’ll likely encounter is how to handle errors gracefully within these RPC methods, particularly when using frameworks like gRPC.