Algoritmos y Estructuras de Datos Compiladores e Intérpretes Herramientas Lenguaje de programación
!Prog C/C++
Linux Matemáticas
Mates Discretas
Programación Orientada a Objetos Redes y Computación Distribuida Sistemas Operativos

Llamadas a métodos remotos y Objetos Distribuidos

[date: 28-12-2024] [last modification: 20-01-2025]
[words: 3471] [reading time: 17min] [size: 51996 bytes]

En este artículo aumentamos el nivel de abstracción respecto al paso de mensajes. Veremos el paradigma de Remote Proceduce Call (RPC) y su equivalente orientado a objetos, los Objetos Distribuidos. Nos centraremos principalmente en Java RMI.

Remote Procedure Call (RPC)

En el paradigma de Llamada a Procedimientos Remotos:

  1. Un proceso A realiza una llamada a un procedimiento del proceso B, pasándole datos a través de sus argumentos.
  2. El proceso B ejecuta el procedimiento y le devuelve a A el resultado o le notifica de que ha terminado.

El funcionamiento es igual que si fuese un procedimiento normal, pero se necesita realizar algo más para que esto sea posible:

Funcionamiento de RPC

Funcionamiento de RPC

  1. En realidad, el proceso A realiza una llamada a un procedimiento proxy, que obtiene los parámetros de entrada y los encapsula en un mensaje.
  2. El proxy envía el mensaje al otro proceso y se queda bloqueado hasta recibir una respuesta.
  3. El proxy del proceso B recibe la petición, extrae los argumentos del mensaje y llama al método real.
  4. Cuando el método termina, vuelve a encapsular los resultados en un mensaje para enviarlo de vuelta al proceso A.
  5. Cuando el proxy del proceso A recupera el resultado, puede continuar con la ejecución del programa principal.

Estos proxies, ¿de dónde salen? Existen dos implementaciones principales:

Ambas proporcionan una herramienta llamada rpcgen, que genera código C de los proxies para transformar las llamadas locales a los procedimientos remotos.

El paradigma de Objetos Distribuidos

Nota
Si el lenguaje a utilizar es Orientado a Objetos, tiene más sentido usar este paradigma que solamente RPC.

El paradigma de Objetos Distribuidos se basa en el paradigma de llamadas a procedimientos remotos, pero en su versión orientada a objetos: se exponen los métodos de un objeto para que se ejecuten desde otras máquinas.

Los componentes básicos de un sistema basado en Objetos Distribuidos son los siguientes:

Interfaz remota
Una interfaz remota es conjunto de métodos que se exponen a ser llamados desde otras máquinas.
Objeto remoto
Un objeto remoto (=Objeto Servidor, =Objeto Distribuido, =Objeto Exportado) es aquel que implementa una interfaz remota. Por tanto tiene algunos métodos que pueden invocarse por un proceso remoto, posiblemente situado en otra máquina conectada a través de la red.

En contrapartida, un objeto local solo es accesible desde la misma máquina, típicamente el mismo proceso.

Referencia remota
Una referencia remota es un «manejador» de un objeto remoto, que se presenta como la interfaz remota para el programador, pero en realidad contiene la dirección IP, puerto y cualquier otra información necesaria para poder identificar la máquina a la que pertenece.
Registro de objetos
Un registro de objetos es un servicio de directorio público que registra los objetos servidores para proporcionar a los clientes una referencia remota.

Sistemas o protocolos de Objetos Distribuidos

Se trata de un paradigma bastante común, por lo que existen muchas implementaciones de herramientas:

Paso de mensajes vs Objetos distribuidos

El paradigma del paso de mensajes es un modelo natural que simula la comunicación entre humanos. Sin embargo, no cumple con el nivel de abstracción que algunas aplicaciones de red complejas requieren.

En cambio, los objetos distribuidos proporcionan:

Entonces, cuando se utiliza este paradigma:

Arquitectura

Arquitectura común de la implementación de un objeto remoto

Arquitectura común de la implementación de un objeto remoto

El funcionamiento a nivel lógico es bastante sencillo y funciona de forma totalmente transparente al programador:

  1. El objeto servidor se añade en un registro para que otros puedan obtener una referencia.
  2. El objeto cliente accede al registro y, a partir de una URL, obtiene la referencia del servidor.
  3. El objeto cliente realiza una llamada a los métodos de la interfaz del servidor, como si fuese un objeto más.
  4. El objeto servidor ejecuta el método para calcular la respuesta.
  5. El objeto cliente recibe el resultado del método como cualquier otro.

Sin embargo, el camino real es el siguiente:

  1. Tras la llamada al método de la interfaz, la implementación realmente la gestionará el proxy del cliente.
  2. A continuación, se obtendrán los parámetros del método y se encapsularán en un paquete con el formato adecuado.
  3. Finalmente, Network support enviará el paquete a través de la red y bloqueará el proceso hasta obtener el resultado.
  4. Cuando el paquete lo reciba el servidor, realizará el proceso inverso: extraerá los argumentos, se seleccionará el objeto remoto indicado, se le llamará al método correspondiente.
  5. Finalmente, una vez que la ejecución del método ha terminado, el proxy del servidor cede el valor de retorno a las siguientes capas para enviar el resultado de vuelta al cliente.

Esto también funciona cuando se lanzan excepciones.

En estos casos, es necesario que un compilador u otras herramientas generen el código necesario de los proxies para dar soporte al objeto remoto.

Java Remote Method Invocation (Java RMI)

Se trata de una implementación básica del paradigma de Objetos Distribuidos exclusivo para el lenguaje Java.

Arquitectura de Java RMI

En Java RMI, el camino lógico inicia con el servidor de objetos exportando un objeto remoto y luego lo registra en el directorio. El objeto remoto proporciona unos métodos, definidos en una interfaz remota, que pueden invocar los clientes.

Por tanto, cuando el cliente quiere interactuar con el servidor, utilizará la interfaz remota con una sintaxis idéntica a la de los métodos locales.

Arquitectura de Java RMI

Arquitectura de Java RMI

Sin embargo, como hemos visto antes, el camino real tiene algunos pasos más. El objeto cliente realiza una llamada a lo que Java RMI llama stub, un código que sirve de placeholder para la interfaz del objeto remoto que se encarga de:

  1. Serializar (marshal) los argumentos y enviar el mensaje a través de la red.
  2. Esperar una respuesta
  3. Deserializar (unmarshal) la respuesta y devolver el resultado.

A su vez, el servidor tiene un skeleton que:

  1. Inicializa el servidor
  2. Espera mensajes a través de la red
  3. Deserializa los argumentos y determina el método correspondiente
  4. Realiza la llamada
  5. Recoge el resultado y lo envía de vuelta

En cuanto a la estructura de las clases, el setup más básico es el siguiente:

Instancia
InterfazRemota
Implementación
ObjetoServidor
Remote
UnicastRemoteObject

Registro de objetos

El posible utilizar diferentes servicios de directorio, pero nosotros usaremos el más sencillo, rmiregistry, dado por el propio Java JDK.

Diagrama de 2 servidores de objetos y varios clientes

Diagrama de 2 servidores de objetos y varios clientes

Y cuando se registra un objeto:

Interfaz Remota

El primer paso es diseñar e implementar la interfaz remota:

Objeto servidor

Debe:

Se recomienda que las dos partes se realicen en clases separadas.

La clase que se exporte al registro debe heredar de UnicastRemoteObject.

El servidor de objetos que instancia y registra el objeto remoto típicamente tiene la siguiente forma:

import java.rmi.*;

public class Server {
    private static final int REGISTRY_PORT = 1099;
    private static final String REGISTRY_URL = "rmi://localhost:" + REGISTRY_PORT + "/object"

    public static void main(String[] args) {
        try {
            // Instanciar el objeto
            Impl exportedObj = new Impl();

            // Iniciar el registro si no existe
            startRegistry(REGISTRY_PORT);

            // Registrar el objeto
            Naming.rebind(REGISTRY_URL, exportedObj);

        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    private static void startRegistry(int port) throws RemoteException {
        try {
            // Lanza una excepción si no existe
            LocateRegistry.getRegistry(port).list();
        } catch (RemoteException e) {
            // En caso de que no exista, se crea
            LocateRegistry.createRegistry(port);
        }
    }
}

La clase Naming proporciona métodos para almacenar y obtener referencias en el registro a partir de una URL de la forma:

rmi://<hostname>:<port>/<reference name>

Alternativamente, se puede activar el registro de forma manual a través de un comando:

rmiregistry 1099
Importante

Nótese que si varios clientes ejecutan métodos a la vez, el servidor deberá responder a ambas peticiones simultáneamente. En Java RMI, cada petición se maneja en un nuevo hilo, por tanto, los métodos deben ser thread-safe.

Los hilos los gestiona el middleware automáticamente.

Nota

Mientras haya objetos exportados, el proceso servidor deberá escuchar por el puerto y esperar a que los clientes se conecten y soliciten el servicio.

Por ello, la JVM no terminará. Si se necesita que el servidor termine, en lugar de hacer System.exit(0), lo mejor es quitar los objetos con:

UnicastRemoteObject.unexportObject(exportedObj, true);

Cliente

Se trata de una clase normal, pero determinado momento, para hacer uso del servicio:

  1. Localizar el registro en el servidor
  2. Obtener una referencia a la interfaz remota
  3. Ejecutar alguno de sus métodos

En código sería algo así:

try {

    // Obtener la referencia a la interfaz remota
    InterfazRemota remote = (InterfazRemota) Naming.lookup(url);

    // Llamar al método remoto
    int result = remote.add(1, 2);

} catch (Exception e) {
    // No se encuentra en el registro, RemoteException...
    e.printStackTrace();
}

La URL no es necesariamente localhost, sino que puede utilizar los servicios de cualquier otra máquina.

Generación de código

El siguiente paso es la compilación del código. Lo que se ha escrito hasta ahora, puede compilarse como cualquier otro programa Java.

javac *.java

Sin embargo, todavía es necesario generar el stub y el skeleton.

rmic Impl  # Implementación de la interfaz remota

El comando anterior, generará los archivos compilados que nos faltaban: Impl_stub.class y Impl_skel.class (!= rpcgen, que genera código C sin compilar).

Por tanto, el resultado final debería ser lo siguiente.

ServidorCliente
InterfazRemota.classInterfazRemota.class
ServidorObjetos.classCliente.class
Impl.classImpl_stub.class
Impl_skel.class

Nótese que InterfazRemota.class es el único archivo que está repetido tanto en el cliente como en el servidor.

Nota

En versiones modernas de Java, no es necesario llamar a rmic: el código se genera de forma automática en tiempo de ejecución.

Entonces, solo sería necesario:

ServidorCliente
InterfazRemota.classInterfazRemota.class
ServidorObjetos.classCliente.class
Impl.class

Java RMI Avanzado

Java RMI aporta cierto número de características adicionales que, aunque no sigan totalmente el paradigma de los Objetos Distribuidos, ayudan bastante al desarrollo de aplicaciones con esta tecnología.

Stub Downloading

El código stub se genera a partir del servidor de objetos y se sitúa del lado del cliente. Como consecuencia, si se modifica el código del servidor, también será necesario modificar el cliente.

Sin embargo, Java RMI se ha diseñado para que los clientes puedan obtener dinámicamente el stub. Se puede colocar en un servidor web y que el cliente lo descargue cuando tenga una versión desactualizada.

Nueva estructura de archivos

Nueva estructura de archivos

  1. El cliente va a buscar una referencia al registro.
  2. El registro le devuelve dicha referencia.
  3. Si el stub no está presente o la versión no es adecuada, procede a descargarse el archivo desde el servidor HTTP.
  4. Ahora con el stub correcto, se pueden realizar las llamadas pertinentes.

Cuando se activa el servidor, es necesario especificar las siguientes opciones:

java \
    -Djava.rmi.serve.codebase="<URL>" \
    -Djava.rmi.server.hostname=$(hostname) \
    -Djava.security.policy="path/to/java.policy" \
    Server

Es necesario establecer ciertas medidas de seguridad a través de una instancia de un Java Security Manager y su configuración en un archivo java.policy.

Archivo java.policy

El gestor de seguridad de RMI no permite un acceso a la red, por lo que es necesario configurar los permisos en un fichero java.policy.

grant {
    // Permits socket access to all common TCP ports, including the default
    // RMI registry port (1099) – need for both the client and the server.
    permission java.net.SocketPermission "*:1024-65535", "connect,accept,resolve";

    // Permits socket access to port 80, the default HTTP port – needed
    // by client to contact an HTTP server for stub downloading
    permission java.net.SocketPermission "*:80", "connect";
};

En bloque grant lista las acciones permitidas y prohíbe todo lo demás. También es posible usar un bloque deny que prohíbe las acciones listadas y acepta el resto. Nunca se deberían usar ambos bloques conjuntamente.

Este archivo se coloca en donde están los .class del servidor, y desde el cliente también se debe especificar:

java -Djava.security.policy=java.policy Client

Security Manager

Java RMI involucra el acceso a una máquina remota y posiblemente la descarga de código, por lo que es importante que proteger ambos sistemas ante accesos no permitido.

Un Security Manager es una clase que se puede instanciar para limitar los privilegios de acceso en función de un archivo java.policy. RMISecurityManager es una posible implementación que aporta Java RMI, pero también es posible escribir nuestro propio gestor de seguridad.

try {

    System.setSecurityManager(new RMISecurityManager());

} catch (Exception e) {
    e.printStackTrace();
}

Esto debería ser lo primero que se ejecute en el programa. Sin embargo, en la versiones actuales de Java ya se hace automáticamente y el Security Manager por defecto nos será suficiente.

Client Callback

En el modelo Cliente-Servidor, el servidor es pasivo dado que espera a la llegada de peticiones y les proporciona respuestas; mientras que la comunicación la inicia el cliente.

Algunas aplicaciones necesitan que sea el servidor que mande un mensaje primero en ciertos eventos.

Formas de implementar la notificación de un evento

Formas de implementar la notificación de un evento

Existen dos estrategias:

Implementación de un Callback

La idea de la implementación es que el cliente proporcione un objeto con métodos remotos a los que el servidor llamará en caso de que el evento suceda. Esto tiene la ventaja de que se evita el sondeo.

  1. El cliente de un callback especifica al método que quiere que se le llame creando una interfaz remota.
  2. Luego, el cliente se registra en el servidor de objetos enviándole la referencia a su interfaz.
  3. El servidor la almacena en una estructura dinámica
  4. Cuando el servidor recibe el evento, recorre la estructura y ejecuta el método de cada interfaz de cada cliente subscrito.

Recuerda que dicha estructura dinámica debe ser synchronized, dado que cada petición se manejará en un hilo aparte y podrían darse carreras críticas al insertar y/o iterar a la vez.

La nueva estructura de archivos será:

ServidorClienteDescripción
IServer.classIServer.classInterfaz remota del servidor
IClientCallback.classIClientCallback.classInterfaz remota del cliente que contiene los callbacks
Server.classClient.classLógica del cliente y del servidor
ServerImpl.classClientCallbackImpl.classImplementación de sus respectivas interfaces
ServerImpl_skel.classServerImpl_stub.classEl servidor tiene el skeleton de su interfaz y el cliente el stub
ClientCallbackImpl_stub.classClientCallbackImpl_skel.classEl servidor tiene el stub de la interfaz del cliente y este tiene el skeleton
java.policyjava.policySe necesita especificar los permisos de seguridad
-subscribers 0..*
Instancia
«interface»
IServer
+subscribe(client: IClientCallback)
+unsubscribe(client: IClientCallback)
«interface»
IClientCallback
+notification(data: Object)
Server
-startRegistry()
Remote
UnicastRemoteObject
ServerImpl
ClientCallbackImpl
Client

Serialización de objetos

A veces es necesario pasar como argumento de un método remoto un tipo de datos complejo: objetos personalizados.

public class Book {
    private final String title;
    private final ArrayList<String> authors;
    private final int pageNumber;

    // ...
}

public interface IRemote {
    void addToStore(Book book) throws RemoteException;
    Book getBookByTitle(String title) throws RemoteException;
    // ...
}

En este ejemplo, la interfaz remota hace uso de la clase Book contiene varios atributos, que a su vez también pueden ser otros objetos. Tenga en cuenta que el para realizar la llamada al método, el stub debe encapsular estos argumentos en un mensaje para poder enviarlo a través de la red.

Esto es posible en Java gracias al concepto de serialización: consiste en transformar el estado de un objeto a un byte stream. El proceso de deserialización es justo lo contrario: se toma ese byte stream y se vuelve a convertir a un objeto. La propia JVM nos garantiza que la reconstrucción será correcta y funcionará sin problemas.

Para hacer una clase serializable, simplemente hay que implementar la interfaz java.io.Serializable, que no require de ningún método: solo sirve a modo de marcador.

Sin embargo, si se desea un mayor control para realizar algunas acciones especiales, se pueden sobreescribir los siguientes métodos:

private void writeObject(java.io.ObjectOutputStream out)
    throws IOException;

private void readObject(java.io.ObjectInputStream in)
    throws IOException, ClassNotFoundException;

private void readObjectNoData()
    throws ObjectStreamException;

Se utilizan las clases ObjectOutputStream para serializar y ObjectInputStream para deserializar:

public class Book implements Serializable { ... }

// ...

Book book = new Book();
book.setTitle("El Quijote");
book.addAuthor("Miguel de Cervantes");
book.setPageNumber(1069);

// Serializar
FileOutputStream fos = new FileOutputStream("file.txt");
ObjectOutputStream oos = new ObjectOutputStream(fos);
oos.writeObject(book);

// Deserializar
FileInputStream fis = new FileInputStream("file.txt");
ObjectInputStream ois = new ObjectInputStream(fis);
Book fromFile = (Book) ois.readObject();

oos.close();
ois.close();

Consideraciones:

Anterior: Hilos en Java Volver a Redes y Computación Distribuida Siguiente: Arquitecturas Orientadas a Servicios