Llamadas a procedimiento remoto (RPC): Un servidor horario

RPC (Remote Procedure Call) es una tecnología, tradicionalmente empleada en ambiente UNIX, que permite el desarrollo de sistemas de procesamiento distribuido basados en el paradigma procedimental.

Una llamada a un procedimiento (función o subrutina) es un método bien conocido para transferir el control de una parte del programa a otra, con un retorno del control a la primera. Asociado con la llamada a un procedimiento están el pase de argumentos y el retorno de uno o varios resultados. Cuando el código que invoca a un procedimiento y dicho procedimiento están en un mismo proceso en un computador dado, se dice que ha ocurrido una llamada a un procedimiento local.

Por el contrario, en una llamada a un procedimiento remoto (RPC, Remote Proceure Call) el sistema local invoca, a través de la red, a una función alojada en otro sistema. Lo que se pretende es hacerle parecer al programador que está ocurriendo una simple llamada local. Este es el flujo de datos en el modelo RPC:

rpc

Para este ejemplo se va a asumir que el entorno de trabajo ahora mismo es linux con gcc y rpcgen instalado.Los pasos para hacer que todo funcione son 4, y se describen a continuación:

  1. La definición del XDR, usando ese lenguaje para especificar qué funciones quiere “publicar” el servidor, y cómo se han de llamar por parte del cliente.
  2. La implementación de los métodos remotos, para tener un código que ejecutar cuando nos hagan una llamada remota.
  3. La implementación de un cliente que invoque los métodos del servidor.
  4. La compilación de todo esto y una ejecución de prueba.

XDR

XDR (External Data Representation) es un estándar para la descripción y codificación de datos que utiliza un lenguaje cuya sintaxis es similar a la del lenguaje de programación C. Es empleado principalmente en la transferencia de información entre diferentes arquitecturas computacionales y se ubica dentro de la capa de presentación del modelo ISO. Involucra un mecanismo de tipificado implícito (Implicit Typing), es decir que sólo viaja el valor de la variable por la red.

Es importante resaltar que XDR no es un lenguaje de programación, sino una especificación que incluye un lenguaje de descripción de datos el cual es extendido por ONC-RPC para la definición de procedimientos remotos. Los tipos de datos de XDR presentan cierta similitud con los tipos de datos de C. Algunos de estos son:

  • int
  • unsigned int
  • enum
  • bool
  • hyper
  • unsigned hyper
  • float
  • double
  • quadruple (punto flotante de cuádruple precisión)
  • opaque (de longitud fija o variable)
  • string
  • array (de longitud fija o variable)
  • struct
  • union (uniones discriminadas)
  • void
  • constant

El lenguaje especificado por RPC es idéntico al lenguaje de XDR, excepto que agrega la definición de «programa», cuya gramática es mostrada a continuación:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
program-def:
     "program" identifier "{"
          version-def
          version-def *
     "}" "=" constant ";"
 
version-def:
     "version" identifier "{"
          procedure-def
          procedure-def *
     "}" "=" constant ";"
 
procedure-def:
     type-specifier identifier
          "(" type-specifier("," type-specifier )* ")" "=" constant ";"

Lo primero es definir qué métodos está ofreciendo el servidor así como los parámetros que admite y el tipo de retorno. Esto lo hacemos en un fichero .x como el siguiente:

1
2
3
4
5
program HORA_PROG {
version HORA_VERS {
string HORA(void) = 1; /*devuelve una cadena*/
} = 1;
} = 0x31230000;

Las partes importantes del fichero son la definición de la estructura Operandos (en el anterior ejemplo no la hay) y el program { version { con las definiciones de las funciones. La definición de Operandos permite definir estructuras prácticamente arbitrarias como tipos para parámetros y retornos. Un ejemplo de fichero XDR con definición de operandos sería el siguiente, aunque no se usa en esta ocasión:

1
2
3
4
5
6
7
8
9
struct hora {
char arg[90];
};
 
program HORA_PROG {
version HORA_VERS {
hora HORA(void) = 1; /*devuelve una cadena*/
} = 1;
} = 0x31230000;

Los program son una manera de agrupar primitivas (he usado el convenio de notación de Sun para nombrarlo). El valor 0×31230000 es un identificador único para el servicio, de manera que que pueda ser localizado por el cliente y el servidor.

Dentro de program, tenemos una version que es la que contiene los métodos. Esto es así porque RPC admite varias versiones de los métodos para un mismo programa (por motivos de compatibilidad hacia atrás, entre otros). En este caso tenemos una única (la 1).

Las definiciones de las funciones son prácticamente iguales a las que haríamos en C, con una consideración adicional (para este caso): a la hora de devolver una cadena no devolvemos char*, sino string. El convenio de Sun que impone poner aquí los nombres de las funciones en mayúscula. Si devolvemos un operando definido, simplemente se escribe antes del nombre de la función (hora). Por tanto, una vez terminada esta parte, he definido un servicio con una primitiva que devuelve la hora.

Implementación del servidor

El siguiente paso es la implementación del servidor; necesitamos crear el código que se ejecutará efectivamente en la máquina remota. Lo bueno de este sistema es que podemos hacer esencialmente lo que queramos: consultar bases de datos, reaizar cálculos, invocar otros servicios remotos, etc. De este modo, los procedimientos RPC pueden ser usados como rutinas de computación, de administración/gestion u obtención de datos.

En este caso, por tener un ejemplo concreto, la implementación en sí de esas funciones es trivial. Lo que interesa ahora es la sintaxis (su significado), la manera en que se nos ofrecen los datos y cómo devolver nuestros resultados. El siguiente código utiliza la primera definición de XDR (sin operandos definidos)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <rpc/rpc.h>
#include "time.h"
 
char **hora_1_svc(struct svc_req *rqstp) {
 
	static long timeval; // debe ser una variable estática
	static char *ptr; // debe ser una variable estática
 
	timeval = time((long *) 0);
 
	ptr = ctime(&timeval);
 
	return(&ptr);
}
 
int hora_prog_1_freeresult(SVCXPRT *transp, xdrproc_t xdr_result, caddr_t result) {
	xdr_free(xdr_result, result);
	return (1);
}

El tipo de retorno es un puntero al tipo que habíamos definido en el XDR (notar que string=char*). Además, a la hora de devolver, devolvemos referencias a datos estáticos (ya que si devolviésemos referencias a la pila éstas no serían válidas al salir de la función). Este comportamiento es configurable en las opciones de rpcgen.

El tipo de los argumentos pueden ser también punteros a los tipos originales (Operandos) (no hay en el código de arriba), y además se añade un nuevo parámetro que es siempre struct svc_req*. El motivo de lo primero tiene que ver con cómo nos llegan los datos remotos (y con cómo los podemos modificar). El segundo parámetro contiene información sobre la petición que nos han hecho y el cliente que la ha realizado.

Con respecto al nombre de las funciones, estas se corresponden con el nombre descrito en el fichero XDR, añadiendo _i_svc, donde i es el número de versión que estamos implementando. Esto es así porque podemos tener diferentes versiones de una misma función, y con este sistema evitamos colisiones en los nombres.

Implementación del cliente

Para completar la parte de implementación, tenemos que escribir un código de cliente que haga uso de los procedimientos remotos. Éste se escribe como un programa local normal, salvo por que hacemos llamadas a ciertas funciones que son las que se encargan de hacer el trabajo de RPC.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <rpc/rpc.h>
#include <stdio.h>
#include "hora.h"
 
int main(int argc, char *argv[]){
	CLIENT *cl;
	char *nombre_servidor;
	char **hora;
 
	if(argc < 2){
		perror("Uso: ./client nombreservidor");
	}
 
	nombre_servidor = argv[1];
 
	cl = clnt_create(nombre_servidor, HORA_PROG, HORA_VERS, "tcp");
 
	if((hora = hora_1(NULL, cl)) == NULL) {
		clnt_sperror(cl, argv[1]);
	}
 
	printf("\nLa hora del servidor %s es %s", nombre_servidor, *hora);
 
	clnt_destroy(cl);
 
	exit(0);
}

El #include se hace sobre un fichero que todavía no hemos creado, hora.h. Éste se genera automáticamente con el comando rpcgen, y suele ser el nombre del fichero .x intercambiando la extensión.

La instanciación se hace con la sentencia

1
cl = clnt_create(nombre_servidor, HORA_PROG, HORA_VERS, "tcp");

Esta llamada devuelve un stub, que es una estructura con toda la información que necesita nuestra biblioteca de rpc. Los parámetros que admiten son el nombre de host del servidor (localhost si hacéis las pruebas en modo local, o una ip, y dos constantes que básicamente tienen que ser el nombre que pusimos después de program y después de version en el fichero .x del principio. El último parámetro indica el protocolo de transporte que queremos usar para nuestra petición.

Las llamadas en sí son muy sencillas, y utilizan exactamente el mismo interfaz que implementamos en el servidor. El segundo parámetro es el stub del cliente. Como esta función no tiene parámetros, se introduce NULL.

Compilación

rpcgen es un programa que toma como argumentos un fichero .x y genera el código de los stubs.

$ rpcgen hora.x

Esta línea ha debido generar los siguientes ficheros: hora.h, hora_svc.c, hora_clnt.c y hora_xdr.c (en el caso de haber definido una estructura de operandos en el XDR).

El primero, hora.h, están las definiciones de tipos, constantes y funciones generales comunes tanto al servidor como al cliente. Es por eso por lo que hemos hecho el include en nuestras implementaciones.

Los ficheros hora_clnt.c y hora_svc.c contienen respectivamente el código los stubs de cliente y de servidor. Dichos stubs son los que se encargan efectivamente de que la comunicación sea transparente: transforman los argumentos y retornos que usamos en las funciones publicadas como servicios y también las decodifican en el otro extremo.

hora_xdr.c es el módulo que contiene información sobre los tipos que ha definido el usuario. Como sólo hemos utilizado tipos básicos, no ha hecho falta.

Lo único que falta es compilar el programa cliente y el servidor como si de cualquier otro programa se tratase. Para el cliente, como hemos hecho una implementación en C, usamos gcc como compilador:

$ gcc client.c hora_clnt.c -o client

Para el servidor:

$ gcc server.c hora_svc.c -o server

Ejecución

Si todo lo anterior ha ido bien, basta con ejecutar el servidor:

$ ./server

y ejecutar el cliente:

$ ./client localhost
La hora del servidor localhost es Thu Jul 21 17:31:56 2011

 

Nota

En recientes versiones de Ubuntu, tras ejecutar el servidor se obtiene un mensaje como este:

Cannot register service: RPC: Unable to receive; errno = Connection refused
unable to register (HORA_PROG, HORA_VERS, udp).

Esto es porque es necesario instalar el paquete portmap, lo cual puede realizarse con el siguiente comando en consola:

sudo apt-get install portmap

Espero que el tema os haya gustado. Dejo abajo el enlace al código del ejemplo.

rpc_time.tar.gz
code