De programación orientada a objetos a la invocación remota de métodos#
La programación orientada a objetos es un paradigma de programación muy extendido. En éste paradigma se hace hincapié en la definición de clases, que indica qué atributos tiene una determinada entidad de nuestro programa, y sus métodos, que indican la interfaz a través de la cual podemos interactuar con los objetos o instancias de esa clase.
En sistemas distribuidos, la contraparte de la orientación a objetos es lo que conocemos como invocación de métodos remotos, o RMI, el acrónimo en inglés de Remote Method Invocation.
Ejemplo de orientación a objetos#
Imaginemos que tenemos que hacer un software que sea capaz de representar un mensaje cualquiera en algún medio. Para ello, podríamos definir una interfaz que indique cuál es el nombre del método, qué parámetros aceptará y qué valor devolverá.
Para nuestro ejemplo, hagamos una interfaz muy simple. Dado que en Python no existe el concepto de interfaz diferenciado del concepto de clase, definiremos una clase la cuál no tenga implementado ningún comportamiento específico:
class Printer:
def write(self, message):
pass
A partir de aquí, cualquier clase que tenga un método write
que acepte
un argumento tendrá la misma interfaz que nuestra clase Printer
. De ese
modo, podríamos implementar diferentes clases con el comportamiento que
queramos.
class ConsolePrinter:
def write(self, message):
print(message, flush=True)
El ejemplo anterior muestra una implementación de la interfaz la cuál
imprimirá el mensaje en la salida estándar del proceso, sin hacer ningún
tipo de buffering (flush=True
indica precisamente eso).
class FilePrinter:
def __init__(self, path):
self._filepath = path
self._file_handler = open(path, "w")
def write(self, message):
self._file_handler.write(message)
def close(self):
self._file_handler.close()
Esta segunda implementación, además del método write
, tiene un método de
inicialización, que acepta la ruta a un fichero, y un método close
que
provocaría la termianción de la escritura en un fichero en el disco duro.
A pesar de tener esos métodos añadidos, la clase FilePrinter
sigue
cumpliendo con la misma interfaz al mantener el método write
y un
argumento.
De este modo, un usuario de nuestras clases podría utilizar, de manera casi indistinta, estas dos clases, modificando sólo la creación del objeto.
Como se ve en la imagen anterior, una vez que tenemos la referencia al
objeto, simplemente podemos realizar invocaciones sobre sus métodos, en
este caso, sobre write
.
Extendiendo a RMI#
Como se ha dicho con anterioridad, RMI es la respuesta para llevar la programación orientada a objetos al mundo de los sistemas distribuidos. La idea es poder realizar una invocación a un objeto local en la memoria de nuestro programa de manera que ésta sea traducida, en el otro extremo de una red de computadores, en una llamada local al objeto real que contiene el comportamiento del método.
Desde el punto de vista de los programadores, la invocación es vista exactamente de la misma manera a una invocación normal en orientación a objetos, pero sin embargo, detrás de las escenas ocurren múltiples cosas de manera transparente al programador que provocan que lo que, desde el punto de vista del diseño, es una invocación a un objeto normal y corriente, se traduzca en una invocación a través de la red.
Para que esta invocacion ocurra, debe existir una serie de librerías que se encarguen de diferentes aspectos necesarios:
Una manera de especificar la API entre el cliente del objeto y el objeto propiamente dicho, normalmente a través de la definición de una interfaz.
Una librería en la memoria del cliente que sea capaz de crear un objeto con la misma interfaz que el objeto remoto y que se encargue de realizar toda la conversión de parámetros entre la llamada local y la llamada a través de la red.
Un proceso de marshalling, que tome los parámetros que deben ser pasados al método remoto y convertirlos en bytes que puedan viajar a través de la red con un formato bien conocido.
Un proceso de unmarshalling, que reciba una serie de bytes de la red y sea capaz de transformarlos en la invocación correcta, al objeto correcto y con los parámetros correctos.
La librería que, una vez realizado el unmarshalling, realiza la invocación sobre el objeto destinatario de la misma y recoge el valor de retorno, si lo hubiera.
Todos estos pasos ocurren sin que el usuario de la librería tenga que ser consciente de ello.
Un ejemplo de RMI: ZeroC Ice#
ZeroC Ice, o en adelante, ICE, es un middleware de comunicación orietado a objetos, multiplataforma y multilenguaje, que realiza todo lo descrito en el apartado anterior.
ICE es software libre, por lo que está portado a infinidad de plataformas y sistemas operativos. Además, de manera nativa soporta múltiples lenguajes: C++, Java, Python, Ruby, JavaScript…
Un poco de nomenclatura#
En ICE se utilizan algunos nombres que es muy conveniente conocer para poder hablar con propiedad:
Servidor: cualquier programa en ejecución en un entorno distribuido con ICE.
Sirviente: objeto inicializado en la memoria de un servidor ICE que es capaz de recibir invocaciones remotas.
Adaptador de objetos: utilidad proporcionada por ICE para manejar la red: se encarga de definir qué protocolos, direcciones, puertos… se van a utilizar en un determinado servidor ICE. Los objetos que se añadan al adaptador de objetos son los que pueden actuar como sirvientes.
Communicator: es el punto de entrada principal de la librería ICE. Proporciona toda una serie de utilidades para crear todo lo necesario para hacer funcionar un servidor ICE.
Proxy: es un objeto “vacío” que se crea en un servidor ICE que representa a un objeto remoto y que, cuando recibe una invocación a uno de sus métodos, es capaz de realizar los pasos necesarios para que el sirviente al que representa reciba dicha invocación.
Interfaz: es uno de los conceptos principales en sistemas distribuidos. Representa el contrato entre un sirviente y sus clientes. Dado que ICE soporta múltiples lenguajes, las interfaces deben definirse en un IDL (Interface Definition Language) llamado Slice.
Translators: son programas proporcionados con ICE que convierten un fichero Slice con unas interfaces definidas en código de un lenguaje en concreto, de modo que pueda ser utilizado como librería por programas en dicho lenguaje.
Definiendo la interfaz#
Como se menciona anteriormente, en ICE se utiliza un IDL particular llamado Slice. La sintaxis está en un punto intermedio entre C++ y Java. Tiene soporte para bastantes tipos básicos y permite la definición de estructuras básicas como listas y mapas, así como la definición de nuevos tipos compuestos.
module Example {
interface Printer {
void write(string message);
};
};
En este caso, se define una interfaz con un único método (write
),
que aceptará una cadena como argumento. Este método no devolverá ningún valor.
Las interfaces escritas usando Slice siempre deben estar definidas dentro de un módulo,
y estos a su vez pueden estar anidados.
En nuestro caso únicamente definimos el módulo Example
para alojar nuestra interfaz.
Traduciendo la interfaz a código Python#
Evidentemente el código Slice no es compatible con ningún lenguaje de programación en concreto, por lo que debemos traducirlo al lenguaje de implementación que vayamos a utilizar. En éste caso, utilizaremos Python, por lo que ICE nos brinda dos posibilidades diferentes para traducir desde la interfaz:
#. Usando el translator slice2py
, proporcionado por el propio middleware,
y que traduce la interfaz desde Slice a un módulo Python listo para ser importado desde nuestro programa.
#. Usando el método Ice.loadSlice
, proporcionado por la propia librería Python para ICE, y que
traduce el Slice a un módulo Python de manera dinámica.
La diferencia principal entre ambos es que mientras que el translator genera un directorio con el código del módulo que podemos inspeccionar, el segundo lo carga de forma dinámica en memoria. Por regla general y facilidad, optaremos por usar el segundo.
#!/usr/bin/env python3
import sys
import Ice
Ice.loadSlice("Printer.ice")
try:
import Example
except ImportError:
print("Error importing Example from Printer.ice file")
sys.exit(1)
print("Everything went well. Congratulations.")
sys.exit(0)
Implementando la interfaz en Python#
Cualquiera de las clases que hemos visto con anterioridad en el ejemplo de orientación a objetos
cumple con la interfaz definida en el Slice.
Pero para que puedan ser utilizadas como una implementación de la interfaz definida en el Slice,
debemos hacer algunos cambios. Partamos de la base del código del ConsolePrinter
:
class ConsolePrinter:
def write(self, message):
print(message, flush=True)
Lo primero de todo, la clase debe heredar de la interfaz para que ICE sea capaz de reconocerla como una implementación válida:
class ConsolePrinter(Example.Printer):
def write(self, message):
print(message, flush=True)
Como se puede observar, el traductor de Slice a Python traduce los módulos como módulos Python y las interfaces, como classes Python. Sin embargo, el código anterior nos arrojaría algunos errores a la hora de recibir invocaciones remotas.
Debido a que puede ser necesario extraer información relativa a la propia invocación en un momento dado, el middleware nos pedirá que añadamos a cada uno de los métodos un argumento adicional. Dicho argumento contendrá información relativa al entorno de ejecución. Por ahora, no ahondaremos más en ello.
class ConsolePrinter(Example.Printer):
def write(self, message, current=None):
print(message, flush=True)
El argumento current
contendrá la ya mencionada información adicional en forma de un objeto de tipo Ice.Current
.
Poniéndole un valor por defecto, esta misma implementación nos puede servir tanto para atender invocaciones locales
como para atender invocaciones remotas.