Architecture Globale d'un Système RPC
L'architecture fondamentale d'un système d'appel de procédure à distance repose sur deux piliers : l'enregistrement des services par le fournisseur auprès d'un annuaire, et la découverte de ces mêmes services par le consommateur. Une implémentation avancée enrichit cette base en intégrant des mécanismes cruciaux tels que la répartition de charge, la surveillance des interfaces, la sérialisation des données et la compression.
Enregistrement des Services
L'enregistrement automatique des services s'appuie sur le cycle de vie des beans Spring, spécifiquement via l'interface BeanPostProcessor. Bien que l'intervention lors de la phase postProcessBeforeInitialization soit fonctionnelle ici (puisqu'il n'y a pas de dépendance forte envers les méthodes d'initialisation comme @PostConstruct), la bonne pratique recommande d'effectuer l'enregistrement dans postProcessAfterInitialization. À ce stade, le bean est entièrement initialisé, éliminant ainsi tout risque de NullPointerException.
@Override
public Object processBeforeInit(Object targetBean, String identifier) throws BeansException {
if (targetBean.getClass().isAnnotationPresent(RemoteEndpoint.class)) {
RemoteEndpoint endpointMeta = targetBean.getClass().getAnnotation(RemoteEndpoint.class);
ServiceRegistrationDetail detail = ServiceRegistrationDetail.construct()
.setGroup(endpointMeta.group())
.setVersion(endpointMeta.version())
.setImplementation(targetBean);
registry.registerService(detail);
}
return targetBean;
}
Découverte des Services
Lorsqu'un client sollicite un service, il récupère d'abord la liste des nœuds disponibles depuis l'annuaire de routage, puis sélectionne une cible spécifique via un algorithme d'équilibrage de charge.
public class ZookeeperDirectoryResolver implements DirectoryResolver {
private final BalancerStrategy balancer;
public ZookeeperDirectoryResolver() {
this.balancer = ExtensionFactory.getLoader(BalancerStrategy.class).getExtension(BalancerType.ROUND_ROBIN.getLabel());
}
@Override
public InetSocketAddress resolveEndpoint(RemoteInvocationRequest request) {
String serviceName = request.getServiceIdentifier();
CuratorFramework zkConn = ZookeeperHelper.createConnection();
List<String> availableHosts = ZookeeperHelper.fetchNodes(zkConn, serviceName);
if (availableHosts == null || availableHosts.isEmpty()) {
throw new RemoteCallException(ErrorCode.SERVICE_UNAVAILABLE, serviceName);
}
String selectedHost = balancer.chooseTarget(availableHosts, request);
String[] addressParts = selectedHost.split(":");
return new InetSocketAddress(addressParts[0], Integer.parseInt(addressParts[1]));
}
}
Mécanisme SPI (Service Provider Interface)
Il est essentiel de distinguer l'API du SPI. Avec une API, le fournisseur ecnapsule la logique métier et expose une interface prête à l'emploi. Le SPI inverse cette dynamique : le fournisseur ne spécifie que le contrat (l'interface), laissant la liberté à des tiers ou au consommateur de fournir l'implémentation concrète. Cette approche offre une flexibilité considérable. Par exemple, le contrat de sérialisation peut être défini comme un SPI, avec des implémentations distinctes telles que Kryo ou Hessian.
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface ProviderContract {}
@ProviderContract
public interface DataMarshaller {
byte[] marshall(Object entity);
<T> T unmarshall(byte[] rawData, Class<T> targetType);
}
Proxy Dynamique
L'utilisation des proxies dynamiques JDK permet d'abstraire la complexité des communications réseau, offrant ainsi l'illusion d'un appel local. L'injection du bean proxyifié doit impérativement s'effectuer durant la phase postProcessAfterInitialization, garantissant que le bean originel est entièrement construit et initialisé avant substitution.
public Object processAfterInit(Object targetBean, String identifier) throws BeansException {
Class<?> beanType = targetBean.getClass();
for (Field attribute : beanType.getDeclaredFields()) {
RemoteInject injectionMeta = attribute.getAnnotation(RemoteInject.class);
if (injectionMeta != null) {
ServiceMetadata meta = ServiceMetadata.construct()
.setGroup(injectionMeta.group())
.setVersion(injectionMeta.version());
NetworkProxyHandler proxyCreator = new NetworkProxyHandler(rpcClient, meta);
Object proxyInstance = proxyCreator.generateProxy(attribute.getType());
attribute.setAccessible(true);
try {
attribute.set(targetBean, proxyInstance);
} catch (IllegalAccessException ex) {
log.error("Erreur d'injection du proxy", ex);
}
}
}
return targetBean;
}
// Logique d'exécution du proxy
public Object execute(Object proxyObj, Method targetMethod, Object[] arguments) {
RemoteInvocationRequest request = RemoteInvocationRequest.construct()
.setMethod(targetMethod.getName())
.setArgs(arguments)
.setInterfaceName(targetMethod.getDeclaringClass().getName())
.setArgTypes(targetMethod.getParameterTypes())
.setTraceId(UUID.randomUUID().toString())
.setGroup(meta.getGroup())
.setVersion(meta.getVersion());
RemoteInvocationResponse response = (RemoteInvocationResponse) transporter.dispatch(request);
this.validateResponse(response, request);
return response.getPayload();
}
Pattern Singleton
Les implémentations de SPI, telles que les compresseurs ou les sérialiseurs, sont des classes sans état (stateless) et donc intrinsèquement thread-safe. Le pattern Singleton s'avère parfaitement adapté pour les instancier. Bien qu'une méthode synchronisée comme fetchInstance() puisse garantir l'unicité, elle engendre une pénalité de performance à chaque appel en raison de l'acquisition et de la libération systématique du verrou. Le verrouillage à double vérification (Double-Checked Locking) optimise ce processus en ne sollicitant le verrou que si l'instence n'a pas encore été créée.
public class UniqueInstance {
private static volatile UniqueInstance instance;
private UniqueInstance() {}
public static UniqueInstance fetchInstance() {
if (instance == null) {
synchronized (UniqueInstance.class) {
if (instance == null) {
instance = new UniqueInstance();
}
}
}
return instance;
}
}