Backend Development 11 min read

Building a Simple RPC Framework in Go

This article walks through building a simple RPC framework in Go using about 300 lines of code, covering RPC fundamentals, TLV network data format, serialization, transport layer, server and client implementations, and a complete example to help readers understand RPC concepts.

360 Tech Engineering
360 Tech Engineering
360 Tech Engineering
Building a Simple RPC Framework in Go

Recently the author has been researching the principles and implementation of RPC. This article explains RPC by building a simple RPC framework in Go with roughly 300 lines of pure Go code, aiming to help readers grasp RPC concepts.

What is RPC

In simple terms, service A wants to call a function on service B, but the two services do not share the same memory space, so they cannot call each other directly. To achieve this, we need to express how to invoke the function and how to transmit the call semantics over the network.

Network Transmission Data Format

We adopt a TLV (fixed-length header + variable-length body) encoding scheme to standardize data transmission over TCP. This format allows both client and server to understand the protocol.

type RPCdata struct {
    Name string        // name of the function
    Args []interface{} // request or response body, expect error
    Err  string        // error from remote server
}

We serialize this structure using Go's default binary serialization (gob) for network transmission.

func Encode(data RPCdata) ([]byte, error) {
    var buf bytes.Buffer
    encoder := gob.NewEncoder(&buf)
    if err := encoder.Encode(data); err != nil {
        return nil, err
    }
    return buf.Bytes(), nil
}

func Decode(b []byte) (RPCdata, error) {
    buf := bytes.NewBuffer(b)
    decoder := gob.NewDecoder(buf)
    var data RPCdata
    if err := decoder.Decode(&data); err != nil {
        return RPCdata{}, err
    }
    return data, nil
}

Network Transport

We choose the TLV protocol because it is easy to implement and provides a clear way to determine the length of incoming data.

type Transport struct {
    conn net.Conn // generic stream-oriented network connection
}

func NewTransport(conn net.Conn) *Transport { return &Transport{conn} }

func (t *Transport) Send(data []byte) error {
    buf := make([]byte, 4+len(data))
    binary.BigEndian.PutUint32(buf[:4], uint32(len(data)))
    copy(buf[4:], data)
    _, err := t.conn.Write(buf)
    return err
}

func (t *Transport) Read() ([]byte, error) {
    header := make([]byte, 4)
    if _, err := io.ReadFull(t.conn, header); err != nil { return nil, err }
    dataLen := binary.BigEndian.Uint32(header)
    data := make([]byte, dataLen)
    _, err := io.ReadFull(t.conn, data)
    return data, err
}

RPC Server

The server registers functions by name and executes them upon receiving a request.

type RPCServer struct {
    addr  string
    funcs map[string]reflect.Value
}

func (s *RPCServer) Register(fnName string, fFunc interface{}) {
    if _, ok := s.funcs[fnName]; ok { return }
    s.funcs[fnName] = reflect.ValueOf(fFunc)
}

func (s *RPCServer) Execute(req RPCdata) RPCdata {
    f, ok := s.funcs[req.Name]
    if !ok {
        e := fmt.Sprintf("func %s not Registered", req.Name)
        log.Println(e)
        return RPCdata{Name: req.Name, Args: nil, Err: e}
    }
    // unpack arguments, call function, pack results and error
    // ... (code omitted for brevity)
    return RPCdata{Name: req.Name, Args: resArgs, Err: er}
}

RPC Client

The client knows only the function prototype and uses reflection to invoke remote calls.

func (c *Client) callRPC(rpcName string, fPtr interface{}) {
    container := reflect.ValueOf(fPtr).Elem()
    f := func(req []reflect.Value) []reflect.Value {
        // encode request, send, receive response, decode, handle errors
        // ... (code omitted for brevity)
        return outArgs
    }
    container.Set(reflect.MakeFunc(container.Type(), f))
}

Testing the Framework

package main

import (
    "encoding/gob"
    "fmt"
    "net"
    "time"
)

type User struct { Name string; Age int }

var userDB = map[int]User{1:{"Ankur",85},9:{"Anand",25},8:{"Ankur Anand",27}}

func QueryUser(id int) (User, error) {
    if u, ok := userDB[id]; ok { return u, nil }
    return User{}, fmt.Errorf("id %d not in user db", id)
}

func main() {
    gob.Register(User{})
    addr := "localhost:3212"
    srv := NewServer(addr)
    srv.Register("QueryUser", QueryUser)
    go srv.Run()
    time.Sleep(1 * time.Second)
    conn, err := net.Dial("tcp", addr)
    if err != nil { panic(err) }
    cli := NewClient(conn)
    var Query func(int) (User, error)
    cli.callRPC("QueryUser", &Query)
    u, err := Query(1)
    if err != nil { panic(err) }
    fmt.Println(u)
    u2, err := Query(8)
    if err != nil { panic(err) }
    fmt.Println(u2)
}

Running the program prints the queried users, demonstrating a functional RPC system.

Conclusion

The simple RPC framework is now complete, providing a hands‑on example to help readers understand RPC principles and practice building networked services in Go.

Backend DevelopmentgolangrpcGoserializationNetwork ProgrammingTLV
360 Tech Engineering
Written by

360 Tech Engineering

Official tech channel of 360, building the most professional technology aggregation platform for the brand.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.