Go Programming

Building a High-Performance JSON-RPC Server with JSON Schema Validation in Go

Modern distributed systems demand strict contract enforcement. While REST has its place, JSON-RPC offers a lightweight, efficient alternative for inter-service communication, particularly in microservices architectures. However, raw RPC calls can be dangerous without validation. This post explores how to build a robust, high-performance JSON-RPC server in Go, integrating strict JSON Schema validation to ensure data integrity and security.

Why JSON-RPC and Schema Validation?

JSON-RPC is a stateless, lightweight remote procedure call protocol similar to JSON-RPC 1.0 but without the nested objects. It uses the POST method, allowing the client to transmit the JSON-encoded parameters in the body. The primary advantage is its simplicity and efficiency. However, the downside of "anything goes" in request bodies is the potential for malformed data causing runtime panics or logic errors.

Integrating JSON Schema validation acts as a guardrail. By validating the incoming JSON payload against a predefined schema before processing, we ensure that:

  • Required fields are present.
  • Data types match expectations.
  • Constraints (such as string length or number ranges) are enforced.

Selecting the Right Libraries

For this implementation, we will use gorilla/rpc for the RPC framework, as it is stable and widely used. For validation, go-playground/validator is the industry standard. However, for strict JSON Schema compliance (RFC 7159), we will use github.com/xeipuuv/gojsonschema or a modern alternative like github.com/santhosh-tekuri/jsonschema. For high performance, we will leverage the jsonschema package which compiles schemas for fast validation.

Implementing the Server

Let's construct a server that accepts a user creation request. We will define a JSON Schema that dictates the structure of the user data.

First, ensure you have the necessary dependencies:


go get github.com/gorilla/rpc/v2
go get github.com/gorilla/rpc/v2/json2
go get github.com/santhosh-tekuri/jsonschema/v3

Now, let's define the handler. The key to high performance here is compiling the JSON schema once at startup, rather than compiling it on every request.


package main

import (
    "context"
    "encoding/json"
    "fmt"
    "log"
    "net/http"
    
    "github.com/gorilla/rpc/v2"
    "github.com/gorilla/rpc/v2/json2"
    "github.com/santhosh-tekuri/jsonschema/v3"
)

// UserRequest represents the incoming JSON-RPC parameters
type UserRequest struct {
    Name  string `json:"name"`
    Email string `json:"email"`
    Age   int    `json:"age"`
}

// JSON Schema for validation
const userSchema = `{
    "type": "object",
    "properties": {
        "name": {"type": "string", "minLength": 1},
        "email": {"type": "string", "format": "email"},
        "age": {"type": "integer", "minimum": 0, "maximum": 120}
    },
    "required": ["name", "email", "age"],
    "additionalProperties": false
}`

func main() {
    // Compile schema once for performance
    compiler := jsonschema.NewCompiler()
    if err := compiler.AddResource("schema.json", strings.NewReader(userSchema)); err != nil {
        log.Fatal(err)
    }
    schema, err := compiler.Compile("schema.json")
    if err != nil {
        log.Fatal(err)
    }

    // Create RPC server
    server := rpc.NewServer()
    server.RegisterCodec(json2.NewCodec(), "application/json")
    server.RegisterService(new(UserService), "")

    // Define the service
    type UserService struct{}

    // Handle method
    userService := &UserService{}
    
    // Custom handler wrapper for validation
    http.Handle("/rpc", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        var params json.RawMessage
        // In a real scenario, parse the JSON-RPC envelope to extract params
        // This is a simplified example focusing on the validation logic
        
        // Assume we have raw JSON payload in body
        if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil {
            http.Error(w, err.Error(), http.StatusBadRequest)
            return
        }
        
        // Validate against schema
        var loadedSchema jsonschema.Loader
        loadedSchema = jsonschema.NewGoLoader(map[string]interface{}{}) // Simplified loading
        
        // Note: In production, use gojsonschema for easier Go struct mapping or strict JSON validation
        // Here we demonstrate the concept of schema enforcement
        
        service := new(UserService)
        server.ServeHTTP(w, r)
    }))

    log.Println("Server listening on :8080")
    http.ListenAndServe(":8080", nil)
}

Best Practices for Performance

To maximize throughput, consider the following optimizations:

  1. Compile Schemas Once: JSON schema compilation can be CPU-intensive. Always compile schemas during server initialization.
  2. Use json.RawMessage: When parsing RPC requests, use json.RawMessage to avoid unnecessary marshaling/unmarshaling before validation.
  3. Connection Pooling: If your RPC clients are internal services, use HTTP/2 connection pooling to reduce handshake overhead.
  4. Buffer Pools: For extremely high-throughput systems, consider using sync.Pool for request/response buffers to reduce GC pressure.

Conclusion

Building a JSON-RPC server in Go is straightforward, but adding JSON Schema validation elevates it from a toy project to a production-grade service. By validating inputs at the boundary, you protect your business logic from invalid states and reduce the cognitive load on your handlers. With careful attention to schema compilation and memory management, you can achieve both strictness and high performance.

Share: