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: