GoRPC
Make it easy to create clear, expressive and elegant gRPC based applications in Golang.
This project itself serves as an example of using GoRPC and gRPC in real world.
TIP: there is a sister project NgRPC which functions similar to
this one but is written in and for Node.js.
Install
go get github.com/ayonli/gorpc
go build -o $GOPATH/bin/gorpc github.com/ayonli/gorpc/cli # build the CLI tool
A Simple Example
First, take a look at this configuration (gorpc.json):
{
"$schema": "gorpc.schema.json",
"entry": "entry/main.go",
"apps": [
{
"name": "user-server",
"uri": "grpcs://localhost:4001",
"serve": true,
"services": [
"services.UserService"
],
"stdout": "out.log",
"ca": "certs/ca.pem",
"cert": "certs/cert.pem",
"key": "certs/cert.key",
},
{
"name": "post-server",
"uri": "grpcs://localhost:4002",
"serve": false,
"services": [
"services.PostService"
],
"ca": "certs/ca.pem",
"cert": "certs/cert.pem",
"key": "certs/cert.key"
}
]
}
Then, the main program (entry/main.go):
package main
import (
"context"
"fmt"
"log"
"github.com/ayonli/gorpc"
_ "github.com/ayonli/gorpc/services"
)
func main() {
app, err := gorpc.Boot("user-server")
if err != nil {
log.Fatal(err)
} else {
app.WaitForExit()
}
}
Explanation
The configuration file (named gorpc.json
or gorpc.local.json
) configures the project:
First we import our services
package and name it _
for use.
Then we use gorpc.Boot()
function to initiate the app by the given name (user-server
), it
initiates the server (if served) and client connections, prepares the services ready for use.
Next we add a deferred call to app.WaitForExit()
to keep the program running and wait for the
interrupt / exit signal from the system.
With these simple configurations, we can write our gRPC application straightforwardly in .proto
files and .go
files, without any headache of when and where to start the server or connect to
the services, all is properly handled behind the scene.
CLI Commands
-
gorpc init
initiate a new GoRPC project
-
gorpc start [app]
start an app or all apps (exclude non-served ones)
app
the app name in the config file
-
gorpc restart [app]
restart an app or all apps (exclude non-served ones)
app
the app name in the config file
-
gorpc stop [app]
stop an app or all apps
app
the app name in the config file
-
gorpc list
list all apps (exclude non-served ones)
NOTE: some of the commands may not functions well in Windows.
About Process Management
This package uses a host-guest model for process management. When using the start
command to start
the app, the CLI tool also starts a host server to hold communication between apps, the host is
responsible to accept commands sent by the CLI tool and distribute them to the app.
When an app crashes, the host server is also responsible for re-spawning it, this feature guarantees
that our app is always online.
It's necessary to point out, though, that the CLI tool only works for the RPC app instance, if the
process contains other logics that prevent the process to exit, the stop
command will not be able
to terminate the process.
Implement a Service
To allow GoRPC to handle the serving and connecting process of our services, we need to implement
our service in a well-designed fashion.
A typical server-side gRPC service is defined like this:
// A service to be served need to embed the UnimplementedServiceServer.
type ExampleService struct {
proto.UnimplementedExampleServiceServer
}
For GoRPC, a client-side service representation struct is needed as well:
// A pure client service is an empty struct, which is only used for referencing to the service.
type ExampleService {}
func init
In each service file, we need to define a init
function to use the service:
func init() {
gorpc.Use(&ExampleService{})
}
func Serve
For a service in order to be served, a Serve()
method is required in the service struct:
func (self *ExampleService) Serve(s grpc.ServiceRegistrar) {
proto.RegisterExampleServiceServer(s, self)
// other initiations, like establishing database connections
}
func Connect
All services (server-side and client-side) must implement the Connect()
method in order to be
connected:
func (self *Service) Connect(cc grpc.ClientConnInterface) proto.ExampleServiceClient {
return proto.NewExampleServiceClient(cc)
}
func Stop
The server-side service may implement the Stop()
method, which is called during the stopping
process of the server:
func (self *ExampleService) Stop() {
// release database connections, etc.
}
func GetClient
The service may implement a GetClient()
which can be used to reference the service client
in a more expressive way:
func (self *ExampleService) GetClient(route string) (proto.ExampleServiceClient, error) {
return gorpc.GetServiceClient(self, route)
}
Then we can use this style of code in another service:
ins, err := (&ExampleService{}).GetClient(route)
result, err := ins.CallMethod(ctx, req)
Actually, we can use dependency injection to reference to that service, which we will be discussing
in the next section.
Dependency Injection
In a service struct, we can reference to another service simple by adding an exported field that
points to that service, when the app boots, such a field will be automatically filled with the
instance registered by gorpc.Use()
function, and we can call it's GetClient()
method (if exists)
directly in this service.
For example, the services.UserService
uses the
services.PostService
, it's defined in this way:
package services
type UserService struct {
proto.UnimplementedUserServiceServer
PostSrv *PostService // set as exported field for dependency injection
// other unexpected fields...
}
And we can use it directly in the service's method without concerning about initiation.
func (self *UserService) GetMyPosts(ctx context.Context, query *proto.UserQuery) (*proto.PostQueryResult, error) {
return goext.Try(func() *services_proto.PostQueryResult {
user := goext.Ok(self.GetUser(ctx, query))
// ---- highlight ----
ins := goext.Ok(self.PostSrv.GetClient(user.Id))
// ---- highlight ----
res := goext.Ok(ins.SearchPosts(ctx, &services_proto.PostsQuery{Author: &user.Id}))
return (*services_proto.PostQueryResult)(res)
})
}
Load Balancing
This module provides a client-side load balancer, we can set the same service in multiple apps, and
when calling gorpc.GetServiceClient()
, we pass the second argument route
for the program to
evaluate routing between all the apps that serves the service.
There are three algorithms are used based on the route
argument:
- When
route
is not empty:
- If it matches one of the name or URI of the apps, the traffic is routed to that app directly.
- Otherwise it hashes the route string against the apps and match one by the mod value of
hash % len(activeNodes)
.
- When
route
is empty, the program uses round-robin algorithm against the active nodes.
Apart from the client-side load balancing, server-side load balancing is automatically supported by
gRPC, either by reverse proxy like NGINX or using the xds:
protocol for Envoy Proxy.
Unnamed App
It it possible to boot an app without providing the name, such an app will not start the server, but
only connects to the services. This is useful when we're using gRPC services in a frontend server,
for example, a web server, which only handles client requests and direct calls to the backend gRPC
services, we need to establish connection between the web server and the RPC servers, but we don't
won't to serve any service in the web server.
The following app do not serve, but connects to all the services according to the configuration file.
We can do all the stuffs provided by GoRPC in the web server as we would in the RPC server,
because all the differences between the gRPC client and the gRPC server are hidden behind the scene.
import "github.com/ayonli/gorpc"
func main() {
app, err := gorpc.Boot("")
}
Good Practices
In order to code a clear, expressive and elegant gRPC based application, apart from the features
that GoRPC provides, we can order our project by performing the following steps.
-
Create a proto
folder to store all the .proto
files in one place.
-
Create a services
folder for all the service files, the package name of those files should be
the same as the folder's name (which is also services
).
-
Design the .proto
files with a reasonable scoped package name, don't just name it services
,
instead, name it something like [org].[repo].services
, the .proto
files should be
shared and reused across different projects, using a long name to prevent collision and provide
useful information about the services. Respectively, the directory path should reflect the
package name. See the proto files of this project as examples.
-
Compile the .proto
files and generate code into the services
folder, with a well designed
package name, the files should be grouped accordingly. See code-gen.sh as a
example.
-
Always implement the GetClient()
method in the service and use a field in the service
struct to reference to each other.