Skip to content

Instantly share code, notes, and snippets.

@tarampampam
Last active January 6, 2023 10:25
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save tarampampam/cd8ac1dca118917c365b3042ec95b3ef to your computer and use it in GitHub Desktop.
Save tarampampam/cd8ac1dca118917c365b3042ec95b3ef to your computer and use it in GitHub Desktop.
Golang simple RPC client/server

Simple RPC client/server

The main use case - is a Docker health check (liveness/readiness probes) for CLI only applications (without exposed tcp/udp ports).

package rpc
type (
API struct{}
LivenessArgs struct{}
LivenessReply struct{ Ok bool }
)
// NewAPI returns a new API instance.
func NewAPI() *API {
return &API{}
}
// Liveness is a handler to check if the server is alive.
func (*API) Liveness(_ *LivenessArgs, reply *LivenessReply) error {
reply.Ok = true
return nil
}
package rpc_test
import (
"testing"
"github.com/stretchr/testify/assert"
"app/internal/rpc"
)
func TestAPI_Liveness(t *testing.T) {
var in, out = rpc.LivenessArgs{}, rpc.LivenessReply{}
assert.NoError(t, rpc.NewAPI().Liveness(&in, &out))
assert.True(t, out.Ok)
}
package rpc
import (
"errors"
"net/rpc"
)
// SocketClient is a client for a SocketServer.
// Dial and Close are NOT safe for concurrent use.
type SocketClient struct {
socketPath string
client *rpc.Client
isStarted bool
}
// NewSocketClient returns a new SocketClient.
func NewSocketClient(socketPath string) *SocketClient {
return &SocketClient{socketPath: socketPath}
}
// Dial establishes a connection to the SocketServer.
func (c *SocketClient) Dial() (err error) {
if c.isStarted {
return errors.New("already started")
}
if c.client, err = rpc.Dial("unix", c.socketPath); err != nil {
return err
}
c.isStarted = true
return
}
// CallLiveness checks if the server is alive.
func (c *SocketClient) CallLiveness() (LivenessReply, error) {
var args, reply = LivenessArgs{}, LivenessReply{}
if err := c.client.Call("API.Liveness", &args, &reply); err != nil {
return LivenessReply{}, err
}
return reply, nil
}
// Close closes the client (including the underlying connection).
func (c *SocketClient) Close() error {
if !c.isStarted {
return errors.New("not started")
}
defer func() { c.isStarted = false }()
if err := c.client.Close(); err != nil {
return err
}
return nil
}
package rpc_test
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"go.uber.org/goleak"
"app/internal/rpc"
)
func TestSocketClient_DialAndClose(t *testing.T) {
defer goleak.VerifyNone(t)
// empty socket path
assert.ErrorContains(t, rpc.NewSocketClient("").Dial(), "missing address")
var tempFilePath string
{ // create empty file
tempFile, err := os.CreateTemp("", "")
assert.NoError(t, err)
defer func() { assert.NoError(t, os.Remove(tempFile.Name())) }()
assert.NoError(t, tempFile.Close())
assert.FileExists(t, tempFile.Name())
tempFilePath = tempFile.Name()
}
// socket path is a regular file
assert.ErrorContains(t, rpc.NewSocketClient(tempFilePath).Dial(), "connection refused")
// non-existent socket path
assert.ErrorContains(t, rpc.NewSocketClient(tempSocketFilePath(t)).Dial(), "no such file")
var (
socketPath = tempSocketFilePath(t)
srv = rpc.NewSocketServer(socketPath)
)
assert.NoError(t, srv.Start())
defer func() { assert.NoError(t, srv.Close()) }()
// normal usage
var client = rpc.NewSocketClient(socketPath)
assert.NoError(t, client.Dial())
assert.NoError(t, client.Close())
assert.Error(t, client.Close()) // already closed
}
func TestSocketClient_CallLiveness(t *testing.T) {
defer goleak.VerifyNone(t)
var (
socketPath = tempSocketFilePath(t)
server = rpc.NewSocketServer(socketPath)
client = rpc.NewSocketClient(socketPath)
)
// prepare server
assert.NoError(t, server.Register())
assert.NoError(t, server.Start())
assert.ErrorContains(t, server.Start(), "already started")
defer func() { assert.ErrorContains(t, server.Close(), "not started") }()
// prepare client
assert.NoError(t, client.Dial())
assert.ErrorContains(t, client.Dial(), "already started")
defer func() { assert.ErrorContains(t, client.Close(), "not started") }()
// call
resp, err := client.CallLiveness()
assert.NoError(t, err)
assert.True(t, resp.Ok)
// and close
assert.NoError(t, server.Close())
assert.NoError(t, client.Close())
}
package rpc
import (
"net"
"net/rpc"
"github.com/pkg/errors"
)
// SocketServer is a server for a SocketClient.
// Start and Close are NOT safe for concurrent use.
type SocketServer struct {
rpc *rpc.Server
socketPath string
listener net.Listener
isStarted bool
}
// NewSocketServer returns a new SocketServer.
func NewSocketServer(socketPath string) *SocketServer {
return &SocketServer{socketPath: socketPath, rpc: rpc.NewServer()}
}
// Register the handlers.
func (s *SocketServer) Register() error {
if err := s.rpc.RegisterName("API", NewAPI()); err != nil {
return err
}
return nil
}
// Start the RPC server.
func (s *SocketServer) Start() (err error) {
if s.isStarted {
return errors.New("already started")
}
if s.socketPath == "" {
return errors.New("missing address")
}
// create a Unix domain socket and listen for incoming connections
if s.listener, err = net.Listen("unix", s.socketPath); err != nil {
return errors.Wrap(err, "failed to listen on socket")
}
s.isStarted = true
go s.rpc.Accept(s.listener)
return
}
// Close stops the RPC server.
func (s *SocketServer) Close() error {
if !s.isStarted {
return errors.New("not started")
}
defer func() { s.isStarted = false }()
if err := s.listener.Close(); err != nil {
return err
}
return nil
}
package rpc_test
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"go.uber.org/goleak"
"app/internal/rpc"
)
func TestSocketServer_StartAndStop(t *testing.T) {
defer goleak.VerifyNone(t)
// empty socket path
assert.ErrorContains(t, rpc.NewSocketServer("").Start(), "missing address")
var tempFilePath string
{ // create empty file
tempFile, err := os.CreateTemp("", "")
assert.NoError(t, err)
defer func() { assert.NoError(t, os.Remove(tempFile.Name())) }()
assert.NoError(t, tempFile.Close())
assert.FileExists(t, tempFile.Name())
tempFilePath = tempFile.Name()
}
// socket path is a regular file
assert.ErrorContains(t, rpc.NewSocketServer(tempFilePath).Start(), "address already in use")
// normal usage
var (
socketPath = tempSocketFilePath(t)
srv = rpc.NewSocketServer(socketPath)
)
assert.NoFileExists(t, socketPath)
assert.NoError(t, srv.Start()) // start
assert.FileExists(t, socketPath)
assert.ErrorContains(t, srv.Start(), "already started") // repeated start
assert.ErrorContains(t, srv.Start(), "already started") // repeated start
assert.NoError(t, srv.Close()) // stop
assert.NoFileExists(t, socketPath)
assert.ErrorContains(t, srv.Close(), "not started") // repeated close
assert.ErrorContains(t, srv.Close(), "not started") // repeated close
}
package rpc_test
import (
"os"
"path"
"strconv"
"testing"
"time"
)
func tempSocketFilePath(t *testing.T) string {
t.Helper()
return path.Join(os.TempDir(), "unit-test-"+strconv.Itoa(int(time.Now().UnixNano()))+".sock")
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment