Backend Development 25 min read

Building a Simple Java RPC Framework: Service Registration, Discovery, and Proxy Generation

This article explains the core concepts and implementation steps of a lightweight Java RPC framework, covering RPC definition, service registration and discovery with Zookeeper, client-side dynamic proxies, network transmission using Netty, serialization, compression, and two server‑side proxy generation strategies (reflection and Javassist).

Top Architect
Top Architect
Top Architect
Building a Simple Java RPC Framework: Service Registration, Discovery, and Proxy Generation

In the era of distributed systems, RPC (Remote Procedure Call) is essential for invoking remote services as if they were local methods. This article walks through building a simple RPC framework in Java, illustrating the complete workflow from service registration to client invocation.

RPC Definition

Remote procedure call originated in 1981 to make remote method invocation as easy as local calls. Modern frameworks such as Dubbo, Thrift, and gRPC implement this concept.

RPC Principles

To achieve transparent remote calls, four problems must be solved:

How to obtain available remote servers.

How to represent data.

How to transmit data.

How the server locates and invokes the target method.

Service Registration and Discovery

The framework uses Zookeeper as a registry. Services register themselves under /rpc/{serviceName}/service as persistent nodes, while each instance creates an EPHEMERAL child node containing its address and metadata.

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 and cache them locally. A Zookeeper watcher clears the cache when the node list changes.

public List
getServices(String name) {
    String servicePath = "rpc/" + name + "/service";
    List
children = zkClient.getChildren(servicePath);
    List
serviceList = Optional.ofNullable(children)
        .orElse(new ArrayList<>())
        .stream()
        .map(str -> {
            String deCh = URLDecoder.decode(str, StandardCharsets.UTF_8);
            return gson.fromJson(deCh, Service.class);
        })
        .collect(Collectors.toList());
    SERVER_MAP.put(name, serviceList);
    return serviceList;
}

Client Proxy Generation

Clients obtain a proxy for a service interface via ClientProxyFactory.getProxy . The proxy implements the interface and forwards calls to the remote server.

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, async)));
    } else {
        return (T) objectCache.computeIfAbsent(
            clazz.getName() + group + version,
            clz -> Proxy.newProxyInstance(clazz.getClassLoader(), new Class[]{clazz},
                new ClientInvocationHandler(clazz, group, version, async)));
    }
}

class ClientInvocationHandler implements InvocationHandler {
    private final Class
clazz;
    private final String group;
    private final String version;
    private final boolean async;
    // constructor omitted
    @Override
    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 and Compression

Before sending, the request object is serialized (e.g., using Kryo, Protobuf, or JSON) and compressed (e.g., Gzip). Netty handles the TCP transport, and its decoders (FixedLengthFrameDecoder, LineBasedFrameDecoder, LengthFieldBasedFrameDecoder) solve the sticky‑packet problem.

Server‑Side Proxy Generation

Two strategies are provided:

Reflection based – registers the actual service bean as the proxy object.

Javassist based – generates a new proxy class at runtime that implements the service interface and forwards calls to the original bean.

Reflection implementation example:

public class DefaultRpcReflectProcessor extends DefaultRpcBaseProcessor {
    @Override
    protected void startServer(ApplicationContext context) {
        Map
beans = context.getBeansWithAnnotation(RpcService.class);
        for (Object obj : beans.values()) {
            Class
clazz = obj.getClass();
            Class
[] interfaces = clazz.getInterfaces();
            ServiceObject so;
            RpcService service = clazz.getAnnotation(RpcService.class);
            if (interfaces.length != 1) {
                so = new ServiceObject(service.value(), Class.forName(service.value()), obj, service.group(), service.version());
            } else {
                Class
sup = interfaces[0];
                so = new ServiceObject(sup.getName(), sup, obj, service.group(), service.version());
            }
            serverRegister.register(so);
        }
    }
}

Javassist implementation example (simplified):

public class DefaultRpcJavassistProcessor extends DefaultRpcBaseProcessor {
    @Override
    protected void startServer(ApplicationContext context) {
        Map
beans = context.getBeansWithAnnotation(RpcService.class);
        for (Map.Entry
entry : beans.entrySet()) {
            String beanName = entry.getKey();
            Object obj = entry.getValue();
            Class
clazz = obj.getClass();
            Class
[] interfaces = clazz.getInterfaces();
            Method[] declaredMethods = clazz.getDeclaredMethods();
            ServiceObject so;
            RpcService service = clazz.getAnnotation(RpcService.class);
            if (interfaces.length != 1) {
                String value = service.value();
                declaredMethods = Class.forName(value).getDeclaredMethods();
                Object proxy = ProxyFactory.makeProxy(value, beanName, declaredMethods);
                so = new ServiceObject(value, Class.forName(value), proxy, service.group(), service.version());
            } else {
                Class
sup = interfaces[0];
                Object proxy = ProxyFactory.makeProxy(sup.getName(), beanName, declaredMethods);
                so = new ServiceObject(sup.getName(), sup, proxy, service.group(), service.version());
            }
            serverRegister.register(so);
        }
    }
}

Performance Test

A benchmark on a MacBook Pro M1 shows that the Javassist proxy is only marginally faster than reflection, with differences in the range of a few percent even at one million invocations.

Calls

Reflection 1

Reflection 2

Javassist 1

Javassist 2

10 000

1303 ms

1159 ms

1126 ms

1235 ms

100 000

6110 ms

6103 ms

6259 ms

5854 ms

1 000 000

54475 ms

51890 ms

52560 ms

52099 ms

Conclusion

The article covered RPC fundamentals, service registration/discovery with Zookeeper, client dynamic proxies, Netty‑based network handling, serialization/compression, and two server‑side proxy generation techniques (reflection and Javassist). Readers can explore the source code for deeper details or extend the framework with custom protocols, serializers, or load‑balancing strategies.

distributed systemsJavaRPCZookeeperNettyJavassist
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.