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).
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.
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.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.