Sistema de ficheros distribuido en Java (JavaAFS)

Se trata de un proyecto práctico de desarrollo en grupos de 2 personas cuyo plazo de entrega termina el 24 de mayo.

Objetivo de la práctica

La práctica consiste en desarrollar un sistema de ficheros distribuido en un entorno Java que permita que el alumno llegue a conocer de forma práctica el tipo de técnicas que se usan en estos sistemas, tal como se estudió en la parte teórica de la asignatura.

Con respecto al sistema que se va a desarrollar, se trata de un SFD basado en el modelo de carga/descarga, con una semántica de sesión, con caché en los clientes almacenada en disco e invalidación iniciada por el servidor. Es, por tanto, un sistema con unas características similares al AFS estudiado en la parte teórica de la asignatura Se recomienda, por tanto, que el alumno revise el sistema de ficheros AFS en la documentación de la asignatura antes de afrontar la práctica.

Evidentemente, dadas las limitaciones de este trabajo, el sistema a desarrollar presenta enormes simplificaciones con respecto a las funcionalidades presentes en un sistema AFS real, entre ellas:

En cuanto a la tecnología de comunicación usada en la práctica, se ha elegido Java RMI (si no está familiarizado con el uso de esta tecnología puede consultar esta guía sobre la programación en Java RMI).

Para completar esta sección introductoria, se incluyen, a continuación, dos fragmentos de código que permiten apreciar las diferencias entre el API de acceso de lectura/escritura a un fichero local usando la clase RandomAccessFile, en la que se inspira el API planteado, y el correspondiente al acceso remoto utilizando el servicio que se pretende desarrollar:


// acceso local
try {
    RandomAccessFile f = new RandomAccessFile("fich", "rw");
    byte[] b = new byte[1024];
    leido = f.read(b);
    f.seek(0);
    f.write(b);
    f.setLength(512);
    f.close();
}
catch (FileNotFoundException e) {
    e.printStackTrace();
}
catch (IOException e) {
    e.printStackTrace();
}

// acceso remoto (en negrilla aparecen los cambios respecto a un acceso local)
try {
    // iniciación de la parte cliente
    Venus venus = new Venus();

    // apertura de un fichero;
    // el modo de apertura solo puede ser "r" o "rw"
    // y tiene el mismo comportamiento que en RandomAccessFile.
    // Si el fichero no está en la caché, se descarga del servidor
    // y se almacena en el directorio Cache.
    // Finalmente, se abre el fichero en Cache como un RandomAccessFile.
    VenusFile f = new VenusFile(venus, "fich", "rw"); 

    // resto de las operaciones igual que en local;
    // de hecho, se realizan sobre la copia local
    byte[] b = new byte[1024];
    leido = f.read(b);
    f.seek(0);
    f.write(b);
    f.setLength(512);

    // si el fichero se ha modificado, se vuelca al servidor
    f.close();
}
catch (FileNotFoundException e) {
    e.printStackTrace();
}
catch (IOException e) {
    e.printStackTrace();
}
catch (Exception e) {
    e.printStackTrace();
}

Para afrontar el trabajo de manera progresiva, se propone un desarrollo incremental en varias fases. Por cada fase, se indicará qué funcionalidad desarrollar como parte de la misma y qué pruebas concretas realizar para verificar el comportamiento correcto del código desarrollado.

  1. Acceso de lectura a un fichero remoto sin tener en cuenta aspectos de coherencia (valoración de 3 puntos).
  2. Acceso de escritura a un fichero remoto sin tener en cuenta aspectos de coherencia (valoración de 3 puntos).
  3. Incorporación de un modelo de coherencia asumiendo que solo puede haber una sesión de escritura, con múltiples de lectura, en cada momento (valoración de 4 puntos).

Arquitectura del software del sistema

Antes de pasar a presentar cada una de las fases, se especifica en esta sección qué distintos componentes hay en este sistema.

En primer lugar, hay que resaltar que la práctica está diseñada para no permitir la definición de nuevas clases (a no ser que se trate de clases anidadas), estando todas ya presentes, aunque mayoritariamente vacías, en el material de apoyo.

No todas ellas serán necesarias en las primeras fases de la práctica, como se irá explicando en esta misma sección y a lo largo del documento. Por tanto, no es necesario que entienda el objetivo de cada clase en este punto, ya que se irá descubriendo a lo largo de las sucesivas fases.

El software de la práctica está organizado en tres directorios: cliente (realmente, hay varios directorios cliente para asegurarse de que cada JVM tiene su propia caché si se ejecutan varios "nodos cliente" en la misma máquina real), servidor y afs. Empecemos por este último, que contiene las clases que proporcionan la funcionalidad del servicio a desarrollar, que estarán incluidas en el paquete afs. A continuación, se comenta brevemente el objetivo de cada una, que será posteriormente explicado en detalle en las secciones del documento dedicadas a presentar progresivamente la funcionalidad requerida.

Con respecto a los directorios cliente, en los mismos se encuentra la clase Test, que es un programa interactivo que sirve para probar la funcionalidad de la práctica, permitiendo que el usuario pueda realizar operaciones de lectura, escritura, posicionamiento y cambio de tamaño sobre ficheros. Asimismo, este programa recibirá como variables de entorno la siguiente información: Mediante el uso de enlaces simbólicos, los directorios de cliente comparten todos los ficheros (excepto, evidentemente, el directorio Cache), no habiendo que realizar ningún desarrollo de código en los mismos a no ser que uno quiera preparar sus propios programas de prueba.

En cuanto al directorio servidor, donde tampoco hay que hacer ningún desarrollo, este incluye la clase ServidorAFS que inicia el servicio dándole de alta en el rmiregistry con el nombre AFS (instancia un objeto de la clase ViceImpl y lo registra). Este programa recibe como argumento el número de puerto por el que escucha el proceso rmiregistry previamente activado. Este directorio contiene un subdirectorio denominado AFSDir que será donde se ubiquen los ficheros del servidor.

Además de las diversas clases, en los distintos directorios se incluyen scripts para facilitar la compilación de las clases y la ejecución de los programas, así como la distribución de las clases requeridas por el cliente y el servidor, en forma de ficheros JAR, teniendo en cuenta que estos pueden residir en distintas máquinas.

Ejecución de la práctica

Aunque para toda la gestión del ciclo de desarrollo del código de la práctica se puede usar el IDE que se considere oportuno, para aquellos que prefieran no utilizar una herramienta de este tipo, se proporcionan una serie de scripts que permiten realizar toda la labor requerida. En esta sección, se explica cómo trabajar con estos scripts.

Para probar la práctica, debería, en primer lugar, compilar todo el código desarrollado que se encuentra en el directorio, y paquete, afs, generando los ficheros JAR requeridos por el cliente y el servidor.

cd afs
./compila_y_construye_JARS.sh
A continuación, hay que compilar y ejecutar el servidor, activando previamente rmiregistry.
cd servidor
./compila_servidor.sh
./arranca_rmiregistry 12345 &
./ejecuta_servidor.sh 12345
Por último, hay que compilar y ejecutar el cliente de prueba.
cd cliente1
./compila_test.sh
export REGISTRY_HOST=triqui3.fi.upm.es
export REGISTRY_PORT=12345
export BLOCKSIZE=... # el tamaño que considere oportuno
./ejecuta_test.sh
Nótese que el servidor y el cliente pueden ejecutarse en distintas máquinas. Además, tenga en cuenta que, si ejecuta varios clientes en la misma máquina, debería hacerlo en diferente directorio de cliente (cliente1, cliente2...).

Fase 1: lectura de un fichero sin tener en cuenta aspectos de coherencia

El objetivo de esta fase es permitir el acceso de lectura a ficheros remotos almacenados en el servidor (ubicados en el subdirectorio AFSDir).

Antes de pasar a describir la funcionalidad de esta fase, se considera conveniente hacer dos reflexiones previas:

Fase 1: funcionalidad del servidor

Para realizar la descarga, se plantea usar un esquema de fábrica de referencias remotas a objetos (véase en la guía de Java RMI la sección dedicada a este esquema), tal que se cree un objeto remoto para encapsular cada sesión de descarga de un fichero. Con este esquema, el servicio Vice ofrece una operación (download) para iniciar la descarga de un fichero que genera una referencia remota de tipo ViceReader que ofrece métodos remotos para ir descargando el fichero bloque a bloque.

En ViceReaderImpl se usará la clase RandomAccessFile para los accesos al fichero real. De hecho, cada objeto ViceReaderImpl almacenará internamente un objeto RandomAccessFile que corresponderá a la sesión de acceso de lectura del fichero.

A continuación, se detallan los cambios a realizar:

Para terminar esta sección, se considera conveniente realizar una aclaración sobre el método remoto read. De forma intutiva, parecería más razonable usar una declaración similar a la del método del mismo nombre de la clase RandomAccessFile permitiendo de esta forma una implementación directa del mismo:
    public int read(byte[] b) throws ...
        return f.read(b); // siendo f un objeto de tipo RandomAccessFile
    }
Sin embargo, este modo de operación no es correcto en Java RMI ya que este método remoto devolvería la información leída en un parámetro pasado por referencia y Java RMI no permite ese tipo de paso de información (véase la guía sobre la programación en Java RMI para profundizar sobre este tema). Es por ello que en la definición propuesta la información leída se devuelve como retorno del método remoto.

Fase 1: funcionalidad del cliente

Con respecto a la parte cliente, en esta fase entran en juego dos clases:

Fase 1: pruebas

En esta sección se comentan qué pruebas se pueden llevar a cabo para verificar la funcionalidad pedida.
  1. Arranque el programa Test y abra un fichero no existente en el servidor. El programa Test debería imprimir el mensaje Fichero no existente lo que significaría que ha recibido la excepción FileNotFoundException que se ha propagado desde el servidor hasta la aplicación.
  2. Arranque el programa Test y abra un fichero que previamente ha creado en el servidor de forma externa a la práctica y que ocupe más de un bloque teniendo un tamaño que no sea múltiplo del tamaño de bloque. Use las operaciones read y seek del programa de prueba para comprobar que el contenido es correcto.
  3. Siguiendo con la prueba anterior, queremos probar ahora que cuando el fichero existe en Cache se usa la copia local. Para verificarlo, cierre el fichero, modifique externamente alguna parte del contenido de la copia del fichero en Cache y compruebe que al volver a abrirlo se accede al contenido modificado y no al servidor.
  4. Como última prueba, compruebe el comportamiento del código desarrollado si se lee un fichero existente pero vacío.

Fase 2: Escritura en un fichero sin tener en cuenta aspectos de coherencia

Una operación de escritura en un fichero puede implicar una operación de descarga, al abrirlo si no está en Cache, y una de carga, al cerrarlo si se ha escrito o se ha cambiado su tamaño durante la sesión de acceso.

Aunque la operación de carga ya se ha implementado en la fase previa, habrá que reajustarla dado el diferente comportamiento de la operación de apertura de un fichero dependiendo del modo de apertura en caso de que este no exista previamente: en una sesión de lectura (modo r) se produce la excepción FileNotFoundException, mientras que en una de escritura (modo rw) el fichero se crea y se abre normalmente.

Fase 2: funcionalidad del servidor

Revisemos los cambios requeridos en las distintas clases:

Fase 2: funcionalidad del cliente

Con respecto a la parte cliente, los cambios requeridos serían:

Fase 2: pruebas

En primer lugar, se debería comprobar que las pruebas de la fase anterior siguen funcionando correctamente. Estas serían las pruebas propuestas para esta fase:
  1. Pruebe a abrir en modo lectura un fichero existente y, a continuación, escriba en el mismo. El programa de prueba debería imprimir que se ha producido una excepción de E/S y continuar operando correctamente.
  2. Repita la prueba anterior cambiando la longitud del fichero.
  3. Abra un fichero no existente en modo escritura, escriba en el mismo y ciérrelo. Compruebe que tanto el contenido almacenado en Cache como en AFSDir es correcto.
  4. Abra un fichero existente en modo escritura, escriba en el mismo y ciérrelo. Compruebe que tanto el contenido almacenado en Cache como en AFSDir es correcto.
  5. Repita la prueba anterior cambiando solo la longitud del fichero.
  6. Pruebe el uso de la caché haciendo dos sesiones sucesivas de escritura sobre un fichero existente.

Fase 3: Implementación del modelo de coherencia

En esta fase se aborda el protocolo de coherencia de AFS pero solo para un escenario simplificado donde se asume que en cada momento solo hay una sesión de escritura activa en cada fichero, que, eso sí, podría ejecutarse concurrente con sesiones de lectura simultáneas sobre ese mismo fichero.

Antes de pasar a describir la funcionalidad concreta de esta fase, hay que revisar dos aspectos relacionados con los problemas de coherencia debido a accesos simultáneos.

Sincronización de cargas y descargas

Tal como se han implementado las fases previas se pueden producir en paralelo cargas y descargas de un mismo fichero, lo que puede causar problemas de coherencia (el lector puede realizar una descarga parcial de un fichero porque el escritor todavía no ha completado la carga).

La solución más directa es usar cerrojos de lectura/escritura sobre un fichero mientras se está descargando (cerrojo de lectura) o cargando (cerrojo de escritura), permitiendo, de esta forma, un modelo de múltiples descargas pero solo una carga. Habría que solicitar un bloqueo de un cerrojo de lectura al principio de la descarga para liberarlo al final de la misma y uno de escritura siguiendo la misma pauta para la carga.

Java ofrece esta funcionalidad dentro de la clase FileLock, que se basa en el mecanismo nativo equivalente del sistema operativo subyacente. Sin embargo, este mecanismo no es aplicable al problema de sincronización planteado, puesto que los accesos que se requiere sincronizar corresponden a threads del mismo proceso (Java RMI va asignando threads para que procesen concurrentemente las peticiones que se van recibiendo), mientras que este mecanismo es solo válido para sincronizar procesos independientes (nótese que sucede lo mismo con los cerrojos de ficheros convencionales de Linux, aunque en este sistema operativo existe una variedad que sí sería válida: los Open file description locks, que quedan fuera de este proyecto práctico).

Para solventar esta limitación, vamos a usar un mecanismo de sincronización no vinculado con los ficheros, como son los mutex de lectura y escritura que ofrece Java. En el código de apoyo de la práctica se ofrece una clase denominada LockManager, que será instanciada por ViceImpl, y que ofrece esta funcionalidad permitiendo asociar un nombre (el fichero) con un mutex de lectura/escritura:

Protocolo de coherencia

Recuerde que el protocolo de coherencia de AFS conlleva que cuando se carga una nueva copia de un fichero al servidor, que ocurrirá al final de una sesión de escritura donde la aplicación ha modificado el contenido del fichero o su longitud, hay que invalidar todas las demás copias de las cachés de los clientes, tengan estos el fichero abierto (nótese que en esta fase si lo tienen abierto debe ser para una sesión de lectura) o no.

En la práctica, la invalidación de la copia del fichero en la caché se realizará directamente borrando el fichero del directorio Cache, lo que hará que posteriores accesos a ese fichero no lo encuentren en la caché y tengan que descargarlo del servidor. Recuerde que, dado como implementa el sistema operativo el acceso a los ficheros, aunque se borre el fichero de Cache, las sesiones de acceso a ese fichero que ya estén activas localmente continuarán sin incidencias.

Para implementar el mecanismo de invalidaciones se usará un esquema de tipo callback (véase la guía sobre la programación en Java RMI para profundizar sobre este tema), que permite a un servidor invocar un método remoto de un cliente. El modo de operación será el siguiente:

Fase 3: funcionalidad del servidor

Esta funcionalidad requiere los siguientes cambios en las clases de servidor:

Fase 3: funcionalidad del cliente

En cuanto a la parte cliente, requiere los siguientes cambios:

Fase 3: pruebas

Para probar el protocolo de coherencia puede ejecutar la siguiente secuencia de procesos:
  1. En cliente1 abra un fichero no existente en modo rw, escríbalo y ciérrelo.
  2. En cliente2 abra ese fichero en modo r, léalo y ciérrelo.
  3. En cliente3 abra ese fichero en modo r y lea parte del mismo pero no lo cierre.
  4. En cliente4 abra ese fichero en modo rw, y modifique parte del mismo. Antes de cerrarlo, compruebe el contenido de los directorios Cache de los cuatro clientes y del directorio AFSDir del servidor. Las copias en los tres primeros clientes deben ser iguales que la del servidor puesto que todavía no se ha cerrado el fichero en el cuarto cliente.
  5. Cierre el fichero en el cuarto cliente. En ese momento, tienen que haber desaparecido las copias de los tres primeros clientes y la copia del cuarto debe ser igual que la del servidor.
  6. Solicite una nueva lectura en cliente3 y pruebe a leer con el descriptor obtenido en la primera apertura que todavía no se cerró y con el nuevo. Verifique que con el primero se accede al contenido original mientras que con el segundo al nuevo.

Material de apoyo de la práctica

El material de apoyo de la práctica se encuentra en este enlace.

Al descomprimir el material de apoyo se crea el entorno de desarrollo de la práctica, que reside en el directorio: $HOME/DATSI/SD/javaAFS.2020/.

Entrega de la práctica

Se realizará en la máquina triqui, usando el mandato:
entrega.sd javaAFS.2020

Este mandato recogerá los siguientes ficheros: