gorpc

package module
v0.0.0-...-ab9d384 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 1, 2023 License: MIT Imports: 19 Imported by: 2

README

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:

  • entry The entry file that is used to spawn apps.

  • apps This property configures the apps that this project serves and connects.

    • name The name of the app.
    • uri The URI of the gRPC server, supported schemes are grpc:, grpcs:, http:, https: or xds:
    • serve (optional) If this app is served by the NgRPC app server. If this property is false, that means the underlying services are served by another program. As we can see from the above example, the post-server sets this property to false, because it's served in a Node.js program (with NgRPC). If we take a look at the services.PostService, we will just see a very simple Go file that contains an struct contains only the Connect() and GetClient() methods.
    • services The services served by this app. if we take a look at the services.ExampleService and the services.UserService, we will see that they're very simple Golang struct files.
    • stdout Log file used for stdout.

    More Options (optional)

    • ca The CA filename when using TLS/SSL.

    • cert The certificate filename when using TLS/SSL.

    • key The private key filename when using TLS/SSL.

      NOTE: We only need a pair of certificates for both the server and the client, since they are inside one project, using different certificates makes no sense.

    • stderr Log file used for stderr. If omitted and stdout is set, the program uses stdout for stderr as well.

    • entry The entry file that is used to spawn this app. This option overwrites the one set in the head.

    • env The environment variables passed to the entry file.

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:

  1. 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).
  2. 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.

  1. Create a proto folder to store all the .proto files in one place.

  2. 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).

  3. 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.

  4. 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.

  5. Always implement the GetClient() method in the service and use a field in the service struct to reference to each other.

Documentation

Overview

Example
package main

import (
	"context"
	"fmt"
	"log"

	"github.com/ayonli/goext"
	"github.com/ayonli/gorpc"
	"github.com/ayonli/gorpc/services"
	"github.com/ayonli/gorpc/services/github/ayonli/services_proto"
)

func main() {
	app, err := gorpc.Boot("user-server")

	if err != nil {
		log.Fatal(err)
	} else {
		defer app.Stop()
	}

	ctx := context.Background()
	userId := "ayon.li"
	userSrv := goext.Ok(gorpc.GetServiceClient(&services.UserService{}, userId))
	user := goext.Ok(userSrv.GetUser(ctx, &services_proto.UserQuery{Id: &userId}))

	fmt.Println("Id:", user.Id)
	fmt.Println("Name:", user.Name)
	fmt.Println("Gender:", user.Gender)
	fmt.Println("Age:", user.Age)
	fmt.Println("Email:", user.Email)
}
Output:

Id: ayon.li
Name: A-yon Lee
Gender: MALE
Age: 28
Email: the@ayon.li

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

func ForSnippet

func ForSnippet() func()

ForSnippet is used for temporary scripting usage, it runs a temporary pure-clients app that connects to all the services and returns a closure function which shall be called immediately after the snippet runs.

Example:

func main() {
	close := gorpc.ForSnippet()
	defer close()
	// snippet to run
}

See https://github.com/ayonli/gorpc/blob/main/script/main.go for example.

func GetServiceClient

func GetServiceClient[T any](service ConnectableService[T], route string) (T, error)

GetServiceClient returns the service client (`T`).

`route` is used to route traffic bye the client-side load balancer.

func Use

func Use[T any](service ConnectableService[T])

Use registers the service for use.

Types

type ConnectableService

type ConnectableService[T any] interface {
	Connect(cc grpc.ClientConnInterface) T
}

ConnectableService represents a service struct that implements the `Connect()` method.

type RpcApp

type RpcApp struct {
	config.App
	// contains filtered or unexported fields
}

RpcApp is used both to configure the apps and hold the app instance.

func Boot

func Boot(name string) (*RpcApp, error)

Boot initiates an app by the given name and loads the config file, it initiates the server (if served) and client connections, prepares the services ready for use.

NOTE: There can only be one named app running in the same process.

The booting process contains multiple internal processes, so even though this function may fail and return an error, the app may have been partially initiated and will be returned alongside the error, we should check if it's not nil and try to call the `Stop()` method to ensure any redundant resource is released (or we could simply terminate the program).

func BootWithConfig

func BootWithConfig(name string, conf config.Config) (*RpcApp, error)

BootWithConfig is like `Boot()` except it takes a config argument instead of loading the config file.

func (*RpcApp) Stop

func (self *RpcApp) Stop()

Stop closes client connections and stops the server (if served), and runs any Stop() method in the bound services.

func (*RpcApp) WaitForExit

func (self *RpcApp) WaitForExit()

WaitForExit blocks the main goroutine to prevent it exit prematurely unless received the interrupt signal from the system.

This method is the default approach to keep the program running, however, we may not use it if we have other mechanisms to keep the program alive.

This method calls the `Stop()` method internally, if we don't use this method, we need to call the `Stop()` method explicitly when the program is going to terminate.

type ServableService

type ServableService interface {
	Serve(s grpc.ServiceRegistrar)
}

ServableService represents a service struct that implements the `Serve()` method.

Directories

Path Synopsis
cli
cmd

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL