13 API Style Introduction to Rpc Apis

13 API Style Introduction to RPC APIs #

Hello, I am Kong Lingfei. In this lecture, we will continue to explore how to design the API style for applications.

In the previous lecture, I introduced the REST API style. In this lecture, I will introduce another commonly used API style, RPC. In Go project development, if there is a high performance requirement and the need to provide calls from multiple programming languages, then we can consider using RPC API interfaces. RPC is also widely used in Go project development, so we need to master it carefully.

Introduction to RPC #

According to the definition provided by Wikipedia, RPC (Remote Procedure Call) is a computer communication protocol. This protocol allows a program running on one computer to call a subroutine on another computer without the programmer having to explicitly program the interaction.

In simple terms, RPC allows the server to implement a function, and the client can call this function using the interfaces provided by the RPC framework, just like calling a local function, and obtain the return value. RPC shields the underlying network communication details, allowing developers to focus on the implementation of business logic instead of worrying about the intricacies of network programming, thereby improving development efficiency.

The calling process of RPC is shown in the following diagram:

The specific steps of an RPC call are as follows:

  1. The client invokes the client stub through a local call.
  2. The client stub packages the parameters (also known as marshalling) into a message and sends this message.
  3. The client’s operating system sends the message to the server.
  4. After receiving the message, the server passes it to the server stub.
  5. The server stub unpackages the message (also known as unmarshalling) and obtains the parameters.
  6. The server stub calls the server’s subroutine (function), processes it, and then returns the final result to the client following the reverse steps.

Note that the stub is responsible for serializing and deserializing parameters and return values, as well as packaging and unpackaging parameters, and communicating at the network layer. The client side is often referred to as the stub, and the server side is often referred to as the skeleton.

Currently, there are many excellent RPC protocols in the industry, such as Tars by Tencent, Dubbo by Alibaba, Motan by Weibo, Thrift by Facebook, RPCX, and so on. However, the most commonly used one is gRPC, which is also the RPC framework used in this column. Therefore, I will focus on introducing the gRPC framework next.

Introduction to gRPC #

gRPC is a high-performance, open-source, cross-platform RPC framework developed by Google. It supports multiple programming languages and is built on the HTTP/2 protocol, with Protocol Buffers as the default data serialization format. Some key features of gRPC include:

  • Support for multiple programming languages, such as Go, Java, C, C++, C#, Node.js, PHP, Python, Ruby, and more.
  • Services are defined using an Interface Definition Language (IDL) file, which can be used to generate data structures, server interfaces, and client stubs in the desired programming language. This allows for decoupling of the server and client, enabling parallel development.
  • Communication protocol is based on the standard HTTP/2 design, supporting features such as bidirectional streaming, message header compression, single TCP connection for multiple requests, and server push.
  • Support for both Protobuf and JSON serialization formats. Protobuf is a language-agnostic, high-performance serialization framework that reduces network traffic and improves communication efficiency.

It’s worth noting that gRPC stands for google Remote Procedure Call, not golang Remote Procedure Call.

The invocation of gRPC is illustrated in the diagram below:

In gRPC, clients can directly call methods provided by gRPC services deployed on different machines. Invoking remote gRPC methods is as simple as calling local methods, making it easy to build distributed applications using gRPC.

Similar to many other RPC services, gRPC uses an IDL language to define interfaces (including the name of the interface, input parameters, and return parameters). On the server side, the gRPC service implements the interfaces we define. On the client side, the gRPC stub provides the same methods as the server.

gRPC supports multiple programming languages. For example, we can implement gRPC services using Go and invoke the methods provided by the gRPC service using a Java client. With this language versatility, the gRPC services we write can meet the language requirements of various clients.

The common data transfer format used by gRPC API interfaces is Protocol Buffers. Next, let’s take a closer look at Protocol Buffers.

Introduction to Protocol Buffers #

Protocol Buffers (ProtocolBuffer/protobuf) is a method developed by Google for serializing data structures, which can be used as a (data) communication protocol, data storage format, and a more flexible and efficient data format, similar to XML and JSON. It has excellent transmission performance and is often used in systems that have high requirements for data transmission performance as a data transmission format. The main features of Protocol Buffers are as follows:

  • Faster data transmission speed: When transmitting data, protobuf serializes the data into binary data. Compared with the text transmission format of XML and JSON, this can save a lot of IO operations and improve data transmission speed.
  • Cross-platform and multi-language: The protobuf’s built-in compilation tool - protoc, can compile client or server codes in different languages based on the protobuf definition file, which can meet the requirements of multi-language scenarios.
  • Excellent scalability and compatibility: It can update existing data structures without breaking or impacting the original programs.
  • Define services based on IDL files and generate data structures, server, and client interfaces in the specified language through the proto3 tool.

In the gRPC framework, Protocol Buffers have three main functions.

First, it can be used to define data structures. For example, the following code defines a SecretInfo data structure:

// SecretInfo contains secret details.
message SecretInfo {
    string name = 1;
    string secret_id  = 2;
    string username   = 3;
    string secret_key = 4;
    int64 expires = 5;
    string description = 6;
    string created_at = 7;
    string updated_at = 8;
}

Second, it can be used to define service interfaces. The following code defines a Cache service, which contains two API interfaces - ListSecrets and ListPolicies:

// Cache implements a cache rpc service.
service Cache {
    rpc ListSecrets(ListSecretsRequest) returns (ListSecretsResponse) {}
    rpc ListPolicies(ListPoliciesRequest) returns (ListPoliciesResponse) {}
}

Third, it can improve transmission efficiency through protobuf serialization and deserialization.

gRPC Example #

We have already gained some understanding of gRPC, a general-purpose RPC framework, but you may not know how to use gRPC to write API interfaces. Next, I will quickly demonstrate it through an official gRPC example. To run this example, you need to install the Go compiler, Protocol Buffer compiler (protoc, v3), and the Go language plugin for protoc on a Linux server. We have already installed them in Lesson 02, so I won’t go into the specific installation methods here.

This example consists of the following steps:

  1. Define the gRPC service.
  2. Generate client and server code.
  3. Implement the gRPC service.
  4. Implement the gRPC client.

The example code is located in the gopractise-demo/apistyle/greeter directory. The code structure is as follows:

$ tree
├── client
│   └── main.go
├── helloworld
│   ├── helloworld.pb.go
│   └── helloworld.proto
└── server
    └── main.go

The client directory contains the code for the client side, the helloworld directory is used to store the service’s IDL definition, and the server directory is used to store the code for the server side.

Now let me introduce the four steps of this example in detail.

  1. Define the gRPC service.

First, we need to define our service. Go to the helloworld directory and create a file named helloworld.proto:

$ cd helloworld
$ vi helloworld.proto

The content is as follows:

syntax = "proto3";

option go_package = "github.com/marmotedu/gopractise-demo/apistyle/greeter/helloworld";

package helloworld;

// The greeting service definition.
service Greeter {
  // Sends a greeting
  rpc SayHello (HelloRequest) returns (HelloReply) {}
}

// The request message containing the user's name.
message HelloRequest {
  string name = 1;
}

// The response message containing the greetings
message HelloReply {
  string message = 1;
}

In the helloworld.proto file, the option keyword is used to make some settings for the .proto file. Among them, go_package is a required setting, and its value must be the import path of the package. The package keyword specifies the package name where the generated .pb.go file will be located. We define the service using the service keyword, and then specify the RPC methods that the service has, and define the request and response message struct types for each method:

service Greeter {
  // Sends a greeting
  rpc SayHello (HelloRequest) returns (HelloReply) {}
}

gRPC supports defining four types of service methods: Simple RPC, Server-side Streaming RPC, Client-side Streaming RPC, and Bidirectional Streaming RPC.

  • Simple RPC: It’s the simplest gRPC mode. The client initiates a request and the server responds with a single piece of data. The definition format is rpc SayHello (HelloRequest) returns (HelloReply) {}.

  • Server-side Streaming RPC: The client sends a request and the server responds with a stream of data. The client reads the data from the stream until it’s empty. The definition format is rpc SayHello (HelloRequest) returns (stream HelloReply) {}.

  • Client-side Streaming RPC: The client sends a stream of messages to the server, and the server responds once after processing all the messages. The definition format is rpc SayHello (stream HelloRequest) returns (HelloReply) {}.

  • Bidirectional Streaming RPC: Both the client and server can send streams of data to each other, allowing real-time interaction between the two sides. The definition format is rpc SayHello (stream HelloRequest) returns (stream HelloReply) {}.

This example uses the Simple RPC mode. The .proto file also includes the definition of Protocol Buffers messages, including request messages and response messages. For example, the request message:

// The request message containing the user's name.
message HelloRequest {
  string name = 1;
}
  1. Generate client and server code.

Next, we need to generate gRPC client and server interfaces based on the .proto service definition. We can use the protoc compiler and specify using its Go language plugin to generate the code:

$ protoc -I. --go_out=plugins=grpc:$GOPATH/src helloworld.proto
$ ls
helloworld.pb.go  helloworld.proto

You can see that a new helloworld.pb.go file has been added.

  1. Implement the gRPC service.

Now we can implement the gRPC service. Go to the server directory and create a main.go file:

$ cd ../server
$ vi main.go

The content of main.go is as follows:

// Package main implements a server for Greeter service.
package main

import (
	"context"
	"log"
	"net"

	pb "github.com/marmotedu/gopractise-demo/apistyle/greeter/helloworld"
	"google.golang.org/grpc"
)

const (
	port = ":50051"
)

// server is used to implement helloworld.GreeterServer.
type server struct {
	pb.UnimplementedGreeterServer
}

// SayHello implements helloworld.GreeterServer
func (s *server) SayHello(ctx context.Context, in *pb.HelloRequest) (*pb.HelloReply, error) {
	log.Printf("Received: %v", in.GetName())
	return &pb.HelloReply{Message: "Hello " + in.GetName()}, nil
}

func main() {
	lis, err := net.Listen("tcp", port)
	if err != nil {
		log.Fatalf("failed to listen: %v", err)
	}
	s := grpc.NewServer()
	pb.RegisterGreeterServer(s, &server{})
	if err := s.Serve(lis); err != nil {
		log.Fatalf("failed to serve: %v", err)
	}
}

The above code implements the Go interface generated according to the service definition in the previous step.

We first define a Go structure server and add a SayHello(context.Context, pb.HelloRequest) (pb.HelloReply, error) method to it. This means that server is an implementation of the GreeterServer interface (defined in the helloworld.pb.go file).

After implementing the methods defined by the gRPC service, we can use net.Listen(...) to specify the port to listen for client requests. Then we create a gRPC server instance using grpc.NewServer(), and register the service with gRPC framework using pb.RegisterGreeterServer(s, &server{}). Finally, we start the gRPC service with s.Serve(lis).

After creating the main.go file, execute go run main.go in the current directory to start the gRPC service.

  1. Implement the gRPC client.

Open a new Linux terminal, go to the client directory, and create a main.go file:

$ cd ../client
$ vi main.go

Here is the content of main.go:

// Package main implements a client for the Greeter service.
package main

import (
	"context"
	"log"
	"os"
	"time"

	pb "github.com/marmotedu/gopractise-demo/apistyle/greeter/helloworld"
	"google.golang.org/grpc"
)

const (
	address     = "localhost:50051"
	defaultName = "world"
)

func main() {
	// Set up a connection to the server.
	conn, err := grpc.Dial(address, grpc.WithInsecure(), grpc.WithBlock())
	if err != nil {
		log.Fatalf("did not connect: %v", err)
	}
	defer conn.Close()
	c := pb.NewGreeterClient(conn)

	// Contact the server and print out its response.
	name := defaultName
	if len(os.Args) > 1 {
		name = os.Args[1]
	}
	ctx, cancel := context.WithTimeout(context.Background(), time.Second)
	defer cancel()
	r, err := c.SayHello(ctx, &pb.HelloRequest{Name: name})
	if err != nil {
		log.Fatalf("could not greet: %v", err)
	}
	log.Printf("Greeting: %s", r.Message)
}

In the code above, we created a gRPC connection to communicate with the server using the following code:

// Set up a connection to the server.
conn, err := grpc.Dial(address, grpc.WithInsecure(), grpc.WithBlock())
if err != nil {
    log.Fatalf("did not connect: %v", err)
}
defer conn.Close()

When establishing the connection, we can specify different options to control how the connection is created, such as grpc.WithInsecure() and grpc.WithBlock(). gRPC supports many options, and you can refer to the dialoptions.go file in the grpc repository for more options.

After establishing the connection, we need to create a client stub to execute RPC requests: c := pb.NewGreeterClient(conn). Once created, we can call remote methods using local calling style. For example, the following code calls the remote SayHello interface using the c.SayHello syntax:

r, err := c.SayHello(ctx, &pb.HelloRequest{Name: name})
if err != nil {
    log.Fatalf("could not greet: %v", err)
}
log.Printf("Greeting: %s", r.Message)

From the calling format above, we can see that RPC calls have two characteristics:

  • Easy to invoke: RPC abstracts away the underlying network communication details, making calling an RPC as convenient as invoking a local method. The calling style is consistent with the familiar class method invocation: ClassName.MethodName(params).
  • No need for packing and unpacking: RPC requests and responses are Go structs, so there’s no need to pack or unpack the parameters. This simplifies the calling process.

Finally, after creating the main.go file, execute go run main.go in the current directory to make an RPC call:

$ go run main.go
2020/10/17 07:55:00 Greeting: Hello world

With these four steps, we have created and called a gRPC service. Next, let me explain some considerations in a specific scenario.

In service development, we often encounter a scenario where we define an interface. Based on whether a certain parameter is passed in, the interface behavior will be determined. For example, suppose we want to provide a GetUser interface that queries user information based on the username parameter. If no username is passed in, the user information will be queried based on the userId parameter by default.

In this case, we need to determine whether the client has passed in the username parameter. We cannot use an empty value to determine this because we cannot distinguish between an empty value passed by the client and the absence of the username parameter. This is determined by the syntax of the Go language: if the client does not pass the username parameter, Go will default it to the zero value of its type, and the zero value for strings is an empty string.

So, how do we determine if the client has passed in the username parameter? The best way is to use pointers to determine if it is a nil pointer. If it is a nil pointer, it means that it has not been passed; if it is a non-nil pointer, it means that it has been passed. The specific implementation steps are as follows:

  1. Write the protobuf definition file.

Create a new user.proto file with the following content:

syntax = "proto3";

package proto;
option go_package = "github.com/marmotedu/gopractise-demo/protobuf/user";

//go:generate protoc -I. --experimental_allow_proto3_optional --go_out=plugins=grpc:.

service User {
  rpc GetUser(GetUserRequest) returns (GetUserResponse) {}
}

message GetUserRequest {
  string class = 1;
  optional string username = 2;
  optional string user_id = 3;
}

message GetUserResponse {
  string class = 1;
  string user_id = 2;
  string username = 3;
  string address = 4;
  string sex = 5;
  string phone = 6;
}

Note that we added the optional modifier in front of the fields we want to make optional.

  1. Compile the protobuf file using the protoc tool.

To enable the optional feature, we need to pass the --experimental_allow_proto3_optional parameter during the protoc command. The compilation command is as follows:

$ protoc --experimental_allow_proto3_optional --go_out=plugins=grpc:. user.proto

The above compilation command will generate the user.pb.go file, where the structure definition of GetUserRequest is as follows:

type GetUserRequest struct {
    state         protoimpl.MessageState
    sizeCache     protoimpl.SizeCache
    unknownFields protoimpl.UnknownFields

    Class    string  `protobuf:"bytes,1,opt,name=class,proto3" json:"class,omitempty"`
    Username *string `protobuf:"bytes,2,opt,name=username,proto3,oneof" json:"username,omitempty"`
    UserId   *string `protobuf:"bytes,3,opt,name=user_id,json=userId,proto3,oneof" json:"user_id,omitempty"`
}

With the optional + --experimental_allow_proto3_optional combination, a field can be compiled into a pointer type.

  1. Implementing the gRPC interface.

Create a new user.go file with the following content:

package user

import (
    "context"

    pb "github.com/marmotedu/api/proto/apiserver/v1"

    "github.com/marmotedu/iam/internal/apiserver/store"
)

type User struct {
}

func (c *User) GetUser(ctx context.Context, r *pb.GetUserRequest) (*pb.GetUserResponse, error) {
    if r.Username != nil {
        return store.Client().Users().GetUserByName(r.Class, r.Username)
    }

    return store.Client().Users().GetUserByID(r.Class, r.UserId)
}

In the GetUser method, we can determine whether the client has passed in the username parameter by checking if r.Username is nil.

RESTful VS gRPC #

So far, we have finished introducing the gRPC API today. You might be wondering, what are the advantages and disadvantages of these two API styles, and in what scenarios are they suitable? I have provided the answer to this question in the table below. You can refer to it and choose the appropriate one based on your specific requirements when implementing.

Of course, in most cases, RESTful API and gRPC API work together, where gRPC API is used for internal services and RESTful API is used for external services, as shown in the following image:

Summary #

In Go project development, we can choose to use RESTful API style or RPC API style, both of which are widely used. Among them, RESTful API style is suitable for scenarios that require providing API interfaces to the outside world because it is standardized, easy to understand, and easy to use. On the other hand, RPC API style is more suitable for internal business due to its high performance and convenience for callers.

RESTful API uses the HTTP protocol, while RPC API uses the RPC protocol. Currently, there are many RPC protocols available for you to choose from, and I recommend using gRPC because it is lightweight and has high performance and stability, making it an excellent RPC framework. Therefore, gRPC is still the most widely used protocol in the industry, and many core online services of major companies such as Tencent and Alibaba are using gRPC.

In addition to using the gRPC protocol, before developing a Go project, you can also explore some other excellent Go RPC frameworks used in the industry, such as Tencent’s tars-go, Alibaba’s dubbo-go, Facebook’s Thrift, rpcx, etc. You can conduct research on them before starting the project and choose according to the actual situation.

Exercise #

  1. Use the gRPC package to quickly implement an RPC API service and implement the PrintHello interface, which will return the string “Hello World”.
  2. Please consider this scenario: you have a gRPC service, but you also want this service to provide RESTful API interfaces at the same time. How can this be achieved?

Looking forward to seeing your thoughts and answers in the comments section. Feel free to discuss any questions related to RPC API. See you in our next lecture!