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:
- Compile Schemas Once: JSON schema compilation can be CPU-intensive. Always compile schemas during server initialization.
- Use json.RawMessage: When parsing RPC requests, use
json.RawMessageto avoid unnecessary marshaling/unmarshaling before validation. - Connection Pooling: If your RPC clients are internal services, use HTTP/2 connection pooling to reduce handshake overhead.
- Buffer Pools: For extremely high-throughput systems, consider using
sync.Poolfor 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.