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<Service> getServices(String name) {
    String servicePath = "rpc/" + name + "/service";
    List<String> children = zkClient.getChildren(servicePath);
    List<Service> 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> T getProxy(Class<T> 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<Service> 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<String, Object> 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<String, Object> beans = context.getBeansWithAnnotation(RpcService.class);
        for (Map.Entry<String, Object> 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.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

JavaRPCZooKeeperNettyJavassist
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

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.