viernes, 21 de mayo de 2010

Unas cuantas operaciones de archivo || Construyendo un Sincronizador

Recientemente en el proyecto que he estado trabajando en el último par de meses, tuvimos que pensar en una manera de resolver el problema de mantener actualizado unas copias locales de repositorios donde los originales se encuentran en servidores remotos. Debido al tipo de operaciones que necesitamos ejecutar, no podíamos darnos el lujo de hacerlo directamente en los servidores remotos. Si usted está pensando ahora mismo "di mae! Use SVN", bueno, digamos que nuestro cliente no lo tiene y no hay posibilidad cercana de que lo instale por nosotros. Sólo tenemos acceso a las unidades de recurso compartido donde está el sistema de archivos.

Este tipo de operación que requerimos se conoce (al menos así es como lo llamamos), como sincronización de directorios. En nuestro caso es necesario para mantener la versión más actualiza posible de los archivos remotos. La sincronización es muy útil cuando el costo de copiar todo el directorio es muy alto. Por ejemplo, si tenemos un directorio remoto con 200 GB, no sería eficiente copiar todo cada vez que tengamos que actualizar la copia local.

Investigué un poco para encontrar una herramienta que podría hacer lo que quería, y me encontré algunas buenas, pero con el único inconveniente que necesitaba algo que pudiera personalizar a nuestros procesos. Así que empecé a jugar un poco con la clase java.io.File y me di cuenta de que podía programar un sincronizador en poco tiempo.

En este post quiero compartir con ustedes algunas operaciones útiles de la clase File a la luz del problema que mi equipo tenía que resolver.
Pongamos un ejemplo de lo que el sincronizador tiene que hacer. Supongamos que tenemos este directorio remoto:

gsolano_remote
+ 20100514
++ calculations.xls
+ 20100514
++ HelloWorld.java
+ readme.txt


Y tenemos esta copia local que tiene que ser actualizada:

gsolano_local
+ 20100514
++ calculations.xls
++ deletelater
+ bck-ups
+ readme.txt
+ dir.txt


Si comparamos estos 2 directorios, la copia local tendría que ejecutar las siguientes acciones (en paréntesis):

gsolano_local
+ 20100514
++ calculations.xls
++ deletelater (remover)
+ bck-ups (remover)
+ readme.txt (actualizar)
+ dir.txt (remover)
+ 20100514 (agregar)
++ HelloWorld.java (agregar)


La lógica que se necesita codificar para ejecutar estas acciones es bastante simple.
  1. Listamos los archivos del directorio fuente(gsolano_remote).
  2. Listamos los archivos del directorio destino(gsolano_local).
  3. Comparamos los dos listados y extraemos:
  • Lista de nuevos archivos a copiar de la fuente al destino.
  • Lista de archivos que tienen que ser actualizados porque fueron modificados en el directorio fuente.
  • Archivos y directorios que ya no existen en el directorio fuente y que por tanto tienen que ser borrados.
Una vez que obtenemos estas listas solo tenemos que ejecutar las respectivas acciones de copiado y borrado.

Examinemos primero cómo escanear los archivos de un directorio:

package gsolano;

import java.io.File;
import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.Map;

public class Dir {

/**
* Returns a list of all file paths relative to the provided path.
* @param path
* @return list of relative paths.
*/
public static Map<String, Long> scan(String path) {
Map<String, Long> fileList = new LinkedHashMap<String, Long>();
scanFiles(path.toLowerCase(), path, fileList);
return fileList;
}

/**
* Method for recursively scan.
* @param rootSource
* @param path
* @param fileList
*/
private static void scanFiles(String rootSource, String path, Map<String, Long> fileList) {
File folder = new File(path); // This is the root directory.
// List files from first level of root directory.
File[] listOfFiles = folder.listFiles();

if (listOfFiles.length == 0) {
// Used to keep record of empty folders.
fileList.put(path.toLowerCase().replace(rootSource, "")
+ File.separator + ".", new Long(0));
}
else {
for (int i = 0; i < listOfFiles.length; i++) {
if (listOfFiles[i].isFile()) { // Is it a file?
try {
// Add it to the file list with the last modified date.
fileList.put(listOfFiles[i].getAbsolutePath().toLowerCase()
.replace(rootSource, ""), listOfFiles[i].lastModified());

} catch (Exception e) {
e.printStackTrace();
}
} else if (listOfFiles[i].isDirectory()) { // Is it a directory?
try {
// Recursively call for new found directory.
System.out.println(listOfFiles[i]);
scanFiles(rootSource, listOfFiles[i].getCanonicalPath(), fileList);
} catch (IOException e) {
e.printStackTrace();
}
}
}
}

}
}


En este código comenzamos a explorer las capacidades de la clase File. La primera es la habilidad de listar archivos de un directorio. Simplemente creamos una instancia de la clase File con el path de un directorio y luego usamos la función “listFiles()”.

File folder = new File(path);
File[] listOfFiles = folder.listFiles();

Ahora, esta function solamente obtiene los archivos y directories del primer nivel. La función no lista los directorios de los siguiente subniveles; es por eso que en la clase “Dir” el escaneo de archivos trabaja con una función recursiva.

Para determinar si se necesita ejecutar una llamada recursiva, se utiliza la función “isFile()” y “isDirectory()”. Si el archivo que se lee es un directorio (suena extraño ¿cierto?) entonces se hace la llamada recursiva. Si es un archivo se agrega a la lista.

En esta clase usamos también la función “lastModified()” para guardar la última fecha de modificación de cada uno de los archivos escaneados. La fecha se usa para determinar si el archivo de la fuente cambió provocando que se tenga que actualizar en el directorio destino.

Antes de saltar a la clase principal, echémole una mirada a la clase para copiar archivos. Modifiqué un poco esta clase que encontré en Internet :

package gsolano;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;

public class FileCopy {

/**
* Copies one file from the source to specified target.
* @param fromFileName
* @param toFileName
* @param overrideFiles
* @throws IOException
*/
public static void copy(String fromFileName, String toFileName,
boolean overrideFiles) throws IOException {

File toFile = new File(toFileName);
if (toFile.exists() && !overrideFiles) {
return;
}
File fromFile = new File(fromFileName);

if (!fromFile.exists())
throw new IOException("FileCopy: " + "no such source file: "
+ fromFileName);
if (!fromFile.isFile())
throw new IOException("FileCopy: " + "can't copy directory: "
+ fromFileName);
if (!fromFile.canRead())
throw new IOException("FileCopy: " + "source file is unreadable: "
+ fromFileName);

if (toFile.isDirectory()) {
toFile = new File(toFile, fromFile.getName());
}
if (toFile.exists()) {
if (!toFile.canWrite()) {
throw new IOException("FileCopy: "
+ "destination file is unwriteable: " + toFileName);
}
String parent = toFile.getParent();
if (parent == null)
parent = System.getProperty("user.dir");
File dir = new File(parent);
if (!dir.exists())
throw new IOException("FileCopy: "
+ "destination directory doesn't exist: " + parent);
if (dir.isFile())
throw new IOException("FileCopy: "
+ "destination is not a directory: " + parent);
if (!dir.canWrite())
throw new IOException("FileCopy: "
+ "destination directory is unwriteable: " + parent);
} else {
// Create directory structure.
new File(toFile.getParent()).mkdirs();
}
createCopy(toFile, fromFile);
}

/**
* Writes the copy from source to target.
* @param toFile
* @param fromFile
* @throws FileNotFoundException
* @throws IOException
*/
private static void createCopy(File toFile, File fromFile)
throws FileNotFoundException, IOException {
FileInputStream from = null;
FileOutputStream to = null;
try {
from = new FileInputStream(fromFile);
to = new FileOutputStream(toFile);
byte[] buffer = new byte[4096];
int bytesRead;

while ((bytesRead = from.read(buffer)) != -1)
to.write(buffer, 0, bytesRead); // write
} finally {
if (from != null)
try {
from.close();
} catch (IOException e) {
;
}
if (to != null)
try {
to.close();
toFile.setLastModified(fromFile.lastModified());

} catch (IOException e) {
;
}
}
}
}

La clase FileCopy utilize 6 funciones más de la clase File:

1. “exists()”: usada para doble chequear que el archivo existe en el directorio fuente.

2. “canRead()”: usado para determiner si el archivo puede ser leido del directorio fuente. “canWrite():”: usado para determiner si el archive destino puede ser sobre escribido. Lo usamos cuando queremos actualzar un archivo.

3. “getParent()”: obtiene el path del directorio padre.

4. “mkDirs()”: tengo que confesar que esta es mi favorita; crea toda la jerarquía de directorios de un path.

5. “setLastModifiedDate()”: cuando terminamos de copiar el archivo en el directorio destino, queremos dejar la misma fecha de modificación del archivo fuente.

Para concluir con la clase “Synchronizer” solo tenemos que mencionar una función y una constante:

+ “delete()”: borra el archivo o directorio

+ “File.separator”: character dependiente del sistema usado para separar los directories de un path. En este ejemplo el separador es la barra inclinada hacia atrás o “back-slash” (“\”).

La clase “Synchronizer” hace todos los pasos requeridos para sincronizar 2 directorios. La lista de copias y borrados son calculados con simples comparaciones de colecciones (conjuntos y mapas).

Me gustaría poder probar si el código funciona en otro sistema operativo. Teóricamente sí funciona.


package gsolano;

import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;

/**
*
* Class used to synchronize two directories. One directory (source)
* is used as base of another directory (target).
* The class determines the operations required to leave the target
* with the same structure as the source.
*
* @author gsolano
*
*/
public class Synchronizer {

public static void main(String[] args) {
Synchronizer.run(args[0], args[1]);
}

public static void run(String source, String target) {
System.out.println("Scanning source directory...");
Map<String, Long> sourceFiles = Dir.scan(source);
System.out.println("[DONE]");

System.out.println("Scanning target directory...");
Map<String, Long> targetFiles = Dir.scan(target);
System.out.println("[DONE]");

List<String> newFilesToCopy = getNewFilesToCopy(sourceFiles.keySet(), targetFiles.keySet());
System.out.println("Total new files to copy: " + newFilesToCopy.size());

List<String> filesToUpdate = getFilesToUpdate(sourceFiles, targetFiles);
System.out.println("Total files to update: " + filesToUpdate.size());

List<String> filesToRemove = getFilesToRemove(sourceFiles.keySet(), targetFiles.keySet());
System.out.println("Total files to remove: " + filesToRemove.size());

List<String> dirsToRemove = getDirectoriesToRemove(sourceFiles.keySet(), targetFiles.keySet());
System.out.println("Total dirs to remove: " + dirsToRemove.size());

System.out.println("Copying new files...");
for(String fileToCopy : newFilesToCopy) {
try {
FileCopy.copy(source + File.separator + fileToCopy,
target + File.separator + fileToCopy, false);
} catch (IOException e) {
System.out.println("Couldn't copy file: " + fileToCopy + "(" + e.getMessage() + ")");
}
}

System.out.println("Updating files...");
for(String fileToUpdate : filesToUpdate) {
try {
FileCopy.copy(source + File.separator + fileToUpdate,
target + File.separator +fileToUpdate, true);
} catch (IOException e) {
System.out.println("Couldn't copy file: " + fileToUpdate + "(" + e.getMessage() + ")");
}
}

System.out.println("Removing files from target...");
for(String fileToRemove : filesToRemove) {
new File(target + fileToRemove).delete();
}

System.out.println("Removing directories from target...");
for(String dirToRemove : dirsToRemove) {
new File(target + dirToRemove).delete();
}
}

/**
* Return the list of directories to be removed. A directory is removed
* if it is present in the target but not in the source.
* @param sourceFiles
* @param targetFiles
* @return
*/
private static List<String> getDirectoriesToRemove(Set<String> sourceFiles,
Set<String> targetFiles) {
List<String> directoriesToRemove = new ArrayList<String>();

Set<String> sourceDirs = buildDirectorySet(sourceFiles);
Set<String> targetDirs = buildDirectorySet(targetFiles);

for(String dir : targetDirs) {
if (!sourceDirs.contains(dir)) {
directoriesToRemove.add(dir);
}
}
return directoriesToRemove;
}

/**
* Return the list of files to be removed.
* A file is removed if it is present in the target
* but not in the source.
* @param sourceFiles
* @param targetFiles
* @return
*/
private static List<String> getFilesToRemove(Set<String> sourceFiles,
Set<String> targetFiles) {
List<String> filesToRemove = new ArrayList<String>();

for (String filePath : targetFiles) {
if (!sourceFiles.contains(filePath) &&
!filePath.endsWith(File.separator + ".")) {
filesToRemove.add(filePath);
}
}
return filesToRemove;
}

/**
* Gets the the list of files missing in the target directory.
* @param sourceFiles
* @param targetFiles
* @return
*/
private static List<String> getNewFilesToCopy(Set<String> sourceFiles,
Set<String> targetFiles) {
List<String> filesToCopy = new ArrayList<String>();

for (String filePath : sourceFiles) {
if (!targetFiles.contains(filePath)) {
if(!filePath.endsWith(File.separator + ".")) {
filesToCopy.add(filePath);
}
}
}
return filesToCopy;
}

/**
* Gets the list of files to be updated according to the last
* modified date.
* @param sourceFiles
* @param targetFiles
* @return
*/
private static List<String> getFilesToUpdate(Map<String, Long> sourceFiles,
Map<String, Long> targetFiles) {
List<String> filesToUpdate = new ArrayList<String>();
Iterator<Map.Entry<String, Long>> it = sourceFiles.entrySet().iterator();

while (it.hasNext()) {
Map.Entry<String, Long> pairs = it.next();
String filePath = pairs.getKey();
if (targetFiles.containsKey(filePath) &&
!filePath.endsWith(File.separator + ".")) {
long sourceModifiedDate = sourceFiles.get(filePath);
long targetModifiedDate = targetFiles.get(filePath);

if(sourceModifiedDate != targetModifiedDate) {
filesToUpdate.add(filePath);
}
}
}
return filesToUpdate;
}

/**
* Returns the set of directories contained in the set of file paths.
* @param files
* @return Set of directories representing the directory structure.
*/
private static Set<String> buildDirectorySet(Set<String> files) {
Set<String> directories = new HashSet<String>();
for(String filePath : files) {
if (filePath.contains(File.separator)) {
directories.add(filePath.substring(0,
filePath.lastIndexOf(File.separator)));
}
}
return directories;
}
}

Salida:

martes, 20 de abril de 2010

Especificación de Requerimientos de Software (SRS)

Andaba buscando alguna plantilla para especificar requerimientos de una herramienta que tengo que desarrollar, y por el momento la que más me ha gustado es esta:

"Sotware Requirements Specifications".

Sin duda es bueno ser formales a la hora de especificar los requerimientos de nuestros programas. Esto nos evita el tiempo perdido que sucede cuando los desarrolladores no entiende claramente lo que se les especifica porque se dice de manera verbal o el documento escrito es ambiguo.

viernes, 5 de marzo de 2010

Error típico SVN




Este es un error que me ha dado un par de ocasiones con el Eclipse al pegarme al servidor SVN.

Error Validating Location: "org.tigris.subversion.javahl.ClientException: The system cannot find the file specified. svn: Can't create tunnel: The system cannot find the file specified"

Lo he solucionado cambiando la opción de cliente en el SVN Interface:

No es un gran post pero por si acaso alguien anda por ahí rompiéndose la cabeza.

martes, 2 de marzo de 2010

Tres Amigos de Persistencia (DAO - DAOFactory - BO)

Hoy en día, aún con todo el buen conocimiento acumulado que hay en patrones de diseño, ingenieros de software todavía tienden (tendía hasta hace un tiempo :)) a programar clases donde la lógica de negocio esta mezclada con la lógica de persistencia. Inclusive los más experimentados desarrolladores omiten este aspecto importante de la arquitectura de software, quizás por falta de conocimiento, o talvez solo por la prisa de sacar una funcionalidad rápidamente. Me parece que la última razón es la más común.

¿Por qué deberíamos molestarnos tanto con esta separación? Bien, la idea no es ahondar mucho en lo que es básico en arquitectura de software: programación en multicapas, sino más bien ayudar con algunos patrones útiles para cumplir con este aspecto de una aplicación bien diseñada. Creo que la mayoría de los desarrolladores están concientes de este principio de separación entre capa de lógica y capa de persistencia, pero lo que tal vez no es tan obvio es cómo se logra codificar las clases para lograr esta independencia.


Primero vamos a comenzar con el patrón más conocido: DAO por sus siglas en inglés de "Data Access Object" u Objeto de Acceso de Datos. Citando de un libro de patrones de diseño [1]:

Problema:

Usted quiere encapsular el acceso y manipulación de datos en una capa separada.

Fuerzas:

  1. Usted desea implementar los mecanismos de acceso a datos para accesar y manipular los datos en el almacenamiento de persistencia.
  2. Usted quiere desacoplar la implementación del almacenamiento de persistencia del resto de su aplicación.
  3. Usted quiere proveer una API uniforme de acceso a datos para un mecanismo de persistencia con varios tipos de fuentes de datos (data sources),tales como: RDBMS, LDAP, OODB, repositorios XML, archivos planos, etc.
  4. Usted quiere organizar la lógica de acceso de datos y encapsular las características propietarias para facilitar el mantenimiento y la portabilidad.

Las clases DAO contiene toda la lógica para conectar por ejemplo una base de datos y obtener todos los datos necesarios correspondientes a las tablas. Un aspecto importante de cualquier clase DAO es que la implementación de cualquier método que retorne datos, nunca debe retornar tipos relacionados con el propietario de almacenamiento. Por ejemplo una implementación DAO de JDBC nunca debe retornar un ResultSet, de lo contrario no se estaría cumpliendo con el punto #4. La aplicación se acoplaría con la implementación de JDBC. Por eso es que en el siguiente diagrama UML el objeto retornado de la clase DAO es un "TransferObject" (Objeto de transferencia). No hay que detallar mucho en este último patrón sino solamente mencionar que tan solo retornando objetos de dominio es suficiente para asegurarse el desacoplo de la capa de negocio con la de persistencia.





El implementar el patrón DAO en nuestra aplicación no es suficente para desacoplar 100% nuestra capa de negocio con la de persistencia. Imaginemos que tenemos la siguiente clase DAO y su cliente que la consume:


package com.foo.dao.jdbc;

class FooDAOJDBCImpl {
public void updateFoo(Foo foo) {
...
}
}

public class FooClient {
void updateChangesInFoo(Foo foo) {
com.foo.dao.jdbc.FooDAOJDBCImpl fooDAOJDBCImpl = new com.foo.dao.jdbc.FooDAOJDBCImpl();
fooDAOJDBCImpl.update(foo);
}
}

Nótese que el cliente necesita instanciar directamente la implementación JDBC. Aún cuando es oculto para el cliente cómo la clase DAO internamente actualiza los datos en el almacenamiento de persistencia, el cliente aún puede conocer que la implementación utiliza JDBC para persistir los datos. Si la implementación de JDBC es reemplazada por otra, el código del cliente tendrá que ser actualizado para usar las nuevas clases DAO.

Para hacer nuestro diseño más flexible a este tipo de cambios, y también para preparar nuestro código para unidades de prueba (uso de clases Mock), podemos unir en matrimonio nuestro patrón DAO con el conocidísimo AbstractFactory para tener un hijo llamado DAOFactory. La clase DAOFactory usa reflexión (en esta versión del patrón) para instanciar las clases DAO.





public interface DAO {

}

public interface FooDAO extends DAO {
public void update(Foo foo);
}

package com.foo.dao.jdbc;
public class FooJDBCImpl implements FooDAO {
public void update(Foo foo) {
....
}
}

public class FooClient {
void updateChangesInFoo(Foo foo) {
FooDAO dao = (FooDAO) DAOFactory.getDAO("foo") ;
dao.update(foo);
}
}



No es hasta el tiempo de ejecución que se conoce cuál clase DAO será usada para ejecutar la función de persistencia. El nombre de las clases se pueden guardar en un archivo de propiedades. Si la implementación de la clase DAO cambia, será totalmente trasparente para la clase cliente.

/**
Properties in some file:
foo=com.foo.dao.jdbc.FooDAOJDBCImpl
foo2=com.foo.dao.jdbc.Foo2DAOJDBCImpl

**/

import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.util.Properties;

public class DAOFactory {

private static Properties props = new Properties();
private static boolean loadedProperties = false;
private static String propertiesPath;

public static void init(String propertiesPath) {
propertiesPath = path;
}

public static DAOFactory getDAO(String name) {
try {

Class daoClass = Class.forName( getClass( name ) );
return (DAO) daoClass.newInstance();
}
catch (ClassNotFoundException e) {
e.printStackTrace();
return null;
}
catch (Exception e) {
e.printStackTrace();
return null;
}

}

private static String getClass( String propertyName ) {
String className = null;
try {

if ( !loadedProperties ) {

FileInputStream file = new FileInputStream( propertiesPath );
props.load( file );
loadedProperties = true;
}

className = props.getProperty( propertyName, "");
if ( className.length() == 0)
return null;
}
catch ( FileNotFoundException e) {
e.printStackTrace();
}
catch ( IOException e) {
e.printStackTrace();
}
catch (Exception e) {
e.printStackTrace();
}
return className;
}

}
El último patrón de diseño para completar nuestra banda es el "Business Object" (BO) u Objeto de Negocio. Aún estoy aprendiendo a usarlo correctamente; mientras escribía este artículo me di cuenta que necesito hacer algunos arreglos a una implementación que tengo de este patrón. De todas maneras, uno de los principales propositos de este patrón es separar la lógica de persistencia con la lógica de negocio. Normalmente en nuestas aplicaciones tenemos modelos conceptuales algo complejos conteniendo objetos interrelacionados, estructurados y compuestos. Estas relaciones de objetos compuestos requieren bastante lógica solo para los mecanismos de persistencia. Así que para evitar mezclar estos dos tipo de lógicas, se agrega una capa intermedia
entre la lógica de negocios y la capa de acceso de datos; la capa de objetos de negocio.

Supongamos que tenemos una clase Foo conteniendo una lista de objetos tipo Foo2:

public class Foo {
private List<Foo2> foo2s;
private String someAttribute;

}
public class Foo2 {
private String someAttribute;
}


Si queremos persistir nuestra clase Foo creamos dos clases BO. La clase FooBO in el principal punto de entrada para guardar todos los objetos compuestos:


public class FooBO {

public void saveFoo(Foo foo) {
FooDAO fooDAO = (FooDAO) DAOFactory.getDAO("foo") ;
fooDAO.saveBasicFooInfo(foo);
Foo2BO foo2Bo = new Foo2BO();

for (Foo2 foo2 : foo.getFoo2s()) {
foo2Bo.saveFoo2(foo2);
}
}
}

public class Foo2BO {
public void saveFoo2(Foo foo) {
FooDAO2 fooDAO2 = (FooDAO2) DAOFactory.getDAO("foo2") ;
fooDAO2.saveFoo2(foo2);
}
}

Pueden haber distintas maneras de implementar cualquiera de los tres patrones descritos en este artículo; nada está escrito en piedra. La idea era dar un vistazo rápido en estos tres patrones. Comentarios son bienvenidos.

[1] Deepak Alur, John Crupi, Dan Malks. "Core J2EE Patterns, Best Practices and Design Strategies", 2003. Pags: 462,463.