In this chapter, we are going to cover the most common way of communicating between microservices – synchronous communication. In Chapter 2, we already implemented the logic for communicating between microservices via the HTTP protocol and returning results in JSON format. In Chapter 4, we illustrated that the JSON format is not the most efficient in terms of data size, and there are many different formats providing additional benefits to developers, including code generation. In this chapter, we are going to show you how to define service APIs using Protocol Buffers and generate both client and server code for them.
By the end of this chapter, you will understand the key concepts of synchronous communication between microservices and will have learned how to implement microservice clients and servers.
The knowledge you gain in this chapter will help you to learn how to better organize the client and server code, generate the code for serialization and communication, and use it in your microservices. In this chapter, we will cover the following topics:
Now, let’s proceed to the main concepts of synchronous communication.
To complete this chapter, you will need Go 1.11, a Protocol Buffers compiler that we installed in the previous chapter, and a gRPC plugin.
You can install the gRPC plugin by running the following command:
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
export PATH="$PATH:$(go env GOPATH)/bin"
You can find the GitHub code for this chapter at https://github.com/PacktPublishing/microservices-with-go/tree/main/Chapter05.
In this section, we are going to cover the basics of synchronous communication and introduce you to some additional benefits of Protocol Buffers that we are going to use for our microservices.
Synchronous communication is the way of interaction between network applications, such as microservices, in which services exchange data using a request-response model. The process is illustrated in the following diagram:
Figure 5.1 – Synchronous communication
There are many protocols allowing applications to communicate in this way. HTTP is among the most popular protocols for synchronous communication. In Chapter 2, we already implemented the logic for calling and handling HTTP requests in our microservices.
The HTTP protocol allows you to send request and response data in different ways:
When a server cannot handle a client request due to an error or the request is not received due to network issues, the client receives a specific response indicating an error. In the case of the HTTP protocol, there are two types of errors:
In Chapter 2, we implemented our API handlers by sending the result data as an HTTP response body in JSON format. We achieved this by using the Go JSON encoder:
if err := json.NewEncoder(w).Encode(details); err != nil {
log.Printf("Response encode error: %v
", err)
}
As discussed in the previous chapter, the JSON format is not the most optimal in terms of data size. Also, it does not offer useful tools, such as the cross-language code generation of data structures, that are provided by the formats, such as Protocol Buffers. Additionally, sending requests over HTTP and encoding the data manually is not the only form of communication between the services. There are some existing remote procedure call (RPC) libraries and frameworks that help to communicate between multiple services and offer some additional features to application developers:
In the next section, we are going to cover some of the RPC libraries that you can use in your Go services, along with the features they provide.
Let’s review some popular RPC frameworks and libraries that are available for Go developers.
We already covered Apache Thrift in Chapter 4 and mentioned its ability to define RPC services – sets of functions provided by an application, such as a microservice. Here is an example of a Thrift RPC service definition:
service MetadataService {
Metadata get(1: string id)
}
The Thrift definition of a service can be used to generate client and server code. The client code would include the logic for connecting to an instance of a service, as well as making requests to it, serializing and deserializing the request and response structures. The advantage of using a library such as Apache Thrift over making HTTP requests manually is the ability to generate such code for multiple languages: a service written in Go could easily talk to a service written in Java, while both would use the generated code for the communication, removing the need of implementing serialization/deserialization logic. Additionally, Thrift allows us to generate the documentation for RPC services.
gRPC is an RPC framework that was created at Google. gRPC uses HTTP/2 as the transport protocol and Protocol Buffers as a serialization format. Similar to Apache Thrift, it provides an ability to define RPC services and generate the client and server code for the services. In addition to this, it offers some extra features, such as the following:
gRPC adoption is much higher than for Apache Thrift, and its support of the popular Protocol Buffers format makes it a great fit for microservice developers. In this book, we are going to use gRPC as a framework for synchronous communication between our microservices. In the next section, we are going to illustrate how to leverage the features provided by Protocol Buffers to define our service APIs.
Let’s demonstrate how to define a service API using the Protocol Buffers format and generate the client and server gRPC code for communication with each of our services using a proto compiler. This knowledge will help you to establish a foundation for both defining and implementing APIs for your microservices using one of the industry’s most popular communication tools.
Let’s start with our metadata service and write its API definition in the Protocol Buffers schema language.
Open the api/movie.proto file that we created in the previous chapter and add the following to it:
service MetadataService {
rpc GetMetadata(GetMetadataRequest) returns (GetMetadataResponse);
rpc PutMetadata(PutMetadataRequest) returns (PutMetadataResponse);
}
message GetMetadataRequest {
string movie_id = 1;
}
message GetMetadataResponse {
Metadata metadata = 1;
}
The code we just added defines our metadata service and its GetMetadata endpoint. We already have the Metadata structure from the previous chapter that we can reuse now.
Let’s note some aspects of the code we just added:
Now, let’s add the definition of the rating service to the same file:
service RatingService {
rpc GetAggregatedRating(GetAggregatedRatingRequest) returns (GetAggregatedRatingResponse);
rpc PutRating(PutRatingRequest) returns (PutRatingResponse);
}
message GetAggregatedRatingRequest {
string record_id = 1;
int32 record_type = 2;
}
message GetAggregatedRatingResponse {
double rating_value = 1;
}
message PutRatingRequest {
string user_id = 1;
string record_id = 2;
int32 record_type = 3;
int32 rating_value = 4;
}
message PutRatingResponse {
}
Our rating service has two endpoints, and we defined requests and responses for them in a similar way to the metadata service.
Finally, let’s add the definition of the movie service to the same file:
service MovieService {
rpc GetMovieDetails(GetMovieDetailsRequest) returns (GetMovieDetailsResponse);
}
message GetMovieDetailsRequest {
string movie_id = 1;
}
message GetMovieDetailsResponse {
MovieDetails movie_details = 1;
}
Now our movie.proto file includes both our structure definitions and the API definitions for our services. We are ready to generate code for the newly added service definitions. In the src directory of the application, run the following:
protoc -I=api --go_out=. --go-grpc_out=. movie.proto
The preceding command is similar to the command that we used in the previous chapter for generating code for our data structures. However, it also passes a --go-grpc_out flag to the compiler. This flag tells the Protocol Buffers compiler to generate the service code in gRPC format.
Let’s see the compiled code that was generated as the output for our command. If the command is executed without any errors, you will find a movie_grpc.pb.go file inside the src/gen directory. The file will include the generated Go code for our services. Let’s take a look at the generated client code:
type MetadataServiceClient interface {
GetMetadata(ctx context.Context, in *GetMetadataRequest, opts ...grpc.CallOption) (*GetMetadataResponse, error)
}
type metadataServiceClient struct {
cc grpc.ClientConnInterface
}
func NewMetadataServiceClient(cc grpc.ClientConnInterface) MetadataServiceClient {
return &metadataServiceClient{cc}
}
func (c *metadataServiceClient) GetMetadata(ctx context.Context, in *GetMetadataRequest, opts ...grpc.CallOption) (*GetMetadataResponse, error) {
out := new(GetMetadataResponse)
err := c.cc.Invoke(ctx, "/MetadataService/GetMetadata", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
This generated code can be used in our applications to call our API from the Go applications. Additionally, we can generate such client code for other languages, such as Java, adding more arguments to the compiler command that we just executed. This is a great feature that can save us lots of time when writing microservice applications – instead of writing client logic for calling our services, we can use the generated clients and plug them into our applications.
In addition to the client code, the Protocol Buffers compiler also generates the service code that can be used for handling the requests. In the same movie_grpc.pb.go file, you will find the following:
type MetadataServiceServer interface {
GetMetadata(context.Context, *GetMetadataRequest) (*GetMetadataResponse, error)
mustEmbedUnimplementedMetadataServiceServer()
}
func RegisterMetadataServiceServer(s grpc.ServiceRegistrar, srv MetadataServiceServer) {
s.RegisterService(&MetadataService_ServiceDesc, srv)
}
We are going to use both the client and server code that we just saw in our application. In the next section, we are going to modify our API handlers to use the generated code and handle requests using the Protocol Buffers format.
In this section, we are going to illustrate how to plug the generated client and server gRPC code into our microservices. This will help us to switch communication between them from JSON-serialized HTTP to Protocol Buffers gRPC calls.
In Chapter 2, we created our internal model structures, such as metadata, and in Chapter 4, we created their Protocol Buffers counterparts. Then, we generated the code for our Protocol Buffers definitions. As a result, we have two versions of our model structures – internal ones, as defined in metadata/pkg/model, and the generated ones, which are located in the gen package.
You might think that having two similar structures is now redundant. While there is certainly some level of redundancy in having such duplicate definitions, these structures practically serve different purposes:
You might be curious why it’s not recommended to use the generated structures across the application code base. There are multiple reasons for this, which are listed as follows:
Because of these reasons, the best practice is to keep both internal structures and the generated ones and only use the generated structures for serialization. Let’s illustrate how to achieve this.
We would need to add some mapping logic to translate the internal data structures and their generated counterparts. In the metadata/pkg/model directory, create a mapper.go file and add the following to it:
package model
import (
"movieexample.com/gen"
)
// MetadataToProto converts a Metadata struct into a
// generated proto counterpart.
func MetadataToProto(m *Metadata) *gen.Metadata {
return &gen.Metadata{
Id: m.ID,
Title: m.Title,
Description: m.Description,
Director: m.Director,
}
}
// MetadataFromProto converts a generated proto counterpart
// into a Metadata struct.
func MetadataFromProto(m *gen.Metadata) *Metadata {
return &Metadata{
ID: m.Id,
Title: m.Title,
Description: m.Description,
Director: m.Director,
}
}
The code we just added transforms the internal model into the generated structures and back. In the following code block, we are going to use it in the server code.
Now, let’s implement a gRPC handler for the metadata service that would handle the client requests to the service. In the metadata/internal/handler package, create a grpc directory and add a grpc.go file:
package grpc
import (
"context"
"errors"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"movieexample.com/gen"
"movieexample.com/metadata/internal/controller"
"movieexample.com/metadata/internal/repository"
"movieexample.com/metadata/pkg/model"
)
// Handler defines a movie metadata gRPC handler.
type Handler struct {
gen.UnimplementedMetadataServiceServer
svc *controller.MetadataService
}
// New creates a new movie metadata gRPC handler.
func New(ctrl *metadata.Controller) *Handler {
return &Handler{ctrl: ctrl}
}
Let’s implement the GetMetadataByID function:
// GetMetadataByID returns movie metadata by id.
func (h *Handler) GetMetadata(ctx context.Context, req *gen.GetMetadataRequest) (*gen.GetMetadataResponse, error) {
if req == nil || req.MovieId == "" {
return nil, status.Errorf(codes.InvalidArgument, "nil req or empty id")
}
m, err := h.svc.Get(ctx, req.MovieId)
if err != nil && errors.Is(err, controller.ErrNotFound) {
return nil, status.Errorf(codes.NotFound, err.Error())
} else if err != nil {
return nil, status.Errorf(codes.Internal, err.Error())
}
return &gen.GetMetadataResponse{Metadata: model.MetadataToProto(m)}, nil
}
Let’s highlight some parts of this implementation:
Now we are ready to update our main file and switch it to the gRPC handler. Update the metadata/cmd/main.go file, changing its contents to the following:
package main
import (
"context"
"flag"
"fmt"
"log"
"net"
"time"
"google.golang.org/grpc"
"movieexample.com/gen"
"movieexample.com/metadata/internal/controller"
grpchandler "movieexample.com/metadata/internal/handler/grpc"
"movieexample.com/metadata/internal/repository/memory"
"movieexample.com/metadata/pkg/model"
)
func main() {
log.Println("Starting the movie metadata service")
repo := memory.New()
svc := controller.New(repo)
h := grpchandler.New(svc)
lis, err := net.Listen("tcp", "localhost:8081")
if err != nil {
log.Fatalf("failed to listen: %v", err)
}
srv := grpc.NewServer()
gen.RegisterMetadataServiceServer(srv, h)
srv.Serve(lis)
}
The updated main function illustrates how we instantiate our gRPC server and start listening for requests in it. The rest of the function is similar to the one we had before.
We are done with the changes to the metadata service and can now proceed to the rating service.
Let’s create a gRPC handler for the rating service. In the rating/internal/handler package, create a grpc directory and add a grpc.go file with the following code:
package grpc
import (
"context"
"errors"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"movieexample.com/gen"
"movieexample.com/rating/internal/controller"
"movieexample.com/rating/pkg/model"
)
// Handler defines a gRPC rating API handler.
type Handler struct {
gen.UnimplementedRatingServiceServer
svc *controller.RatingService
}
// New creates a new movie metadata gRPC handler.
func New(svc *controller.RatingService) *Handler {
return &Handler{ctrl: ctrl}
}
Now, let’s implement the GetAggregatedRating endpoint:
// GetAggregatedRating returns the aggregated rating for a
// record.
func (h *Handler) GetAggregatedRating(ctx context.Context, req *gen.GetAggregatedRatingRequest) (*gen.GetAggregatedRatingResponse, error) {
if req == nil || req.RecordId == "" || req.RecordType == "" {
return nil, status.Errorf(codes.InvalidArgument, "nil req or empty id")
}
v, err := h.svc.GetAggregatedRating(ctx, model.RecordID(req.RecordId), model.RecordType(req.RecordType))
if err != nil && errors.Is(err, controller.ErrNotFound) {
return nil, status.Errorf(codes.NotFound, err.Error())
} else if err != nil {
return nil, status.Errorf(codes.Internal, err.Error())
}
return &gen.GetAggregatedRatingResponse{RatingValue: v}, nil
}
Finally, let’s implement the PutRating endpoint:
// PutRating writes a rating for a given record.
func (h *Handler) PutRating(ctx context.Context, req *gen.PutRatingRequest) (*gen.PutRatingResponse, error) {
if req == nil || req.RecordId == "" || req.UserId == "" {
return nil, status.Errorf(codes.InvalidArgument, "nil req or empty user id or record id")
}
if err := h.svc.PutRating(ctx, model.RecordID(req.RecordId), model.RecordType(req.RecordType), &model.Rating{UserID: model.UserID(req.UserId), Value: model.RatingValue(req.RatingValue)}); err != nil {
return nil, err
}
return &gen.PutRatingResponse{}, nil
}
Now, we are ready to update our rating/cmd/main.go file. Replace it with the following:
package main
import (
"log"
"net"
"google.golang.org/grpc"
"movieexample.com/gen"
"movieexample.com/rating/internal/controller"
grpchandler "movieexample.com/rating/internal/handler/grpc"
"movieexample.com/rating/internal/repository/memory"
)
func main() {
log.Println("Starting the rating service")
repo := memory.New()
svc := controller.New(repo)
h := grpchandler.New(svc)
lis, err := net.Listen("tcp", "localhost:8082")
if err != nil {
log.Fatalf("failed to listen: %v", err)
}
srv := grpc.NewServer()
gen.RegisterRatingServiceServer(srv, h)
srv.Serve(lis)
}
The way we start the service is similar to the metadata service. Now, we are ready to link the movie service to both the metadata and rating services.
In the previous examples, we created gRPC servers to handle client requests. Now, let’s illustrate how to add logic for calling our servers. This will help us to establish communication between our microservices via gRPC.
First, let’s implement a function that we can reuse in our service gateways. Create the src/internal/grpcutil directory, and add a file called grpcutil.go to it. Add the following code to it:
package grpcutil
import (
"context"
"math/rand"
"pkg/discovery"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"movieexample.com/pkg/discovery"
)
// ServiceConnection attempts to select a random service
// instance and returns a gRPC connection to it.
func ServiceConnection(ctx context.Context, serviceName string, registry discovery.Registry) (*grpc.ClientConn, error) {
addrs, err := registry.ServiceAddresses(ctx, serviceName)
if err != nil {
return nil, err
}
return grpc.Dial(addrs[rand.Intn(len(addrs))], grpc.WithTransportCredentials(insecure.NewCredentials()))
}
The function that we just implemented will try to pick a random instance of the target service using the provided service registry, and then it will create a gRPC connection for it.
Now, let’s create a gateway for our metadata service. In the movie/internal/gateway package, create a directory called metadata. Inside it, create a grpc directory with a metadata.go file, containing the following code:
package grpc
import (
"context"
"google.golang.org/grpc"
"movieexample.com/gen"
"movieexample.com/internal/grpcutil"
"movieexample.com/metadata/pkg/model"
"movieexample.com/pkg/discovery"
)
// Gateway defines a movie metadata gRPC gateway.
type Gateway struct {
registry discovery.Registry
}
// New creates a new gRPC gateway for a movie metadata
// service.
func New(registry discovery.Registry) *Gateway {
return &Gateway{registry}
}
Let’s implement the function for getting the metadata from a remote gRPC service:
// Get returns movie metadata by a movie id.
func (g *Gateway) Get(ctx context.Context, id string) (*model.Metadata, error) {
conn, err := grpcutil.ServiceConnection(ctx, "metadata", g.registry)
if err != nil {
return nil, err
}
defer conn.Close()
client := gen.NewMetadataServiceClient(conn)
resp, err := client.GetMetadataByID(ctx, &gen.GetMetadataByIDRequest{MovieId: id})
if err != nil {
return nil, err
}
return model.MetadataFromProto(resp.Metadata), nil
}
Let’s highlight some details of our gateway implementation:
Now we are ready to create a gateway for our rating service. Inside the movie/internal/gateway package, create a rating/grpc directory and add a grpc.go file with the following contents:
package grpc
import (
"context"
"pkg/discovery"
"rating/pkg/model"
"google.golang.org/grpc"
"movieexample.com/internal/grpcutil"
"movieexample.com/gen"
)
// Gateway defines an gRPC gateway for a rating service.
type Gateway struct {
registry discovery.Registry
}
// New creates a new gRPC gateway for a rating service.
func New(registry discovery.Registry) *Gateway {
return &Gateway{registry}
}
Add the implementation of the GetAggregatedRating function:
// GetAggregatedRating returns the aggregated rating for a
// record or ErrNotFound if there are no ratings for it.
func (g *Gateway) GetAggregatedRating(ctx context.Context, recordID model.RecordID, recordType model.RecordType) (float64, error) {
conn, err := grpcutil.ServiceConnection(ctx, "rating", g.registry)
if err != nil {
return 0, err
}
defer conn.Close()
client := gen.NewRatingServiceClient(conn)
resp, err := client.GetAggregatedRating(ctx, &gen.GetAggregatedRatingRequest{RecordId: string(recordID), RecordType: string(recordType)})
if err != nil {
return 0, err
}
return resp.RatingValue, nil
}
At this point, we are almost done with the changes. The last step is to update the main function of the movie service. Change it to the following:
package main
import (
"context"
"log"
"net"
"google.golang.org/grpc"
"movieexample.com/gen"
"movieexample.com/movie/internal/controller"
metadatagateway "movieexample.com/movie/internal/gateway/metadata/grpc"
ratinggateway "movieexample.com/movie/internal/gateway/rating/grpc"
grpchandler "movieexample.com/movie/internal/handler/grpc"
"movieexample.com/pkg/discovery/static"
)
func main() {
log.Println("Starting the movie service")
registry := static.NewRegistry(map[string][]string{
"metadata": {"localhost:8081"},
"rating": {"localhost:8082"},
"movie": {"localhost:8083"},
})
ctx := context.Background()
if err := registry.Register(ctx, "movie", "localhost:8083"); err != nil {
panic(err)
}
defer registry.Deregister(ctx, "movie")
metadataGateway := metadatagateway.New(registry)
ratingGateway := ratinggateway.New(registry)
svc := controller.New(ratingGateway, metadataGateway)
h := grpchandler.New(svc)
lis, err := net.Listen("tcp", "localhost:8083")
if err != nil {
log.Fatalf("failed to listen: %v", err)
}
srv := grpc.NewServer()
gen.RegisterMovieServiceServer(srv, h)
srv.Serve(lis)
}
You might have noticed that the format hasn’t changed, and we just updated the imports for our gateways, changing them from HTTP to gRPC.
We are done with the changes to our services. Now the services can communicate with each other using the Protocol Buffers serialization, and you can run them using the go run *.go command inside each cmd directory.
In this chapter, we covered the basics of synchronous communication and learned how to make microservices communicate with each other using the Protocol Buffers format. We illustrated how to define our service APIs using the Protocol Buffers schema language and generate code that can be reused in microservice applications written in Go and other languages.
The knowledge you gained in this chapter should help you write and maintain the existing services using Protocol Buffers and gRPC. It also serves as an example of how to use code generation for your services. In the next chapter, we are going to continue our journey into different ways of communication by covering another model, asynchronous communication.
3.22.27.45