Backend Development 26 min read

Implementing a Simple Java RPC Framework: Architecture, Service Registration, and Proxy Generation

This article walks through building a simple Java RPC framework, covering core concepts such as service registration with Zookeeper, client-side dynamic proxies, network communication via Netty, serialization, compression, and both reflection and Javassist-based proxy generation, complete with code examples and performance comparisons.

Top Architect
Top Architect
Top Architect
Implementing a Simple Java RPC Framework: Architecture, Service Registration, and Proxy Generation

In the era of distributed systems, Remote Procedure Call (RPC) is a fundamental technique that lets a client invoke methods on a remote server as if they were local. Popular frameworks such as Dubbo, Thrift, and gRPC each address the original goal of simplifying remote method calls.

RPC Definition and Core Problems

To make remote calls feel like local calls, an RPC system must solve four key problems: discovering available service nodes, representing data, transmitting data, and locating & invoking the target method on the server.

System Architecture Overview

The overall architecture consists of a service registration center (Zookeeper), a client that subscribes to node information, and a network layer that transports serialized and compressed request objects to the server.

Service Registration & Discovery (Zookeeper)

The implementation uses Zookeeper as a high‑performance, in‑memory coordination service. Services register a persistent node under /rpc/{serviceName}/service and create an ephemeral child node for each instance.

public void exportService(Service serviceResource) {
  String name = serviceResource.getName();
  String uri = GSON.toJson(serviceResource);
  String servicePath = "rpc/" + name + "/service";
  if (!zkClient.exists(servicePath)) {
    zkClient.createPersistent(servicePath, true);
  }
  String uriPath = servicePath + "/" + uri;
  if (zkClient.exists(uriPath)) {
    zkClient.delete(uriPath);
  }
  zkClient.createEphemeral(uriPath);
}

Clients lazily fetch the list of available nodes when a remote method is first invoked and cache the result locally, clearing the cache when Zookeeper notifies of topology changes.

Client Proxy Generation

Clients obtain a proxy that implements the service interface via ClientProxyFactory.getProxy . The proxy delegates calls to ClientInvocationHandler.invoke , which builds an RpcRequest , selects a service node, serializes and compresses the request, sends it over Netty, and finally deserializes the RpcResponse .

public
T getProxy(Class
clazz, String group, String version, boolean async) {
  if (async) {
    return (T) asyncObjectCache.computeIfAbsent(clazz.getName() + group + version,
      clz -> Proxy.newProxyInstance(clazz.getClassLoader(), new Class[]{clazz},
        new ClientInvocationHandler(clazz, group, version, true)));
  } else {
    return (T) objectCache.computeIfAbsent(clazz.getName() + group + version,
      clz -> Proxy.newProxyInstance(clazz.getClassLoader(), new Class[]{clazz},
        new ClientInvocationHandler(clazz, group, version, false)));
  }
}

public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
  String serviceName = clazz.getName();
  List
serviceList = getServiceList(serviceName);
  Service service = loadBalance.selectOne(serviceList);
  RpcRequest rpcRequest = new RpcRequest();
  rpcRequest.setRequestId(UUID.randomUUID().toString());
  rpcRequest.setAsync(async);
  rpcRequest.setServiceName(service.getName());
  rpcRequest.setMethod(method.getName());
  rpcRequest.setGroup(group);
  rpcRequest.setVersion(version);
  rpcRequest.setParameters(args);
  rpcRequest.setParametersTypes(method.getParameterTypes());
  RpcProtocolEnum messageProtocol = RpcProtocolEnum.getProtocol(service.getProtocol());
  RpcCompressEnum compresser = RpcCompressEnum.getCompress(service.getCompress());
  RpcResponse response = netClient.sendRequest(rpcRequest, service, messageProtocol, compresser);
  return response.getReturnValue();
}

Network Transmission, Serialization & Compression

Before transmission, request objects are serialized (e.g., using Kryo, Protobuf, or Java's Serializable ) and then compressed (commonly with Gzip) to reduce bandwidth.

public interface MessageProtocol {
  byte[] marshallingRequest(RpcRequest request) throws Exception;
  RpcRequest unmarshallingRequest(byte[] data) throws Exception;
  byte[] marshallingResponse(RpcResponse response) throws Exception;
  RpcResponse unmarshallingResponse(byte[] data) throws Exception;
}

public interface Compresser {
  byte[] compress(byte[] bytes);
  byte[] decompress(byte[] bytes);
}

Server‑Side Request Handling

The server receives the byte stream, decompresses and deserializes it, then looks up the appropriate service object and invokes the target method. The abstract RequestBaseHandler defines the workflow, while two concrete implementations provide different proxy strategies.

public abstract class RequestBaseHandler {
  public RpcResponse handleRequest(RpcRequest request) throws Exception {
    ServiceObject serviceObject = serverRegister.getServiceObject(
      request.getServiceName() + request.getGroup() + request.getVersion());
    if (serviceObject == null) {
      return new RpcResponse(RpcStatusEnum.NOT_FOUND);
    }
    try {
      return invoke(serviceObject, request);
    } catch (Exception e) {
      RpcResponse resp = new RpcResponse(RpcStatusEnum.ERROR);
      resp.setException(e);
      return resp;
    }
  }
  public abstract RpcResponse invoke(ServiceObject serviceObject, RpcRequest request) throws Exception;
}

Reflection vs. Javassist Proxy Generation

DefaultRpcReflectProcessor registers the original bean as the service proxy, invoking methods via Java reflection:

public RpcResponse invoke(ServiceObject serviceObject, RpcRequest request) throws Exception {
  Method method = serviceObject.getClazz().getMethod(request.getMethod(), request.getParametersTypes());
  Object value = method.invoke(serviceObject.getObj(), request.getParameters());
  RpcResponse response = new RpcResponse(RpcStatusEnum.SUCCESS);
  response.setReturnValue(value);
  return response;
}

In contrast, DefaultRpcJavassistProcessor creates a new proxy class with Javassist, registers it, and then delegates to an InvokeProxy implementation:

public RpcResponse invoke(ServiceObject serviceObject, RpcRequest request) throws Exception {
  InvokeProxy invokeProxy = (InvokeProxy) serviceObject.getObj();
  return invokeProxy.invoke(request);
}

Performance Comparison

Benchmarks on a MacBook Pro M1 show that the Javassist mode is marginally faster than pure reflection, but the difference is small (e.g., ~5 % improvement for 1 000 000 calls), indicating that either approach is acceptable for most applications.

Conclusion

The article demonstrates the complete lifecycle of an RPC call—from service registration, client proxy creation, network transport, to server‑side method execution—while exposing the trade‑offs between reflection‑based and bytecode‑generated proxies and providing practical code samples for each component.

Distributed SystemsJavaRPCReflectionSerializationZookeepernettyJavassist
Top Architect
Written by

Top Architect

Top Architect focuses on sharing practical architecture knowledge, covering enterprise, system, website, large‑scale distributed, and high‑availability architectures, plus architecture adjustments using internet technologies. We welcome idea‑driven, sharing‑oriented architects to exchange and learn together.

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.